diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..b00408dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (individual padding in the Layout panel, M2) 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 @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-18** · Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5643ac70..b545e52d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -149,6 +149,34 @@ mod tests { assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); } + #[test] + fn each_side_padding_sets_its_side_alone() { + let mut s = Session::new(); + let (f, _, _) = al_frame(&mut s); + // One side at a time: the other three stay 0 ([top, right, bottom, left]). + for (key, v, want) in [ + ("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]), + ("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]), + ("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]), + ("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]), + ] { + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}})); + ex(&mut s, "node.set", json!({"id": f, "props": {key: v}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}"); + } + // The [t, r, b, l] array and the axis pairs agree with the four sides. + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]); + // Negatives clamp to 0; junk is refused. + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0); + assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err()); + } + #[test] fn baseline_alignment_lines_up_text() { let mut s = Session::new(); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5..d5d28eeb 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -172,6 +172,7 @@ name = "English" "Both directions" = "Both directions" "Bottom" = "Bottom" "Bottom left radius" = "Bottom left radius" +"Bottom padding" = "Bottom padding" "Bottom right radius" = "Bottom right radius" "Branch" = "Branch" "Branch archived" = "Branch archived" @@ -632,6 +633,7 @@ name = "English" "Included" = "Included" "Included in layout" = "Included in layout" "Individual corners" = "Individual corners" +"Individual padding" = "Individual padding" "Individual strokes" = "Individual strokes" "Ink" = "Ink" "Inner shadow" = "Inner shadow" @@ -702,6 +704,7 @@ name = "English" "Leaves" = "Leaves" "Left" = "Left" "Left & right" = "Left & right" +"Left padding" = "Left padding" "Left side" = "Left side" "Left side width" = "Left side width" "Letter case" = "Letter case" @@ -1134,6 +1137,7 @@ name = "English" "Review and merge" = "Review and merge" "Ribbon" = "Ribbon" "Right" = "Right" +"Right padding" = "Right padding" "Right side" = "Right side" "Right side width" = "Right side width" "Rotate 90°" = "Rotate 90°" @@ -1402,6 +1406,7 @@ name = "English" "Top" = "Top" "Top & bottom" = "Top & bottom" "Top left radius" = "Top left radius" +"Top padding" = "Top padding" "Top right radius" = "Top right radius" "Transform" = "Transform" "Triangle arrow" = "Triangle arrow" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..85f03521 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -175,6 +175,7 @@ status = "draft — needs native review" "Both directions" = "Ambas direcciones" "Bottom" = "Abajo" "Bottom left radius" = "Radio inferior izquierdo" +"Bottom padding" = "Margen interno inferior" "Bottom right radius" = "Radio inferior derecho" "Branch" = "Rama" "Branch archived" = "Rama archivada" @@ -633,6 +634,7 @@ status = "draft — needs native review" "Included" = "Incluidos" "Included in layout" = "Incluidos en el diseño" "Individual corners" = "Esquinas individuales" +"Individual padding" = "Margen interno individual" "Individual strokes" = "Trazos individuales" "Ink" = "Tinta" "Inner shadow" = "Sombra interior" @@ -703,6 +705,7 @@ status = "draft — needs native review" "Leaves" = "Hojas" "Left" = "Izquierda" "Left & right" = "Izquierda y derecha" +"Left padding" = "Margen interno izquierdo" "Left side" = "Lado izquierdo" "Left side width" = "Ancho del lado izquierdo" "Letter case" = "Mayúsculas y minúsculas" @@ -1135,6 +1138,7 @@ status = "draft — needs native review" "Review and merge" = "Revisar y fusionar" "Ribbon" = "Cinta" "Right" = "Derecha" +"Right padding" = "Margen interno derecho" "Right side" = "Lado derecho" "Right side width" = "Ancho del lado derecho" "Rotate 90°" = "Girar 90°" @@ -1403,6 +1407,7 @@ status = "draft — needs native review" "Top" = "Arriba" "Top & bottom" = "Arriba y abajo" "Top left radius" = "Radio superior izquierdo" +"Top padding" = "Margen interno superior" "Top right radius" = "Radio superior derecho" "Transform" = "Transformar" "Triangle arrow" = "Flecha triangular" diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fa4fb66e..f4f2f31b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -85,6 +85,8 @@ icons! { CornerRadius, Angle, IndependentCorners, + /// Individual padding: a frame with one inward tick per side, each set alone. + IndependentPadding, LinkOn, LinkOff, LayoutH, @@ -139,6 +141,11 @@ icons! { Fixed, PaddingH, PaddingV, + /// Individual padding sides: a frame with the controlled side filled. + PaddingTop, + PaddingRight, + PaddingBottom, + PaddingLeft, Gap, Reset, Detach, @@ -706,6 +713,14 @@ fn draw(p: &P, icon: Icon) { p.corner(x, y, dx, dy, 4.0, 2.0); } } + IndependentPadding => { + // A frame outline with one inward tick per side: each side's padding, set alone. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.line(8.0, 2.5, 8.0, 5.0); + p.line(13.5, 8.0, 11.0, 8.0); + p.line(8.0, 13.5, 8.0, 11.0); + p.line(2.5, 8.0, 5.0, 8.0); + } LinkOn | Link => { p.path(&capsule((4.0, 12.0), (6.5, 9.5), 2.4), true); p.path(&capsule((9.5, 6.5), (12.0, 4.0), 2.4), true); @@ -923,12 +938,32 @@ fn draw(p: &P, icon: Icon) { p.line(3.0, 7.5, 13.0, 7.5); } PaddingH => { - p.rect(2.5, 3.5, 13.5, 12.5, 1.0); - p.fill_rect(5.0, 6.0, 11.0, 10.0, 0.0); + // The horizontal axis: bars across the top and bottom in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); } PaddingV => { - p.rect(3.5, 2.5, 12.5, 13.5, 1.0); - p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.0); + // The vertical axis: bars down the left and right in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingTop => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + } + PaddingRight => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingBottom => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } + PaddingLeft => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); } Online => { p.circle(8.0, 8.0, 5.5); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 11d30369..72d44b9a 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -4,7 +4,7 @@ //! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*` //! commands. -use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align, Align2, CornerRadius, Id, Layout, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::*; use serde_json::json; @@ -404,31 +404,91 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; + let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + if open { + // Each side on its own, like Figma's individual padding: a 2x2 grid of sides with + // the toggle at its right, replacing the H/V row while open. Every measure copies + // a plain row: 32px rows, the panel's own inter-row gap, 8px between the fields. + let gap = ui.spacing().item_spacing.y; + let total = 32.0 * 2.0 + gap; + let fields_w = col * 2.0 + 8.0; + let top = ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0); + let right = ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1); + let bottom = ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2); + let left = ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3); + block(ui, total, 0.0, |ui| { + ui.allocate_ui_with_layout(vec2(fields_w, total), Layout::top_down(Align::LEFT), |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + // The horizontal pair (left, right) on the left, the vertical pair on the right. + for (r, pair) in [[&left, &top], [&right, &bottom]].iter().enumerate() { + if r > 0 { + ui.add_space(gap); + } + ui.allocate_ui_with_layout(vec2(fields_w, 32.0), Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (key, name, icon, v, k) in pair.iter() { + let e = crate::variable_ui::number_field( + app, + ui, + ids, + key, + Id::new(("padding", *k)), + Lead::Icon(*icon, name), + Some(*v), + "", + col, + ); + app.field(ids, key, e, |v| json!(v.max(0.0))); + } + }); + } + }); + // The toggle keeps the shut row's rect: full-height strip, button 4px + // down, centred in the top 32px exactly like the shut row. + ui.allocate_ui_with_layout(vec2(24.0, total), Layout::top_down(Align::Center), |ui| { + ui.add_space(4.0); + if icons::button(ui, Icon::IndependentPadding, 24.0, true, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), false)); + } + }); + }); + return; + } row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "paddingHorizontal", - Id::new("al_padh"), - Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), - (pl == pr).then_some(pl), - "", - col, - ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field( + // Sides that disagree show both values ("16,54") instead of "Mixed". + let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); + let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; + let e = crate::variable_ui::number_field_or( app, ui, ids, "paddingVertical", Id::new("al_padv"), - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), (pt == pb).then_some(pt), - "", col, + v_empty, + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + "", ); app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + let h_pair = format!("{},{}", widgets::fmt_num(pl), widgets::fmt_num(pr)); + let h_empty = if pl == pr { tr("Mixed") } else { h_pair.as_str() }; + let e = crate::variable_ui::number_field_or( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + (pl == pr).then_some(pl), + col, + h_empty, + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), + "", + ); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + if icons::button(ui, Icon::IndependentPadding, 24.0, false, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), true)); + } }); if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..47c6d960 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod padding_individual_tests; +#[cfg(test)] mod palette_tests; #[cfg(test)] mod panel_remove_tests; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs new file mode 100644 index 00000000..e6397033 --- /dev/null +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -0,0 +1,75 @@ +//! Individual padding in the Design panel, driven headless: the four side fields only +//! appear once the toggle opens, and typing in one sets that side alone. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// An auto layout frame with uniform padding 10, selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let f = + NodeId( + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "props": {"layout": "horizontal", "padding": 10}})) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + (Ui::new(s), f) +} + +fn padding(u: &Ui, f: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn enter(u: &mut Ui) { + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +#[test] +fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { + let (mut u, f) = scene(); + assert_eq!(padding(&u, f), [10.0; 4]); + // Shut: no per-side fields. + assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + // Click the toggle: the four sides appear. + let t = named_button(&mut u, "Individual padding"); + click(&mut u, t); + let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + // Typing in the top field sets only the top, in one undo step. + let history = u.app.session.undo_depth().0; + click(&mut u, top); + u.frame(vec![Event::Text("8".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [8.0, 10.0, 10.0, 10.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(padding(&u, f), [10.0; 4]); +} diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ea2cc54f..5342135a 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -64,7 +64,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Nested hug/fill settling in one pass (three levels, wrapping fill text, grid rows) | done | | | Absolute position (ignore auto layout) with constraints | done | | | Strokes included in layout (inside strokes), canvas stacking | done | | -| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) | +| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation); the Layout panel's Spacing row opens per-side padding fields (Top/Right/Bottom/Left) behind a toggle | | Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` | | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | @@ -77,6 +77,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings (`padding_individual_tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |