M1: canvas navigation, guides and grids behaviour suite and fixes
A behaviour suite for canvas navigation, rulers, guides, layout grids, outline mode and measuring (`crates/engine/src/view_behaviour_tests.rs`, 18 tests, ~120 table rows and checks), written from the public documentation with a one-line derivation per case; where the documentation is silent the case says so and pins today's behaviour. Plus the fixes it found: - **Pixel grid** shows from 400% (it waited for 800%). The threshold, pixel preview's resolution and wheel panning (`ViewState::pixel_grid_shown`, `pixel_preview_scale`, `scroll`) now live in the engine; the canvas calls them. - **Guides belong to the page or a top-level frame.** A guide dropped over a nested frame goes to the frame holding it (`guides::drop_owner`), `guide.add` refuses nested frames, and a frame moved into another frame keeps its guides but they stop drawing, snapping and measuring. - **Selecting guides:** a click selects a guide (`guide.select`, new command, Spanish label added); Delete/Backspace removes it, the arrows across it nudge it (Shift: big nudge), Escape deselects; right-click opens the context menu with Remove guide (`guide.remove` without an index removes the selected guide). A click without a move no longer records an empty undo step. - **Alt-drag** on a guide drops a copy; **holding Alt while dragging** a guide shows its distances to the nearest edges inside the frame it crosses (`distance::from_guide`). - **Layout grids:** columns/rows with a fixed width and count 0 are **Auto** (as many as fit; the Count field shows Auto); grids don't show or snap on a **rotated frame**. - **Outline mode:** shapes are hit on their outlines, so a click reaches layers drawn behind (`geometry::hit_outlines`).
This commit is contained in:
parent
8b2607de8e
commit
f2c22213d6
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
|
||||
|
|
@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~49%** | estimated: weekly areas 71.0% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~170–305 h** one agent | estimated |
|
||||
| Remaining to full parity | **~307–596 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
| Remaining to full parity | **~306–595 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
|
||||
| Dimension | Ready | Hours | Doc |
|
||||
|---|---|---|---|
|
||||
| Features (depth) | 61% (core editor 70%) | 47–95 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Features (depth) | 61% (core editor 70%) | 46–94 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
|
|
@ -72,7 +72,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
|
||||
| Area | Breadth | Ready | Hours | Doc |
|
||||
|---|---|---|---|---|
|
||||
| Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) |
|
||||
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Shapes & vector | 98% | 72% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
|
||||
|
|
@ -128,6 +128,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel
|
||||
grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide,
|
||||
Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames),
|
||||
outline mode (clicks reach layers behind) and measuring, each rule from the documentation.
|
||||
- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows
|
||||
only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under
|
||||
them, layer names with slashes export into folders; export settings gained contents only, include bounding box and
|
||||
|
|
@ -209,6 +213,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h |
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
|
||||
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) |
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ pub fn is_closed(n: &Node) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> bool {
|
||||
/// Whether `pt` hits `n` itself; in `outline` mode (nothing filled on the canvas) a shape only where
|
||||
/// its outline is.
|
||||
fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, outline: bool) -> bool {
|
||||
let inv = doc.world_transform(id).inverse();
|
||||
let lp = inv * pt;
|
||||
// Tolerance in local units (approximate under scale).
|
||||
|
|
@ -66,7 +68,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -
|
|||
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
|
||||
n.local_rect().inflate(tol_l, tol_l).contains(lp)
|
||||
}
|
||||
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, is_closed(n) && path.winding(lp) != 0)),
|
||||
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,10 +91,10 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo
|
|||
pub type BoolResult<'a> = Option<&'a dyn Fn(NodeId, &Node) -> Option<BezPath>>;
|
||||
|
||||
/// With a result for boolean group `id`, whether `pt` hits it (`None`: no result, test operands).
|
||||
fn hits_result(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, result: BoolResult) -> Option<bool> {
|
||||
fn hits_result(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, result: BoolResult, outline: bool) -> Option<bool> {
|
||||
let path = result.and_then(|f| f(id, n))?;
|
||||
let lp = doc.world_transform(id).inverse() * pt;
|
||||
Some(hits_path(n, &path, lp, tol, path.winding(lp) % 2 != 0))
|
||||
Some(hits_path(n, &path, lp, tol, !outline && path.winding(lp) % 2 != 0))
|
||||
}
|
||||
|
||||
fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
|
||||
|
|
@ -104,7 +106,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
|
|||
/// boolean groups are hit where their `result` is.
|
||||
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
let p = doc.node(page)?;
|
||||
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result).filter(|_| !masked_out(doc, p, *c, pt)))
|
||||
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, false).filter(|_| !masked_out(doc, p, *c, pt)))
|
||||
}
|
||||
|
||||
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
|
||||
|
|
@ -121,16 +123,25 @@ fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
|
|||
/// Whether mask `id` draws at `pt` (its shape, or for a group or frame what its layers draw).
|
||||
fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool {
|
||||
depth < 512
|
||||
&& (hits_node_itself(doc, id, n, pt, 0.0)
|
||||
&& (hits_node_itself(doc, id, n, pt, 0.0, false)
|
||||
|| n.children.iter().any(|c| doc.node(*c).is_some_and(|k| k.visible && !k.is_mask && covers(doc, *c, k, pt, depth + 1))))
|
||||
}
|
||||
|
||||
/// Like [`hit_leaf`], but only within layer `root` (it included).
|
||||
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
hit_rec(doc, root, pt, tol, 0, result)
|
||||
hit_rec(doc, root, pt, tol, 0, result, false)
|
||||
}
|
||||
|
||||
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult) -> Option<NodeId> {
|
||||
/// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit
|
||||
/// where their outlines are, so a click inside one reaches the layers drawn behind it.
|
||||
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
match doc.node(root) {
|
||||
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, true)),
|
||||
_ => hit_rec(doc, root, pt, tol, 0, result, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, outline: bool) -> Option<NodeId> {
|
||||
if depth > 512 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -139,25 +150,25 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
|
|||
return None;
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden: true } = n.kind {
|
||||
return hits_node_itself(doc, id, n, pt, tol).then_some(id);
|
||||
return hits_node_itself(doc, id, n, pt, tol, outline).then_some(id);
|
||||
}
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
if clips && !hits_node_itself(doc, id, n, pt, 0.0) {
|
||||
if clips && !hits_node_itself(doc, id, n, pt, 0.0, false) {
|
||||
return None;
|
||||
}
|
||||
// A boolean group is hit where its result is (without one: where an operand is). The leaf is
|
||||
// the operand under the point, so a plain click still selects the group (see `select_target`)
|
||||
// while a deep click, a double click or a selected sibling operand reaches the operand itself.
|
||||
let in_result = if matches!(n.kind, NodeKind::BooleanOp { .. }) { hits_result(doc, id, n, pt, tol, result) } else { None };
|
||||
let in_result = if matches!(n.kind, NodeKind::BooleanOp { .. }) { hits_result(doc, id, n, pt, tol, result, outline) } else { None };
|
||||
if in_result == Some(false) {
|
||||
return None;
|
||||
}
|
||||
for c in n.paint_order().rev() {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result).filter(|_| !masked_out(doc, n, c, pt)) {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, outline).filter(|_| !masked_out(doc, n, c, pt)) {
|
||||
return Some(h);
|
||||
}
|
||||
}
|
||||
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol) {
|
||||
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) {
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
|
|
@ -174,13 +185,13 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
|
|||
return;
|
||||
}
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
if clips && !hits_node_itself(doc, id, n, pt, 0.0) {
|
||||
if clips && !hits_node_itself(doc, id, n, pt, 0.0, false) {
|
||||
return;
|
||||
}
|
||||
let at = out.len();
|
||||
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
|
||||
if whole {
|
||||
if hit_rec(doc, id, pt, tol, depth, result).is_some() {
|
||||
if hit_rec(doc, id, pt, tol, depth, result, false).is_some() {
|
||||
out.push((id, depth));
|
||||
}
|
||||
return;
|
||||
|
|
@ -192,7 +203,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
|
|||
out.truncate(before);
|
||||
}
|
||||
}
|
||||
if out.len() > at || hits_node_itself(doc, id, n, pt, tol) {
|
||||
if out.len() > at || hits_node_itself(doc, id, n, pt, tol, false) {
|
||||
out.insert(at, (id, depth));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1169,8 +1169,9 @@ pub enum GridAlign {
|
|||
#[serde(default)]
|
||||
pub struct LayoutGrid {
|
||||
pub kind: GridKind,
|
||||
/// Grid: cell size. Columns/rows: count (0 = auto).
|
||||
/// Grid: cell size. Fixed (not stretched) columns/rows: their width/height.
|
||||
pub size: f64,
|
||||
/// Columns/rows: how many (0: Auto, as many fixed-size ones as fit; a stretched grid has 1).
|
||||
pub count: u32,
|
||||
pub gutter: f64,
|
||||
pub offset: f64,
|
||||
|
|
|
|||
|
|
@ -125,16 +125,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
s.doc_mut()?.get_mut(owner)?.guides_mut().get_mut(k).ok_or_else(|| bad("guide.move", "no such guide"))?.offset = offset;
|
||||
ok()
|
||||
}),
|
||||
spec!("guide.remove", "Remove guide", "{index, frame? | page?}", true, |s, p| {
|
||||
let owner = guide_owner(s, "guide.remove", p)?;
|
||||
let k = guide_index("guide.remove", p)?;
|
||||
spec!("guide.remove", "Remove guide", "{index?, frame? | page?} (no index: the selected guide)", true, |s, p| {
|
||||
let (owner, k) = match p.get("index") {
|
||||
None | Some(Value::Null) => s.ds()?.selected_guide().ok_or_else(|| bad("guide.remove", "no guide is selected (pass `index`)"))?,
|
||||
Some(_) => (guide_owner(s, "guide.remove", p)?, guide_index("guide.remove", p)?),
|
||||
};
|
||||
let guides = s.doc_mut()?.get_mut(owner)?.guides_mut();
|
||||
if k >= guides.len() {
|
||||
return Err(bad("guide.remove", "no such guide"));
|
||||
}
|
||||
guides.remove(k);
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.guide == Some((owner, k)) {
|
||||
ds.guide = None;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"guide.select",
|
||||
"Select guide",
|
||||
"{index?, frame? | page?} a ruler guide to delete or nudge (deselects layers; no index: none)",
|
||||
false,
|
||||
|s, p| {
|
||||
let picked = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let owner = guide_owner(s, "guide.select", p)?;
|
||||
let k = guide_index("guide.select", p)?;
|
||||
if k >= s.doc()?.node(owner).map_or(0, |n| n.guides().len()) {
|
||||
return Err(bad("guide.select", "no such guide"));
|
||||
}
|
||||
Some((owner, k))
|
||||
}
|
||||
};
|
||||
if picked.is_some() {
|
||||
s.set_selection(vec![]);
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
ds.guide = picked;
|
||||
ds.revision += 1;
|
||||
Ok(match picked {
|
||||
Some((owner, index)) => json!({"owner": owner, "index": index}),
|
||||
None => Value::Null,
|
||||
})
|
||||
}
|
||||
),
|
||||
spec!("guide.clear", "Remove all guides", "{frame? | page?}", true, |s, p| {
|
||||
let owner = guide_owner(s, "guide.clear", p)?;
|
||||
s.doc_mut()?.get_mut(owner)?.guides_mut().clear();
|
||||
|
|
@ -150,9 +185,10 @@ fn guide_owner(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
|
|||
Some(f) => f,
|
||||
None => id(p, "page").unwrap_or(s.page()?),
|
||||
};
|
||||
match s.doc()?.node(owner) {
|
||||
Some(n) if n.is_page() || n.kind.is_frame_like() => Ok(owner),
|
||||
Some(_) => Err(bad(cmd, "guides go on a page or a frame")),
|
||||
let doc = s.doc()?;
|
||||
match doc.node(owner) {
|
||||
Some(_) if crate::guides::keeps_guides(doc, owner) => Ok(owner),
|
||||
Some(_) => Err(bad(cmd, "guides go on a page or a top-level frame")),
|
||||
None => Err(bad(cmd, "no such page or frame")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
//! - Alt while dragging layers: live distances to the nearest neighbours on each side within the
|
||||
//! parent (to the parent's edges where there is none).
|
||||
//! - Shift+Alt over an auto layout frame: its padding and the gaps between its children.
|
||||
//! - Alt while dragging a ruler guide across a frame: from the guide to the nearest edges inside.
|
||||
//!
|
||||
//! The canvas draws the lines and places the labels so they never overlap (see the UI crate).
|
||||
|
||||
|
|
@ -145,19 +146,47 @@ pub fn to_guide(a: Rect, vertical: bool, at: f64) -> Vec<Guide> {
|
|||
out
|
||||
}
|
||||
|
||||
/// The ruler guides near page point `p` (within `tol`): the page's and those of the frames
|
||||
/// around `around` (those that run along the page's axes), as (vertical, page offset).
|
||||
/// Alt while dragging a ruler guide (`vertical` at page x = `at`, else at y = `at`) across frame
|
||||
/// `frame`: the distances from it to the nearest edge on each side, of the frame's visible layers
|
||||
/// or the frame itself, across the middle of what each measures to. Nothing when it misses the frame.
|
||||
pub fn from_guide(doc: &Document, frame: NodeId, vertical: bool, at: f64) -> Vec<Guide> {
|
||||
let mut out = Vec::new();
|
||||
let Some(f) = doc.world_bounds(frame).filter(|f| f.is_finite() && at.is_finite()) else { return out };
|
||||
// Along the guide's normal: (start, end, middle across) of a box.
|
||||
let span = |b: Rect| if vertical { (b.x0, b.x1, b.center().y) } else { (b.y0, b.y1, b.center().x) };
|
||||
let (lo, hi, mid) = span(f);
|
||||
if !(at > lo && at < hi) {
|
||||
return out;
|
||||
}
|
||||
let (mut before, mut after) = ((lo, mid), (hi, mid));
|
||||
for b in sibling_boxes(doc, frame, &[]) {
|
||||
let (b0, b1, m) = span(b);
|
||||
for e in [b0, b1] {
|
||||
if e <= at && e > before.0 {
|
||||
before = (e, m);
|
||||
}
|
||||
if e >= at && e < after.0 {
|
||||
after = (e, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pt = |along: f64, across: f64| if vertical { Point::new(along, across) } else { Point::new(across, along) };
|
||||
measured(&mut out, pt(before.0, before.1), pt(at, before.1));
|
||||
measured(&mut out, pt(at, after.1), pt(after.0, after.1));
|
||||
out
|
||||
}
|
||||
|
||||
/// The ruler guides near page point `p` (within `tol`): the page's and those of the top-level
|
||||
/// frames around `around` (those that run along the page's axes), as (vertical, page offset).
|
||||
pub fn guide_at(doc: &Document, page: NodeId, around: &[NodeId], p: Point, tol: f64) -> Option<(bool, f64)> {
|
||||
let mut lines: Vec<(bool, f64)> = Vec::new();
|
||||
if let Some(n) = doc.node(page) {
|
||||
lines.extend(n.guides().iter().map(|g| (g.vertical, g.offset)));
|
||||
}
|
||||
let mut frames: Vec<NodeId> = Vec::new();
|
||||
for a in around {
|
||||
for f in doc.ancestors(*a) {
|
||||
if f != page && !frames.contains(&f) {
|
||||
frames.push(f);
|
||||
}
|
||||
for f in around.iter().filter_map(|a| doc.top_level_frame(*a)) {
|
||||
if !frames.contains(&f) {
|
||||
frames.push(f);
|
||||
}
|
||||
}
|
||||
for f in frames {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,29 @@
|
|||
//! Ruler guides on the page. A frame keeps its guides in its own coordinates, so on a rotated frame
|
||||
//! they run along the frame's rotated axes and span only the frame; a page's guides are page lines
|
||||
//! across the whole canvas.
|
||||
//! across the whole canvas. Only top-level frames (on the page or in sections) have guides: a
|
||||
//! frame moved into another keeps its guides, but they stop showing and snapping until it's back.
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::{Affine, Point};
|
||||
|
||||
/// Whether `owner`'s guides apply: it's a page or a top-level frame.
|
||||
pub fn keeps_guides(doc: &Document, owner: NodeId) -> bool {
|
||||
doc.node(owner).is_some_and(|n| n.is_page() || (n.kind.is_frame_like() && doc.top_level_frame(owner) == Some(owner)))
|
||||
}
|
||||
|
||||
/// The spacing (page pixels) of the rulers' labelled ticks at `zoom`: the first of 1, 2, 5, 10, 20, …
|
||||
/// that leaves at least 50 screen pixels between labels.
|
||||
pub fn ruler_step(zoom: f64) -> f64 {
|
||||
const STEPS: [f64; 12] = [1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0];
|
||||
STEPS.into_iter().find(|s| s * zoom >= 50.0).unwrap_or(10000.0)
|
||||
}
|
||||
|
||||
/// Who gets a guide dragged out of a ruler and dropped at page point `p`: the top-level frame under
|
||||
/// it (dropped over a nested frame, the frame holding it), else the page.
|
||||
pub fn drop_owner(doc: &Document, page: NodeId, p: Point) -> NodeId {
|
||||
crate::cmd::create::frame_at(doc, page, p, &[]).and_then(|f| doc.top_level_frame(f)).unwrap_or(page)
|
||||
}
|
||||
|
||||
/// Below this, a transform coefficient counts as zero (rotations that keep the axes).
|
||||
const EPS: f64 = 1e-7;
|
||||
|
||||
|
|
|
|||
|
|
@ -201,6 +201,29 @@ impl ViewState {
|
|||
self.cx = r.center().x;
|
||||
self.cy = r.center().y;
|
||||
}
|
||||
/// The zoom from which the pixel grid shows (400%).
|
||||
pub const PIXEL_GRID_ZOOM: f64 = 4.0;
|
||||
/// Whether the pixel grid draws: it's on and the canvas is zoomed in to 400% or more.
|
||||
pub fn pixel_grid_shown(&self) -> bool {
|
||||
self.pixel_grid && self.zoom >= Self::PIXEL_GRID_ZOOM - 1e-9
|
||||
}
|
||||
/// Pixel preview's resolution (rendered pixels per page pixel) when it's on and zoomed in past
|
||||
/// it; below that the canvas is already coarser than the preview.
|
||||
pub fn pixel_preview_scale(&self) -> Option<f64> {
|
||||
let k = f64::from(self.pixel_scale.clamp(1, 2));
|
||||
(self.pixel_preview && self.zoom > k).then_some(k)
|
||||
}
|
||||
/// A wheel or trackpad scroll of (`dx`, `dy`) screen points: the canvas pans with it (content
|
||||
/// follows the fingers); with Shift a plain vertical wheel pans sideways.
|
||||
pub fn scroll(&mut self, dx: f64, dy: f64, shift: bool) {
|
||||
let (dx, dy) = if shift && dx == 0.0 { (dy, 0.0) } else { (dx, dy) };
|
||||
if !(dx.is_finite() && dy.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let z = self.zoom.max(1e-6);
|
||||
self.cx -= dx / z;
|
||||
self.cy -= dy / z;
|
||||
}
|
||||
pub fn page_to_screen(&self, x: f64, y: f64) -> (f64, f64) {
|
||||
let (ox, oy) = self.origin();
|
||||
((x - ox) * self.zoom, (y - oy) * self.zoom)
|
||||
|
|
@ -277,6 +300,8 @@ pub struct DocState {
|
|||
checkpoint: Arc<Document>,
|
||||
/// Dev Mode focus view: the frame or section shown alone on the canvas (see [`Self::focused`]).
|
||||
pub focus: Option<NodeId>,
|
||||
/// The ruler guide clicked last: its page or frame and index (see [`Self::selected_guide`]).
|
||||
pub guide: Option<(NodeId, usize)>,
|
||||
}
|
||||
|
||||
/// A version shown on the canvas in place of the document: read-only until it's restored or the
|
||||
|
|
@ -330,11 +355,20 @@ impl DocState {
|
|||
preview: None,
|
||||
text_preview: None,
|
||||
focus: None,
|
||||
guide: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
st
|
||||
}
|
||||
|
||||
/// The selected ruler guide (its owner and index): it counts while no layer is selected and it
|
||||
/// is still there on this page.
|
||||
pub fn selected_guide(&self) -> Option<(NodeId, usize)> {
|
||||
let (owner, i) = self.guide.filter(|_| self.selection.is_empty())?;
|
||||
let on_page = owner == self.page || self.doc.top_level_frame(owner).is_some_and(|f| self.doc.ancestors(f).contains(&self.page));
|
||||
(on_page && guides::keeps_guides(&self.doc, owner) && self.doc.node(owner).is_some_and(|n| i < n.guides().len())).then_some((owner, i))
|
||||
}
|
||||
|
||||
/// With the version history read from its file.
|
||||
pub fn with_versions(mut self, versions: Vec<figcraft_format::Version>) -> Self {
|
||||
self.versions = versions;
|
||||
|
|
@ -1377,3 +1411,5 @@ mod vector_behaviour_tests;
|
|||
mod version_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
#[cfg(test)]
|
||||
mod view_behaviour_tests;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Snapping and smart guides: while moving or resizing, edges and centres snap to nearby layers'
|
||||
//! edges/centres, the parent frame, layout grid columns and page guides; the matches come back as
|
||||
//! edges/centres, the parent frame, layout grid columns and ruler guides (the page's and the
|
||||
//! top-level frame's); the matches come back as
|
||||
//! guide lines (with spacing labels) for the canvas to draw in red. Vector points also snap to
|
||||
//! other points (see `vectoredit::snap`); landing on one shows a marker (a zero-length guide).
|
||||
|
||||
|
|
@ -126,17 +127,11 @@ impl Targets {
|
|||
}
|
||||
};
|
||||
lines(page, to, None, &mut t);
|
||||
// The guides of the frames the moving layers are in, across each frame.
|
||||
let mut f = parent;
|
||||
for _ in 0..128 {
|
||||
let Some(n) = doc.node(f).filter(|_| f != page) else { break };
|
||||
if !n.guides.is_empty()
|
||||
&& let Some(b) = bbox(f)
|
||||
{
|
||||
lines(f, to * doc.world_transform(f), Some(b), &mut t);
|
||||
}
|
||||
let Some(up) = n.parent else { break };
|
||||
f = up;
|
||||
// The guides of the top-level frame the moving layers are in, across it.
|
||||
if let Some(f) = doc.top_level_frame(parent)
|
||||
&& let Some(b) = bbox(f)
|
||||
{
|
||||
lines(f, to * doc.world_transform(f), Some(b), &mut t);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
|
|
|||
|
|
@ -831,7 +831,11 @@ impl Session {
|
|||
match ev.kind {
|
||||
PointerKind::Hover => self.hover(ev),
|
||||
PointerKind::Down => match tool {
|
||||
ToolId::Move | ToolId::Scale => self.move_down(ev),
|
||||
ToolId::Move | ToolId::Scale => {
|
||||
// A click on the canvas deselects a ruler guide (the UI selects guides itself).
|
||||
self.ds_mut()?.guide = None;
|
||||
self.move_down(ev)
|
||||
}
|
||||
ToolId::Hand => {
|
||||
let v = &self.ds()?.view;
|
||||
self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy };
|
||||
|
|
@ -1901,15 +1905,56 @@ impl Session {
|
|||
if self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
|
||||
return crate::vectoredit::key(self, name, shift, alt, cmd);
|
||||
}
|
||||
if !alt && !cmd && self.guide_key(name, shift)? {
|
||||
return Ok(true);
|
||||
}
|
||||
crate::shapebuilder::key(self, name)
|
||||
}
|
||||
|
||||
/// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big
|
||||
/// nudge), Escape deselects it.
|
||||
fn guide_key(&mut self, name: &str, shift: bool) -> Result<bool> {
|
||||
let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) };
|
||||
// Backspace is Delete, `Left` is `ArrowLeft`, and so on.
|
||||
let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k);
|
||||
let page = self.page()?;
|
||||
let at = |mut p: serde_json::Value| {
|
||||
if owner != page {
|
||||
p["frame"] = json!(owner);
|
||||
}
|
||||
p
|
||||
};
|
||||
let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) };
|
||||
let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small };
|
||||
let d = match (name, g.vertical) {
|
||||
("Delete", _) => {
|
||||
self.execute("guide.remove", &at(json!({"index": index})))?;
|
||||
return Ok(true);
|
||||
}
|
||||
("Escape", _) => {
|
||||
self.execute("guide.select", &json!({}))?;
|
||||
return Ok(true);
|
||||
}
|
||||
("ArrowLeft", true) | ("ArrowUp", false) => -step,
|
||||
("ArrowRight", true) | ("ArrowDown", false) => step,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused
|
||||
/// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]).
|
||||
/// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in
|
||||
/// outline mode shapes only on their outlines.
|
||||
pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option<NodeId> {
|
||||
let sel = self.selection();
|
||||
let result = crate::booleans::result_outline(doc, &sel);
|
||||
match self.ds().ok().and_then(crate::DocState::focused) {
|
||||
let focus = self.ds().ok().and_then(crate::DocState::focused);
|
||||
// Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind.
|
||||
if self.ds().is_ok_and(|d| d.view.outlines) {
|
||||
return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result));
|
||||
}
|
||||
match focus {
|
||||
Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)),
|
||||
None => doc.hit_test(page, p, tol, Some(&result)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,727 @@
|
|||
//! Canvas navigation, rulers, guides, layout grids, outline mode and measuring: a behaviour suite
|
||||
//! written from the public documentation of design tools. Each rule is a table row with a one-line
|
||||
//! derivation; where the documentation is silent the row says so and pins today's behaviour.
|
||||
//!
|
||||
//! Space-drag and middle-button panning, the rulers' numbering and selection highlight, dragging
|
||||
//! guides out of the rulers (Alt for a copy) and the context menu's Remove guide are drawn and
|
||||
//! read by the UI crate from the engine pieces tested here; they're checked in the running app.
|
||||
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::keymap::{Context, Mods, Platform, lookup};
|
||||
use crate::params::id_of;
|
||||
use crate::snap::Targets;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use crate::{Session, ViewState, distance, guides};
|
||||
use figcraft_doc::{GridAlign, GridKind, LayoutGrid, NodeId};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn frame(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(&ex(s, "frame.create", p))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(&ex(s, "shape.rectangle", p))
|
||||
}
|
||||
|
||||
fn view(s: &Session) -> ViewState {
|
||||
s.ds().unwrap().view.clone()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Mod {
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
cmd: bool,
|
||||
}
|
||||
|
||||
const NO: Mod = Mod { shift: false, alt: false, cmd: false };
|
||||
const ALT: Mod = Mod { shift: false, alt: true, cmd: false };
|
||||
const CMD_ALT: Mod = Mod { shift: false, alt: true, cmd: true };
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64, m: Mod) -> PointerEvent {
|
||||
let mut e = PointerEvent::at(kind, x, y, 1.0);
|
||||
(e.shift, e.alt, e.cmd) = (m.shift, m.alt, m.cmd);
|
||||
e
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64)) {
|
||||
s.pointer(&ev(PointerKind::Down, a.0, a.1, NO)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, b.0, b.1, NO)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, b.0, b.1, NO)).unwrap();
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
s.pointer(&ev(PointerKind::Down, x, y, NO)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, x, y, NO)).unwrap();
|
||||
}
|
||||
|
||||
fn origin(s: &Session, n: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(n).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
/// The labels of the measurement lines Alt-hovering at `p` with modifiers `m` shows.
|
||||
fn measured(s: &mut Session, p: (f64, f64), m: Mod) -> Vec<String> {
|
||||
s.pointer(&ev(PointerKind::Hover, p.0, p.1, m)).unwrap();
|
||||
let mut v: Vec<String> = s.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_steps_presets_and_limits() {
|
||||
let mut s = Session::new();
|
||||
// (command, zoom before, zoom after, derivation)
|
||||
let rows: Vec<(&str, f64, f64, &str)> = vec![
|
||||
("view.zoomIn", 1.0, 2.0, "docs silent on the step: zooming in doubles (pinned)"),
|
||||
("view.zoomOut", 1.0, 0.5, "docs silent on the step: zooming out halves (pinned)"),
|
||||
("view.zoomIn", 1.5, 2.0, "docs silent: between steps, zooming in goes to the next step up (pinned)"),
|
||||
("view.zoomOut", 0.25, 0.13, "docs silent: below 25% the steps are rounded to whole percents (pinned)"),
|
||||
("view.zoomIn", 256.0, 256.0, "docs silent: the most is 25,600% (pinned)"),
|
||||
("view.zoomOut", 0.02, 0.02, "docs silent: the least is 2% (pinned)"),
|
||||
("view.zoom100", 3.0, 1.0, "the zoom presets include 100% (Shift+0)"),
|
||||
("view.zoom50", 3.0, 0.5, "the zoom presets include 50%"),
|
||||
("view.zoom200", 0.3, 2.0, "the zoom presets include 200%"),
|
||||
];
|
||||
for (cmd, before, after, why) in rows {
|
||||
ex(&mut s, "view.set", json!({"zoom": before, "cx": 100, "cy": 50}));
|
||||
ex(&mut s, cmd, json!({}));
|
||||
let v = view(&s);
|
||||
assert!(near(v.zoom, after), "{why}: {cmd} from {before} gave {}", v.zoom);
|
||||
assert!(near(v.cx, 100.0) && near(v.cy, 50.0), "docs silent: keyboard zoom keeps the middle of the view in place (pinned): {cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fit_selection_and_frames() {
|
||||
let mut s = Session::new();
|
||||
// Reading order: f1 and f2 share the top row, f3 is below.
|
||||
let f1 = frame(&mut s, 0.0, 0.0, 400.0, 300.0, None);
|
||||
let f2 = frame(&mut s, 600.0, 0.0, 200.0, 200.0, None);
|
||||
let f3 = frame(&mut s, 0.0, 500.0, 100.0, 100.0, None);
|
||||
let r = rect(&mut s, 900.0, 0.0, 40.0, 40.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1200, "vh": 800}));
|
||||
// The zoom that fits a box with the view's 64-point margin.
|
||||
let fit = |b: Rect| ((1200.0 - 128.0) / b.width()).min((800.0 - 128.0) / b.height());
|
||||
let content = Rect::new(0.0, 0.0, 940.0, 600.0);
|
||||
// (command, selection before, zoom, centre, selection after, derivation)
|
||||
let rows: Vec<(&str, Vec<NodeId>, f64, (f64, f64), Vec<NodeId>, &str)> = vec![
|
||||
("view.zoomFit", vec![], fit(content), (470.0, 300.0), vec![], "Shift+1 fits everything on the page into the view"),
|
||||
("view.zoomSelection", vec![r], fit(Rect::new(0.0, 0.0, 40.0, 40.0)), (920.0, 20.0), vec![r], "Shift+2 zooms to the selection"),
|
||||
(
|
||||
"view.nextFrame",
|
||||
vec![],
|
||||
fit(Rect::new(0.0, 0.0, 400.0, 300.0)),
|
||||
(200.0, 150.0),
|
||||
vec![f1],
|
||||
"N goes to the next frame (from nothing: the first; docs silent that it selects it, pinned)",
|
||||
),
|
||||
(
|
||||
"view.nextFrame",
|
||||
vec![f1],
|
||||
fit(Rect::new(0.0, 0.0, 200.0, 200.0)),
|
||||
(700.0, 100.0),
|
||||
vec![f2],
|
||||
"N goes to the next frame (docs silent on the order: reading order, pinned)",
|
||||
),
|
||||
(
|
||||
"view.nextFrame",
|
||||
vec![f3],
|
||||
fit(Rect::new(0.0, 0.0, 400.0, 300.0)),
|
||||
(200.0, 150.0),
|
||||
vec![f1],
|
||||
"docs silent: after the last frame N wraps to the first (pinned)",
|
||||
),
|
||||
("view.prevFrame", vec![f2], fit(Rect::new(0.0, 0.0, 400.0, 300.0)), (200.0, 150.0), vec![f1], "Shift+N goes to the previous frame"),
|
||||
(
|
||||
"view.prevFrame",
|
||||
vec![f1],
|
||||
fit(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
(50.0, 550.0),
|
||||
vec![f3],
|
||||
"docs silent: before the first frame Shift+N wraps to the last (pinned)",
|
||||
),
|
||||
];
|
||||
for (cmd, before, zoom, (cx, cy), after, why) in rows {
|
||||
ex(&mut s, "view.set", json!({"zoom": 1, "cx": 0, "cy": 0}));
|
||||
ex(&mut s, "select.set", json!({"ids": before}));
|
||||
ex(&mut s, cmd, json!({}));
|
||||
let v = view(&s);
|
||||
assert!(near(v.zoom, zoom) && near(v.cx, cx) && near(v.cy, cy), "{why}: {} at ({}, {})", v.zoom, v.cx, v.cy);
|
||||
assert_eq!(s.selection(), after, "{why}");
|
||||
}
|
||||
// Docs silent: zoom to selection with nothing selected leaves the view alone (pinned).
|
||||
ex(&mut s, "view.set", json!({"zoom": 3, "cx": 7, "cy": 8}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
assert!(near(v.zoom, 3.0) && near(v.cx, 7.0) && near(v.cy, 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_tool_and_pointer_zoom_keep_the_point_under_the_pointer() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "view.set", json!({"zoom": 1, "cx": 0, "cy": 0, "vw": 1200, "vh": 800}));
|
||||
let at = |s: &Session, x: f64, y: f64| view(s).page_to_screen(x, y);
|
||||
// (params, zoom after, derivation)
|
||||
let rows: Vec<(Value, f64, &str)> = vec![
|
||||
(json!({"x": 100, "y": 50}), 2.0, "a click with the zoom tool zooms in a step about the clicked point"),
|
||||
(json!({"x": 100, "y": 50, "out": true}), 0.5, "Alt with the zoom tool zooms out about the clicked point"),
|
||||
];
|
||||
for (p, zoom, why) in rows {
|
||||
ex(&mut s, "view.set", json!({"zoom": 1, "cx": 0, "cy": 0}));
|
||||
let before = at(&s, 100.0, 50.0);
|
||||
ex(&mut s, "view.zoomAt", p);
|
||||
let after = at(&s, 100.0, 50.0);
|
||||
assert!(near(view(&s).zoom, zoom), "{why}");
|
||||
assert!(near(before.0, after.0) && near(before.1, after.1), "{why}: the point moved from {before:?} to {after:?}");
|
||||
}
|
||||
// Dragging an area with the zoom tool fills the view with it.
|
||||
ex(&mut s, "view.zoomAt", json!({"x": 0, "y": 0, "width": 300, "height": 200}));
|
||||
let v = view(&s);
|
||||
assert!(near(v.zoom, (1072.0f64 / 300.0).min(672.0 / 200.0)) && near(v.cx, 150.0) && near(v.cy, 100.0), "a dragged area fills the view");
|
||||
// Cmd/Ctrl+scroll and pinching zoom about the pointer: the page point under it stays put.
|
||||
let mut v = ViewState { vw: 1200.0, vh: 800.0, ..ViewState::default() };
|
||||
let before = v.page_to_screen(300.0, -40.0);
|
||||
v.set_zoom_at(3.7, (300.0, -40.0));
|
||||
let after = v.page_to_screen(300.0, -40.0);
|
||||
assert!(near(before.0, after.0) && near(before.1, after.1), "pointer zoom keeps the point under the pointer");
|
||||
v.set_zoom_at(1e9, (0.0, 0.0));
|
||||
assert!(near(v.zoom, ViewState::MAX_ZOOM), "pointer zoom stops at the most");
|
||||
v.set_zoom_at(f64::NAN, (0.0, 0.0));
|
||||
assert!(near(v.zoom, ViewState::MAX_ZOOM), "a junk zoom is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_shortcuts_follow_the_documented_keys() {
|
||||
let m = |shift: bool, alt: bool, cmd: bool, ctrl: bool| Mods { shift, alt, cmd, ctrl };
|
||||
// (platform, key, modifiers, command, derivation)
|
||||
let rows: Vec<(Platform, &str, Mods, &str, &str)> = vec![
|
||||
(Platform::Mac, "=", m(true, false, false, false), "view.zoomIn", "Shift and + zooms in"),
|
||||
(Platform::Other, "-", m(true, false, false, false), "view.zoomOut", "Shift and - zooms out"),
|
||||
(Platform::Mac, "0", m(true, false, false, false), "view.zoom100", "Shift+0 zooms to 100%"),
|
||||
(Platform::Other, "1", m(true, false, false, false), "view.zoomFit", "Shift+1 zooms to fit"),
|
||||
(Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection"),
|
||||
(Platform::Mac, "N", m(false, false, false, false), "view.nextFrame", "N zooms to the next frame"),
|
||||
(Platform::Other, "N", m(true, false, false, false), "view.prevFrame", "Shift+N zooms to the previous frame"),
|
||||
(Platform::Mac, "R", m(true, false, false, false), "view.rulers", "Shift+R shows and hides the rulers"),
|
||||
(Platform::Mac, "'", m(false, false, true, false), "view.pixelGrid", "Cmd+' shows and hides the pixel grid"),
|
||||
(Platform::Other, "'", m(false, false, false, true), "view.pixelGrid", "Ctrl+' shows and hides the pixel grid"),
|
||||
(Platform::Mac, "'", m(true, false, true, false), "view.snapPixel", "Shift+Cmd+' turns snapping to the pixel grid on and off"),
|
||||
(Platform::Other, "'", m(true, false, false, true), "view.snapPixel", "Ctrl+Shift+' turns snapping to the pixel grid on and off"),
|
||||
(Platform::Mac, "P", m(false, false, false, true), "view.pixelPreview", "Control+P toggles pixel preview on macOS"),
|
||||
(Platform::Other, "P", m(false, true, false, true), "view.pixelPreview", "Ctrl+Alt+P toggles pixel preview elsewhere"),
|
||||
(Platform::Mac, "G", m(false, false, false, true), "view.layoutGrids", "Control+G shows and hides layout grids on macOS"),
|
||||
(Platform::Other, "4", m(true, false, false, true), "view.layoutGrids", "Ctrl+Shift+4 shows and hides layout grids elsewhere"),
|
||||
(Platform::Other, "G", m(true, false, false, false), "view.layoutGrids", "Shift+G shows and hides layout grids (the newer key)"),
|
||||
(Platform::Mac, "O", m(true, false, true, false), "view.outlines", "Shift+Cmd+O shows outlines"),
|
||||
(Platform::Other, "O", m(true, false, false, true), "view.outlines", "Ctrl+Shift+O shows outlines"),
|
||||
];
|
||||
for (p, key, mods, want, why) in rows {
|
||||
assert_eq!(lookup(p, Context::Canvas, key, mods).map(|b| b.id), Some(want), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panning_with_the_hand_tool_and_the_wheel() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "view.set", json!({"zoom": 1, "cx": 0, "cy": 0}));
|
||||
ex(&mut s, "tool.hand", json!({}));
|
||||
drag(&mut s, (0.0, 0.0), (100.0, 40.0));
|
||||
let v = view(&s);
|
||||
assert!(near(v.cx, -100.0) && near(v.cy, -40.0), "dragging with the hand tool moves the canvas with the pointer");
|
||||
// (zoom, scroll dx, dy, shift, centre moves by, derivation)
|
||||
let rows: Vec<(f64, f64, f64, bool, (f64, f64), &str)> = vec![
|
||||
(1.0, 0.0, -30.0, false, (0.0, 30.0), "scrolling pans up and down"),
|
||||
(2.0, 0.0, -30.0, false, (0.0, 15.0), "a scroll moves the canvas by screen points, whatever the zoom"),
|
||||
(1.0, 0.0, -30.0, true, (30.0, 0.0), "Shift and scroll pans left and right"),
|
||||
(1.0, -20.0, 0.0, true, (20.0, 0.0), "a sideways scroll stays sideways with Shift"),
|
||||
(1.0, -20.0, -10.0, false, (20.0, 10.0), "a trackpad scrolls both ways at once"),
|
||||
(1.0, f64::NAN, 5.0, false, (0.0, 0.0), "a junk scroll is ignored"),
|
||||
];
|
||||
for (zoom, dx, dy, shift, (mx, my), why) in rows {
|
||||
let mut v = ViewState { zoom, ..ViewState::default() };
|
||||
v.scroll(dx, dy, shift);
|
||||
assert!(near(v.cx, mx) && near(v.cy, my), "{why}: ({}, {})", v.cx, v.cy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_grid_and_pixel_preview_show_from_their_zooms() {
|
||||
// (pixel grid on, zoom, shown, derivation)
|
||||
let grid: Vec<(bool, f64, bool, &str)> = vec![
|
||||
(true, 4.0, true, "the pixel grid shows at 400%"),
|
||||
(true, 3.99, false, "the pixel grid doesn't show below 400%"),
|
||||
(true, 64.0, true, "the pixel grid shows at any zoom above 400%"),
|
||||
(false, 64.0, false, "the pixel grid can be turned off"),
|
||||
];
|
||||
for (on, zoom, shown, why) in grid {
|
||||
assert_eq!(ViewState { pixel_grid: on, zoom, ..ViewState::default() }.pixel_grid_shown(), shown, "{why}");
|
||||
}
|
||||
// (preview on, scale, zoom, rendering scale, derivation)
|
||||
let preview: Vec<(bool, u8, f64, Option<f64>, &str)> = vec![
|
||||
(true, 1, 2.0, Some(1.0), "pixel preview at 1x renders one pixel per page pixel"),
|
||||
(true, 2, 4.0, Some(2.0), "pixel preview at 2x renders two pixels per page pixel"),
|
||||
(true, 1, 1.0, None, "docs silent: at or below its resolution the canvas already shows pixels (pinned)"),
|
||||
(true, 2, 2.0, None, "docs silent: at or below its resolution the canvas already shows pixels (pinned)"),
|
||||
(false, 1, 8.0, None, "pixel preview can be turned off"),
|
||||
];
|
||||
for (on, pixel_scale, zoom, k, why) in preview {
|
||||
assert_eq!(ViewState { pixel_preview: on, pixel_scale, zoom, ..ViewState::default() }.pixel_preview_scale(), k, "{why}");
|
||||
}
|
||||
let mut s = Session::new();
|
||||
assert_eq!(ex(&mut s, "view.pixelPreview", json!({"scale": 2})), json!({"on": true, "scale": 2}), "picking 2x turns pixel preview on at 2x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_to_the_pixel_grid_places_and_moves_on_whole_pixels() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "view.snapObjects", json!({"on": false}));
|
||||
let r = rect(&mut s, 100.0, 100.0, 40.0, 40.0, None);
|
||||
// (snap to pixel grid, drag to, origin after, derivation)
|
||||
let rows: Vec<(bool, (f64, f64), (f64, f64), &str)> = vec![
|
||||
(true, (130.4, 120.3), (110.0, 100.0), "with snapping to the pixel grid a moved layer lands on whole pixels"),
|
||||
(false, (130.4, 120.3), (110.4, 100.3), "without it a layer moves by fractions"),
|
||||
];
|
||||
for (on, to, at, why) in rows {
|
||||
ex(&mut s, "view.snapPixel", json!({"on": on}));
|
||||
drag(&mut s, (120.0, 120.0), to);
|
||||
let o = origin(&s, r);
|
||||
assert!(near(o.0, at.0) && near(o.1, at.1), "{why}: {o:?}");
|
||||
s.undo().unwrap();
|
||||
}
|
||||
ex(&mut s, "view.snapPixel", json!({"on": true}));
|
||||
ex(&mut s, "tool.rectangle", json!({}));
|
||||
drag(&mut s, (300.3, 300.6), (340.7, 330.2));
|
||||
let b = s.doc().unwrap().world_bounds(s.selection()[0]).unwrap();
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| near(*v, v.round())), "a new shape is placed on whole pixels: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rulers() {
|
||||
let mut s = Session::new();
|
||||
assert!(!view(&s).rulers, "docs silent: the rulers start hidden (pinned)");
|
||||
assert_eq!(ex(&mut s, "view.rulers", json!({})), json!({"on": true}), "View ▸ Rulers (Shift+R) shows them");
|
||||
// (zoom, labelled tick spacing in page pixels, derivation)
|
||||
let rows: Vec<(f64, f64, &str)> = vec![
|
||||
(1.0, 50.0, "docs silent: rulers count page pixels with labels at least 50 points apart (pinned)"),
|
||||
(10.0, 5.0, "docs silent: zoomed in, the labels get closer in page pixels (pinned)"),
|
||||
(100.0, 1.0, "docs silent: at most one label per page pixel (pinned)"),
|
||||
(0.1, 500.0, "docs silent: zoomed out, the labels get further apart (pinned)"),
|
||||
(0.0001, 10000.0, "docs silent: never fewer than one label per 10,000 pixels (pinned)"),
|
||||
];
|
||||
for (zoom, step, why) in rows {
|
||||
assert!(near(guides::ruler_step(zoom), step), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A top-level frame `f` (0,0 400×300) holding the nested frame `n` (200,20 150×150) with `a`
|
||||
/// (220,40 20×20); the page's guide at x = 600 and nothing else on the page.
|
||||
struct GuideScene {
|
||||
f: NodeId,
|
||||
n: NodeId,
|
||||
a: NodeId,
|
||||
page: NodeId,
|
||||
}
|
||||
|
||||
fn guide_scene(s: &mut Session) -> GuideScene {
|
||||
let f = frame(s, 0.0, 0.0, 400.0, 300.0, None);
|
||||
let n = frame(s, 200.0, 20.0, 150.0, 150.0, Some(f));
|
||||
let a = rect(s, 20.0, 20.0, 20.0, 20.0, Some(n));
|
||||
ex(s, "guide.add", json!({"vertical": true, "offset": 600}));
|
||||
let page = s.page().unwrap();
|
||||
ex(s, "select.none", json!({}));
|
||||
GuideScene { f, n, a, page }
|
||||
}
|
||||
|
||||
fn guide_list(s: &Session, owner: NodeId) -> Vec<(bool, f64)> {
|
||||
s.doc().unwrap().node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_belong_to_the_page_or_a_top_level_frame() {
|
||||
let mut s = Session::new();
|
||||
let k = guide_scene(&mut s);
|
||||
// (page point a guide is dropped at, owner, derivation)
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let rows: Vec<((f64, f64), NodeId, &str)> = vec![
|
||||
((100.0, 100.0), k.f, "a guide dragged into a frame becomes that frame's guide"),
|
||||
((250.0, 60.0), k.f, "only top-level frames have guides: over a nested frame it goes to the frame holding it"),
|
||||
((900.0, 900.0), k.page, "a guide dropped on the canvas is the page's"),
|
||||
];
|
||||
for (p, owner, why) in rows {
|
||||
assert_eq!(guides::drop_owner(&doc, k.page, Point::new(p.0, p.1)), owner, "{why}");
|
||||
}
|
||||
assert!(s.execute("guide.add", &json!({"frame": k.n, "vertical": true, "offset": 5})).is_err(), "only top-level frames have guides");
|
||||
// A frame's guides are kept in its coordinates and move with it.
|
||||
ex(&mut s, "guide.add", json!({"frame": k.f, "vertical": false, "offset": 25}));
|
||||
ex(&mut s, "node.set", json!({"ids": [k.f], "props": {"x": 50}}));
|
||||
assert_eq!(guide_list(&s, k.f), vec![(false, 25.0)], "frame guides are objects of the frame: they move with it");
|
||||
// Moving, removing, undoing.
|
||||
ex(&mut s, "guide.move", json!({"index": 0, "offset": 650}));
|
||||
assert_eq!(guide_list(&s, k.page), vec![(true, 650.0)], "a guide can be dragged to a new place");
|
||||
ex(&mut s, "guide.remove", json!({"index": 0}));
|
||||
assert!(guide_list(&s, k.page).is_empty(), "a guide dragged back to its ruler is removed");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(guide_list(&s, k.page), vec![(true, 650.0)], "removing a guide can be undone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frames_guides_stop_applying_once_it_is_inside_another_frame() {
|
||||
let mut s = Session::new();
|
||||
let k = guide_scene(&mut s);
|
||||
let g = frame(&mut s, 500.0, 400.0, 200.0, 200.0, None);
|
||||
let r = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(g));
|
||||
ex(&mut s, "guide.add", json!({"frame": g, "vertical": true, "offset": 77}));
|
||||
// Snapping a box inside g whose left edge is 1 past the guide (page x 577).
|
||||
let snap = |s: &Session| {
|
||||
let doc = s.doc().unwrap();
|
||||
let x = doc.world_transform(g).as_coeffs()[4] + 77.0;
|
||||
Targets::gather(doc, k.page, &[r]).snap_rect(Rect::new(x + 1.0, 500.0, x + 9.0, 510.0), 4.0).0
|
||||
};
|
||||
assert_eq!(snap(&s), -1.0, "layers snap to their top-level frame's guides");
|
||||
ex(&mut s, "node.reparent", json!({"ids": [g], "parent": k.f}));
|
||||
assert_eq!(snap(&s), 0.0, "a frame with guides put into another frame loses them (they stop snapping)");
|
||||
assert_eq!(guide_list(&s, g), vec![(true, 77.0)], "docs silent: the guides are kept, so they come back with the frame (pinned)");
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let x = s.doc().unwrap().world_transform(g).as_coeffs()[4] + 77.0;
|
||||
let y = s.doc().unwrap().world_bounds(g).unwrap().y1 + 50.0;
|
||||
assert!(measured(&mut s, (x, y), ALT).iter().all(|l| l != &format!("{}", x - s.doc().unwrap().world_bounds(r).unwrap().x1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() {
|
||||
let mut s = Session::new();
|
||||
let k = guide_scene(&mut s);
|
||||
ex(&mut s, "guide.add", json!({"frame": k.f, "vertical": false, "offset": 40}));
|
||||
ex(&mut s, "select.set", json!({"ids": [k.a]}));
|
||||
ex(&mut s, "guide.select", json!({"index": 0}));
|
||||
assert!(s.selection().is_empty(), "selecting a guide deselects layers");
|
||||
// (key, shift, page guide after, derivation)
|
||||
let rows: Vec<(&str, bool, f64, &str)> = vec![
|
||||
("ArrowRight", false, 601.0, "arrows nudge a selected guide by a pixel"),
|
||||
("ArrowRight", true, 611.0, "Shift and an arrow nudge it by the big nudge"),
|
||||
("ArrowLeft", false, 610.0, "the left arrow nudges a vertical guide left"),
|
||||
];
|
||||
for (key, shift, after, why) in rows {
|
||||
assert!(s.tool_key(key, shift, false, false).unwrap(), "{why}");
|
||||
assert_eq!(guide_list(&s, k.page), vec![(true, after)], "{why}");
|
||||
}
|
||||
assert!(!s.tool_key("ArrowUp", false, false, false).unwrap(), "docs silent: arrows along a guide do nothing to it (pinned)");
|
||||
assert!(s.tool_key("Backspace", false, false, false).unwrap());
|
||||
assert!(guide_list(&s, k.page).is_empty(), "a selected guide is removed with Delete (Backspace)");
|
||||
// A frame guide, removed from the context menu (no index: the selected guide).
|
||||
ex(&mut s, "guide.select", json!({"frame": k.f, "index": 0}));
|
||||
assert!(s.tool_key("ArrowDown", false, false, false).unwrap());
|
||||
assert_eq!(guide_list(&s, k.f), vec![(false, 41.0)], "the down arrow nudges a horizontal guide down");
|
||||
ex(&mut s, "guide.remove", json!({}));
|
||||
assert!(guide_list(&s, k.f).is_empty(), "the context menu's Remove guide removes the guide right-clicked");
|
||||
assert!(s.execute("guide.remove", &json!({})).is_err(), "with no guide selected there's nothing to remove");
|
||||
// A click on the canvas or Escape deselects it; so does selecting a layer.
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 600}));
|
||||
ex(&mut s, "guide.select", json!({"index": 0}));
|
||||
click(&mut s, 900.0, 900.0);
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), None, "a click on the canvas deselects the guide");
|
||||
ex(&mut s, "guide.select", json!({"index": 0}));
|
||||
assert!(s.tool_key("Escape", false, false, false).unwrap());
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), None, "Escape deselects the guide");
|
||||
ex(&mut s, "guide.select", json!({"index": 0}));
|
||||
ex(&mut s, "select.set", json!({"ids": [k.a]}));
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), None, "selecting a layer deselects the guide");
|
||||
assert!(!s.tool_key("Delete", false, false, false).unwrap(), "Delete then deletes layers, not the guide");
|
||||
assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_snap_and_measure() {
|
||||
let mut s = Session::new();
|
||||
let k = guide_scene(&mut s);
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 203}));
|
||||
let r = rect(&mut s, 500.0, 500.0, 40.0, 40.0, None);
|
||||
// Docs silent on snapping to guides (they're for positioning): a layer dragged near one snaps.
|
||||
drag(&mut s, (520.0, 520.0), (225.0, 520.0));
|
||||
assert!(near(origin(&s, r).0, 203.0), "docs silent: a moved layer snaps to a guide (pinned): {:?}", origin(&s, r));
|
||||
s.undo().unwrap();
|
||||
// Hidden with the rulers, a guide still snaps (docs silent: pinned).
|
||||
ex(&mut s, "view.rulers", json!({"on": false}));
|
||||
drag(&mut s, (520.0, 520.0), (225.0, 520.0));
|
||||
assert!(near(origin(&s, r).0, 203.0), "docs silent: guides hidden with the rulers still snap (pinned)");
|
||||
s.undo().unwrap();
|
||||
// A locked frame's guides can still be moved (docs silent: pinned).
|
||||
ex(&mut s, "guide.add", json!({"frame": k.f, "vertical": true, "offset": 10}));
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": true}));
|
||||
ex(&mut s, "guide.move", json!({"frame": k.f, "index": 0, "offset": 12}));
|
||||
assert_eq!(guide_list(&s, k.f), vec![(true, 12.0)], "docs silent: guides aren't locked with their frame (pinned)");
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": false}));
|
||||
// Alt with a selection measures to a guide under the pointer (from r at 500..540 to x 600).
|
||||
ex(&mut s, "guide.add", json!({"vertical": false, "offset": 700}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
assert_eq!(measured(&mut s, (600.5, 900.0), ALT), vec!["60"], "Alt measures from the selection to a guide");
|
||||
assert_eq!(measured(&mut s, (900.0, 700.5), ALT), vec!["160"], "Alt measures to horizontal guides too");
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
assert!(measured(&mut s, (600.5, 900.0), ALT).is_empty(), "with nothing selected Alt measures nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_while_dragging_a_guide_measures_to_the_layers_in_its_frame() {
|
||||
let mut s = Session::new();
|
||||
let k = guide_scene(&mut s);
|
||||
rect(&mut s, 20.0, 100.0, 60.0, 40.0, Some(k.f));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let labels = |vertical: bool, at: f64| {
|
||||
let mut v: Vec<String> = distance::from_guide(&doc, k.f, vertical, at).into_iter().filter_map(|g| g.label).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
// (vertical, page offset, labels, derivation)
|
||||
let rows: Vec<(bool, f64, Vec<&str>, &str)> = vec![
|
||||
(true, 150.0, vec!["50", "70"], "across a frame, to the nearest edges on each side: b's right (80) and n's left (200)"),
|
||||
(true, 50.0, vec!["30", "30"], "a guide through a layer measures to its edges"),
|
||||
(false, 250.0, vec!["50", "80"], "a horizontal guide measures up to the nearest bottom edge (n's, 170) and down to the frame's (300)"),
|
||||
(true, 450.0, vec![], "a guide that misses the frame measures nothing"),
|
||||
];
|
||||
for (v, at, want, why) in rows {
|
||||
let mut want: Vec<String> = want.into_iter().map(String::from).collect();
|
||||
want.sort();
|
||||
assert_eq!(labels(v, at), want, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
fn columns(align: GridAlign, count: u32, size: f64, gutter: f64, offset: f64) -> LayoutGrid {
|
||||
LayoutGrid { kind: GridKind::Columns, align, count, size, gutter, offset, ..LayoutGrid::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_grid_columns_rows_and_grids_lay_out_as_documented() {
|
||||
use GridAlign::*;
|
||||
// (grid, frame w × h, first and last spans along the grid (columns: x, rows: y), count, derivation)
|
||||
let rows: Vec<(LayoutGrid, (f64, f64), (f64, f64), (f64, f64), usize, &str)> = vec![
|
||||
(columns(Stretch, 5, 0.0, 20.0, 0.0), (400.0, 300.0), (0.0, 64.0), (336.0, 400.0), 5, "stretched columns share the width less the gutters"),
|
||||
(columns(Stretch, 5, 0.0, 20.0, 10.0), (400.0, 300.0), (10.0, 70.0), (330.0, 390.0), 5, "a stretched grid's margin keeps it off both edges"),
|
||||
(columns(Stretch, 5, 0.0, 20.0, 0.0), (800.0, 300.0), (0.0, 144.0), (656.0, 800.0), 5, "stretched columns change size with the frame"),
|
||||
(columns(Min, 3, 60.0, 20.0, 10.0), (400.0, 300.0), (10.0, 70.0), (170.0, 230.0), 3, "left columns have a width and start at the offset"),
|
||||
(
|
||||
columns(Min, 3, 60.0, 20.0, 10.0),
|
||||
(800.0, 300.0),
|
||||
(10.0, 70.0),
|
||||
(170.0, 230.0),
|
||||
3,
|
||||
"fixed columns stay where they are when the frame is resized",
|
||||
),
|
||||
(
|
||||
columns(Center, 3, 60.0, 20.0, 50.0),
|
||||
(400.0, 300.0),
|
||||
(90.0, 150.0),
|
||||
(250.0, 310.0),
|
||||
3,
|
||||
"centred columns sit in the middle; the offset is for left and right only",
|
||||
),
|
||||
(columns(Max, 3, 60.0, 20.0, 10.0), (400.0, 300.0), (170.0, 230.0), (330.0, 390.0), 3, "right columns end the offset before the right edge"),
|
||||
(
|
||||
columns(Min, 0, 60.0, 20.0, 10.0),
|
||||
(400.0, 300.0),
|
||||
(10.0, 70.0),
|
||||
(330.0, 390.0),
|
||||
5,
|
||||
"Auto count fits as many fixed columns as there's room for",
|
||||
),
|
||||
(
|
||||
columns(Center, 0, 100.0, 0.0, 0.0),
|
||||
(400.0, 300.0),
|
||||
(0.0, 100.0),
|
||||
(300.0, 400.0),
|
||||
4,
|
||||
"Auto count with centred columns uses the whole width",
|
||||
),
|
||||
(
|
||||
columns(Max, 0, 500.0, 20.0, 0.0),
|
||||
(400.0, 300.0),
|
||||
(-100.0, 400.0),
|
||||
(-100.0, 400.0),
|
||||
1,
|
||||
"docs silent: Auto shows at least one column (pinned)",
|
||||
),
|
||||
(
|
||||
columns(Stretch, 0, 0.0, 20.0, 0.0),
|
||||
(400.0, 300.0),
|
||||
(0.0, 400.0),
|
||||
(0.0, 400.0),
|
||||
1,
|
||||
"docs silent: a stretched grid has no Auto count, it shows one (pinned)",
|
||||
),
|
||||
(
|
||||
LayoutGrid { kind: GridKind::Rows, align: Max, count: 2, size: 50.0, gutter: 10.0, offset: 20.0, ..LayoutGrid::default() },
|
||||
(400.0, 300.0),
|
||||
(170.0, 220.0),
|
||||
(230.0, 280.0),
|
||||
2,
|
||||
"bottom rows end the offset above the bottom edge",
|
||||
),
|
||||
(
|
||||
LayoutGrid { kind: GridKind::Rows, align: Min, count: 2, size: 50.0, gutter: 10.0, offset: 20.0, ..LayoutGrid::default() },
|
||||
(400.0, 300.0),
|
||||
(20.0, 70.0),
|
||||
(80.0, 130.0),
|
||||
2,
|
||||
"top rows start the offset below the top edge",
|
||||
),
|
||||
];
|
||||
for (g, (w, h), first, last, count, why) in rows {
|
||||
let r = figcraft_layout::grid_lines(&g, w, h);
|
||||
let span = |r: &Rect| if g.kind == GridKind::Columns { (r.x0, r.x1) } else { (r.y0, r.y1) };
|
||||
assert_eq!(r.len(), count, "{why}");
|
||||
let (a, b) = (span(r.first().unwrap()), span(r.last().unwrap()));
|
||||
assert!(near(a.0, first.0) && near(a.1, first.1) && near(b.0, last.0) && near(b.1, last.1), "{why}: {a:?} … {b:?}");
|
||||
}
|
||||
// A square grid: lines every `size` both ways.
|
||||
let r = figcraft_layout::grid_lines(&LayoutGrid { size: 10.0, ..LayoutGrid::default() }, 40.0, 20.0);
|
||||
assert_eq!(r.len(), 5 + 3, "a grid of 10 draws a line every 10 pixels across and down");
|
||||
assert_eq!(LayoutGrid::default().size, 10.0, "the default grid is 10 pixels");
|
||||
assert_eq!((LayoutGrid::columns().count, LayoutGrid::columns().gutter), (5, 20.0), "docs silent: new columns are 5 with 20 gutters (pinned)");
|
||||
}
|
||||
|
||||
/// A 400 × 300 frame at (100, 100) with `grids`, holding a 20 × 20 rectangle at (10, 200).
|
||||
fn grid_frame(s: &mut Session, grids: Value) -> (NodeId, NodeId) {
|
||||
let f = frame(s, 100.0, 100.0, 400.0, 300.0, None);
|
||||
ex(s, "node.set", json!({"ids": [f], "props": {"grids": grids}}));
|
||||
let r = rect(s, 10.0, 200.0, 20.0, 20.0, Some(f));
|
||||
(f, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_snap_to_layout_grids() {
|
||||
// Columns 5 × stretch, gutter 20: the second column starts at frame x 84 (page 184). Rows: 2 ×
|
||||
// top, 50 high from 20 with gutter 10: the second row starts at frame y 80 (page 180).
|
||||
let cols = json!({"kind": "columns", "count": 5, "gutter": 20, "align": "stretch"});
|
||||
let rows = json!({"kind": "rows", "count": 2, "size": 50, "gutter": 10, "offset": 20, "align": "min"});
|
||||
// The correction snapping a box whose left edge is at page x 185 and top at page y 181 gives.
|
||||
let snap = |s: &Session, r: NodeId| {
|
||||
let page = s.page().unwrap();
|
||||
let (dx, dy, _) = Targets::gather(s.doc().unwrap(), page, &[r]).snap_rect(Rect::new(185.0, 181.0, 195.0, 186.0), 3.0);
|
||||
(dx, dy)
|
||||
};
|
||||
// (grids, rotation, layout grids shown, expected correction, derivation)
|
||||
let table: Vec<(Value, f64, bool, (f64, f64), &str)> = vec![
|
||||
(json!([cols]), 0.0, true, (-1.0, 0.0), "docs silent on snapping: layers snap to the columns' edges (pinned)"),
|
||||
(json!([cols, rows]), 0.0, true, (-1.0, -1.0), "several layout grids on one frame combine (both snap)"),
|
||||
(
|
||||
json!([{"kind": "columns", "count": 5, "gutter": 20, "visible": false}]),
|
||||
0.0,
|
||||
true,
|
||||
(0.0, 0.0),
|
||||
"docs silent: a grid hidden with its eye doesn't snap (pinned)",
|
||||
),
|
||||
(json!([cols]), 0.0, false, (-1.0, 0.0), "docs silent: grids hidden with Shift+G still snap (pinned)"),
|
||||
(json!([cols]), 30.0, true, (0.0, 0.0), "layout grids don't show on a rotated frame, so they don't snap there either"),
|
||||
];
|
||||
for (grids, rotation, shown, want, why) in table {
|
||||
let mut s = Session::new();
|
||||
let (f, r) = grid_frame(&mut s, grids);
|
||||
ex(&mut s, "view.layoutGrids", json!({"on": shown}));
|
||||
if rotation != 0.0 {
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"rotation": rotation}}));
|
||||
assert!(!figcraft_layout::grids_shown(s.doc().unwrap(), f), "{why}");
|
||||
let (gx, gy) = figcraft_layout::grid_snap_lines(s.doc().unwrap(), f);
|
||||
assert!(gx.is_empty() && gy.is_empty(), "{why}");
|
||||
continue;
|
||||
}
|
||||
assert!(figcraft_layout::grids_shown(s.doc().unwrap(), f));
|
||||
assert_eq!(snap(&s, r), want, "{why}");
|
||||
}
|
||||
// Docs silent: an auto layout frame's grids still snap its absolutely placed layers (pinned).
|
||||
let mut s = Session::new();
|
||||
let (f, r) = grid_frame(&mut s, json!([cols]));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"absolute": true}}));
|
||||
let (gx, _) = figcraft_layout::grid_snap_lines(s.doc().unwrap(), f);
|
||||
assert_eq!(gx.len(), 10, "docs silent: auto layout frames keep their layout grids (pinned): {gx:?}");
|
||||
let t = Targets::gather(s.doc().unwrap(), s.page().unwrap(), &[r]);
|
||||
assert!(gx.iter().all(|x| t.xs.iter().any(|(tx, _, _)| tx == x)), "docs silent: they snap its absolutely placed layers (pinned)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_grids_are_saved_as_styles() {
|
||||
let mut s = Session::new();
|
||||
let (a, _) = grid_frame(&mut s, json!([{"kind": "columns", "count": 4}, {"kind": "grid", "size": 8}]));
|
||||
let b = frame(&mut s, 700.0, 0.0, 100.0, 100.0, None);
|
||||
let st = ex(&mut s, "style.create", json!({"type": "grid", "name": "Grid/8", "from": a}));
|
||||
ex(&mut s, "style.apply", json!({"ids": [b], "style": st["id"]}));
|
||||
let kinds: Vec<GridKind> = s.doc().unwrap().node(b).unwrap().grids.iter().map(|g| g.kind).collect();
|
||||
assert_eq!(kinds, vec![GridKind::Columns, GridKind::Grid], "several layout grids are saved and applied as one style");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_mode_selects_layers_behind_others() {
|
||||
let mut s = Session::new();
|
||||
// `back` (0,0 100×100) with `front` over its left edge (-20,20 60×60), both filled.
|
||||
let back = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
let front = rect(&mut s, -20.0, 20.0, 60.0, 60.0, None);
|
||||
// (outline mode, click, selected, derivation)
|
||||
let rows: Vec<(bool, (f64, f64), Vec<NodeId>, &str)> = vec![
|
||||
(false, (0.5, 50.0), vec![front], "filled, the front layer takes the click"),
|
||||
(true, (0.5, 50.0), vec![back], "in outline mode a layer behind another can be selected by its outline"),
|
||||
(true, (-19.5, 50.0), vec![front], "in outline mode a layer is selected by its outline"),
|
||||
(true, (70.0, 60.0), vec![], "docs silent: in outline mode the inside of a shape isn't a target (pinned)"),
|
||||
];
|
||||
for (on, p, after, why) in rows {
|
||||
ex(&mut s, "view.outlines", json!({"on": on}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
click(&mut s, p.0, p.1);
|
||||
assert_eq!(s.selection(), after, "{why}");
|
||||
}
|
||||
// Docs silent: outlines for the selection only don't change what a click hits (pinned).
|
||||
ex(&mut s, "view.outlines", json!({"on": false}));
|
||||
ex(&mut s, "view.outlinesSelection", json!({"on": true}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
click(&mut s, 0.5, 50.0);
|
||||
assert_eq!(s.selection(), vec![front]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_measures_between_layers() {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, 0.0, 400.0, 300.0, None);
|
||||
let a = rect(&mut s, 20.0, 20.0, 40.0, 40.0, Some(f));
|
||||
let n = frame(&mut s, 200.0, 20.0, 150.0, 150.0, Some(f));
|
||||
rect(&mut s, 30.0, 30.0, 20.0, 20.0, Some(n));
|
||||
rect(&mut s, 120.0, 20.0, 40.0, 40.0, Some(f));
|
||||
// (selection, hover point, modifiers, labels, derivation)
|
||||
let rows: Vec<(Vec<NodeId>, (f64, f64), Mod, Vec<&str>, &str)> = vec![
|
||||
(vec![a], (140.0, 40.0), ALT, vec!["60"], "Alt over another layer measures the gap to it"),
|
||||
(vec![a], (100.0, 250.0), ALT, vec!["20", "20", "240", "340"], "Alt over the parent frame's empty space measures to its edges"),
|
||||
(vec![a], (240.0, 60.0), ALT, vec!["140"], "Alt over a nested frame measures to the frame as a whole"),
|
||||
(vec![a], (240.0, 60.0), CMD_ALT, vec!["170"], "Cmd/Ctrl+Alt measures to the nested layer under the pointer"),
|
||||
(vec![], (140.0, 40.0), ALT, vec![], "with nothing selected Alt measures nothing"),
|
||||
(vec![a], (140.0, 40.0), NO, vec![], "without Alt nothing is measured"),
|
||||
];
|
||||
for (sel, p, m, want, why) in rows {
|
||||
ex(&mut s, "select.set", json!({"ids": sel}));
|
||||
let mut want: Vec<String> = want.into_iter().map(String::from).collect();
|
||||
want.sort();
|
||||
assert_eq!(measured(&mut s, p, m), want, "{why}");
|
||||
}
|
||||
}
|
||||
|
|
@ -992,9 +992,17 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec<Rect> {
|
|||
GridKind::Columns | GridKind::Rows => {
|
||||
let cols = g.kind == GridKind::Columns;
|
||||
let total = if cols { w } else { h };
|
||||
let count = g.count.clamp(1, 500) as usize;
|
||||
let gutter = if g.gutter.is_finite() { g.gutter.max(0.0) } else { 0.0 };
|
||||
let offset = if g.offset.is_finite() { g.offset.max(0.0) } else { 0.0 };
|
||||
let count = match g.count {
|
||||
// Auto: as many fixed-size columns (rows) as fit in the room the type leaves them.
|
||||
0 if g.align != GridAlign::Stretch => {
|
||||
let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 };
|
||||
let room = if g.align == GridAlign::Center { total } else { total - offset };
|
||||
((room + gutter) / (size + gutter)).floor().clamp(1.0, 500.0) as usize
|
||||
}
|
||||
n => n.clamp(1, 500) as usize,
|
||||
};
|
||||
let (start, size) = match g.align {
|
||||
GridAlign::Stretch => {
|
||||
let size = ((total - 2.0 * offset - gutter * (count - 1) as f64) / count as f64).max(0.0);
|
||||
|
|
@ -1020,6 +1028,11 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec<Rect> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Whether a frame's layout grids apply: they don't show (or snap) on a frame turned off 0°.
|
||||
pub fn grids_shown(doc: &Document, frame: NodeId) -> bool {
|
||||
figcraft_geom::rotation_degrees(doc.world_transform(frame)).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Snap targets of a frame's visible layout grids (page coordinates): column/row edges.
|
||||
pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec<f64>, Vec<f64>) {
|
||||
grid_snap_lines_in(doc, frame, doc.world_transform(frame))
|
||||
|
|
@ -1028,7 +1041,7 @@ pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec<f64>, Vec<f64>) {
|
|||
/// [`grid_snap_lines`] mapped from the frame's coordinates by `xf` (the frame's world transform
|
||||
/// for page lines).
|
||||
pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec<f64>, Vec<f64>) {
|
||||
let Some(f) = doc.node(frame) else { return (vec![], vec![]) };
|
||||
let Some(f) = doc.node(frame).filter(|_| grids_shown(doc, frame)) else { return (vec![], vec![]) };
|
||||
let mut xs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for g in f.grids.iter().filter(|g| g.visible) {
|
||||
|
|
|
|||
|
|
@ -112,9 +112,11 @@ name = "English"
|
|||
"Auto (space between)" = "Auto (space between)"
|
||||
"Auto (space evenly)" = "Auto (space evenly)"
|
||||
"Auto ({mode})" = "Auto ({mode})"
|
||||
"Auto columns" = "Auto columns"
|
||||
"Auto gap" = "Auto gap"
|
||||
"Auto height" = "Auto height"
|
||||
"Auto layout" = "Auto layout"
|
||||
"Auto rows" = "Auto rows"
|
||||
"Auto width" = "Auto width"
|
||||
"Back" = "Back"
|
||||
"Back to Ready for dev" = "Back to Ready for dev"
|
||||
|
|
@ -890,6 +892,7 @@ name = "English"
|
|||
"Remove annotation" = "Remove annotation"
|
||||
"Remove auto layout" = "Remove auto layout"
|
||||
"Remove export setting" = "Remove export setting"
|
||||
"Remove guide" = "Remove guide"
|
||||
"Remove interaction" = "Remove interaction"
|
||||
"Remove library from this file" = "Remove library from this file"
|
||||
"Remove min and max" = "Remove min and max"
|
||||
|
|
|
|||
|
|
@ -113,9 +113,11 @@ status = "draft — needs native review"
|
|||
"Auto (space between)" = "Auto (espacio entre)"
|
||||
"Auto (space evenly)" = "Auto (espacio uniforme)"
|
||||
"Auto ({mode})" = "Auto ({mode})"
|
||||
"Auto columns" = "Columnas automáticas"
|
||||
"Auto gap" = "Espacio auto"
|
||||
"Auto height" = "Alto automático"
|
||||
"Auto layout" = "Diseño automático"
|
||||
"Auto rows" = "Filas automáticas"
|
||||
"Auto width" = "Ancho automático"
|
||||
"Back" = "Atrás"
|
||||
"Back to Ready for dev" = "Volver a Listo para desarrollo"
|
||||
|
|
@ -891,6 +893,7 @@ status = "draft — needs native review"
|
|||
"Remove annotation" = "Quitar anotación"
|
||||
"Remove auto layout" = "Quitar diseño automático"
|
||||
"Remove export setting" = "Quitar ajuste de exportación"
|
||||
"Remove guide" = "Quitar guía"
|
||||
"Remove interaction" = "Quitar interacción"
|
||||
"Remove library from this file" = "Quitar biblioteca de este archivo"
|
||||
"Remove min and max" = "Quitar mín. y máx."
|
||||
|
|
@ -1497,6 +1500,7 @@ status = "draft — needs native review"
|
|||
"guide.clear" = "Quitar todas las guías"
|
||||
"guide.move" = "Mover guía"
|
||||
"guide.remove" = "Quitar guía"
|
||||
"guide.select" = "Seleccionar guía"
|
||||
"help.about" = "Acerca de FigCraft"
|
||||
"help.page" = "Página de ayuda"
|
||||
"help.shortcuts" = "Atajos de teclado"
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
|||
// in order, and an edit every frame (a drag) would otherwise never show until it stopped.
|
||||
if let Some(done) = worker.poll() {
|
||||
// A pixel preview render is magnified with hard pixel edges.
|
||||
let filter = if app.session.ds().ok().and_then(pixel_preview) == Some(done.view.scale) {
|
||||
let filter = if app.session.ds().ok().and_then(|d| d.view.pixel_preview_scale()) == Some(done.view.scale) {
|
||||
egui::TextureOptions::NEAREST
|
||||
} else {
|
||||
egui::TextureOptions::LINEAR
|
||||
|
|
@ -281,17 +281,11 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// Pixel preview's resolution (rendered pixels per page pixel) when it's on and zoomed in past it.
|
||||
fn pixel_preview(ds: &figcraft_engine::DocState) -> Option<f64> {
|
||||
let k = f64::from(ds.view.pixel_scale.clamp(1, 2));
|
||||
(ds.view.pixel_preview && ds.view.zoom > k).then_some(k)
|
||||
}
|
||||
|
||||
fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let rect = app.canvas.rect;
|
||||
let (mut ox, mut oy) = ds.view.origin();
|
||||
let (scale, w, h) = if let Some(k) = pixel_preview(ds) {
|
||||
let (scale, w, h) = if let Some(k) = ds.view.pixel_preview_scale() {
|
||||
// Pixel preview: render `k` pixels per page unit from a whole-pixel origin, shown magnified
|
||||
// without smoothing.
|
||||
(ox, oy) = ((ox * k).floor() / k, (oy * k).floor() / k);
|
||||
|
|
@ -403,10 +397,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) {
|
|||
} else if scroll != egui::Vec2::ZERO
|
||||
&& let Ok(ds) = app.session.ds_mut()
|
||||
{
|
||||
let z = ds.view.zoom.max(1e-6);
|
||||
let (dx, dy) = if mods.shift && scroll.x == 0.0 { (scroll.y, 0.0) } else { (scroll.x, scroll.y) };
|
||||
ds.view.cx -= f64::from(dx) / z;
|
||||
ds.view.cy -= f64::from(dy) / z;
|
||||
ds.view.scroll(f64::from(scroll.x), f64::from(scroll.y), mods.shift);
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -734,7 +725,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
|||
let zoom = ds.view.zoom;
|
||||
let view = ds.view.clone();
|
||||
// Pixel grid at high zoom.
|
||||
if view.pixel_grid && zoom >= 8.0 {
|
||||
if view.pixel_grid_shown() {
|
||||
let r = app.canvas.rect;
|
||||
let p0 = app.to_page(r.min);
|
||||
let p1 = app.to_page(r.max);
|
||||
|
|
@ -763,7 +754,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
|||
if !on_screen.intersects(app.canvas.rect) {
|
||||
continue;
|
||||
}
|
||||
if view.layout_grids && !n.grids.is_empty() {
|
||||
if view.layout_grids && !n.grids.is_empty() && figcraft_layout::grids_shown(&doc, *c) {
|
||||
let w = doc.world_transform(*c);
|
||||
for g in n.grids.iter().filter(|g| g.visible) {
|
||||
let col = doc_c32(painter.ctx(), g.color);
|
||||
|
|
@ -1270,8 +1261,7 @@ fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel
|
|||
painter.rect_filled(Rect::from_min_size(r.min, vec2(r.width(), size)), CornerRadius::ZERO, t.panel);
|
||||
painter.rect_filled(Rect::from_min_size(r.min, vec2(size, r.height())), CornerRadius::ZERO, t.panel);
|
||||
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
|
||||
let step =
|
||||
[1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0].into_iter().find(|s| s * zoom >= 50.0).unwrap_or(10000.0);
|
||||
let step = figcraft_engine::guides::ruler_step(zoom);
|
||||
let p0 = app.to_page(r.min);
|
||||
let p1 = app.to_page(r.max);
|
||||
// Highlight the selection's span.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! Ruler guides on the canvas: drag out of a ruler to add one, drag a guide to move it, drag it
|
||||
//! back onto its ruler to remove it. A guide dropped on a frame belongs to that frame: it's kept in
|
||||
//! the frame's coordinates, moves with it and spans only the frame (along the frame's own axes when
|
||||
//! it's rotated). Guides show while the rulers do; layers snap to them.
|
||||
//! Ruler guides on the canvas: drag out of a ruler to add one, drag a guide to move it (Alt: a copy),
|
||||
//! drag it back onto its ruler to remove it; a click selects one (Delete, arrows and the context
|
||||
//! menu act on it). A guide dropped on a top-level frame belongs to that frame: it's kept in the
|
||||
//! frame's coordinates, moves with it and spans only the frame (along the frame's own axes when
|
||||
//! it's rotated). Guides show while the rulers do; layers snap to them. Alt while dragging measures
|
||||
//! from the guide to the layers in the frame it crosses.
|
||||
|
||||
use egui::{Color32, CornerRadius, Id, Pos2, Rect, Stroke, pos2, vec2};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
|
|
@ -19,9 +21,18 @@ const GUIDE: Color32 = Color32::from_rgb(0xF2, 0x48, 0x22);
|
|||
#[derive(Clone, Copy, Debug)]
|
||||
enum GuideDrag {
|
||||
/// Dragging a new guide out of a ruler.
|
||||
New { vertical: bool },
|
||||
/// Moving guide `index` of `owner` (a page or frame).
|
||||
Move { owner: NodeId, index: usize, vertical: bool },
|
||||
New { vertical: bool, frame: Option<NodeId> },
|
||||
/// Moving guide `index` of `owner` (a page or frame); `copy` (Alt at the press) drops a new one.
|
||||
Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option<NodeId> },
|
||||
}
|
||||
|
||||
impl GuideDrag {
|
||||
/// The frame Alt measures in: the one selected when the drag began, else the guide's own.
|
||||
fn frame(self) -> Option<NodeId> {
|
||||
match self {
|
||||
GuideDrag::New { frame, .. } | GuideDrag::Move { frame, .. } => frame,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A guide where it lands on the page.
|
||||
|
|
@ -47,7 +58,7 @@ fn preview_id() -> Id {
|
|||
Id::new("guide_preview")
|
||||
}
|
||||
|
||||
/// The page's guides and those of the frames on it (frames last, so they're found first).
|
||||
/// The page's guides and those of its top-level frames (frames last, so they're found first).
|
||||
fn placed_guides(app: &FigApp) -> Vec<Placed> {
|
||||
let Ok(ds) = app.session.ds() else { return vec![] };
|
||||
let doc = &ds.doc;
|
||||
|
|
@ -59,21 +70,27 @@ fn placed_guides(app: &FigApp) -> Vec<Placed> {
|
|||
.enumerate()
|
||||
.map(|(index, g)| Placed { owner: ds.page, index, vertical: g.vertical, offset: g.offset, line: None })
|
||||
.collect();
|
||||
let mut stack: Vec<(NodeId, usize)> = doc.node(ds.page).map(|n| n.children.iter().map(|c| (*c, 0)).collect()).unwrap_or_default();
|
||||
while let Some((id, depth)) = stack.pop() {
|
||||
let Some(n) = doc.node(id).filter(|n| n.visible && depth < 64) else { continue };
|
||||
if !n.guides.is_empty() {
|
||||
for (index, g) in n.guides.iter().enumerate() {
|
||||
if let Some(p) = place(doc, id, index, g.vertical, g.offset) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.extend(n.children.iter().map(|c| (*c, depth + 1)));
|
||||
for f in doc.top_frames(ds.page) {
|
||||
let Some(n) = doc.node(f).filter(|n| n.visible) else { continue };
|
||||
out.extend(n.guides.iter().enumerate().filter_map(|(index, g)| place(doc, f, index, g.vertical, g.offset)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A placed guide as a page line (vertical, page offset), when it runs along the page's axes.
|
||||
fn page_line(app: &FigApp, g: &Placed) -> Option<(bool, f64)> {
|
||||
match g.line {
|
||||
None => Some((g.vertical, g.offset)),
|
||||
Some(_) => guides::line_in(app.session.doc().ok()?.world_transform(g.owner), g.vertical, g.offset),
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level frame of the selection, if any.
|
||||
fn selected_frame(app: &FigApp) -> Option<NodeId> {
|
||||
let ds = app.session.ds().ok()?;
|
||||
ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i))
|
||||
}
|
||||
|
||||
/// Where guide `index` of frame `owner` at `offset` (frame coordinates) lands on the page.
|
||||
fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f64) -> Option<Placed> {
|
||||
Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) })
|
||||
|
|
@ -89,13 +106,13 @@ fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point,
|
|||
}
|
||||
|
||||
/// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the
|
||||
/// frame under it, in the frame's coordinates and along its axis nearer the ruler's, or on the page.
|
||||
/// top-level frame under it, in the frame's coordinates and along its axis nearer the ruler's, or
|
||||
/// on the page.
|
||||
fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option<Placed> {
|
||||
let ds = app.session.ds().ok()?;
|
||||
let frame = figcraft_engine::cmd::create::frame_at(&ds.doc, ds.page, p, &[]).filter(|f| ds.doc.node(*f).is_some_and(|n| n.kind.is_frame_like()));
|
||||
match frame {
|
||||
Some(f) => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap),
|
||||
None => through(app, ds.page, usize::MAX, vertical, p, snap),
|
||||
match guides::drop_owner(&ds.doc, ds.page, p) {
|
||||
f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap),
|
||||
page => through(app, page, usize::MAX, vertical, p, snap),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,17 +181,24 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
|
|||
return false;
|
||||
}
|
||||
let r = app.canvas.rect;
|
||||
let (pressed, released) =
|
||||
ctx.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary)));
|
||||
let (pressed, released, alt) = ctx.input(|i| {
|
||||
(i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.modifiers.alt)
|
||||
});
|
||||
if let Some(drag) = ctx.data(|d| d.get_temp::<GuideDrag>(drag_id())) {
|
||||
let vertical = match drag {
|
||||
GuideDrag::New { vertical } | GuideDrag::Move { vertical, .. } => vertical,
|
||||
GuideDrag::New { vertical, .. } | GuideDrag::Move { vertical, .. } => vertical,
|
||||
};
|
||||
let p = app.to_page(pos);
|
||||
let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
|
||||
let at = match drag {
|
||||
GuideDrag::New { vertical } => drop_target(app, p, vertical, snap),
|
||||
GuideDrag::Move { owner, index, vertical } => through(app, owner, index, vertical, p, snap),
|
||||
GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, snap),
|
||||
GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, snap),
|
||||
};
|
||||
// Alt: distances from the guide to the layers in the frame it crosses.
|
||||
let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o)));
|
||||
app.session.tool.overlay.guides = match (alt && !released, frame, at.and_then(|g| page_line(app, &g))) {
|
||||
(true, Some(f), Some((v, off))) => app.session.doc().map(|d| figcraft_engine::distance::from_guide(d, f, v, off)).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ctx.data_mut(|d| match at {
|
||||
Some(g) => {
|
||||
|
|
@ -196,14 +220,19 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
|
|||
});
|
||||
let on_ruler = if down { pos.x < r.min.x + RULER } else { pos.y < r.min.y + RULER };
|
||||
let page = app.session.ds().map(|d| d.page).ok();
|
||||
let before = app.session.doc().ok().and_then(|d| match drag {
|
||||
GuideDrag::Move { owner, index, .. } => d.node(owner).and_then(|n| n.guides().get(index)).map(|g| g.offset),
|
||||
GuideDrag::New { .. } => None,
|
||||
});
|
||||
match (drag, on_ruler, at) {
|
||||
(GuideDrag::New { .. }, false, Some(g)) => {
|
||||
(GuideDrag::New { .. } | GuideDrag::Move { copy: true, .. }, false, Some(g)) if Some(g.offset) != before => {
|
||||
app.run("guide.add", owned(g.owner, page, json!({"vertical": g.vertical, "offset": g.offset})));
|
||||
}
|
||||
(GuideDrag::Move { owner, index, .. }, true, _) => {
|
||||
(GuideDrag::Move { owner, index, copy: false, .. }, true, _) => {
|
||||
app.run("guide.remove", owned(owner, page, json!({"index": index})));
|
||||
}
|
||||
(GuideDrag::Move { owner, index, .. }, false, Some(g)) => {
|
||||
// A click without a move leaves it where it is (selected).
|
||||
(GuideDrag::Move { owner, index, copy: false, .. }, false, Some(g)) if Some(g.offset) != before => {
|
||||
app.run("guide.move", owned(owner, page, json!({"index": index, "offset": g.offset})));
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -217,7 +246,8 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
|
|||
if in_top || in_left {
|
||||
ctx.set_cursor_icon(cursor(in_left));
|
||||
if pressed {
|
||||
ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::New { vertical: in_left }));
|
||||
let frame = selected_frame(app);
|
||||
ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::New { vertical: in_left, frame }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -229,8 +259,20 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
|
|||
app.session.tool.tool == figcraft_engine::ToolId::Move && app.session.ds().is_ok_and(|d| d.text_edit.is_none() && d.vector_edit.is_none());
|
||||
if idle && let Some(g) = guide_at(app, pos) {
|
||||
ctx.set_cursor_icon(cursor_across(app, &g, r));
|
||||
if pressed {
|
||||
ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical }));
|
||||
let page = app.session.ds().map(|d| d.page).ok();
|
||||
// The context menu opens on the release, like the canvas's.
|
||||
let secondary = ctx.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
|
||||
if pressed || secondary {
|
||||
// The frame selected before the click is the one Alt measures in.
|
||||
let frame = selected_frame(app).or((Some(g.owner) != page).then_some(g.owner));
|
||||
app.run("guide.select", owned(g.owner, page, json!({"index": g.index})));
|
||||
if secondary {
|
||||
app.ui.context_menu = Some((pos, Vec::new()));
|
||||
} else {
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame })
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -247,15 +289,16 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
|
|||
let ctx = painter.ctx();
|
||||
let drag = ctx.data(|d| d.get_temp::<GuideDrag>(drag_id()));
|
||||
let moving = match drag {
|
||||
Some(GuideDrag::Move { owner, index, .. }) => Some((owner, index)),
|
||||
Some(GuideDrag::Move { owner, index, copy: false, .. }) => Some((owner, index)),
|
||||
_ => None,
|
||||
};
|
||||
let hover = if drag.is_none() { ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)).and_then(|p| guide_at(app, p)) } else { None };
|
||||
let selected = app.session.ds().ok().and_then(|d| d.selected_guide());
|
||||
for g in placed_guides(app) {
|
||||
if Some((g.owner, g.index)) == moving {
|
||||
continue;
|
||||
}
|
||||
let hovered = hover == Some(g);
|
||||
let hovered = hover == Some(g) || selected == Some((g.owner, g.index));
|
||||
painter.line_segment(segment(app, &g, r), Stroke::new(if hovered { 2.0 } else { 1.0 }, GUIDE));
|
||||
if hovered {
|
||||
label(app, painter, r, &g);
|
||||
|
|
|
|||
|
|
@ -507,6 +507,10 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
|||
{
|
||||
entries.insert(at + 1, (label, "section.toggleContents"));
|
||||
}
|
||||
// A right-clicked ruler guide (selected, with no layers) can be removed.
|
||||
if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) {
|
||||
entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);
|
||||
}
|
||||
for (label, id) in &entries {
|
||||
if *label == "-" {
|
||||
separator(ui);
|
||||
|
|
|
|||
|
|
@ -1921,11 +1921,18 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
fn grid_label(g: &LayoutGrid) -> String {
|
||||
match g.kind {
|
||||
GridKind::Grid => trf("Grid ({size})", &[("size", &g.size)]),
|
||||
GridKind::Columns => trn("{n} columns", g.count as usize, &[]),
|
||||
GridKind::Rows => trn("{n} rows", g.count as usize, &[]),
|
||||
GridKind::Columns if auto_count(g) => tr("Auto columns").to_string(),
|
||||
GridKind::Rows if auto_count(g) => tr("Auto rows").to_string(),
|
||||
GridKind::Columns => trn("{n} columns", g.count.max(1) as usize, &[]),
|
||||
GridKind::Rows => trn("{n} rows", g.count.max(1) as usize, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed-size columns or rows counted Auto: as many as fit.
|
||||
fn auto_count(g: &LayoutGrid) -> bool {
|
||||
g.count == 0 && g.align != GridAlign::Stretch
|
||||
}
|
||||
|
||||
/// `list` with grid `i` changed by `f`.
|
||||
fn grids_with(list: &[LayoutGrid], i: usize, f: impl FnOnce(&mut LayoutGrid)) -> Vec<LayoutGrid> {
|
||||
let mut gs = list.to_vec();
|
||||
|
|
@ -1957,7 +1964,14 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr
|
|||
let cols = g.kind == GridKind::Columns;
|
||||
widgets::sublabels(ui, tr("Count"), Some(tr("Type")));
|
||||
row(ui, 32.0, |ui| {
|
||||
field(app, ui, "count", f64::from(g.count), &|x, v| x.count = v.round().clamp(1.0, 1000.0) as u32);
|
||||
// 0 is Auto for fixed-size columns (rows); a stretched grid has at least one.
|
||||
let count = (!auto_count(g)).then(|| f64::from(g.count.max(1)));
|
||||
let key = format!("layoutGrids/{i}/count");
|
||||
let e =
|
||||
crate::variable_ui::number_field_or(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, tr("Auto"), Lead::None, "");
|
||||
app.field_run(e, "node.set", |v| {
|
||||
set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32)
|
||||
});
|
||||
let names =
|
||||
if cols { [tr("Stretch"), tr("Left"), tr("Center"), tr("Right")] } else { [tr("Stretch"), tr("Top"), tr("Center"), tr("Bottom")] };
|
||||
let aligns = [GridAlign::Stretch, GridAlign::Min, GridAlign::Center, GridAlign::Max];
|
||||
|
|
@ -1974,6 +1988,9 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr
|
|||
if a != GridAlign::Stretch && !(x.size.is_finite() && x.size > 0.0) {
|
||||
x.size = 60.0;
|
||||
}
|
||||
if a == GridAlign::Stretch {
|
||||
x.count = x.count.max(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
ui.close();
|
||||
|
|
|
|||
|
|
@ -228,9 +228,25 @@ pub fn number_field(
|
|||
value: Option<f64>,
|
||||
suffix: &str,
|
||||
width: f32,
|
||||
) -> Option<FieldEdit> {
|
||||
number_field_or(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix)
|
||||
}
|
||||
|
||||
/// [`number_field`] showing `empty` (e.g. "Auto") for no value.
|
||||
pub fn number_field_or(
|
||||
app: &mut FigApp,
|
||||
ui: &mut Ui,
|
||||
ids: &[NodeId],
|
||||
field: &str,
|
||||
id: Id,
|
||||
value: Option<f64>,
|
||||
width: f32,
|
||||
empty: &str,
|
||||
lead: Lead,
|
||||
suffix: &str,
|
||||
) -> Option<FieldEdit> {
|
||||
let name = app.session.doc().ok().and_then(|d| bound_name(d, ids, field));
|
||||
let (edit, ev) = widgets::number_field_bindable(ui, id, lead, value, suffix, width, 1.0, Some(name.as_deref()));
|
||||
let (edit, ev) = widgets::number_field_bindable(ui, id, lead, value, suffix, width, 1.0, Some(name.as_deref()), empty);
|
||||
handle(app, ui, ids, field, VariableType::Number, ev);
|
||||
edit
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ pub enum BindEvent {
|
|||
/// A number field `width` wide. `value` None shows "Mixed". `suffix` (e.g. "%" or "°") is shown
|
||||
/// after the number. Typing supports simple math (`10+5`, `*2`).
|
||||
pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix: &str, width: f32, step: f64) -> Option<FieldEdit> {
|
||||
number_field_bindable(ui, id, lead, value, suffix, width, step, None).0
|
||||
number_field_bindable(ui, id, lead, value, suffix, width, step, None, tr("Mixed")).0
|
||||
}
|
||||
|
||||
/// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto").
|
||||
|
|
@ -73,7 +73,7 @@ pub fn number_field_or(ui: &mut Ui, id: Id, value: Option<f64>, empty: &str, wid
|
|||
|
||||
/// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows
|
||||
/// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the
|
||||
/// detach button unbinds).
|
||||
/// detach button unbinds). No value shows `empty` ("Mixed", "Auto").
|
||||
pub fn number_field_bindable(
|
||||
ui: &mut Ui,
|
||||
id: Id,
|
||||
|
|
@ -83,8 +83,9 @@ pub fn number_field_bindable(
|
|||
width: f32,
|
||||
step: f64,
|
||||
binding: Option<Option<&str>>,
|
||||
empty: &str,
|
||||
) -> (Option<FieldEdit>, Option<BindEvent>) {
|
||||
number_field_impl(ui, id, lead, value, suffix, width, step, binding, tr("Mixed"))
|
||||
number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty)
|
||||
}
|
||||
|
||||
fn number_field_impl(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -91,6 +91,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
### 4. Interaction fidelity not compared tool by tool
|
||||
- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging
|
||||
(e.g. Ctrl to ignore snapping on macOS), hover states, popovers, menus, snapping thresholds.
|
||||
- **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout,
|
||||
selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline
|
||||
mode and measuring (`crates/engine/src/*_behaviour_tests.rs`), each with its fixes.
|
||||
- **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent").
|
||||
- **Impact:** Figma users notice the feel within minutes; it decides whether they stay.
|
||||
- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md).
|
||||
|
|
|
|||
25
docs/mcp.md
25
docs/mcp.md
|
|
@ -1031,12 +1031,25 @@ first, the last one is on top (the Design panel lists them the other way round,
|
|||
|
||||
`guide.add {vertical, offset, frame? | page?}` (returns `{index}`), `guide.move {index, offset, …}`,
|
||||
`guide.remove {index, …}` and `guide.clear {…}` edit the guides of the current page, another
|
||||
`page`, or a `frame` (frames, components and instances). A frame's guides are in its own
|
||||
coordinates: they move with the frame, span only it, are copied with it and snap the layers inside
|
||||
it. On a rotated frame they run along its own axes, and the layers inside it snap to them (and to
|
||||
their siblings and the frame's edges) along those axes, keeping whole pixels in the frame's
|
||||
coordinates. In the app, a guide dragged out of a ruler and dropped on a frame becomes that frame's
|
||||
guide, along the frame's axis nearer the ruler's.
|
||||
`page`, or a top-level `frame` (frames, components and instances on the page or in a section;
|
||||
nested frames are refused). A frame's guides are in its own coordinates: they move with the frame,
|
||||
span only it, are copied with it and snap the layers inside it. A frame moved into another frame
|
||||
keeps its guides, but they stop showing, snapping and measuring until it's top-level again. On a
|
||||
rotated frame they run along its own axes, and the layers inside it snap to them (and to their
|
||||
siblings and the frame's edges) along those axes, keeping whole pixels in the frame's coordinates.
|
||||
|
||||
`guide.select {index, frame? | page?}` selects a guide (and deselects the layers; `{}` deselects
|
||||
it), as clicking one does in the app; `guide.remove {}` without an index removes the selected one
|
||||
(the context menu's Remove guide). While a guide is selected and no layer is, Delete removes it,
|
||||
the arrows across it nudge it (Shift: the big nudge) and Escape deselects it. In the app, a guide
|
||||
dragged out of a ruler and dropped on a frame becomes the guide of the top-level frame there, along
|
||||
the frame's axis nearer the ruler's; Alt-dragging a guide drops a copy, and holding Alt while
|
||||
dragging one shows its distances to the nearest edges inside the frame it crosses (the frame
|
||||
selected before the drag, else its own).
|
||||
|
||||
Layout grids: a columns or rows grid with a fixed width (left, centre, right) and a `count` of 0 is
|
||||
Auto, as many as fit (a stretched grid shows at least one); grids don't show or snap on a frame
|
||||
turned off 0°.
|
||||
|
||||
### Measuring, the Scale tool, arc handles and selecting
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
|
||||
| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 71.0% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~307–596 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~306–595 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
|
||||
|
||||
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
|
||||
|
|
@ -94,7 +94,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
|
||||
| # | Area | Weight | Breadth | Ready | Remaining | Hours |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 |
|
||||
| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 |
|
||||
| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
|
||||
| 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 |
|
||||
| 4 | Shapes & vector | 8 | 98% | 72% | Corner smoothing on polygons, stars and vectors; a join or cap per point; rounding points beside curved segments; several handles dragged at once; vector QA with real files (behaviour suite from the documentation, corner radius and star ratio handles, rounded vector points drawn, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 2–4 |
|
||||
|
|
@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **149–310** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~93% | ~70% | | 47–96 |
|
||||
| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **148–309** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~93% | ~70% | | 46–95 |
|
||||
|
||||
## By dimension (ready for real work)
|
||||
|
||||
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|
||||
|---|---|---|---|---|---|
|
||||
| Features (depth) | 35 | 61% | 47–95 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file |
|
||||
| Features (depth) | 35 | 61% | 46–94 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file |
|
||||
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
|
||||
| File formats | 12 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
|
|
@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
| **Ready for real work** | 100 | **~51%** | **~307–596** | | |
|
||||
| **Ready for real work** | 100 | **~51%** | **~306–595** | | |
|
||||
|
||||
Dimension hours add up to the full-parity total without double counting: the feature-area rows
|
||||
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
|
||||
|
|
@ -569,7 +569,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
snapping, frame/section/slice/shapes/line/arrow with Shift/Alt, text, pen, pencil, hand,
|
||||
eyedropper), Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee toggling,
|
||||
Select layer (every layer under the click), measuring (Alt-hover distances from the selection to
|
||||
layers, parents and ruler guides, Cmd+Alt for nested layers, extension lines, one box inside the
|
||||
layers, parents and ruler guides, Cmd+Alt for nested layers, from a guide being dragged to the layers of its frame, extension lines, one box inside the
|
||||
other, overlaps, live distances to the neighbours while Alt-dragging, Shift+Alt for auto layout
|
||||
padding and gaps; labels that never overlap at any zoom), text editing (caret, selection, words,
|
||||
lines), auto layout reorder by dragging, drop into frames, align/distribute/tidy, z-order,
|
||||
|
|
@ -602,6 +602,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h; feature breadth (~80%), ready for real work (~49%), core editor (~68%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~320–615 h → ~319–614 h |
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
|
||||
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation (10 shape tests, 6 vector tests and 3 handle tests: about 70 table rows plus stepwise checks) and its fixes: rounded vector points are drawn and exported (the radius was stored only; a vector's corner radius now rounds every point); a boolean group looks like its top layer, a subtraction like the bottom one (always the bottom); Alt draws a line from its centre; stars keep to 3–60 points; delete and heal fits the curve through the deleted point; Shift-clicking a selected point at a corner of the points' box takes it out (it scaled); outline stroke turns the stroke's colour style into the fill's and keeps opacity and effects; the miter field shows the miter angle (it showed the ratio under that name); individual corners only for rectangles and frames; Option/Alt+Shift+F flattens; edit-mode keys reach the edit tools through MCP `key` (Delete removed the layer). Added: corner radius and star ratio handles on the canvas, Count and Ratio fields for polygons and stars, cut tool drags that divide a shape into layers, paint bucket drags across areas. Shapes & vector 97%/68% → 98%/72%, 1–2 h → 2–4 h (the suite found gaps the estimate didn't count: corner smoothing beyond rectangles, a join or cap per point). Totals recomputed by the doc's method, which earlier rows had left stale: the area table's weighted figures are breadth ~82% (core ~93%) and ready ~61% (core ~69%), not the ~80%/~58% (~91%/~68%) carried since #130; Features (depth) follows the weighted ready, 57% → 61%; ready for real work ~49% → ~51%; mainstream ~46% → ~48% (weekly areas 67.8% → 69.4%); the selection suite's area 2 hours (−3/−5) hadn't reached the totals, and neither it nor the text suite had reached the Features (depth) row: Features 60–115 h → 56–109 h, full parity ~321–618 h → ~319–615 h (−3/−5 for area 2, +1/+2 for area 4) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
|
|
@ -44,7 +44,7 @@ interaction feel not yet compared.
|
|||
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's |
|
||||
| Ctrl to suspend snapping while dragging (macOS) | missing | |
|
||||
| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap |
|
||||
| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels |
|
||||
| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) |
|
||||
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | partial | check scrub speed and Shift/Alt steps |
|
||||
| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation |
|
||||
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
|
||||
|
|
@ -73,16 +73,16 @@ interaction feel not yet compared.
|
|||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | |
|
||||
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | |
|
||||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
|
||||
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only |
|
||||
| Frame labels (rotated, right-to-left), size badge | done | |
|
||||
| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines |
|
||||
| Outline mode | partial | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; the Include hidden layers and Include object bounds options are missing |
|
||||
| Slices shown on the canvas (View ▸ Slices) | done | dashed rectangles |
|
||||
| Dragging a layer inside a group keeps it in the group | done | frames and auto layout take other drops |
|
||||
| Frames and sections drawn around layers take those fully inside; sections never drop into frames | done | not in auto layout frames |
|
||||
| Resizing a section leaves its contents in place | done | |
|
||||
| Masks: what a mask hides doesn't take clicks; a mask group's box is the mask's | done | |
|
||||
| Layout grids (Ctrl+G) | done | |
|
||||
| Layout grids (Ctrl+G) | done | columns, rows and grid; stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them |
|
||||
| Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) |
|
||||
| Gradient and padding/gap handles on the canvas | done | |
|
||||
| Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots |
|
||||
|
|
@ -104,6 +104,7 @@ interaction feel not yet compared.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite and fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame; a guide can be selected, deleted, nudged and removed from the context menu, Alt-dragged into a copy, and measured from with Alt while dragging; Auto column and row counts; layout grids hidden on rotated frames; outline mode clicks reach layers behind. Outline mode back to partial (two documented options missing) |
|
||||
| 2026-10-11 | minor | Shapes and vector behaviour suite and fixes: corner radius and star ratio handles on the canvas, Count and Ratio fields, Alt draws lines from the centre, paint bucket and cut drags, delete and heal along the curve, rounded vector points drawn, Shift-click deselects a point at a box corner, the miter field shows degrees, individual corners only for rectangles and frames |
|
||||
| 2026-10-11 | minor | Paint and effects behaviour suite: the blend menu's Normal sets Normal (it set pass-through), a blend menu for each paint in the colour picker, the eyedropper's rules covered by tests |
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks: drawing a frame or section around layers takes them, sections stay out of frames, section resizing leaves contents in place, masked-out areas don't take clicks, a mask group's box follows the mask, X/Y of layers in groups are measured in the frame |
|
||||
|
|
|
|||
Loading…
Reference in New Issue