From 1ddc2c338b601d33e0fd10806406442a409ed62b Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:12:53 -0700 Subject: [PATCH 001/245] Fit contributor columns inside the About window Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- crates/ui-egui/src/credits.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 3fc8b7a2..3c8dfca7 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -245,7 +245,7 @@ const TABLE_ROW_H: f32 = 22.0; /// Room for the sort arrow in every header, so the columns don't move when the sort does. const SORT_ARROW_W: f32 = 12.0; /// Padding inside each cell, on each side. -const CELL_PAD: f32 = 6.0; +const CELL_PAD: f32 = 5.0; const NAME_MIN_W: f32 = 110.0; /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. From 7a8e5373b19b021c7c2a76fc4ad4f6e499a8eeb4 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 00:02:41 +0200 Subject: [PATCH 002/245] M7: keep Windows windowed UI and canvas text sharp --- ROADMAP.md | 3 +- apps/figcraft/src/graphics.rs | 28 +++- apps/figcraft/src/graphics_tests.rs | 67 ++++++++- crates/ui-egui/src/canvas.rs | 140 +++++++++++++++++- docs/hardware-parity.md | 18 ++- docs/images/shot-windowed-canvas-after-en.png | Bin 0 -> 261324 bytes .../images/shot-windowed-canvas-before-en.png | Bin 0 -> 305165 bytes docs/ui-parity.md | 4 +- 8 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 docs/images/shot-windowed-canvas-after-en.png create mode 100644 docs/images/shot-windowed-canvas-before-en.png diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..18a7f59e 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-14 · **Change:** minor (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-14 · **Change:** minor (DirectComposition presentation for sharp Windows windowed UI; canvas texture placement aligned to device pixels to avoid interpolation blur; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Windows UI sharpness: use DirectComposition for Direct3D 12 presentation, preserving explicit overrides. Native-density canvas images align to device pixels at fractional zoom, pan and display scaling. Readiness scores unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/apps/figcraft/src/graphics.rs b/apps/figcraft/src/graphics.rs index ee7b74fa..e456b2de 100644 --- a/apps/figcraft/src/graphics.rs +++ b/apps/figcraft/src/graphics.rs @@ -5,12 +5,14 @@ //! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop //! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows //! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver. +//! Windows Direct3D 12 uses DirectComposition by default so window resizing keeps the UI sharp. +//! `WGPU_DX12_PRESENTATION_SYSTEM` can select `Visual` or roll back to `Hwnd`. //! //! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and //! systems other than Windows keep eframe's defaults. use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup}; -use eframe::wgpu::Backends; +use eframe::wgpu::{Backends, Dx12SwapchainKind}; /// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one. const WINDOWS: Backends = Backends::DX12.union(Backends::GL); @@ -25,10 +27,32 @@ pub fn backends(requested: Option, windows: bool, defaults: Backends) } } +/// The DX12 presentation system: explicit setting wins, else Windows uses DirectComposition. +pub(super) fn presentation_system(requested: Option, windows: bool, defaults: Dx12SwapchainKind) -> Dx12SwapchainKind { + match requested { + Some(requested) => requested, + None if windows => Dx12SwapchainKind::DxgiFromVisual, + None => defaults, + } +} + /// Offer wgpu [`backends`] instead of eframe's defaults. pub fn configure(options: &mut WgpuConfiguration) { + configure_for_platform(options, Backends::from_env(), Dx12SwapchainKind::from_env(), cfg!(windows)); +} + +pub(super) fn configure_for_platform( + options: &mut WgpuConfiguration, + requested_backends: Option, + requested_presentation: Option, + windows: bool, +) { if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup { let offered = &mut create.instance_descriptor.backends; - *offered = backends(Backends::from_env(), cfg!(windows), *offered); + *offered = backends(requested_backends, windows, *offered); + if windows { + let presentation = &mut create.instance_descriptor.backend_options.dx12.presentation_system; + *presentation = presentation_system(requested_presentation, windows, *presentation); + } } } diff --git a/apps/figcraft/src/graphics_tests.rs b/apps/figcraft/src/graphics_tests.rs index e103d32f..14d0bd40 100644 --- a/apps/figcraft/src/graphics_tests.rs +++ b/apps/figcraft/src/graphics_tests.rs @@ -1,9 +1,10 @@ //! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan //! driver crashed the app as its window opened), and nothing changed anywhere else. -use eframe::wgpu::Backends; +use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup}; +use eframe::wgpu::{Backends, Dx12SwapchainKind}; -use crate::graphics::backends; +use crate::graphics::{backends, configure_for_platform, presentation_system}; /// What eframe offers when `WGPU_BACKEND` isn't set. const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL); @@ -30,3 +31,65 @@ fn other_systems_keep_eframes_defaults() { assert_eq!(backends(None, false, DEFAULTS), DEFAULTS); assert_eq!(backends(None, false, Backends::METAL), Backends::METAL); } + +#[test] +fn windows_defaults_to_direct_composition_visual() { + assert_eq!(presentation_system(None, true, Dx12SwapchainKind::DxgiFromHwnd), Dx12SwapchainKind::DxgiFromVisual); +} + +#[test] +fn valid_presentation_override_wins_on_all_platforms() { + for requested in [Dx12SwapchainKind::DxgiFromHwnd, Dx12SwapchainKind::DxgiFromVisual] { + for windows in [true, false] { + assert_eq!(presentation_system(Some(requested), windows, Dx12SwapchainKind::DxgiFromVisual), requested); + } + } +} + +#[test] +fn other_platforms_keep_existing_presentation_system() { + assert_eq!(presentation_system(None, false, Dx12SwapchainKind::DxgiFromHwnd), Dx12SwapchainKind::DxgiFromHwnd); +} + +#[test] +fn create_new_configuration_uses_windows_default_and_explicit_override() { + let mut options = WgpuConfiguration::default(); + set_presentation_system(&mut options, Dx12SwapchainKind::DxgiFromHwnd); + configure_for_platform(&mut options, None, None, true); + assert_eq!(configured_presentation_system(&options), Some(Dx12SwapchainKind::DxgiFromVisual)); + + for requested in [Dx12SwapchainKind::DxgiFromHwnd, Dx12SwapchainKind::DxgiFromVisual] { + let mut options = WgpuConfiguration::default(); + set_presentation_system(&mut options, Dx12SwapchainKind::DxgiFromHwnd); + configure_for_platform(&mut options, None, Some(requested), true); + assert_eq!(configured_presentation_system(&options), Some(requested)); + } +} + +#[test] +fn non_windows_configuration_keeps_existing_presentation_system() { + let mut options = WgpuConfiguration::default(); + set_presentation_system(&mut options, Dx12SwapchainKind::DxgiFromHwnd); + let backends_before = configured_backends(&options); + configure_for_platform(&mut options, None, Some(Dx12SwapchainKind::DxgiFromVisual), false); + assert_eq!(configured_presentation_system(&options), Some(Dx12SwapchainKind::DxgiFromHwnd)); + assert_eq!(configured_backends(&options), backends_before); +} + +fn set_presentation_system(options: &mut WgpuConfiguration, presentation: Dx12SwapchainKind) { + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup { + create.instance_descriptor.backend_options.dx12.presentation_system = presentation; + } +} + +fn configured_presentation_system(options: &WgpuConfiguration) -> Option { + if let WgpuSetup::CreateNew(create) = &options.wgpu_setup { + Some(create.instance_descriptor.backend_options.dx12.presentation_system) + } else { + None + } +} + +fn configured_backends(options: &WgpuConfiguration) -> Option { + if let WgpuSetup::CreateNew(create) = &options.wgpu_setup { Some(create.instance_descriptor.backends) } else { None } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de..ba24e258 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -204,9 +204,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); - let w = (f64::from(v.width) / v.scale * zoom) as f32; - let h = (f64::from(v.height) / v.scale * zoom) as f32; - painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let pixel_preview = app.session.ds().ok().and_then(|ds| ds.view.pixel_preview_scale()) == Some(v.scale); + let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview); + painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } app.session.tool.labels = overlays(app, &painter, &t); // The zoom tool's area. @@ -238,6 +238,22 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } +/// The image's screen rect; align native-density renders to device pixels to avoid linear filtering +/// blending neighbouring texels. Stale and pixel-preview renders retain their original projection. +fn presented_image_rect(tl: Pos2, view: &figcraft_render::View, zoom: f64, ppp: f32, pixel_preview: bool) -> Rect { + let w = (f64::from(view.width) / view.scale * zoom) as f32; + let h = (f64::from(view.height) / view.scale * zoom) as f32; + let native_density = !pixel_preview + && ppp.is_finite() + && ppp > 0.0 + && zoom.is_finite() + && view.scale.is_finite() + && view.scale > 0.0 + && (view.scale - zoom * f64::from(ppp)).abs() <= view.scale.abs().max(1.0) * 1e-9; + let min = if native_density { pos2((tl.x * ppp).round() / ppp, (tl.y * ppp).round() / ppp) } else { tl }; + Rect::from_min_size(min, vec2(w, h)) +} + /// What the canvas says to screen readers: the selected layer ("Rectangle 1, Rectangle, 120 by 80, /// selected"), how many are selected, or the page with nothing selected. pub fn describe(s: &figcraft_engine::Session) -> String { @@ -1342,3 +1358,121 @@ pub fn fmt(v: f64) -> String { let r = (v * 100.0).round() / 100.0; if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } } + +#[cfg(test)] +mod canvas_image_rect_tests { + use super::presented_image_rect; + use egui::{Pos2, RawInput, Rect, pos2, vec2}; + use figcraft_render::View; + + fn resized_frame(ui: &mut crate::layers_drag_tests::Ui, worker: &mut crate::render_worker::Worker, size: [f32; 2], ppp: f32) -> egui::FullOutput { + let ctx = ui.ctx.clone(); + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(size[0], size[1]))), time: Some(time), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + let mut output = ctx.run_ui(input, |egui_ui| super::show(&mut ui.app, egui_ui, worker)); + output.textures_delta.clear(); + output + } + + #[test] + fn native_density_image_edges_align_to_device_pixels_without_changing_extent() { + for ppp in [1.0_f32, 1.25, 1.5, 2.0] { + for zoom in [0.37_f64, 0.67, 1.0, 1.3, 2.0] { + let scale = zoom * f64::from(ppp); + let view = View { origin: (-31.25, 18.75), scale, width: 375, height: 240 }; + for (cx, cy, canvas_x, canvas_y) in [(-200.5, 74.25, 100.25, 50.5), (10.0, -20.5, 3.25, 9.75), (913.75, 481.125, 212.5, 77.125)] { + let tl = pos2(canvas_x + ((view.origin.0 - cx) * zoom) as f32, canvas_y + ((view.origin.1 - cy) * zoom) as f32); + let rect = presented_image_rect(tl, &view, zoom, ppp, false); + assert!((rect.min.x - tl.x).abs() * ppp <= 0.5001, "x snap bound: ppp={ppp}, zoom={zoom}, tl={tl:?}, rect={rect:?}"); + assert!((rect.min.y - tl.y).abs() * ppp <= 0.5001, "y snap bound: ppp={ppp}, zoom={zoom}, tl={tl:?}, rect={rect:?}"); + assert!(((rect.min.x * ppp).round() - rect.min.x * ppp).abs() < 1e-4, "x edge: ppp={ppp}, zoom={zoom}, tl={tl:?}, rect={rect:?}"); + assert!(((rect.min.y * ppp).round() - rect.min.y * ppp).abs() < 1e-4, "y edge: ppp={ppp}, zoom={zoom}, tl={tl:?}, rect={rect:?}"); + assert!((rect.width() * ppp - view.width as f32).abs() < 1e-3, "width: ppp={ppp}, zoom={zoom}, rect={rect:?}"); + assert!((rect.height() * ppp - view.height as f32).abs() < 1e-3, "height: ppp={ppp}, zoom={zoom}, rect={rect:?}"); + } + } + } + } + + #[test] + fn stale_pixel_preview_and_invalid_density_keep_original_projection() { + let tl = Pos2::new(149.5, 91.25); + let view = View { origin: (0.0, 0.0), scale: 1.95, width: 300, height: 180 }; + let original = egui::Rect::from_min_size(tl, egui::vec2(200.0, 120.0)); + + let stale = presented_image_rect(tl, &view, 1.2, 1.5, false); + assert_eq!(stale, egui::Rect::from_min_size(tl, egui::vec2(300.0 / 1.95 * 1.2, 180.0 / 1.95 * 1.2))); + let pixel_preview = presented_image_rect(tl, &view, 1.3, 1.5, true); + assert_eq!(pixel_preview, original); + for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!(presented_image_rect(tl, &view, 1.3, ppp, false), original); + } + assert_eq!(tl, pos2(149.5, 91.25)); + } + + #[test] + fn canvas_mesh_stays_sharp_through_window_resizes() { + use std::time::{Duration, Instant}; + + use crate::live_tests::flat; + use figcraft_engine::Session; + + for ppp in [1.0_f32, 1.25, 1.5] { + let mut ui = crate::layers_drag_tests::Ui::new(Session::new()); + let mut worker = ui.app.worker.take().expect("worker from headless UI"); + let (zoom, center) = { + let ds = ui.app.session.ds_mut().expect("document state"); + ds.view.zoom = 1.3; + ds.view.cx = 123.5; + ds.view.cy = -87.25; + (ds.view.zoom, (ds.view.cx, ds.view.cy)) + }; + for size in [[1440.0, 900.0], [1000.0, 700.0], [800.0, 500.0], [1440.0, 900.0]] { + let start = Instant::now(); + let mut output; + loop { + output = resized_frame(&mut ui, &mut worker, size, ppp); + let has_current_texture = ui.app.canvas.tex_view.as_ref().is_some_and(|v| { + (v.scale - zoom * f64::from(ppp)).abs() < 1e-9 + && v.width == (ui.app.canvas.rect.width() * ppp).ceil() as u32 + 1 + && v.height == (ui.app.canvas.rect.height() * ppp).ceil() as u32 + 1 + && !worker.in_flight() + }); + if has_current_texture { + break; + } + assert!(start.elapsed() < Duration::from_secs(10), "render did not finish for size={size:?}, ppp={ppp}"); + std::thread::sleep(Duration::from_millis(2)); + } + + let (ds_zoom, ds_center) = { + let ds = ui.app.session.ds().expect("document state"); + (ds.view.zoom, (ds.view.cx, ds.view.cy)) + }; + assert_eq!(ds_zoom, zoom, "resize preserves zoom"); + assert_eq!(ds_center, center, "resize preserves page centre"); + + let tex = ui.app.canvas.texture.as_ref().expect("canvas texture"); + let mesh = flat(&output.shapes) + .into_iter() + .find_map(|shape| match shape { + egui::Shape::Mesh(mesh) if mesh.texture_id == tex.id() => Some(mesh), + _ => None, + }) + .expect("canvas image mesh"); + let min_x = mesh.vertices.iter().map(|v| v.pos.x).fold(f32::INFINITY, f32::min); + let min_y = mesh.vertices.iter().map(|v| v.pos.y).fold(f32::INFINITY, f32::min); + let max_x = mesh.vertices.iter().map(|v| v.pos.x).fold(f32::NEG_INFINITY, f32::max); + let max_y = mesh.vertices.iter().map(|v| v.pos.y).fold(f32::NEG_INFINITY, f32::max); + let view = ui.app.canvas.tex_view.as_ref().expect("canvas render view"); + assert!((min_x * ppp - (min_x * ppp).round()).abs() < 1e-4, "mesh left edge: {min_x}, ppp={ppp}"); + assert!((min_y * ppp - (min_y * ppp).round()).abs() < 1e-4, "mesh top edge: {min_y}, ppp={ppp}"); + assert!(((max_x - min_x) * ppp - view.width as f32).abs() < 1e-3, "mesh width matches texture"); + assert!(((max_y - min_y) * ppp - view.height as f32).abs() < 1e-3, "mesh height matches texture"); + } + assert!(!worker.in_flight(), "last resize render finished"); + } + } +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 8995db25..a19211ea 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (DirectComposition presentation for Windows Direct3D 12; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -46,10 +46,26 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | +## Windows: presentation and text sharpness + +The desktop app offers Direct3D 12 and OpenGL on Windows. Direct3D 12 uses DirectComposition presentation (`DxgiFromVisual`) by default: a Windows 11 comparison found the entire interface sharp in smaller and snapped windows with this path, while the previous HWND presentation softened menus, the layers panel and canvas text. Backend selection and non-Windows presentation are unchanged. + +`WGPU_DX12_PRESENTATION_SYSTEM` still overrides presentation. To compare the previous HWND path, or use RenderDoc (which wgpu does not support with Visual presentation), start the executable from a temporary PowerShell session: + +```powershell +$env:WGPU_DX12_PRESENTATION_SYSTEM = 'Hwnd' +.\figcraft.exe +``` + +`Visual` explicitly selects DirectComposition; `WGPU_BACKEND=gl` selects OpenGL as another diagnostic comparison. Environment overrides belong to that shell and its child processes; closing the shell discards them. These comparisons do not change Windows display or driver settings. + +Canvas-only interpolation blur is addressed separately: native-density canvas textures start on device-pixel boundaries. The presentation change concerns the entire interface and does not change document geometry, text shaping or exported images. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Use DirectComposition for Windows Direct3D 12 presentation after a same-binary HWND/Visual/OpenGL comparison; preserve explicit presentation and backend overrides. Document the HWND override for diagnostics and RenderDoc. | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | diff --git a/docs/images/shot-windowed-canvas-after-en.png b/docs/images/shot-windowed-canvas-after-en.png new file mode 100644 index 0000000000000000000000000000000000000000..4805d0e7513f89d2a4966234aa0c74f4ef9315e2 GIT binary patch literal 261324 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ⅆXe^%p)vKucy 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

(`DCM~DknC6}AXfKm~!;XQevYvzu8JqmO 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 z z+IiumH-}E@I`aKzx3G>$Uu{?ld_NEO_&;r zJJlhni^M}X_=*d@loH=RGJf=mT%6Nd%@u~?&)3uyAp1-M_|-GdtYyQbxwS&5i|~xm zqcfU0jD(qjp+!00rKfUm>Pzu~odp2lSiUg4?IPL={PsUv6Z4+N{M}8*O(9^fi&4nC{|iL0 z6Rv&Y$J*ClEarN0D;-u()G1h>$O|8I`;vyBS*ctxR%i8th6MtITs$||N9_!MZaqX* zf+f%%*Ape5x!~2e-o+_HnD15UAN$IW9V4po*x7ajcF0Jm<|0VD17;pZu)^TYDoch8 zldYVQ_W#JBIINzSJ)^7fMxA6HC-shKCK+bP0)h{M4I-JOA$dMte8_|pxvin60dVk@ zH@s_u%#JuGJ~ZB4_{gDP9VET(Q4R(LBFLXG!NYlGMTRHh`wfco4CM`9I^x|8H)US# zX7mbd!Ive4Omf_KI;mLs2{L|oNC1}6;`dIprO%e`gdcpuvpQfBOJ;CDU?LBV4)`OZ zJ0@M7!j{_jk@oG#b9KlFr4pz9*R$uy$AmB9Bj?{U9Q!nvb6(ydp$;%FmsSpsAPoVg zdicE$0ZMIHkX^rgV9fUoRnC6GP_E=8I*fn)+=R$x1X96v94-+DOvxF{4oGJ~tgXLa zjsY(7PXS$rJ<}1u%ZC>fBYn%qA8!rLPp5#Ut*w7@aa|5N>Pd(eZytdvbotpYCgThR zc9n-21c}rai`+R1bjXA&f&v|cN%Bw$cQTaOU^`dW&~bjjU8c<2{Yp(zFmcdpqDBh? zoAiWNYrUcYhaZHvs~@gMfUFO5XNs@e(Mm?^SixOJHSUj*TI0dR?fZA}usZUBA9tU9c8@ylEaPU{-N zxBh8%$R^)49#m9smmeWw2^_tnpAd-6x|rQU{SmLzF|u@q`3NXH!=j9b{PINya3Fk* zh=mOePBPZzvLzp~jJ#_2G0{inHBQ8=hU|SWx&`(!zF`Uq6h1`%PoV59E6GOk0j!MM z%|X2x1{V_#HBcm?;}sZV^A`>ikeduQvh%kK-+^USMROPVChG$O?s4c3lM)06IC21e zBsoNKXskLMLDk({jU0@fQ-UMxmm0p{%OoG%pfG17pP-^*&OPHY)G6Rc2xCIcp_4=F zHm#v(jj&auM9%B{6T9hN1Xt$X4T&lK=$YXIF^o5<&>VGfk+7%JGGOHWf~sxjWx$BD z#NCMlr{nIvl!gj)XlSzw|EZKO@(J*Yw)4@F0&i<>p}VcUHu@155Rt$x30FKMHi`Yc z!8;bMT18vWtgJm#6RFdi*AA^~6tLEjw8>PjRrt$qs%* zMr;ka>8-i7Y}3LbdtdiYZ&EEd=cxgEm)_qnVO`2|11J6lGg8ujK4wheDjim~0x z_TW!H0WdjuWZAsi@pqi7t#sz9<@q^E%pf1S3SU5)~O zXD--%sebn3<5pcz^Wh7Fo>ZF+|Gv2he}`@LzC%DRvW1betT}p}mjoh+fDhHUTEto4 zu>i)CvphrzB;FGW5!;4z1^28}J4V_pRSD{!2Ke4OYT)*-rEVd1NI1N0*c~Ac;_xxM zTiq|ZcG-})_7eo=)+craDmd$D_;bj}3-s}f# zVaiD6gNxMvom8bo?T?>$OGDxvbT_Dzvp0}FO&h(lo~08hr|^9qy-OqQ*&VDV2XUg= zqIUKS661}h-|*wi2ku%9M(k5+)f>2J+rYfnmrNWG8@+VP(e3C(nl(J{f#I9^+WBk7 zPa1m1E2s6ZoYYrrS@OB-dD~k-t^Fo{oVsuA&_T0>I!n%m8ksq0ES-lM0U?s79dpMy zdQxX(jHBTdPL`|J1SAzdUI|k=#p~ubUW$~)?Nqw@gjWx9npnxs9yN`_B7k?Ek+MQ9 z#=C}QKSQK-YeD}{e)(nUqrA%|xcZrirkNFEMvm;hF>Ad#23a?_qANjE$YSnpc!os$ zoxoeOcsGvUwE6fZ4rD1f@Iw6bDd_8%w{hjtHul5%d}B6t&e!fAq_6VR#?XCxt%nj~1And1$B{p;kIGzo9{#t6CFuBK zqK&eEf}fV29= zt7r8sDZ*mFDIWo2=V%k%Mq=#cw~RZ!xar~IwREUsIx?RaqdU@<&v-C&BSc_`T^yX# zC1ppTpBeQb_gC+DV==?MhTUp{_zNd|xG=9kY<&K#^=9-tw0=pw0>ZOdh0}7d2z%8P zFa$NJsG^nj0IHt*E+J3N(YSIejexo))jmQG|HBX7WG(9M@>|E(ve>}7Af#og?wHGZ z)s7e92VQ7qt96cd#p{UzX zx!Uieuwsjn3X8R@gzeYg{aVz&4;xl0Rn~183J1%pORCb_i%Yuf zO`_k#98}@dxZ-oo^@k|`gu%z99a!%$I0~YCt`dPU=1s0!fhP@osY6gDkn)Ah2}kFT zkXOU%kXSm7@qsDkT^L!=|vZoq+NQW(N52w#}+2-NNBBP7G4c+Fu! z`;gk&=^o#kRIXqlHb5?fn+ce?c$&9RKB3KHL?A#YL_q`iExOMk;P=m0GK3opxuPci zM8NPo9P}O@?8t62rwC(fyVyvV0kQ<05lA9@E_ld`(+{!534g+24-{s7n(@E;MTw*% zxOFk9j#iQ5mTrij>lhb?je=-;NaA@2<@+cS-Z#wV0`S~Kn?nLJ?~do!VrGWt*y;5r zaUDtB#xsy_NrWUk#V$DSfT0mO_OEK#@IX6NSbdgeUq!iOBW*B0?W}a7t)fuf?}Ojx(@w;ENtq)Qp@gTP2v%*P%30ZFoUWPB(Tg}dx&ceVF1mVSu(A|XmX z)dt}apYS13cm{J#k=kx}b``k2>Q6_2MfrX-7;xz})I7*dAAeXz&|zQGKc%ifX9sIF zzIL|4A8&3)59W6>9KjKOv6qyE2|r05>H*gpm95pLA*VfeuZVN$9C+Y86n5Ehw#tO> zR+0dJ+Bv^8WngfJEl1{vK_+L@XZdt`V8M@ zbzWzTY<1E~L@w>&I^+C;h~IwW=p;19=4%jI4sYw>N^z+sY(rSk@ogff))mV`Ga6n^ zom#Upkw%Flql@&@@#CHZ`B0h+-fZz@ap(_JUQRB9u{T{B=DJD12MF_%fV~DXq+f*# z|F%Q+5+cIBe;+F>qXzYdqe!0WGUiUvOIB9`7`V8Fj**cs~e4)phM~VyGu{- z(gwC}Nv$F}Asw{>D2>m4d3WA?$!OVly)^}&uc@Pmi^$s0Ld;%$XTOxqR6%TlEXz2- zR@rSIWbe2kx8a8T(L@E`p$_7>gj6tu4U4T!Wk8AU6x>=~ONAdTCP{#)D=0KpPXy6e z+7NwOLy{eaza03V44Z{0e0lG-L62hDsca;FFo1RwP(ujcH2wTW^&^Xi^>p{8=j!a<)cY@!NQ z`{Du&P<~1@(`M6SBpkdXf9{ek)N>(39wVcfA^;>~=g)s{yVQ}OxO8>De*N-5QqPXf z+xOc(wb0;NT?(T5rC_vYzahn}!5IzLW*{XP%#R=(kSA3jYD-HOuYkC`G@E`ktf?!d z(N3Hqc?{{#@^_^@WXdY616-V6*IT@E&gH11j?WW6MuedbK}9lDPVk?DkZrpZH4uZ9JGIcW*f=d;e|OZ|zx9`Tp;( zd!dOU{5o_{e6QT{=8P|!YM~_YRBxaRqj1J|7L^`HD(3yYW1DcwiU=5P{_ywLS=#~6 z82RmYZ+-LFn5rad8<2!2cQF0ux7~KXL?Lv9Db5wc?hblbzowo6N|*Mor659UQ=xZp z3{x_SSMqPpngJ*(wQYTBEzk5MltKoj{*sFB?c1{Fy)dZZ_mlpTHu)rwU5mf8Lb8%S z`Z8Nj-c6Z|OL~#^PJ$B@a~V4Dvuk6|X2<7cc(obbH{*pRK3B~)l`#i?H|8Clpt`|C zL{X&MQL?OR;_^OK??1Oq&^gGdw?6vlqj~cm+Dt&bnWIKyC_^mT@Nj)qSv^rUR05Is zxrS+%(kidEL7VEOvw#M5k!7b3%YWy(pD(Vhpv1VO zwqAldlxAY_O2t0fv}tEUPIZGc#r7_1%ozlW^PHs4xFFT{=2YF=^v8^r3lH({^FAq` ze5;PUbiuh~d84JDBds%P^JUdNR=AxLuvMv%?EjpJC3CicBreI0*Q6+=!5M6Ddb3}w zy6)T6EA{U`Eo%R?V$7hT5AHqFCdsC^*Gx~TVGD|Gdgj{w`}Q}xYRzD90O9?RzThT}eo??v&P1{K!q@`OPJOh0Y zO~*n8s9+lB&hQ#{+05xf%}*6Jp9*_fFrC-1b>vfJRjs(~Ju4e0SFZW;yqd0i8@Vr~ z`$r}E>3q-zrPTZ#J#L>@SKH{HCA{JWwlBy$a2ASKo2KBOH+I`h@gbe9=gRZXl+cWN z;ihhaQ+$T4I$H|3v#;J*u&2D>kuydwojN_cb;~Ucag0!N=#0B(yg-jT6c1RgABlx6 ztqvzJpA}Y5ts>}R5E3aO1U?2V*=iSqq-gs?(+7S&;)?J>HQdfIBl&!((aRccjOp@mGj^)22)ZlR^zKr_nq?^_eP&nJoKU?& z+MpGX_PyymmXKFTHjvVanoCOU&U|mpMXoetlcWVfnw0ijVsT2yQ7}9I6y=E%!K@ASE&Bf|- z!I`1T7KQhD1$L}_phszS%ks<^Nf95U$lQs;Wd`jv*HrB`%KX}II&D^|iJb%HX5F>jm!$uMZXQ-;IsETtEy5wP| z@a3;9S(3l@^7NGhmTwzS2a3okQ&>jYd>dkilG?Y<$X8(nMeLZ&D&-r~cWvH0MY@L1 zjCy$sRih?XzH-xI!0wL<>~fH42uZZTO)}?2;!c$E-^r}(&(`0U6v=0=l>ehv>Ll2y zKF{}jYwUK$;!V^=ls;%7tk@g<6pJUe(-`G(m)H(@^u|eu87P&PyK)VhQ$h~ zEXR#_=H=ZNqk2T`;{5r#z5^@k2R5?+PnT+)g%H}^tnMJpA|A}h0iSofWEWNMXB+3r zK9i1;CMGwqp4Wa7MAc4{JLKxtQ-!gjTGQY$Ts;R>$xRQvkhS9*lMr+D&wNk4b}tSc z_%QEm%4U`*CpSD{=xcbH)6uBnI+PZ z!e8 zA<7*=_t>}xva_!ebDEiNYY6~agCW{JnCLR2FM>Zij&?^nB(u!39CiKO5bX$BdjNwb z=^7nbN}waXn)J-D4+&v6fZ&rH5WLM_MpsQRS()~;lc?w+L0)vvf4b~tY*STriii5p zd&pij_$uC&IIT+)BYSc<#I*w$d6@d1K(umSH3Z+4tb$~wK2l*ms0%3qb9ELu6IQSz zhF`R*aQ5Xj@u$Ri_ToNt|L;Q~jj%#Q!xg8AJ}=7Y+SQwWl{DWxoYrIrGJ=sfq|bL` zodo$IqRtc2F`@}E0RV*FAAh9QO*}waC?gbVy7-kUERdfju(A|3C>;`#>u`cS&;=a8 zL+e;D`xsm5TN^Skn!Cf*L_GZRfoki5Vb%X2`6U1MkAyRa!d|K^^l@-#YLcd!E6x}o z8&*xe=;8eW6(B*eKFM0Dj|WLXksIzrijt~imKsE#1%;x>DGp23_CwdNkhJMQVV6DR zqp->+Biq@bo}9Gd0bSgccbqv)E(RPNSdC}N6J(APe1sn*a4B7d4;#YG;aS5O530Nl zKMpKvn72keLPQaR;5qF-g*6PB`trXQ`Eb>}4#E+7%`dqwm;aE>PcC$Hs0Kw!48alp zr+C)~avyYk0;;elQK%!8?~X;>PN@P%DoqkaGEUtO$rIu-kLRQdDpX4IvIOx+-e_hP zC3`;qyru$C%SRu7yk#nhv*2)*?fKo@(OXNNL!*AxYY-P7xAEhTM~oafy*of|g)d9U z?y9$+R7vaXp@$l(2oRIZ{c3W`E$%1s>xhB^Pw z=X=hXIm1P*?S1zBzt6MpGtw}eIp=rz{=T1^s|KT>*wt=#IhB@cbpJ~#&l>DAPpP?a zs95}5Bn8h!%d_XKJy`ru$OGcCq~AKDyh7I$YEZsT+a=E(Xow5HR{D9&V;x2lb_SGT zF8JPPw&5P|4UmCZp87*WuLa-IKS^5y#u|EO+8!eN=*>tL?hcKc zTI|^}po&wU*7}Nt#qNG>vC|dz4UtD=xco=JpBt+;g5|Zr5Fg&U;^P9#^Kql{SNWoP zVWE+@+$%NLRcl*qv1{!ydfkkdn|(XdwKQl{RjHA&rGdTf`a%XNcxov(&W8jf_peiQ0B~la{J`D%K%2b(86-)xKcDG#++oZliC*4Z@f>z3DCuT-GUx zlI7kx54E>B$5ZHJ@)SQgB6G&{QuQY2S)3Q}g+L=Z{KMxd3)xw(WN@0OQ_S`h^WeOx zAzw=tr5eGTC3)htHBRSaH3k3F&pd_5yA4LLBz1AV)e`2`1|Q z9kHDGclv(^1XSF*B|=UR6gKkI_%6o$qyYxWW+S~x8GYxaWi0O%@RK*!By@V1iCrKp zRE6l8XC(_}NDI(WReR!}soduV|#6PZOq6a(TA=m#Nc5xw6tEDAq64+zh$5jBHUWb^4kuk** zzs6V(LYrl1@wG@I%1GqV#4uS2RqC$;ZFaU8{lDW^M*Asbn=L-qb^+R;%*$V8b>jsb z{^pxjzW26@hkacLi2yCK$M+evb?9WY?i0~UMC*PLPIm=3;<^sqH#H(L6g676!6ls6 z=aGJ}RP8Gol{VwK)7!z4DgEQFVX-YXKAL)Z??OKT2_?K{zu8$XX&NTz5i+5a@fYtP zpelzc;;5wA816}v(Ua$m5-(ER>Q?PMO0ZK z*(hSfH$c>IX|<=)He0Q}*<3zr&Cm=}&lCu3r%+{yK65H_XH5ACwlfl< zy=;5&$B|VS`lA~AZAq~XX0Y)YhEa0f+*QdZ%$yBj6bRwt?!Nii^qy-{hUWH8alV7C zlXW*;DV@DIVR@VQl#7~&#WbSK3*A-CT#b{ zD%y=kN{w>+v(Dh&>LzU&`Wsq*faQzD1TVP#HN-CbZ~S9kt5zY~!jx-aCH%IrbHZbq zW#JaJK`wJ#>5i2{yO}6>Js_9?p%qcOmn`w-qElk(_vKT`Y(e85>{i2FxZ=4C8*ET5+%QVJv{i z{bc0ign}CrLbh>L^mfl-VwM^m&{IKZ2sV!qO3Mf3Tl$^E`V-xNNXr3-!bpn=3!~7@Bp9XeAtqyk+ z;v2>_Ii+}HwS3B=?OYgYDN|L7+1^HqD$7O*qQvBJpvDRcuK8#1VFr*Pa%B`tzD^6C zU)lYep`$Y1s0fCp&i(+Odbty|SUe~c{Q=V;oavp7o8Rb;DvlxPtj~maBQim6h5_~r zuIxE}!h~{(r($|uG9Vu$##G&Tbj@SCyJem4*5nthSwb8sK)h8~?>M!Z z$&=zLFaWtz0(tqyo!qU`y}S#dQ^B|DTEP5iVQ&_^K-+KYctZEjD9paCyGqyn`wyV$O z>klTe5-MQ`obP*+CS^0HXEIwK#MZJ(;a{ku6@pi4)MGcS>2A3G6fU;by{PX|21LU~ zD~3zL`bbto;b73&jfAbiUFS@0FMPNxpm=-|!RJPW7LWQz4k6+4U^^7VcMZSscElek z2lOiJ2CxtBvA4#1Lhm>&Tn*3%kp*}By*KxxJ`k~>cP+?uJxcAVr3bm@O)lMGs}MCe zQ^DoOKJnQ;4DsdXRlW{f-+}F^51%7uR-lSz)}Bl*|1oGLGM0S*zdf)mGKV;pfE zuTkAhn#VO)OHQY*dH|da$cPDxHHwyM9V_d+hHVwmO)4k?cgDMrB!%slBJ}p$E4hgN z!iiC`gw((w@u`-di#k;oQfinX51NLv=N3+tCWWbSSI@C!C4WS7Aq%KaZT!z#QHuoUEW@cT&KR)+a|TD2+Qu&fAgaQ*QPqi>uq}fx3kR z(#wUR-%Uxb9WTJtzsu;BVnOjkQ`>bOv59Tu(gOo`fi z%#ZpC7|p2Dyi6P6)lr5R9_j9CR;{xT3YT*h8e25XISAEYYWebc1E^?(p{3^^2 z=&3Hw?&>zF^>qW?1f90Suc@zQ?)j-h2?n(jS168bvz`$3fQM-tI=ncpaoy$_+0Cxk zp|?8s0Z+p{5^TC$EYil7 zpX#~m25t-wNnNFNB-HahXq5y%x&dAXhZ27q90Ki)hK+KSHj$^_qvJR75$@@$fjVju zfd>cT7KL6zR9?6BBe`YH@IZl3zr0j{n97D0Sog%No3-~-w^nRA+%n*bAqAllh#Jl{ z+x}xw^DQPQzw@6~>mq zRrf*vSl*yped(?kb;nD2#$g==rN0aGwee(!=lZ^vcH_5!Wb2lLQqqo9zeF9D~2y zR5Sz7RAN$a)y&|+#DJR30f!lcTp=EM6qB$O7aqGJ{G2!x3P^}lk>zX(UCc;eD}=qo z9JM7bNQW5yvK{3?__r18Wi-2dv%KjW z^UhNW5_9PHsk%nz!2N-2opV!aNhs$|IbgU-Z+_I1P|3VB2o zB`~n>=bJb06!1w#DetL?4Y?c%{@`185>@VsD%Z1#c=m z>901)Dk$0atFyXt6dmv;kBknf`DZKp7o8kECcf+JBSCNXk(&)@1XUtwe^#e+e55IDp zbLY2_i%Q-H@FbwOEG*^807;<=Zk7$Lp-6ejW|+iQDCLO`K)|gyz!R~qrSIm=pL}jt zvxa{Ea@N($6@SOPkqjuHLFVk}8rtV#8VsMwg{fG?rxl%}c1c)FcSt|PFOYaVfh1GR zwrO#FBWJj!U~S?my5I8kS=A-lFf!%`ivX@js*O3G`0U> z*hX@q<#HW0UCpYQ$a&f<2||uCV#%2p^F41{Aqk0VhO_#zDy$=}L>(e!?afq$BvZl! zUjM(OkW81%soQH?-~_0hyW9t*I+&!+x~or>CbQB^3M9^o6;w$;w)8tmWiiK1T& zT{-HP?uq`&b~f(sTbqFM#k-$_^pIxa4ho;GPo6PJHKoLn1k%Erdj-knf)K9oc>m$78Js*=5a@ z*eEFXUh#)Uvg}((Gch$9$z7q1rSKXc!ioWX=V?b4zCA0* z8i`P{OuGt~eimm4Jt6MQZiiCmpF4KsFtY3{^wdi^!BzJJDPkA(J$qo-j^o2dtiC?| z?rhp9RBqqjf3eTw{rVMh-yxwP24Rd(7uf#i)oz=rQ6zYtUJYk_wrttuboJi!E|f3?X+H? z(vk)qs}54Z&Sk}v#9MJL;cQ7l6?g^@&mB>SG}A2^1)~CLUUL@uI+J{=v0{b5+$Hft zt~C4mNN5>75d{ zZ>Kc>)PH*V%GN!}8HuMl>ZpIFBexIa~>p+2d)w2%_>QatFjhTe4l8`kM@<5 zsec}X_Fve^PQx}XKCEfFJ`?Lj`e-_{@+UgH7>#qRpq^@w*ejCC2q)$S99cE>+zd}Y zZWYSY8f>+vRn?tJp1)ad=~ft6s{L^FoaE<04DYIacH^3Ejqihq{Ltm7CsuP7v z2{^$u1+^9u7dEYbBmRECbVdr0#O zTeT6T3@}=AG?avw>hKL*SzyRUYJ4$fLZ~?zQa9zx8tm>6J(LpV1 z8$1fSmJ8f606dTiXl+{=D=PM3D2mmyBmA-ipz~ETmsKE3@0}YeF*nQ0?HyXD7^~B5 zsNUulE4W81zptN{1|K{nGq;X@xSq@hEB38HK~eDYA`Qm^{Vrj#SU5r7fSz_?V1_=5 z`b&+LnYZ`N>XfQNpNLwj+QVKqf9P>(jh|RFEtC#m8f)2`Rs&c94ZxJ;PHmYQVZ3=a zE2V$5_hCKkRNobO_U)qMG-7n3{}*6h`WOFEqZvca!_meC6d8_q>d0kZnv3S}f!esv z)t0V$YtGF#b$w`So!7?5LsSfKf$AW7)Y|l?G(!UAS*fqM5*Qw&dpfE=yaiz98qz8d zT4zd2f#aqnEz_-^G?3DR%iwYUZ~v>g&e?|J6v6yinrb~@tcRzsahdVdIz~=f56@I~t3$kC+~rO@t}o+y=mT!5D>2QcQ%~!JJL8{N0!1XQR%?b+r5du1nc_ZVx*{d?qiLP| zXI@Bn|GuYZ(>Fu)_tYcTnr3^d6P2Op{8Qd`jtVEiCY+93WlbLJ(9S7;7C{S;tA|8? zMN<2>Q#<%xV{1S<61E!H5WVIE60%J4P1uoOGcA+ml#pRr)Ql|Y;$HeJ>yg|mOxLj! zkGX1lFunXigCT0|nS$~W0XIhEr^C;wyq$Ha+*;assloQ*is^jIcH|mof4EUa*VsMY*+itxYB&(hvIW`xiBZDH8azS7HiluRqC#huq*Vg*T zm&-g6x#NEEuM*|NS0bSj49|L8v>M_MW{xOp7LhG}fPJDW`w$qkO;5KqDWtLx=$+~R ztT?eikDSvD6zY=AID>^i)i7S@A_?fUqDD!QEXXah3O`lKCuWvEBf8l!=r%eyTK&4e zQAlUs?X@>fKL*1*vFHF1R#k}yidWB{r_?1b^-%ZL}rxTe!-%j`h_XUi#ekwzU+oaCUh z80?=A`Ww{R<*M4G2XtY73aEZa2D-FUXDkjd9~GsGqykb)#qOS03+dmJh z>3y|tTJHB>F0LW4@9%4=+U+H#o{~A{M~%!j^jnKpBQI`y0dwanIUF+~#0wSM##-_8 z4Xc_R*0ERYTB_DWEhChSHM#co>hc;w18f=Pj3t!ZET3vhf+TMeiF76*SisJgvhPZ^ zLA!ATK--g>OM9ftwe2zdDnPf=H`r2IoQuHRAAB)(dyQSAs(B==5by1k}xrf z-Ay!3bpYh(Dw=yL+{#E85W!;J@VH-_;K5gLz%St&Ke08YPOfl9Q279KouhWqdQL3= z=*pNMQ?y8gx+WTg9-$gl+=OBC7k19P8(6+H-vJXYPI?(_1#c=#)LR?{1W(lu9bM zL8ge}OumU_voatRjJdWhU{TOjKZqp3Vd%Fw-$f(XWxc>TZ>PR2>&odYkP7IFiipDH zSFTI|8bY~ZER^Bn+{Fb6CBRA^F|9iI=Cm_=MJJpCb-V1`rfp~5Zzr`7sSZuAWb6nnI6-@bhrUCfscX}f=^>R6n}{hn9X z6z;}I=_CyKQd($^*sHQyHQp*L0O9h(mx*mV89 zflUTGm6 zmzxDjJW|FLiKjZc2rPYk%BmNRH674(&d_d%T@4loAlJduV8p5l4B5O;f^N@(RUgD^ z=A#xo_f3X_K}6)DaUZ|4ap|hjozZ(bq(>Xp$08*{QVAFw7&!mV=equDXxH_o zw^k2n@n)V-3}3@UQNc;&-KguYMm5Wp5KXqh_hW}1bWxyDrg|CNdl*O#`G)GUMsRg^ zj~*|!v9+5!1NPTk#aR|r&h8R21J=7ep_}iE_kLOekW_5a-1qbiNupJMNO7hn!o03O zCTn(|_4hKqf@ZoRn%;eP-yW-TzN<++GJ41G-$$-5jdSjBy<3bT8?e|FFk!(V6(Rd1rzp|ws)l7_a{AjWOQd3_?{a7%)m0U=n&kK#r5Z58zvoW8kF!NBM#LG z3v$C8%CFQ*`+_tE5Fc`pV|#%-;>V_4ZI3j8(Vf*RCt`mmR zyZU_bdV1dI&dM-Z$r~zq1*kZvsXbO|6ONFIp%`OFcuULONfS(<+Td2@*5>D4B!Lx< zFbKuf?$Mjq&3V0FB*B$_0kSFMy%(o6Pns}yMBP**_ZJn&#PYD#f$ZXfvD~_+Psd_1 z9BKNN7|??vSFndp$ROUi@4!7@oH}#n;?XBcFEFV0f&EL5zuRoUn4XI}d76$I6$ssa zCuYJ>dc45)p0+Pp4a43Mb&LxehwzT!m3z8`E|o$V#_YPAsuHv8Z(D~%J{-utfaPT8 zUSPhC`}iL@mU~bC09OMxMMBCKgk7M6d8;Q2Y%d%e*mcg37Qs4nL!!f<1yj;8u;pnS z)*F)M4;pAdZ&_4ypca?(acA+!$D)A4v^tNJm^bq_XJi~4-)G(OnPC14{PdY~)U$tH zz60FGFI5m~UB9y9*cza^K|1|(4u*#nBL;?^ zd}QMKX==H9K;$Am|20ocTa0TP{?WN>>rTe4vW<*A{MnLr8|=9+FFxAKdd2sY{j<_P znQa550qqRNB9v8XPaD{J>#NmST>`6;N46Vm8r^w!kFSS9Lezi5squ4zz?3l`!k);1 zPChX$9@@^GkyU!|g`ttlhg2yjF!ZjXF)vq!iE@wrMP!W26DR3KS~>2i%75a1CSod| zc#sz5N2l-T+$;9Ud;Dwqtg-CKy=WeGy5HV&>k4M>9%OB{A+e~}sp({b~7c3vA=ZkN=v33Gv|x1*2BOXd!vYD%50&=!Sga zW3Ji>+E#Uz51#6CU1f!Dq|3@xgPkLpHGZpf-L%^|)tSEK#mKvpPCU5kV2_p@e52dm zF? zS$_f(r0+ik0KR`2b`Ry3Mg9KyM)wEidL@nfczeW{iijum^98O=+SBs2-_^&0?(t45 zJe}C&VW(hOc;Z1@`g^hDR+VDg zI52WEwmhc`sa?u?M)~E&M|-9mX}WgnzFHZqX*zpGkE8~{$>acmF_eo;?y=Mxy}Q4$ zJsVbKCQ|xoI`b#rgkQ2>c9d){n4NO!_{~(eIi<<%^zNHOj+ z&42Ifs!qXeAKdBe@_kJz+~=JP9zwFjcIeRZgE1MGt$Oy)Tkk+>hxN!FaORDRB--~e zBj^O51y``uWLS=Y5sU-Fe=;J%D_96SaAxj}+#AXFA@6NF=+mauia8YX33FiS)7 z5ba<+(zIhQlve6QE)ViTgl6-=nMW>IpC2>ixpO>AqJL-koNvouzP1K!t4+P|9g{Xb z3f_Fy;%y11w_RlK1F;&Je|z<_`y{8~bY{-!GRhV{KY@}-tP!j_=C}|`N`w3dd%@8@ zz<9Jvdenn%rgR%04Htk&eGTh-bD^ zs@qtPHpb~{4GlnB$~Tc`vej7}BQ8bQf#feXEIci^Oaw&aGZ;e750qa*K4LlOlkl~V z_)n_1OB-$04KVi4G?BUT-SLS@eZQQ86(~H8O+>hMsND)i!uWsJDmkX9m{AcXshmG3 zWTrAv&OG!Slv2IF`!>zEy1nQgVOcXBUU>%;&S2@AaBJtcx^IA5EnE6&vy26tt&gY8i`HVV?U~%6ww0kEI>8tdNE-ool?R1Wh7; zQ;4ZBNvpM!-#NY#;a8jEk%rSwl8h|#*n2OIHrt|OdE=bhrtj(GzcVGxG1{3*RzB+#x$L1Kg0a?y||H;_}m>=zuh*cD`Z)q2fwc|pU%82h7I>Q!C?D;ln z2YSEgh!O#ySb>S*9?hLBE4Dd{%_SSmRTpE|x?b9oJ1_9t%e<2Ed|3#%#%;mbYHEIT zZhw;)LryD5=&S!vIfjYkVx&roO6!1x{cC6uD@;2h51m_T>DwrM*+KDp2EF{;xu?ly zC7i#(mdGPQ6=0Q`jq`(OXtAUWw5*piu0Z?Cyd*nKDYl=$w~dO0OP{-I&dOaldhPJ3iDdJY|L{ zGQkB8L@3Zi6wn|ut9d^ZY5Kv?XJx`pzj!a1;%s9{$_bubol207a!u(QsMaJqASGUjdTcNHo(b`dF9t@nrg{PvO zdi2=uK7De}MMks<{cf+_ck-33c@_E0xT-952RW&YMkY5&$eHM88~T|tloy`nCVL$(bvSAXLeR4pJ0Zaif}-br+qJO@9#b+V>dk8jNzemFXg407dZ!CtsFRT5?k zG0Un<_@n|IrK66q%l1U4%nk}F&mUhIafJ&2Zg-W>#Egsgvv*%+&7sSmxN=Qyk8=+- zkLNzYOrlfd(YuhNo-Wg1qt%}46>~Yt8GW{Twe!RJKU3By-HGJHZkRT`3n=i3vz12# zT418rWCYw`bXQ+aFCo?<>l)JB&d&D3InhA*(onSfP(gD~Luz2e`mOtTqVDx#8>zg8 zX%wQ}Mh_vK%clYY%7UyPUOBfe;seV{c1_9fFJ{2r_5x}L1!IzV5+^cNmqW2vnS_Dr zWPv^83e^3XbsulN03sBj=9lu(l>)`e;YT|+e2K!uPLUP<-0)d7{j0x8E@t8qyYxAh z+0u~Em!XUTZWN+$l=ZRTJaY3c%2QsH7jySj8?D|%j3DE~UoLc^sz@MiiaL@a%-|zp z$SVIHTa6q%Z!weO4=;$KkOCgnkPQ#wBDD&Xh-DO9&x^<deTk2SfGn=7pP#6r)ic+hvpBRbFBzmUDCL-zajDs` z?kgRze0)$jE}45c0bt|5uH3&uDqF?J(6>P{0K@>va@+}c@w~k{KYV8Fj9};U&KH@F zB~O9*jTg`PTKb_yZ#f_arqF|)QSZd3G@BH#S#l)#Ex7p$x`P&Yyl+_i`8M{nOF(`u zvb>Iko9gfV%jXX&j+dHW>U@rZLH)-cvj07}au z*Wb=L`$LXQOrb{rf;lU>Tk^Nj9%r}$7uh)(D2g)ZQJQ;$e_o75Y5CbKTQYz7vtQkh zeH^ot9;^df%S>ghU3sC4d6m+F8|YC%UTIAFuc5JAY((rb09vJhJ?xQJMxaSpwNmi7 z4v;7c_?ODPmbDwQ0qFxPGMltJ2~`aqQ#t zulOf`4hQ@4N;`x}8SU+=P(I6Jnv!uLqDY*JSy8^b5$z=L;uMV@1M(yfv2V<*z5`nO zQWzqetKPbal?7fJ$f`7ifnYrPE6=4>S3uu)%5unhkiNc>4NTIG1arC{0pX}s~}dkfUzAaX;A4Fxn1C5S+m(DDV22!t)7ASNcxlY-v`N@qAIt-EB*P(1bQ zd0*&+OjQJMo#Jju!P)xZm)6HHnOGiS#JNStc8lR+9twZek0!VG5_1I;2`5?bZ(PM* zVu=_Nq(kS3=aYMdlc$s>#oVFGIFe-jpx33WA5x?K8o@x{6c=|6woM7%-=pOlnK4TR zy(dpVtOIJLthaPSo4Og7s#rc**{6NJ1Qfg-lAxHid;%A_Pi^N=$N%g!aAO-zYsx6i zV8_W$up_dT$#JMcEIZ*3n=U#lk+h?NHt&~hI$eq=F>6jJsFcGC8*1Y57BNq61V3z8 zQ0f?7C{z37qTu^S&i<^=Kk}|ZLl20c#%pX_#kNbM;xDWfZ}7ULgmqlBd^l}ZrT#Woak9kfCq5b zU{9r(r9TGA9LCHn8PRt8^%KMFdDXm$S8Z^Z*ez*>Y7CB>%h^nKSy7#@&|RcVvPjo9WqB;?q#=U`7KL# zw;k8}Dv#OXSox)%9%iXb^Da9UfSr77Y{tsC$HXa|;I~g9c#T&WQmPymwIeZZwQ}HR z9ZPe*x5?_9&eNSq_-ZfVPh*vE+{t)ow!gWTkS_0rjI@7xzulRfwt^z6t6$srDk>XC z3uP#>h3Op*ZCAvf@gUXeeVAuM_ELnAItps?va0mAR(JW~gZ*m?|2ZgN-b%*^=O8&8 zUA57WMJhtyYp$2!wfpT1s4x)&Pv!S4K}u$RDfXm`n{znPqL1HoL+oVOdGqn{LH|}f zirKp7B2LCi=DBuR56QME?(o+(m_Jco!b3~L9#Pg*{AOg%G8Lg8&>3ZgSP>JlqnE?e zt+{pYRo)9rtVoHK`iViP?)vH9thv0)P82vwr04>9Mt`aJ^L_&TLO;6Z46X-pk-Ai45;@1tJJ`x5$R@TXXYwO;X=^Y?Q0QI?5A4_nW<(E7unjq^(7_Da{3JNuSXZ`>#u zyZ0;Q5yua!cduK#d)Blr zL;pp}OVX5D7f|~S&klE~CE6LdYgB!lZhz3(O{)AaPERb7q;fQH-g0i(lEZF+SDCBa zQ3BD1f@idIX5Z=S`Zifx8Ye?af{&ZAMYBbHDEQl_P!^yNx})%5SYl82zS%{TJ1#I= ztyh%yRvok+hsL~$?m)5c{sS&oR7Uw}AmS;DSH5tpr4>h;M^@WH4{-9xmp+?+$l z?+TW2((cK&TF014!hKJLWzf^`G4D9X8!@LT~ume6MGoCUWexZ*1*GLdtyun}cX7m^>Fgqb)aga8y3r=+s7C4M*xosxPj zU;e))&7hRJ4xIiDQb`!cYuPBt_gKYbycJx6*%3tPQWgil3~@;V7EcFLx(KaF8U`qE zr^~Ne50l(WxKL(NRJm-q=!%BoxNgafu3;jDaOJSSMV)6;Hx|DeoCQOxPw4x^@*_XK zGADof<&F8ly@bo;uT<@?WInPqb)+-qN}S<1Ey4kJmb~+zfcDd=@mFyh4;6#&b%J{ zK}|wl5qb+PCZ6hPeIMg4rOel_D&R6lLCi!sqALSs&a&-LVLcA(da0Yy5RT)qez|uzxG~o9$IqNLjS*u&8DmBS&P?eNHihU& z`gO9v90)_|toY*zcNCSgGXmmNM7a5p$SHG}=f*1Bnx_Z}i9b-ym2Hi9KOsWen$5tP z3Nd=3mwMhC(Ra%zC5fP8^CUK8N;uCL$ld_IoGl;Kvy2Hiq1S(JUg7g^_;|gEg+XK% zWC%W>wuR*cf#sUMU|FWYQZ+JC;ht<+J-CoRI}+89h#jy3bP!UpcL#Ig8cwH@zlS9} zf}$ou?BBi1Xc~YspmZt*&=Vqul!i)D=-8!{EN~O!#YwvIA~Hr+qb-h8UUrTdm|G!V zKbrT(ZbU7_P85gvjOdb>ylDyHVx6;YT_^uv23C>i7>a7{-lS9G_r1IIyu)58?o!xj zftZU!fLl=QGSv>R*<~(?jo)3cb9Yume17R}^BcrstEW|?q<9;=5SVONemBVcac#x9 z2DdtiK9H1puIXrz$1@VcRs)BQA+UQ3oq>&T6X66> z7o~vXiF>;m2gdV^@RQltiQz%o|GQ2I(W!!=MlUSiUR9JN6jwebkgB8@a6E!KQ%hIo zK#){b38RO4O=+HWJ)m&c2Wl7U{^a-zo$T-*e1&^)8``FL%0rY-=NGb`v~R2W+PC&) z0oRcKR2Fre(Iu*6QZawg9C_I0VFTY zCH^<8@_t-2P46#sUu1}F5uM4jfdhgZ*QVi|f%`5Z+;*U!fWGJYe11E^}u))7MiwC=Y5}IT}|UccC#R z7aX(8dV+pl>@O#xL9%RR!l{)P_bh0WS>De28@3+Y!87)i*!UbRNf;8Ok;alY@yu)?K|Q3s1DV!25)^4TX6hJN#ou9X zF!?UO?q%r8Wtl*KD5t!J>Nh$YhI~z*xEpUr?Az^sqlfIM+$4+V*w^}2ca++XT>Fr( zxjt1M7!(k}MT2 z?rRryVdL3L8%?jJ=b185z4cFy%P)#qJ8PCVoTG*>qI8Ua3hH(2gP;1b_8i`T3NdYO zpH@5X8nPI5?=vCuro5M`E|430rYPfi_HP365{@Ivo8Xdv+}YVzVlqOu9l2nB4$Gc$ zcOCLirv+K1u{^6j*VPf;b$uBd3fB^n)jq&V<|GOa+!N#xL8xPxY>GIoOE8qg(Mb(E zB>4J}_>19*4?~F=u#a4n3K3$LzI8Dw(A z7f$E^>HAP*BcK8M`yp2+Cl~P;C3{*T3|~{Y-TM^Mb*+jb(f6lKv(FBzYRXgR!cRWw zz}Tx4*-p8n2;am1JNiS#kOm-DSHPu>!!`!Nt&WAxW zPunM_Gj70OuvRd0(y8F8M+Kp=^sRv{eQbrS}rjb?di>;C6 z`0RPh2SdAkkpUYzWd%;P&kXFbxKnulbQ}=IR|bq2HEI@dY^epS%8Q;Ii9GBYsx1Gk z^Uj3F?TDC$h^Xm1ip5nDOoZ-9XbXT7@9jaWuS?XTCQ!E&J~URWCR{`=okN?q6K|%MD+#E1x)?N7$4Xj?E5Y zEau3~Sle=kJU3$VYm?wJ8}u9e$4{h?wQOa+qjMd)Wh@i}nsZdmybYQ1o;9PXt{Z=Q8XY4gR>*%N}mpg4KQfY$nJ6CT=+cx@W& ze*e$&+UqS`kai7?yf(>2tMB>gubU>RU!I?H8?U{VXg3S*w_8C{soVEnfU8QHzjo_0 zsEmpuvpSPmFnHjSRUzhK$u(W8wCx8qwASYOd1tujBr_bsvb0adGVit1Wvc_>AK|Zadct;DsEql@(wmw;$wOH?S#m z`P@N5eHu_;IfdU(N9HHoj%uw&PTqYv2jQ z;kr%h@7*^UkBS;Hc2FOGd?a+Aet$97G<``y{-{=eAMMc+Exma`&MyCeexv2?Z0jE< z&;{+23Hv64W9;lSVdBIoP?F(eUM#SgmJivU26CUjMi|Nqg>hWV@dA zYM*7=00>#4|5rYHZ6uf)du(1EWymaR@I%{~Fle+Z@PAetNpd0TfRGE(K7oHqy8_qm zVEo?d51ZV#!NfIE9j(AS<-#X7WO+@wv-!6JCs~KIz;i}h-_z!r}mcI<`wtDqyfrBu$@ljh}9q;{%Rt}4(xaiiC zn~TK(`yF>`C|2i){upO~jn8!z%uBX1G$LUK{}#3rj;UrZ+bw)x7|FOTGCOQ3`ouh+ zs6UOt`C!IKbx3|>a#A-=Rg1=oH1B%MEp8Bw?FO|Wqg1#Cw~|Q&P1yoUKaBtK6wGU?J{Ga zR*;9J|9ugox)eYvge;(g5Zxf$p<(2*ED zp{9j6*{%tRT}G`)=J{?}fx^G2#f(OMJOp^Ez_08@Ml3j_s3ve0b+1M98TS$OfkTbP z!VQtgB9H@dcebg@TviX69_Xvk{9U&N6xtz3(l+66*Y=K~kxa)OTEuR>co_uF#L)%8 zx>7apBGUeT;!t_f{tPys$ZY>|(YNJ3^0ID-~bXZY&XZ^j99(


~d7Ix4gu zqus0i@NPBWOf^kf`00s5-q#ayYrojG4X0Bc`7{6Z{EmCUM;Hl%i}{)ahhMc*;7J_6 zNtxL5d}dR6OF!9l4tktd^KAWn{}6dmc~C2PR@2LXcLp&4@Z+aZ2K0h?QwKI(wgkek zO_yOmgc}=OabYn2>fg0*+63$5K&gS@t*I6oIhas8-rP<_C#VcNN=Fy3-50OY_r|FR>cJr>nJ8<3CXD)#w67J+R*^ zU7O(u0Eo3`%Ju0lcAW#T3Hjr>S$SiG+Chx((W$4lz!)N+b5V@KX$*iZMj>k zxm;;QV)@?m`` zH&Gdbi90-y=5nV)VC?}(1JL7^~zcG;c?qgrTh>Vpi%513c2%-K@wA*)7O`9F0JFozZ`in@y$HeF>A$P z;4|a&&(Y-u+5xw(iX2lXIaCePQcr7!?u4;Z@zY-$<}uCyd}``o`Com_>@V$48D9Rl z^XTl`J-@DoP(ZCX74Qs=#Xjk&?#vgw80jk-y zchIC^9OT80v1+G*Dqn5-Li~pj^@-O1(wcnWBgrm~c7qtqb9>PX4D>)=rq@7C>ZZ$R zP!7O>1{PA@PdCNFZH%Gw3LYS4OgoB9wz#h`iAz1bQgV!dt z3N{>K@CQ6jMx18c|8bPjU!qU?Q(t?YK=m*4^M9|Phgg9dMQM$P4n$wBNo9+XMq>-^ zoI7GI)TdqN>>bIruY&bO_69?o_@}}o)&9TakgE$saJTg!jV2UeQ5yX0pG)IX1Ms_= zj|g2ExKUvb-~mQ;WSG{m3WrjURsfF_x7|l5o}oWI-ym%`#CFtfN##W!cY7X%2E&121f|=2sD|ugMGf1gF{}1x_!a~tf}+w#O-D47gNn( z1Mi`q)f^ba)?NP(4ojO>fNN3pV^Y=({%vba$@ztLJ(GmQY)QCD*y- zUqd04SMd2PbG22(RY{VvQ0qHr(s5;+T%UqYtLvME_Yc-!a)Z&c4y!|IO*i}d6AF5* z&Y6Fw^DF&H!6yV;pC*yltoK!qX5DdEM0Bo9$TqFeen4wRc|`hobu99EeSJGJ0YPb_ z-#(9f>FlC+1>6_T211zXr>EX_%uwE48qtcONxdXsTetN+(sEbjj6nxqeGZy&w1*k(uoR@PtR93;%U;J+>*M+R|;XZ(fBNIB{Lf7w;rz`FgZ zp1h8ypZ42}ikEt_Loorn0v+$T?Uy+EaaZM%1e_cI@|J4t7bv$ZL@pQs! zs?y@F9^Kwttp_h3f@(1e*}3&>x72f~`>{3QCVz|X@7xO!hWNWb?lm8}>GbH%Ce-S9 z>fW#fgzETEEa?g^D?J+m$-gR-{t^wO;nDBptpMGiJrr*9b^Zx;V+h*bb literal 0 HcmV?d00001 diff --git a/docs/images/shot-windowed-canvas-before-en.png b/docs/images/shot-windowed-canvas-before-en.png new file mode 100644 index 0000000000000000000000000000000000000000..7b67291ceea04b10b88e72090c8c1050ac7b5f7e GIT binary patch literal 305165 zcmd443s_X;_CG#|ig!{=%L{lxDLqPQiX||5(#T9XWkz{{%#sQfm6QR78PgQel+=tA zW%}kZN)F;BMPy(=RJ0{e#Sa=;-JLt8dH=T=>Wz-KVT*_jUHYjJ^wgK+0qO5WD5J_nF*l zt@*=GKK^$PGLnw%To3w~n7L6(58?~GhlJMS^iKz=M=Y}LbJ$j7f^K5)0>x?e6< zwH6!Jgc;XJ&ΝFmwLp-f+L9WsN%Tp}Qr&MDM4%GFWRYUJ!N9SS8;R+pv7k|PsnVU1wDbM)%pz$)QB|kN)S{>{@b9v; z#(8OHyVuWy|5{vznY^rF)ql)|F zM4n#UJYaU~xsY+HTCdwNTLa$LR+q-gQ`4Fcrd0$m4FTp%Rm)^+g_m_*L`+dcwYnlP z`(C0X%^2n7_OYMBkk&X$-7rg86rI)IwP8=1d5@)4&G<4!mo?$d+4&O#edY18hW(Mt z)Rv$0-iI-a{ zBQ2Hgg}5|sj49t>$`>c$qwuL)8DA;?o>nGNWlp5UNh%J_t_iI#P+JO=HKExfnW;Zo z`kTKjH1%+^l(`tPwJq6$1`l@9;a{Y8ied&U^v@`AH8G_cy?T+4`9EWoP07d1Uk6t= zc*P7#E&i~zEU@x=nrvLv`2z`&?rW?SnUXiNDn@4y^J+QjbqV**Ds7grzB!F)_G!Hr zpizIYc=7tTx-mfm2M+xDEfX`X&xwrtFZteoGrl6Lv^L9Br?oCmGM(}4p!?C^cQZUI zV-2i#W@JTQ#d-cn+HFUps!(gWqSfWXc2k}URd&`kE!Un~?B4ROhu)RZxt6TVA1u|m z$e&jkCad!1GI}?|xA{7Yk5wP9k$6k>cHdhxgPHgT6+`R88n*lT_ZO{;D#)wWK0`Q-|kzrVjy-IC>A6cjxs*m5@bvOIRZ zzv?NGr%coRJ+~L-U#kE?d)%DWmC|YkT^tgL04FSxHu5wdehB{w* zIbVl|&0^Iuej-?B)uo=^C8C`9SvThEJ>~VDmOJX!!SdE3dBKFD1$oZC_1ojCyk(W% zCOF#jt*}HJBcjtIoXhaN?APLJX>fg2GC3@K4(u~E`xRH+TYIO~k4o#1gE#rB(bw3r zE7<&0(ES5Jn*(ER1XhQ^=b7km>0Ftw@_lMv5Gj8hoz(~KOI1j>9w=`NT)K3rZi;); zBKLE2M|AbL8loMNs^RVz==+3A)S+x{aN}Gv9DspV*j(GhmOOFQ3-OUXMDDFu-3!p_ z=;=e?AIFO14gT^o^j-8?xw~~}So05I7hA1muGY11h*afT_!!fEar5w?>bXH1TC(yc zE1l&{A@Xzd)z`$W*L*5^$BWAH{2t_);Kw=iwZZ0H!A@nng6{1yfjx+^Hia?YsG3t$ zcpR9e0n&=TX59LvV3`vA;W+rviGZy|FXM@5$Nsl!8z;lgE&(TNfgS0VX1$+wB_->( z6m~O6D!0Pnu=E1®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

Bol@7by_IE6Sk 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 z%0A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%0AqQ@ASsHuV{SaykT`77zSroIL`3f1?c4I8X05I7 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 literal 261324 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ⅆXe^%p)vKucy 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

(`DCM~DknC6}AXfKm~!;XQevYvzu8JqmO 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 z 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


~d7Ix4gu zqus0i@NPBWOf^kf`00s5-q#ayYrojG4X0Bc`7{6Z{EmCUM;Hl%i}{)ahhMc*;7J_6 zNtxL5d}dR6OF!9l4tktd^KAWn{}6dmc~C2PR@2LXcLp&4@Z+aZ2K0h?QwKI(wgkek zO_yOmgc}=OabYn2>fg0*+63$5K&gS@t*I6oIhas8-rP<_C#VcNN=Fy3-50OY_r|FR>cJr>nJ8<3CXD)#w67J+R*^ zU7O(u0Eo3`%Ju0lcAW#T3Hjr>S$SiG+Chx((W$4lz!)N+b5V@KX$*iZMj>k zxm;;QV)@?m`` zH&Gdbi90-y=5nV)VC?}(1JL7^~zcG;c?qgrTh>Vpi%513c2%-K@wA*)7O`9F0JFozZ`in@y$HeF>A$P z;4|a&&(Y-u+5xw(iX2lXIaCePQcr7!?u4;Z@zY-$<}uCyd}``o`Com_>@V$48D9Rl z^XTl`J-@DoP(ZCX74Qs=#Xjk&?#vgw80jk-y zchIC^9OT80v1+G*Dqn5-Li~pj^@-O1(wcnWBgrm~c7qtqb9>PX4D>)=rq@7C>ZZ$R zP!7O>1{PA@PdCNFZH%Gw3LYS4OgoB9wz#h`iAz1bQgV!dt z3N{>K@CQ6jMx18c|8bPjU!qU?Q(t?YK=m*4^M9|Phgg9dMQM$P4n$wBNo9+XMq>-^ zoI7GI)TdqN>>bIruY&bO_69?o_@}}o)&9TakgE$saJTg!jV2UeQ5yX0pG)IX1Ms_= zj|g2ExKUvb-~mQ;WSG{m3WrjURsfF_x7|l5o}oWI-ym%`#CFtfN##W!cY7X%2E&121f|=2sD|ugMGf1gF{}1x_!a~tf}+w#O-D47gNn( z1Mi`q)f^ba)?NP(4ojO>fNN3pV^Y=({%vba$@ztLJ(GmQY)QCD*y- zUqd04SMd2PbG22(RY{VvQ0qHr(s5;+T%UqYtLvME_Yc-!a)Z&c4y!|IO*i}d6AF5* z&Y6Fw^DF&H!6yV;pC*yltoK!qX5DdEM0Bo9$TqFeen4wRc|`hobu99EeSJGJ0YPb_ z-#(9f>FlC+1>6_T211zXr>EX_%uwE48qtcONxdXsTetN+(sEbjj6nxqeGZy&w1*k(uoR@PtR93;%U;J+>*M+R|;XZ(fBNIB{Lf7w;rz`FgZ zp1h8ypZ42}ikEt_Loorn0v+$T?Uy+EaaZM%1e_cI@|J4t7bv$ZL@pQs! zs?y@F9^Kwttp_h3f@(1e*}3&>x72f~`>{3QCVz|X@7xO!hWNWb?lm8}>GbH%Ce-S9 z>fW#fgzETEEa?g^D?J+m$-gR-{t^wO;nDBptpMGiJrr*9b^Zx;V+h*bb diff --git a/docs/images/shot-windowed-canvas-before-en.png b/docs/images/shot-windowed-canvas-before-en.png index 7b67291ceea04b10b88e72090c8c1050ac7b5f7e..f1fd64f5f4bd9ebd4d6b1f8d238c491fc77d0fcc 100644 GIT binary patch literal 186314 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

Q1&g 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

plain

"), None); + assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None); + assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None); + assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None); +} + +#[test] +fn layers_paste_into_the_page_and_undo() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); + assert_eq!(s.selection(), ids); + let doc = s.doc().unwrap(); + assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page))); + let card = doc.node(ids[0]).unwrap(); + assert_eq!(names(&s, &card.children), ["Badge"]); + assert_eq!((card.width, card.height), (200.0, 100.0)); + assert!(s.undo().unwrap()); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn a_second_paste_repeats_the_figma_layers() { + let mut s = Session::new(); + s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).unwrap(); + let r = s.execute("edit.paste", &json!({})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); +} + +#[test] +fn pastes_into_the_selected_frame() { + let mut s = Session::new(); + let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); + s.execute("select.set", &json!({"ids": [host]})).unwrap(); + let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let first = params::id_of(&r["ids"][0]).unwrap(); + assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); +} + +#[test] +fn other_content_is_refused_without_changes() { + let mut s = Session::new(); + let before = s.doc().unwrap().nodes().count(); + assert!(s.execute("edit.pasteFromFigma", &json!({})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert_eq!(s.doc().unwrap().nodes().count(), before); + assert!(s.clipboard.nodes.is_empty()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..908c952a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1528,6 +1528,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod figma_paste_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..e4749235 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -899,6 +899,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Link einer Teambibliothek einfügen (ein Ansichtslink)" "Paste here" = "Hier einfügen" "Paste to replace" = "Zum Ersetzen einfügen" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Aus Figma eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Aus Figma eingefügt; {n} Arten von Inhalt wurden ausgelassen" } "Pattern" = "Muster" "Pattern alignment" = "Musterausrichtung" "Pattern scale" = "Musterskalierung" @@ -1654,6 +1655,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplizieren" "edit.findReplace" = "Suchen und ersetzen" "edit.paste" = "Einfügen" +"edit.pasteFromFigma" = "Aus Figma einfügen" "edit.pasteOver" = "Über Auswahl einfügen" "edit.pasteProperties" = "Eigenschaften einfügen" "edit.pasteReplace" = "Zum Ersetzen einfügen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..f0865ea4 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -943,6 +943,7 @@ name = "English" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" "Paste here" = "Paste here" "Paste to replace" = "Paste to replace" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Pasted from Figma; {n} kind of content was left out", other = "Pasted from Figma; {n} kinds of content were left out" } "Pattern" = "Pattern" "Pattern alignment" = "Pattern alignment" "Pattern scale" = "Pattern scale" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..3a07c3ab 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -944,6 +944,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" "Paste here" = "Pegar aquí" "Paste to replace" = "Pegar para reemplazar" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Pegado desde Figma; se omitió {n} tipo de contenido", other = "Pegado desde Figma; se omitieron {n} tipos de contenido" } "Pattern" = "Patrón" "Pattern alignment" = "Alineación del patrón" "Pattern scale" = "Escala del patrón" @@ -1731,6 +1732,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Buscar y reemplazar" "edit.paste" = "Pegar" +"edit.pasteFromFigma" = "Pegar desde Figma" "edit.pasteOver" = "Pegar sobre la selección" "edit.pasteProperties" = "Pegar propiedades" "edit.pasteReplace" = "Pegar para reemplazar" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..2f5ae5b9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -893,6 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Collez le lien d’une bibliothèque d’équipe (un lien de consultation)" "Paste here" = "Coller ici" "Paste to replace" = "Coller pour remplacer" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Collé depuis Figma ; {n} type de contenu a été omis", other = "Collé depuis Figma ; {n} types de contenu ont été omis" } "Pattern" = "Motif" "Pattern alignment" = "Alignement du motif" "Pattern scale" = "Échelle du motif" @@ -1645,6 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "Dupliquer" "edit.findReplace" = "Rechercher et remplacer" "edit.paste" = "Coller" +"edit.pasteFromFigma" = "Coller depuis Figma" "edit.pasteOver" = "Coller par-dessus la sélection" "edit.pasteProperties" = "Coller les propriétés" "edit.pasteReplace" = "Coller pour remplacer" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..493bf999 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -939,6 +939,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け" "Paste here" = "ここに貼り付け" "Paste to replace" = "貼り付けて置換" +"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma から貼り付け済み。{n} 種類のコンテンツは含まれていない" } "Pattern" = "パターン" "Pattern alignment" = "パターンの配置" "Pattern scale" = "パターンの拡大縮小" @@ -1723,6 +1724,7 @@ status = "draft — needs native review" "edit.duplicate" = "複製" "edit.findReplace" = "検索と置換" "edit.paste" = "貼り付け" +"edit.pasteFromFigma" = "Figma から貼り付け" "edit.pasteOver" = "選択範囲の上に貼り付け" "edit.pasteProperties" = "プロパティを貼り付け" "edit.pasteReplace" = "貼り付けて置換" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..253cc54e 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -893,6 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기" "Paste here" = "여기에 붙여 넣기" "Paste to replace" = "붙여 넣어 바꾸기" +"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma에서 붙여 넣음. 콘텐츠 {n}종은 제외됨" } "Pattern" = "패턴" "Pattern alignment" = "패턴 정렬" "Pattern scale" = "패턴 배율" @@ -1645,6 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "복제" "edit.findReplace" = "찾기 및 바꾸기" "edit.paste" = "붙여 넣기" +"edit.pasteFromFigma" = "Figma에서 붙여 넣기" "edit.pasteOver" = "선택 항목 위에 붙여 넣기" "edit.pasteProperties" = "속성 붙여 넣기" "edit.pasteReplace" = "붙여 넣어 바꾸기" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..12e7c0b2 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -892,6 +892,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Cole o link de uma biblioteca de equipe (um link de visualização)" "Paste here" = "Colar aqui" "Paste to replace" = "Colar para substituir" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Colado do Figma; {n} tipo de conteúdo ficou de fora", other = "Colado do Figma; {n} tipos de conteúdo ficaram de fora" } "Pattern" = "Padrão" "Pattern alignment" = "Alinhamento do padrão" "Pattern scale" = "Escala do padrão" @@ -1644,6 +1645,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Localizar e substituir" "edit.paste" = "Colar" +"edit.pasteFromFigma" = "Colar do Figma" "edit.pasteOver" = "Colar sobre a seleção" "edit.pasteProperties" = "Colar propriedades" "edit.pasteReplace" = "Colar para substituir" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..91c251ec 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -894,6 +894,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" "Paste here" = "粘贴到此处" "Paste to replace" = "粘贴以替换" +"Pasted from Figma; {n} kinds of content were left out" = { other = "已从 Figma 粘贴;有 {n} 类内容未包含" } "Pattern" = "图案" "Pattern alignment" = "图案对齐" "Pattern scale" = "图案缩放" @@ -1646,6 +1647,7 @@ status = "draft — needs native review" "edit.duplicate" = "创建副本" "edit.findReplace" = "查找和替换" "edit.paste" = "粘贴" +"edit.pasteFromFigma" = "从 Figma 粘贴" "edit.pasteOver" = "粘贴到所选项上方" "edit.pasteProperties" = "粘贴属性" "edit.pasteReplace" = "粘贴以替换" diff --git a/crates/ui-egui/src/figma_paste_tests.rs b/crates/ui-egui/src/figma_paste_tests.rs new file mode 100644 index 00000000..6147ba5e --- /dev/null +++ b/crates/ui-egui/src/figma_paste_tests.rs @@ -0,0 +1,48 @@ +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services}; + +struct Clip { + html: String, + text: Option, +} + +impl Services for Clip { + fn clipboard_get_html(&mut self) -> Option { + Some(self.html.clone()) + } + fn clipboard_get_text(&mut self) -> Option { + self.text.clone() + } +} + +fn figma_html() -> String { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "From Figma"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + format!(r#""#, r["data"].as_str().unwrap()) +} + +fn selected_names(app: &FigApp) -> Vec { + let doc = app.session.doc().unwrap(); + app.session.selection().iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn paste_takes_layers_copied_in_figma() { + let html = figma_html(); + let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) })); + crate::menus::paste(&mut app, None); + assert_eq!(selected_names(&app), ["From Figma"]); +} + +#[test] +fn html_from_other_apps_pastes_as_before() { + let clip = Clip { html: "Hello".into(), text: Some("Hello".into()) }; + let mut app = FigApp::new(Session::new(), Box::new(clip)); + crate::menus::paste(&mut app, None); + let doc = app.session.doc().unwrap(); + let id = app.session.selection()[0]; + assert_eq!(doc.node(id).unwrap().kind.text().unwrap().characters, "Hello"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..04fe3861 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,8 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] +mod figma_paste_tests; +#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; @@ -226,6 +228,9 @@ pub trait Services { fn clipboard_get_text(&mut self) -> Option { None } + fn clipboard_get_html(&mut self) -> Option { + None + } fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {} /// An image on the system clipboard as (width, height, straight RGBA). fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..60adf027 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::i18n::{self, tr, tr_noop, trf}; +use crate::i18n::{self, tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular}; use crate::toolbar::pretty_shortcut; @@ -30,10 +30,19 @@ pub const UI_COMMANDS: &[&str] = &[ ]; /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. -/// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG -/// or text from another app); otherwise our copied layers. `event_text` is the text the platform -/// handed over with the paste event (the browser has no other way to read it). +/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied +/// in Figma, an image, SVG or text from another app); otherwise our copied layers. `event_text` is +/// the text the platform handed over with the paste event (the browser has no other way to read it). pub fn paste(app: &mut FigApp, event_text: Option<&str>) { + if let Some(html) = app.services.clipboard_get_html().filter(|h| h.contains("(figma)")) { + if let Some(r) = app.run("edit.pasteFromFigma", json!({"html": html})) { + let left_out = r["warnings"].as_array().map_or(0, Vec::len); + if left_out > 0 { + app.toast(trn("Pasted from Figma; {n} kinds of content were left out", left_out, &[])); + } + } + return; + } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..63f281f6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (paste from Figma (desktop); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -36,7 +36,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text, **layers copied in Figma** (desktop; best effort) (newest clipboard content wins) | layers copied in Figma arrive as a `.fig` canvas in the clipboard's HTML; `edit.pasteFromFigma` reads it with the `.fig` import and pastes it as Paste does (into the selected frame, in place or in the view's centre), with `warnings`. Image fills come without their pixels (the clipboard doesn't carry them). Not in the browser (its paste event carries plain text only). Tested on copies written by our own `.fig` export, not yet on real Figma copies | with `.fig` | ## Export @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paste from Figma (desktop): layers copied in Figma paste as layers. Paste reads the clipboard's HTML; its `.fig` canvas goes through the `.fig` import and lands as Paste places our own layers (`edit.pasteFromFigma`, with `warnings`). Tested on copies written by our own `.fig` export only. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 657a8f4df62a35e647198ac43b8dcca820fcba19 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:16:10 +0200 Subject: [PATCH 041/245] M1: translate the vector style import notes --- crates/ui-egui/locales/de.toml | 3 +++ crates/ui-egui/locales/en.toml | 3 +++ crates/ui-egui/locales/es.toml | 3 +++ crates/ui-egui/src/import_report.rs | 3 +++ crates/ui-egui/src/import_report_tests.rs | 13 +++++++++++++ 5 files changed, 25 insertions(+) diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index c7bd13f7..520d4ba6 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1499,7 +1499,10 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" "set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie verwenden die Konturecken der Ebene und einfache Ecken" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "einige Vektorsegmente verweisen auf Stileinstellungen, die in der Datei fehlen; sie verwenden die Kontur der Ebene" "stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "Stileinstellungen einiger Vektorsegmente werden nicht unterstützt; diese Segmente verwenden die Kontur der Ebene" "text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge" "the file has no pages" = "die Datei hat keine Seiten" "the plugin" = "das Plugin" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c3e75570..8f1f09a2 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1571,7 +1571,10 @@ name = "English" "set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped" "set-variable actions without a value were skipped" = "set-variable actions without a value were skipped" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" "stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke" "text style overrides refer to missing table entries" = "text style overrides refer to missing table entries" "the file has no pages" = "the file has no pages" "the plugin" = "the plugin" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 49dd765f..8552b617 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1572,7 +1572,10 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable" "set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa" "stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "los ajustes de estilo de algunos segmentos vectoriales no son compatibles; esos segmentos usan el trazo de la capa" "text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan" "the file has no pages" = "el archivo no tiene páginas" "the plugin" = "el plugin" diff --git a/crates/ui-egui/src/import_report.rs b/crates/ui-egui/src/import_report.rs index b756e871..0540a2fc 100644 --- a/crates/ui-egui/src/import_report.rs +++ b/crates/ui-egui/src/import_report.rs @@ -53,7 +53,10 @@ const IMPORT_NOTES: &[&str] = &[ tr_noop("set-variable actions with values that don't fit the variable were skipped"), tr_noop("set-variable actions without a value were skipped"), tr_noop("set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"), + tr_noop("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"), + tr_noop("some vector segments refer to style settings missing from the file; they use the layer's stroke"), tr_noop("stars and polygons without point counts or geometry use the default point count"), + tr_noop("style settings of some vector segments aren't supported; those segments use the layer's stroke"), tr_noop("text style overrides refer to missing table entries"), tr_noop("the file has no pages"), tr_noop("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"), diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index cc493e4b..37b51642 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -59,6 +59,19 @@ fn note_text_keeps_the_repeat_suffix_and_unknown_notes() { assert_eq!(note_text("weird (×12) tail"), "weird (×12) tail"); } +#[test] +fn vector_style_notes_are_known_import_notes() { + for note in [ + "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners", + "some vector segments refer to style settings missing from the file; they use the layer's stroke", + "style settings of some vector segments aren't supported; those segments use the layer's stroke", + ] { + let shown = note_text(note); + assert!(shown.chars().next().is_some_and(char::is_uppercase), "{shown}"); + assert_eq!(shown[shown.chars().next().unwrap().len_utf8()..], note[note.chars().next().unwrap().len_utf8()..]); + } +} + #[test] fn opening_a_rest_json_file_reports_what_was_skipped() { let path = std::env::temp_dir().join(format!("figcraft-import-report-{}.json", std::process::id())); From a4156d291066fe1f6e4a1ffd83dd92ae02f0e714 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 08:11:20 +0200 Subject: [PATCH 042/245] M3: re-read a library file whose stamp is as recent as the read A library file is cached by its length and modification time. File systems keep the time in coarse ticks, so a same-length rewrite right after a read can keep the stamp, and library.updates then reports no change. A stamp now proves the file unchanged only once its modification time lies at least two seconds before the read; otherwise the file is read again. --- crates/engine/src/library.rs | 33 +++++++++++++++++++++++++++--- crates/engine/src/library_tests.rs | 18 ++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 73c599a4..8d3b90fd 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -449,9 +449,25 @@ struct Stamp { #[derive(Clone, Debug)] struct Entry { stamp: Option, + /// When the file was read: a stamp only proves the file unchanged once its modification time + /// is safely older than that, since file systems keep the time in coarse ticks. + read_at: Option, result: std::result::Result, String>, } +/// How far a file's modification time must lie before its read for the stamp to be trusted. +const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2); + +impl Entry { + fn still_valid(&self, now: &Stamp) -> bool { + let settled = match (now.modified, self.read_at) { + (Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at), + _ => false, + }; + settled && self.stamp.as_ref() == Some(now) + } +} + /// Library files read by a session, by path (re-read when they change on disk). #[derive(Clone, Debug, Default)] pub struct Cache { @@ -481,10 +497,11 @@ impl Cache { return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024))); } if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp) - && e.stamp.as_ref() == Some(st) + && e.still_valid(st) { return e.result.clone().map_err(EngineError::Other); } + let read_at = now(); let result = match &stamp { Ok(_) => read_file(path).map(Arc::new), Err(e) => Err(e.clone()), @@ -492,7 +509,7 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() }); + self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() }); result.map_err(EngineError::Other) } @@ -506,10 +523,20 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: None, result }); + self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result }); } } +#[cfg(not(target_arch = "wasm32"))] +fn now() -> Option { + Some(std::time::SystemTime::now()) +} + +#[cfg(target_arch = "wasm32")] +fn now() -> Option { + None +} + #[cfg(not(target_arch = "wasm32"))] fn stat(path: &str) -> std::result::Result { let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs index 23d5af09..77880ecd 100644 --- a/crates/engine/src/library_tests.rs +++ b/crates/engine/src/library_tests.rs @@ -399,3 +399,21 @@ fn library_survives_saving_with_versions() { healthy(&user); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() { + use std::time::{Duration, SystemTime}; + let read_at = SystemTime::now(); + let entry = |modified: SystemTime| Entry { + stamp: Some(Stamp { len: 10, modified: Some(modified) }), + read_at: Some(read_at), + result: Err("unused".into()), + }; + // Written in the same coarse tick as the read: a same-length rewrite would keep this stamp. + let fresh = entry(read_at); + assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) })); + // Written well before the read: an unchanged stamp means an unchanged file. + let old = read_at - Duration::from_secs(10); + assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) })); + assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) })); +} From 771f7e869f669f478e0b9277a2123307269272fc Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 043/245] M1: reword the unresolved point style import note --- crates/format/src/fig/map.rs | 2 +- crates/format/src/fig/tail_tests.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 914843d3..0760e889 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -942,7 +942,7 @@ impl<'a> Mapper<'a> { self.warn("filled areas of a vector with their own fill use the layer's fill"); } if unresolved_style { - self.warn("vector points with unresolved style IDs lost their point styles"); + self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"); } if unknown_join { self.warn("vector points with unknown stroke joins use the layer join"); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index cd27300d..51871ccd 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -301,7 +301,12 @@ fn vectors_import_as_editable_networks() { let fallback = network_of(&doc, "Join Fallbacks"); assert!(fallback.vertices.iter().all(|v| v.join.is_none())); assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter); - assert!(warnings.iter().any(|w| w.contains("unresolved style IDs lost their point styles")), "{warnings:?}"); + assert!( + warnings + .iter() + .any(|w| w.contains("vector points refer to point settings missing from the file; they use the layer's joins and plain corners")), + "{warnings:?}" + ); assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}"); } From d8e64a73bf0686277c0a06c2fdec2bb51261d064 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 044/245] M1: reword the vector segment style import notes --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tail_tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 1dc8e138..712a90fd 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1302,10 +1302,10 @@ impl<'a> Mapper<'a> { } } if has_uninterpreted { - self.warn("metadata for styled vector network segments was not interpreted"); + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { - self.warn("vector network segments reference missing style entries"); + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 657ff6ff..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -280,9 +280,9 @@ fn per_segment_stroke_styles_are_reported() { }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); - let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); - let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); // Style 1 has a row in the layer's override table; its contents are not applied. let known = styled_import(1); assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); From 41d465a678411260bb1c414e078d774a3b3b4d3b Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 045/245] M1: reword the vector segment style import notes --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tail_tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 05fa8566..36f45f31 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1211,10 +1211,10 @@ impl<'a> Mapper<'a> { } } if has_uninterpreted { - self.warn("metadata for styled vector network segments was not interpreted"); + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { - self.warn("vector network segments reference missing style entries"); + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 657ff6ff..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -280,9 +280,9 @@ fn per_segment_stroke_styles_are_reported() { }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); - let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); - let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); // Style 1 has a row in the layer's override table; its contents are not applied. let known = styled_import(1); assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); From 12d43b1f997554df0a5681d354bfb7d7e19db933 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 046/245] fix(format): open large .fig files A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are expanded) didn't open: "message: too many values". Lifting that cap alone made it import only its first 200 000 layers, and lifting that one took 41 GB of memory. The global caps stay as they are. The .fig import gets budgets from the size of its compressed canvas instead (counted up to the file's own size), not from the message it unpacks to, since chunks inflate up to 512 MB: - the kiwi value budget is one value per 2 bytes of the canvas, at least the old 20 million (kiwi is only read for .fig files); - the layer cap is one layer per 32 bytes of the canvas, at least the REST import's 200 000 and at most 2 000 000. A small or crafted file can't expand its instances into millions of layers, however far it unpacks; the REST JSON import keeps 200 000. Memory: - The mapper no longer passes the document through `json!`, which serialises its arguments: the whole tree was deep-copied twice. - Vector networks are built once per distinct network and handed to the REST importer beside the JSON (`vectorNetworkRef`), not written into it as JSON objects for every instance that shows them. - Decoded kiwi objects are trimmed, and each import stage's input is dropped as soon as the next stage is built. On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 24 +++++- crates/format/src/fig/map.rs | 127 +++++++++++++++++++--------- crates/format/src/fig/mod.rs | 30 +++++-- crates/format/src/fig/tail_tests.rs | 4 +- crates/format/src/fig/tests.rs | 61 ++++++++++++- crates/format/src/rest/mod.rs | 54 ++++++++++-- crates/format/src/rest/tests.rs | 15 ++++ 7 files changed, 251 insertions(+), 64 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 528dbbf0..46a9daac 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -10,8 +10,12 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; -/// Most values decoded from one message (every scalar, array element and field counts). -const MAX_VALUES: usize = 20_000_000; +/// Most values decoded from one message (every scalar, array element and field counts): at least +/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, +/// so a large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. +const MIN_VALUES: usize = 20_000_000; +const PACKED_BYTES_PER_VALUE: usize = 2; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -284,13 +288,23 @@ impl Schema { self.defs.iter().position(|d| d.name == name) } - /// Decode a message of definition `root`. + /// Decode a message of definition `root`, within [`MIN_VALUES`] values. pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES }; + self.decode_message_within(root, data, MIN_VALUES) + } + + /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). + pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; d.def(root, 0) } } +/// Most values decoded from a file whose compressed canvas is `packed` bytes. +pub(crate) fn value_budget(packed: usize) -> usize { + MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) +} + struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, @@ -327,6 +341,8 @@ impl<'s> Decoder<'s, '_> { loop { let id = self.r.var_uint()?; if id == 0 { + // Grown by doubling: trimmed, a large file's tree takes a good part less. + out.shrink_to_fit(); return Ok(Kv::Object(out)); } let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..28fd90f5 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,9 +6,10 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::rc::Rc; +use figcraft_doc::VectorNetwork; use figcraft_doc::geom::{Affine, Point}; use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; use serde_json::{Map, Value, json}; @@ -18,8 +19,7 @@ use super::network; type Obj = Map; -/// Most layers produced (instance expansion included) and deepest nesting followed. -const MAX_NODES: usize = crate::rest::MAX_NODES; +/// Deepest nesting followed. const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; @@ -28,11 +28,15 @@ const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. const MAX_ASSET_PART: usize = 256; -/// The REST API file JSON for message `msg`, with the warnings about what was skipped. -pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { - let mut m = Mapper::new(msg); +/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). +pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); let doc = m.document(); - let mut out = json!({"name": name, "document": doc}); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -49,7 +53,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { if !styles.is_empty() { out["styleDefinitions"] = Value::Array(styles); } - (out, m.warnings) + (out, m.warnings, m.networks) } /// REST importer warnings reworded for a `.fig` import (`None`: drop it). @@ -261,10 +265,18 @@ struct Mapper<'a> { sets: Obj, warnings: Vec, count: usize, + /// Most layers produced, instance expansion included (see [`super::layer_budget`]). + max_nodes: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), usize>, } impl<'a> Mapper<'a> { - fn new(msg: &'a Kv<'a>) -> Self { + fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); @@ -302,7 +314,21 @@ impl<'a> Mapper<'a> { let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); } - Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + Self { + nodes, + kids, + blobs, + prop_defs, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + warnings: Vec::new(), + count: 0, + max_nodes, + networks: Vec::new(), + network_ids: HashMap::new(), + } } /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, @@ -360,7 +386,9 @@ impl<'a> Mapper<'a> { if out.is_empty() { self.warn("the file has no pages"); } - json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) + let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"}); + doc["children"] = Value::Array(out); + doc } fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value { @@ -409,8 +437,9 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; } let base = *self.nodes.get(id)?; @@ -521,7 +550,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { return None; } let mut s = Obj::new(); @@ -553,7 +582,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= MAX_NODES { + if self.count >= self.max_nodes { return None; } self.count += 1; @@ -587,7 +616,7 @@ impl<'a> Mapper<'a> { if own_text(v).is_some() { self.warn("connector labels were skipped"); } - if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { + if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { return; } let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); @@ -879,7 +908,8 @@ impl<'a> Mapper<'a> { /// False when there's none or it's damaged (the outlines stand in). fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; - let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; + let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; + let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; @@ -899,28 +929,6 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); - let vertices: Vec = net - .vertices - .iter() - .map(|p| { - let mut out = json!({"x": p.x * sx, "y": p.y * sy}); - let st = style(p.style); - if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { - out["radius"] = json!(r); - } - match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { - Some("ANGLE") => out["mirror"] = json!("angle"), - Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), - Some("NONE") => out["mirror"] = json!("none"), - _ => {} - } - out - }) - .collect(); - let segments: Vec = - net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { self.warn("filled areas of a vector with their own fill use the layer's fill"); } @@ -946,7 +954,46 @@ impl<'a> Mapper<'a> { o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node)))); } - o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions})); + // Every instance of a main shows the same network: it is written once and shared. + let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize); + let id = match self.network_ids.get(&key) { + Some(id) => *id, + None => { + let vertices: Vec = net + .vertices + .iter() + .map(|p| { + let mut out = json!({"x": p.x * sx, "y": p.y * sy}); + let st = style(p.style); + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { + out["radius"] = json!(r); + } + match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { + Some("ANGLE") => out["mirror"] = json!("angle"), + Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), + Some("NONE") => out["mirror"] = json!("none"), + _ => {} + } + out + }) + .collect(); + let segments: Vec = net + .segments + .iter() + .map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})) + .collect(); + let regions: Vec = + net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + let mut entry = json!({}); + entry["vertices"] = Value::Array(vertices); + entry["segments"] = Value::Array(segments); + entry["regions"] = Value::Array(regions); + self.networks.push(crate::rest::network(&entry)); + self.network_ids.insert(key, self.networks.len() - 1); + self.networks.len() - 1 + } + }; + o.insert("vectorNetworkRef".into(), json!(id)); true } diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..0f6a38ff 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -39,6 +39,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024; const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; +/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its +/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances +/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the +/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large +/// count, however it's built; a large one gets room. +const PACKED_BYTES_PER_LAYER: usize = 32; +const MAX_FIG_NODES: usize = 2_000_000; + +/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. +pub(crate) fn layer_budget(packed: usize) -> usize { + (packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES) +} /// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. pub fn is_fig(bytes: &[u8]) -> bool { @@ -57,14 +69,22 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - let archive = Archive::open(bytes)?; - let (schema, message) = decode_canvas(&archive.canvas)?; + let mut archive = Archive::open(bytes)?; + // The budgets follow the compressed canvas, which the file itself bounds (its zip member may + // inflate too). + let packed = archive.canvas.len().min(bytes.len()); + // Each stage's input is dropped as soon as the next one is built: in a large file every one + // of them takes gigabytes, and holding them all at once runs out of memory. + let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?; let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?; - let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; + let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?; + let max_nodes = layer_budget(packed); + drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (json, mut warnings) = map::to_rest(&msg, file_name); + let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); + drop(msg); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import(&json, &images)?; + let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index fc0c5815..9b834805 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -392,8 +392,8 @@ fn mangled_tail_files_never_panic() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..574f860c 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -588,7 +588,7 @@ fn mangled_kiwi_never_panics() { } for cut in (0..msg.len()).step_by(3) { if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { - let _ = map::to_rest(&kv, "x"); + let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES); } } for _ in 0..2000 { @@ -598,8 +598,8 @@ fn mangled_kiwi_never_panics() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } let mut sbytes = schema_bytes.clone(); let i = (r.next() as usize) % sbytes.len(); @@ -621,6 +621,59 @@ fn mangled_kiwi_never_panics() { } } +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let (_, warnings, _) = map::to_rest(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. @@ -916,7 +969,7 @@ fn dump_samples() { std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, _) = map::to_rest(&msg, &stem); + let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); } } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 798b2645..1bd029c6 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -49,7 +49,7 @@ use crate::FormatError; use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text}; pub use variables::export as export_variables; -/// Most layers imported from one file. +/// Most layers imported from one file. The `.fig` import sets its own, from the file's size. pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; @@ -138,6 +138,28 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_parts(v, variables, images, Vec::new(), MAX_NODES) +} + +/// [`import`] for the `.fig` import's JSON, whose vectors name their networks by +/// `vectorNetworkRef`, an index in `networks` (built once each, and far smaller than as JSON). +/// `max_nodes`: the layer cap, set by the `.fig` import in place of [`MAX_NODES`]. +pub(crate) fn import_with_networks( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_parts(v, None, images, networks, max_nodes) +} + +fn import_parts( + v: &Value, + variables: Option<&Value>, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); @@ -157,6 +179,8 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"); } cx.style_definitions(v); + cx.networks = networks; + cx.max_nodes = max_nodes; for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -309,6 +333,10 @@ pub(crate) struct Ctx<'a> { /// Imported variables by id, and the mode ids of imported collections (default first). var_kinds: BTreeMap, var_modes: BTreeMap>, + /// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`. + networks: Vec>, + /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. + max_nodes: usize, } impl<'a> Ctx<'a> { @@ -332,6 +360,8 @@ impl<'a> Ctx<'a> { overlay_meta: BTreeMap::new(), var_kinds: BTreeMap::new(), var_modes: BTreeMap::new(), + networks: Vec::new(), + max_nodes: MAX_NODES, } } @@ -387,8 +417,9 @@ impl<'a> Ctx<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return; } let Some(ty) = text(j, "type") else { @@ -586,12 +617,17 @@ impl<'a> Ctx<'a> { } } - /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our - /// extension, written by the `.fig` import), else from its geometry. A stroke-only vector - /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape). + /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or + /// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its + /// geometry. A stroke-only vector uses the stroke outline, + /// painted with the stroke paints (exact look, editable as a shape). fn vector(&mut self, n: &mut Node, j: &Value) { - if let Some(net) = j.get("vectorNetwork") { - match network(net) { + let net = match j.get("vectorNetworkRef") { + Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())), + None => j.get("vectorNetwork").map(network), + }; + if let Some(net) = net { + match net { Some(network) => { n.kind = NodeKind::Vector { network }; return; @@ -1110,7 +1146,7 @@ fn local(parent: Affine, world: Affine) -> Affine { /// Finite path data within the coordinate cap. /// A vector network in our own JSON shape, when it's within bounds and refers only to what it has. -fn network(v: &Value) -> Option { +pub(crate) fn network(v: &Value) -> Option { use serde::Deserialize; let mut net = VectorNetwork::deserialize(v).ok()?; let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..58dfc5ef 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -707,3 +707,18 @@ fn layer_count_is_capped() { assert_eq!(doc.node_count(), MAX_NODES + 1); assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); } + +#[test] +fn the_fig_import_sets_its_own_layer_cap() { + // The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in. + let kids: Vec = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let json = file(json!([page("Page", Value::Array(kids))])); + let (doc, warnings) = import_with_networks(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap(); + assert_eq!(doc.node_count(), MAX_NODES + 6); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); + // Below it, the import stops at the cap it was given. + let few: Vec = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let (doc, warnings) = import_with_networks(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap(); + assert_eq!(doc.node_count(), 5); + assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}"); +} From 0efddc3894be71e31a3d10bc0ece63cdff3df550 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 047/245] fix(format): reopen large documents once saved `document.json` was read under the 512 MB cap for any archive member, so a large imported document saved but didn't open again: 900 000 layers save as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16 times its packed size (counted up to the archive's own size), within 512 MB and 4 GB; images and other members keep 512 MB. A small archive still can't unpack into gigabytes. That saved file reopens in 13 s with a 5.9 GB peak. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/large_tests.rs | 15 +++++++++++++++ crates/format/src/lib.rs | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 3ff352ac..2dc9eb4c 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -202,3 +202,18 @@ fn the_header_is_read_on_its_own() { let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap(); assert_eq!(index, Some(["ab".to_string()].into_iter().collect())); } + +#[test] +fn document_json_may_unpack_in_proportion_to_its_packed_size() { + const MB: u64 = 1024 * 1024; + // A small archive keeps the member cap: it can't unpack into gigabytes. + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and + // didn't open again under the 512 MB cap. + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + // The packed size an archive claims counts only up to the archive's own size. + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + // Never more than the ceiling. + assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f88416e2..dd96b932 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -39,6 +39,11 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; /// Largest archive member we read (guards against zip bombs). const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within +/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs +/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +const DOCUMENT_RATIO: u64 = 16; +const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). @@ -455,6 +460,11 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`. +pub(crate) fn document_limit(packed: u64, len: u64) -> u64 { + packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} + /// How many images, and how many bytes of them together, one file may give. #[derive(Clone, Copy, Debug)] pub(crate) struct ImageBudget { @@ -476,7 +486,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result Date: Fri, 16 Oct 2026 10:23:57 +0200 Subject: [PATCH 048/245] M4: Flatten keeps open paths Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Closes #377 --- ROADMAP.md | 4 +- crates/engine/src/cmd/structure.rs | 53 +++++++++++++-------- crates/engine/src/shapes_behaviour_tests.rs | 24 ++++++++++ docs/roadmap.md | 3 +- 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..5eb2ba18 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-16 · **Change:** minor (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-16 · **Change:** minor (Flatten keeps open paths (#377); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-16** · Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..3fb1dc2b 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -309,33 +309,46 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; let index = doc.index_in_parent(top).map(|i| i + 1); let pw = doc.world_transform(parent); - let mut path = BezPath::new(); - let mut even = false; + let mut net = VectorNetwork::default(); for i in &ids { let Some(n) = doc.node(*i) else { continue }; - // Booleans flatten to their result; strokes don't get outlined. - let Some((mut op, rule)) = (match &n.kind { - NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), - _ => figcraft_render::node_outline(doc, n), - }) else { - continue; + // Vectors keep their own network (open paths and unfilled loops have no fill outline); + // booleans flatten to their result; strokes don't get outlined. + let mut part = match &n.kind { + NodeKind::Vector { network } => network.clone(), + _ => { + let Some((op, rule)) = (match &n.kind { + NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => { + figcraft_render::subtree_outline(doc, n, 0) + } + _ => figcraft_render::node_outline(doc, n), + }) else { + continue; + }; + let mut part = VectorNetwork::from_path(&op); + if rule == figcraft_render::FillRule::EvenOdd { + for r in &mut part.regions { + r.winding = WindingRule::EvenOdd; + } + } + part + } }; - even |= rule == figcraft_render::FillRule::EvenOdd; // Into the common parent's space. - let w = doc.world_transform(*i); - op.apply_affine(pw.inverse() * w); - path.extend(op.elements().iter().copied()); + let all: Vec = (0..part.vertices.len()).collect(); + crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i)); + let (nv, ns) = (net.vertices.len(), net.segments.len()); + net.vertices.extend(part.vertices); + net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s })); + net.regions.extend( + part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }), + ); } - if path.elements().is_empty() { + let Some(bb) = net.bounds() else { return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); - } + }; let first = ids.first().and_then(|i| doc.node(*i)).cloned(); - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; - } + net.translate(-bb.x0, -bb.y0); let vid = doc.alloc_id(); let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index 154c5ae1..62069da3 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -396,6 +396,30 @@ fn boolean_results_and_the_look_they_take() { assert!(path.area().abs() < 1e-6, "no overlap, nothing to show"); } +#[test] +fn flatten_keeps_open_paths() { + // #377: open paths have no fill outline, so two pen paths flattened to "nothing to flatten" + // and next to a closed shape they disappeared. + let mut s = Session::new(); + let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 50], [200, 0]]})); + let b = create(&mut s, "vector.create", json!({"points": [[0, 100], [100, 150], [200, 100]]})); + let tri = create(&mut s, "vector.create", json!({"points": [[300, 0], [400, 0], [400, 100]], "closed": true})); + let v = create(&mut s, "vector.flatten", json!({"ids": [a, b, tri]})); + let NodeKind::Vector { network } = node(&s, v).kind else { panic!("flatten makes one vector") }; + assert_eq!((network.vertices.len(), network.segments.len()), (9, 7), "both open paths and the triangle"); + assert_eq!(network.regions.len(), 1, "only the triangle is filled"); + let bb = s.doc().unwrap().world_bounds(v).unwrap(); + assert_eq!((bb.x0, bb.y0, bb.x1, bb.y1), (0.0, 0.0, 400.0, 150.0), "nothing moves"); + + // Joining two open paths goes the same way. + let mut s = Session::new(); + let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]})); + let b = create(&mut s, "vector.create", json!({"points": [[0, 50], [100, 50]]})); + let v = create(&mut s, "vector.join", json!({"ids": [a, b]})); + let NodeKind::Vector { network } = node(&s, v).kind else { panic!("join makes one vector") }; + assert_eq!(network.segments.len(), 2); +} + #[test] fn flatten_and_outline_stroke() { // Flatten: one vector layer from the selection, in one step; Alt+Shift+F does it. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e9eaec9..8acce139 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flatten keeps open paths (#377); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From afdb21bad6e7cfb16fa25e666d43ce65a1c75651 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:24:39 +0300 Subject: [PATCH 049/245] fix(ui): drag the line between pages and layers to size the pages list The pages list took a fixed 40% of the left panel (#272). The line under it now drags to make the list taller or shorter: at least three rows, and always leaving the layers their header and three rows. A double-click puts back the default. The main window keeps the height in the preferences, as it does the panel widths, saved once the drag is over. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/engine/src/lib.rs | 4 ++ crates/ui-egui/src/layers.rs | 51 +++++++++++++- crates/ui-egui/src/layers_behaviour_tests.rs | 71 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/windows.rs | 4 +- 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..dcf7e2f2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -606,6 +606,9 @@ pub struct Prefs { /// The widths of the main window's left and right panels (the interface keeps them within its /// limits; other windows have their own while they are open). pub panel_widths: (f32, f32), + /// The height asked for the main window's pages list, dragged at its lower edge (`None`: a + /// share of the panel; the interface keeps it within its limits). + pub pages_height: Option, /// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser /// language). Languages without a catalog show English. pub language: String, @@ -666,6 +669,7 @@ impl Default for Prefs { show_dimensions: true, theme: "system".into(), panel_widths: (240.0, 240.0), + pages_height: None, language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8b614762..a6bbe5f5 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -217,7 +217,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); // A long list scrolls in its own area, so the layers keep their room below it. - let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); + let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); @@ -306,7 +306,54 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { { app.run("page.select", json!({"id": p})); } - crate::widgets::separator(ui); + pages_edge(app, ui, moved.inner_rect.height()); +} + +/// The pages list's height in a panel with `avail` below the Pages header: the height asked for +/// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. +pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { + let least = 3.0 * ROW; + let most = (avail - 40.0 - 3.0 * ROW).max(least); + asked.filter(|h| h.is_finite()).unwrap_or(avail * 0.4).clamp(least, most) +} + +/// The line between the pages and the layers: dragging it sizes the pages list (`shown`: its +/// height now), a double-click puts back the default. The main window keeps it in the preferences. +fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (line, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); + let grab = ui.style().interaction.resize_grab_radius_side; + let id = Id::new("pages_edge"); + let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::click_and_drag()); + let active = resp.hovered() || resp.dragged(); + if active { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let (width, colour) = if active { (2.0, t.accent) } else { (1.0, t.border) }; + ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(width, colour)); + let asked = if resp.double_clicked() { + Some(None) + } else if resp.dragged() { + // The edge stays under the pointer where it was grabbed. + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(id, shown)); + } + let from: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(shown); + let moved = ui.input(|i| Some(i.pointer.latest_pos()?.y - i.pointer.press_origin()?.y)).unwrap_or(0.0); + Some(Some(from + moved)) + } else { + None + }; + if let Some(h) = asked + && h != app.ui.pages_height + { + app.ui.pages_height = h; + if app.windows.current == 0 { + app.session.prefs.pages_height = h; + app.panels_unsaved = true; + } + } } /// A page rename just asked for by a double click: its field takes the focus when drawn (temp data). diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 43a8b76e..14d625a7 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -331,3 +331,74 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1)); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// The y of the line between the pages and the layers, above the layers' row at `layer` (found by +/// the resize cursor it shows when hovered). +fn pages_edge_at(u: &mut Ui, layer: f32) -> f32 { + let hits: Vec = ((layer - 120.0) as i32..layer as i32) + .map(|y| y as f32) + .filter(|y| u.frame_output(vec![Event::PointerMoved(Pos2::new(150.0, *y))]).platform_output.cursor_icon == egui::CursorIcon::ResizeVertical) + .collect(); + assert!(!hits.is_empty(), "no edge above {layer}"); + (hits[0] + hits[hits.len() - 1]) / 2.0 +} + +#[test] +fn the_line_between_pages_and_layers_drags_to_size_the_pages_list() { + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let first = s.doc().unwrap().pages[0]; + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let before = u.row_at(rect).y; + let edge = pages_edge_at(&mut u, before); + // A drag down by 150 px makes the pages list taller and moves the layers down with it. + let at = |dy: f32| Pos2::new(150.0, edge + dy); + let button = |p, pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + u.frame(vec![Event::PointerMoved(at(0.0))]); + u.frame(vec![button(at(0.0), true)]); + for k in 1..=15 { + u.frame(vec![Event::PointerMoved(at(10.0 * k as f32))]); + } + u.frame(vec![button(at(150.0), false)]); + for _ in 0..30 { + u.frame(vec![]); + } + let after = u.row_at(rect).y; + assert!((after - before - 150.0).abs() < 3.0, "the layers moved from {before} to {after}"); + // The height is kept for the next start. + let kept = u.app.session.prefs.pages_height.expect("kept in the preferences"); + let mut next = Session::new(); + next.prefs = u.app.session.prefs.clone(); + assert_eq!(crate::FigApp::new(next, Box::new(crate::NoServices)).ui.pages_height, Some(kept)); + // A double-click puts back the default. + let edge = pages_edge_at(&mut u, after); + let p = Pos2::new(150.0, edge); + u.frame(vec![Event::PointerMoved(p)]); + for _ in 0..2 { + u.frame(vec![button(p, true)]); + u.frame(vec![button(p, false)]); + } + for _ in 0..30 { + u.frame(vec![]); + } + assert_eq!(u.app.session.prefs.pages_height, None); + assert!((u.row_at(rect).y - before).abs() < 3.0); +} + +#[test] +fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() { + use crate::layers::{ROW, pages_list_height}; + // The default: 40% of the panel. + assert_eq!(pages_list_height(None, 800.0), 320.0); + // Dragged short or tall, it stays within three rows and the room the layers need. + assert_eq!(pages_list_height(Some(0.0), 800.0), 3.0 * ROW); + assert_eq!(pages_list_height(Some(5000.0), 800.0), 800.0 - 40.0 - 3.0 * ROW); + assert_eq!(pages_list_height(Some(f32::NAN), 800.0), 320.0); + // A panel too short for both keeps the three rows. + assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..21f30f62 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -398,6 +398,8 @@ pub struct UiState { pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, + /// The height asked for the pages list (`None`: a share of the panel). + pub pages_height: Option, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, @@ -528,6 +530,7 @@ impl Default for UiState { last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, + pages_height: None, expanded: BTreeSet::new(), rename: None, rename_page: None, @@ -646,8 +649,9 @@ impl FigApp { pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; + let pages_height = session.prefs.pages_height; Self { - ui: UiState { left_width, right_width, ..UiState::default() }, + ui: UiState { left_width, right_width, pages_height, ..UiState::default() }, session, canvas: canvas::CanvasState::default(), services, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 510c24f6..b6a21cda 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -208,13 +208,15 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option, path: Option< docs: vec![uid], active: Some(uid), pane: Pane { - // A new window opens like the one it comes from: the same tabs and panel widths. + // A new window opens like the one it comes from: the same tabs, panel widths and pages + // list height. ui: UiState { title_bar: app.ui.title_bar, right_tab: app.ui.right_tab, left_tab: app.ui.left_tab, left_width: app.ui.left_width, right_width: app.ui.right_width, + pages_height: app.ui.pages_height, ..UiState::default() }, ..Pane::default() From a911ab9614c3bc01c19ad86035bd8872a7ffc9cf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:29:55 +0000 Subject: [PATCH 050/245] Reject negative or fractional index in fill.set and stroke.set Found while testing crates/engine/src/cmd/edit.rs:1180 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/lib.rs | 2 ++ crates/engine/src/paint_index_tests.rs | 39 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/paint_index_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..35930cb7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1177,7 +1177,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = num(p, "index").map(|x| x.max(0.0) as usize); + let idx = if get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..a84ca21b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1594,6 +1594,8 @@ mod page_copy_navigation_tests; #[cfg(test)] mod page_target_tests; #[cfg(test)] +mod paint_index_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/paint_index_tests.rs b/crates/engine/src/paint_index_tests.rs new file mode 100644 index 00000000..eddea306 --- /dev/null +++ b/crates/engine/src/paint_index_tests.rs @@ -0,0 +1,39 @@ +//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of +//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`). + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn paints(s: &Session, id: NodeId) -> String { + let n = s.doc().unwrap().node(id).unwrap(); + format!("{:?} {:?}", n.fills, n.strokes) +} + +#[test] +fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() { + let mut s = Session::new(); + let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + for add in ["fill.add", "stroke.add"] { + for c in ["#0000FF", "#FFFF00"] { + ex(&mut s, add, json!({"ids": [r], "color": c})); + } + } + let before = paints(&s, r); + for cmd in ["fill.set", "stroke.set"] { + for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] { + let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"})); + assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}"); + assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document"); + } + ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"})); + assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(paints(&s, r), before); + } +} From 11455e0fe9f7f74a1411065ccab815293de149c0 Mon Sep 17 00:00:00 2001 From: loyet Date: Fri, 16 Oct 2026 10:44:29 +0200 Subject: [PATCH 051/245] M1: .fig import reads images carried in the message (image.dataBlob) Copies written by plugins and tools carry their image bytes in the message: a paint's image.dataBlob indexes the message's blobs. A bare canvas (what the clipboard holds, see #647) has no images/, so these fills came in as grey placeholders. The importer now falls back to the embedded bytes, keyed by image hash, after the archive's images/. Blobs over the image size cap and indexes past the blobs are skipped and stay placeholders. Tests: a bare canvas carrying its image in a blob, and a dataBlob past the blobs. --- crates/format/src/fig/mod.rs | 43 ++++++++++++++++++++++++++--- crates/format/src/fig/tests.rs | 49 +++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 ++-- 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..091492da 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -6,7 +6,8 @@ //! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file //! carries its own schema, so the message is decoded generically and read by field name: a list //! of node changes (each with its id, parent id and position among its siblings) that make up the -//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`] +//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`], and the bytes +//! of images that travel inside the message, [`embedded_images`]). [`map`] //! turns that into the REST API file JSON shape and the REST importer builds the document, so both //! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors, //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped @@ -23,6 +24,7 @@ mod network; #[cfg(test)] mod tests; +use std::collections::BTreeMap; use std::io::Read; use figcraft_doc::Document; @@ -30,7 +32,7 @@ use figcraft_doc::Document; pub use export::export; use crate::FormatError; -use kiwi::Schema; +use kiwi::{Kv, Schema}; /// Largest `canvas.fig` member read, and largest decompressed chunk. const MAX_CANVAS: u64 = 256 * 1024 * 1024; @@ -63,7 +65,8 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings) = map::to_rest(&msg, file_name); - let images = |r: &str| archive.image(r); + let embedded = embedded_images(&msg); + let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); let (doc, more) = crate::rest::import(&json, &images)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) @@ -94,6 +97,40 @@ fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!(".fig file: {}", msg.into())) } +/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's +/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed +/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The +/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. +fn embedded_images(msg: &Kv) -> BTreeMap> { + let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); + let mut found = BTreeMap::new(); + let mut pending = vec![msg]; + while let Some(value) = pending.pop() { + match value { + Kv::Object(fields) => { + if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { + found.entry(hash).or_insert_with(|| bytes.to_vec()); + } + pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); + } + Kv::Array(items) => pending.extend(items), + _ => {} + } + } + found +} + +/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob. +fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> { + let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?; + let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None }; + let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?; + if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE { + return None; + } + Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes)) +} + /// The parts of a `.fig` archive the import reads. struct Archive<'b> { canvas: Vec, diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..c8749513 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -38,7 +38,7 @@ struct Color float r, float g, float b, float a struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position -message Image byte[] hash, string name +message Image byte[] hash, string name, uint dataBlob message AssetRef string key, string version message VariableID GUID guid, AssetRef assetRef message PropRefValue GUID defId @@ -539,6 +539,53 @@ fn bare_canvas_and_zlib_chunks_open() { assert!(import(&out, "Z").is_ok()); } +/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins +/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift` +/// places past it (0 is the PNG itself). +fn message_with_image_in_blobs(shift: u64) -> Kv<'static> { + fn point_images_at(value: &mut Kv<'static>, blob: u64) { + match value { + Kv::Object(fields) => { + for (name, field) in fields.iter_mut() { + match field { + Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))), + _ => point_images_at(field, blob), + } + } + } + Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)), + _ => {} + } + } + let mut msg = message(); + let index = { + let Kv::Object(fields) = &mut msg else { panic!("the message is an object") }; + let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") }; + blobs.push(o(vec![("bytes", Kv::Bytes(png()))])); + blobs.len() as u64 - 1 + }; + point_images_at(&mut msg, index + shift); + msg +} + +#[test] +fn a_bare_canvas_reads_images_carried_in_its_blobs() { + // The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`. + let c = canvas(&schema(), &message_with_image_in_blobs(0)); + let (doc, warnings) = import(&c, "Pasted").unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +#[test] +fn a_data_blob_past_the_blobs_stays_a_placeholder() { + let c = canvas(&schema(), &message_with_image_in_blobs(5)); + let (_, warnings) = import(&c, "Pasted").unwrap(); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + #[test] fn other_files_are_not_fig() { assert!(try_import(b"{\"document\": {}}", "x").is_none()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..c9e5da2f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` import: images carried in the message (`image.dataBlob`); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills (from the archive, or carried in the message itself as `image.dataBlob`, the way copies written by plugins and tools carry them) come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | `.fig` import: image fills whose bytes travel in the message itself (`image.dataBlob`, an index into the message's blobs) come across. Copies written by plugins and tools carry their images this way, and a bare canvas, the shape the clipboard holds, has no `images/`: they were grey placeholders. Blobs over the image size cap and indexes past the blobs are skipped (placeholder, as before). Checked on a bare canvas generated in code and on copies written by an HTML-to-Figma browser extension. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From b962603707a78e61cd457b15c93c53effe81909b Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Fri, 16 Oct 2026 10:46:36 +0200 Subject: [PATCH 052/245] M4: points dropped on a point merge In vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so a path couldn't be closed or joined by dragging. A single dragged point dropped within the point hit radius (6 px) of another point now merges into it, in the same undo step as the move, and that point stays selected. Closes #401 --- ROADMAP.md | 4 ++- crates/engine/src/vectoredit/pointer.rs | 37 ++++++++++++++++++++++++- crates/engine/src/vectoredit/tests.rs | 22 +++++++++++++++ docs/roadmap.md | 3 +- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..129c44bb 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-16 · **Change:** minor (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-16 · **Change:** minor (points dropped on a point merge (#401); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-16** · Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 388fe245..a42b0603 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -996,8 +996,13 @@ impl Session { self.tool.overlay.guides.clear(); let Gesture::Vector(g) = g else { return Ok(()) }; match g { - VGesture::Points { moved, split, click_clears, .. } => { + VGesture::Points { verts, others, moved, split, click_clears, .. } => { if moved || split.is_some() { + if let ([v], None) = (verts.as_slice(), split) + && others.iter().all(|(_, o)| o.is_empty()) + { + self.merge_dropped_point(ve.id, *v, 6.0 / ev.zoom.max(1e-6))?; + } self.commit_interaction()?; } else { self.cancel_interaction()?; @@ -1057,4 +1062,34 @@ impl Session { } Ok(()) } + + /// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its + /// points, merges into that point (in the same undo step as the move), which stays selected. + fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> { + let moved = self.ds()?.doc.get(id)?.clone(); + let w = self.ds()?.doc.world_transform(id); + let NodeKind::Vector { network: net } = &moved.kind else { return Ok(()) }; + let Some(p) = net.vertices.get(v).map(|x| w * x.point()) else { return Ok(()) }; + let Some(keep) = net + .vertices + .iter() + .enumerate() + .filter(|(i, _)| *i != v) + .map(|(i, x)| (i, (w * x.point() - p).hypot())) + .filter(|(_, d)| *d <= tol) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + else { + return Ok(()); + }; + // The preview starts again from the document before the drag: bring the moved layer. + self.preview(|doc, _| { + *doc.get_mut(id)? = moved; + edit(doc, id, |net| ops::merge_vertices(net, keep, v)) + })?; + set_state(self, |e| { + e.vertices = vec![if keep > v { keep - 1 } else { keep }]; + e.segments.clear(); + }) + } } diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index ae7ac27e..63469d67 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -280,6 +280,28 @@ fn mirroring_position_and_join_commands() { assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok()); } +#[test] +fn a_point_dropped_on_another_merges_into_it() { + // #401: an open path's end dragged onto its start closes the path at that point. + let mut s = Session::new(); + let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]]})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + drag(&mut s, (0.0, 100.0), (2.0, 3.0)); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 3, "the dropped point merged"); + assert!((0..3).all(|v| n.incident(v).len() == 2), "a closed path: {n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 0.0, 0.0)], "the point it merged into stays selected"); + s.undo().unwrap(); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 4, "one undo step takes back the move and the merge"); + vertex_at(&n, 0.0, 100.0); + // A drop away from every point only moves it. + ex(&mut s, "vector.edit.select", json!({"vertices": []})); + drag(&mut s, (0.0, 100.0), (0.0, 50.0)); + assert_eq!(net(&s, id).vertices.len(), 4); +} + #[test] fn undo_back_past_the_conversion_leaves_edit_mode() { let mut s = Session::new(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e9eaec9..680c5b68 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (points dropped on a point merge (#401); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From 6d3e7aad20d91f63aab90fa9b94ae0332da5f730 Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 00:03:58 +0500 Subject: [PATCH 053/245] M7: isolate presenter tab and device controls --- ATTRIBUTION.md | 2 + ROADMAP.md | 3 +- crates/doc/src/proto.rs | 93 +++++++++++- crates/ui-egui/locales/en.toml | 6 +- crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/src/comments_tests.rs | 2 +- crates/ui-egui/src/lib.rs | 14 +- crates/ui-egui/src/present.rs | 146 ++++++++++++++++++- crates/ui-egui/src/present_view_tests.rs | 32 +++- crates/ui-egui/src/proto_responsive_tests.rs | 4 +- crates/ui-egui/src/tabs.rs | 65 ++++++++- docs/gaps.md | 6 +- docs/images/shot-8-presenter-tab.png | Bin 0 -> 75341 bytes docs/images/shot-9-device-picker.png | Bin 0 -> 106019 bytes 14 files changed, 352 insertions(+), 25 deletions(-) create mode 100644 docs/images/shot-8-presenter-tab.png create mode 100644 docs/images/shot-9-device-picker.png diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1f72aeb7..cf2e7e63 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -14,6 +14,8 @@ MIT OR Apache-2.0. No third-party icon sets are used. | `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) | | `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only | | `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork | +| `docs/images/shot-8-presenter-tab.png` | @YarilaFox | Original screenshot of FigCraft presenting a synthetic document made in FigCraft | MIT OR Apache-2.0 | Presenter tab and top bar; no third-party artwork | +| `docs/images/shot-9-device-picker.png` | @YarilaFox | Original screenshot of FigCraft presenting a synthetic document made in FigCraft | MIT OR Apache-2.0 | Device and scale controls; no third-party artwork | | `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork | ## Fonts (build input, not in this repository) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..25439517 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-16 · **Change:** minor (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-16 · **Change:** minor (presenter tab, device picker and thumbnail; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Presenter has a separate tab, keeps its current screen when returning to design, and adds a frame thumbnail, device picker and scale controls. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 91d2e8eb..a844774c 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -860,7 +860,35 @@ pub struct Device { impl PrototypeSettings { /// The devices to pick from ("None" presents the frame at its own size). - pub const DEVICES: [Device; 6] = [ + pub const DEVICES: [Device; 19] = [ + Device { + name: "iPhone 17", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)], + }, + Device { + name: "iPhone 17 Pro", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 17 Pro Max", + width: 440.0, + height: 956.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone Air", + width: 420.0, + height: 912.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, Device { name: "iPhone 16", width: 393.0, @@ -874,6 +902,13 @@ impl PrototypeSettings { c("Blue", 0x72, 0x84, 0xD4), ], }, + Device { + name: "iPhone 16 Pro", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)], + }, Device { name: "iPhone 16 Pro Max", width: 440.0, @@ -881,6 +916,55 @@ impl PrototypeSettings { rotates: true, colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)], }, + Device { + name: "iPhone 16 Plus", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, + Device { + name: "iPhone 15 Pro", + width: 393.0, + height: 852.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 15 Pro Max", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 15 Plus", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, + Device { + name: "iPhone 14 Pro", + width: 393.0, + height: 852.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 14 Pro Max", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 14", + width: 390.0, + height: 844.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, Device { name: "Android Compact", width: 412.0, @@ -888,6 +972,13 @@ impl PrototypeSettings { rotates: true, colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)], }, + Device { + name: "Android Medium", + width: 700.0, + height: 840.0, + rotates: true, + colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)], + }, Device { name: "iPad mini", width: 744.0, diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..961b3c70 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -337,6 +337,7 @@ name = "English" "Current version" = "Current version" "Cursor chat" = "Cursor chat" "Custom" = "Custom" +"Custom size" = "Custom size" "Cut" = "Cut" "Dark" = "Dark" "Darken" = "Darken" @@ -801,6 +802,7 @@ name = "English" "New design file" = "New design file" "Newest first" = "Newest first" "Next or previous control in the focused region" = "Next or previous control in the focused region" +"Next frame" = "Next frame" "Next ready for dev" = "Next ready for dev" "Next size option" = "Next size option" "No assets: no export settings, icons or images. Add export settings in the Design tab." = "No assets: no export settings, icons or images. Add export settings in the Design tab." @@ -815,6 +817,7 @@ name = "English" "No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}." "No conflicts." = "No conflicts." "No contributor data was built into this copy." = "No contributor data was built into this copy." +"No device" = "No device" "No fonts match" = "No fonts match" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." "No layer changes to show." = "No layer changes to show." @@ -996,6 +999,7 @@ name = "English" "Preview" = "Preview" "Preview this flow" = "Preview this flow" "Preview this frame" = "Preview this frame" +"Previous frame" = "Previous frame" "Previous ready for dev" = "Previous ready for dev" "Progressive" = "Progressive" "Properties" = "Properties" @@ -1603,4 +1607,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" \ No newline at end of file +"⌥ drag" = "⌥ drag" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..557add9b 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -338,6 +338,7 @@ status = "draft — needs native review" "Current version" = "Versión actual" "Cursor chat" = "Chat del cursor" "Custom" = "Personalizado" +"Custom size" = "Tamaño personalizado" "Cut" = "Cortar" "Dark" = "Oscuro" "Darken" = "Oscurecer" @@ -802,6 +803,7 @@ status = "draft — needs native review" "New design file" = "Nuevo archivo de diseño" "Newest first" = "Más recientes primero" "Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada" +"Next frame" = "Marco siguiente" "Next ready for dev" = "Siguiente listo para desarrollo" "Next size option" = "Siguiente opción de tamaño" "No assets: no export settings, icons or images. Add export settings in the Design tab." = "No hay recursos: ni ajustes de exportación, ni iconos, ni imágenes. Añade ajustes de exportación en la pestaña Diseño." @@ -816,6 +818,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}." "No conflicts." = "Sin conflictos." "No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores." +"No device" = "Sin dispositivo" "No fonts match" = "Ninguna fuente coincide" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace." "No layer changes to show." = "No hay cambios de capas que mostrar." @@ -997,6 +1000,7 @@ status = "draft — needs native review" "Preview" = "Vista previa" "Preview this flow" = "Vista previa de este flujo" "Preview this frame" = "Vista previa de este marco" +"Previous frame" = "Marco anterior" "Previous ready for dev" = "Anterior listo para desarrollo" "Progressive" = "Progresivo" "Properties" = "Propiedades" diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index bf30d5e5..696ff322 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -213,7 +213,7 @@ fn the_presenter_shows_the_frames_comments() { key(&mut u, Key::C, Modifiers::SHIFT); assert_eq!(u.app.inspect()["presenter"]["comments"], true); // The frame is presented at 100% (Fit never scales up), centred under the bar. - let screen = egui::Rect::from_min_size(pos2(0.0, 40.0), vec2(1440.0, 860.0)); + let screen = egui::Rect::from_min_size(pos2(0.0, 80.0), vec2(1440.0, 820.0)); let frame_min = screen.center() - vec2(200.0, 150.0); let pin = frame_min + vec2(100.0, 100.0) + vec2(14.0, -14.0); click(&mut u, pin); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..91f7a56b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -426,6 +426,10 @@ pub struct UiState { /// A message at the bottom of the window and how long it has been showing. pub toast: Option<(String, ToastClock)>, pub present: Option, + /// A presentation kept alive while its design tab or Home is in front. + pub paused_present: Option, + /// The file that owns the presentation tab. + pub present_uid: Option, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, @@ -547,6 +551,8 @@ impl Default for UiState { icon_gallery: false, toast: None, present: None, + paused_present: None, + present_uid: None, present_view: present::PresentView::default(), preview: None, picker: None, @@ -837,6 +843,9 @@ impl FigApp { let mut p = present::Presenter::new(start); p.view = self.ui.present_view; self.ui.present = Some(p); + self.ui.paused_present = None; + self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid); + self.ui.home = false; } } "present.options" => { @@ -1301,6 +1310,7 @@ impl FigApp { // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; self.end_pen_outside_design(); + tabs::tab_bar(self, ui); if self.ui.present.is_some() { present::show(self, ui); // What went wrong while presenting (a widget's plugin failing) shows over it. @@ -1308,7 +1318,6 @@ impl FigApp { return; } if !self.has_files() || self.ui.home { - tabs::tab_bar(self, ui); home::show(self, ui); dialogs::keys(self, &ctx); dialogs::show(self, &ctx); @@ -1317,7 +1326,6 @@ impl FigApp { return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); - tabs::tab_bar(self, ui); if !hidden { egui::Panel::left("left_rail") .exact_size(56.0) @@ -1480,7 +1488,7 @@ impl FigApp { "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), - "presenter": self.ui.present.as_ref().map(present::Presenter::inspect), + "presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index b5f177af..bb611588 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -270,6 +270,8 @@ pub struct Presenter { anim_tex: Option, temp: Option, textures: HashMap, + /// Small current-screen preview in the full-window bar, cached until its content changes. + bar_thumb: Option<(NodeId, usize, egui::TextureHandle)>, arrived: f64, delays_fired: HashSet<(NodeId, usize)>, /// Mouse triggers waiting out their delay. @@ -348,6 +350,7 @@ impl Presenter { anim_tex: None, temp: None, textures: HashMap::new(), + bar_thumb: None, arrived: -1.0, delays_fired: HashSet::new(), waiting: Vec::new(), @@ -1651,6 +1654,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action); }); + let picked_device = navigation_bar(&mut pres, &ctx, now); // Keys the prototype left alone: C shows the comments, F fills the screen. if !typing { if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::C)) { @@ -1679,12 +1683,123 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } + if let Some(device) = picked_device { + app.run("proto.settings", json!({"device": device})); + } if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.present_uid = None; return; } app.ui.present = Some(pres); } +/// Controls that stay in reach while viewing a prototype, including screen stepping, device and +/// scale. The selected device is returned to the caller so it can go through the normal document +/// command (and undo/save) rather than changing only the player's copy. +fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option { + let t = Tokens::get(ctx); + let doc = p.doc.as_ref()?; + let frames = doc.top_frames(doc.page_of(p.current).unwrap_or(p.current)); + let index = frames.iter().position(|id| *id == p.current); + let settings = page_settings(doc, p.current); + let mut navigate = None; + let mut device_choice = None; + egui::Area::new(Id::new("present_navigation")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -18.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let prev = index.and_then(|i| i.checked_sub(1)).and_then(|i| frames.get(i)).copied(); + let next = index.and_then(|i| frames.get(i + 1)).copied(); + let previous = + ui.add_enabled(prev.is_some(), egui::Button::new("").min_size(vec2(26.0, 24.0))).on_hover_text(tr("Previous frame")); + icons::paint( + ui.painter(), + Rect::from_center_size(previous.rect.center(), vec2(21.0, 21.0)), + Icon::ChevronLeft, + if prev.is_some() { t.icon } else { t.icon_secondary }, + ); + if previous.clicked() { + navigate = prev; + } + ui.label(egui::RichText::new(format!("{} / {}", index.map_or(0, |i| i + 1), frames.len())).color(t.text)); + let following = ui.add_enabled(next.is_some(), egui::Button::new("").min_size(vec2(26.0, 24.0))).on_hover_text(tr("Next frame")); + icons::paint( + ui.painter(), + Rect::from_center_size(following.rect.center(), vec2(21.0, 21.0)), + Icon::ChevronRight, + if next.is_some() { t.icon } else { t.icon_secondary }, + ); + if following.clicked() { + navigate = next; + } + ui.separator(); + let model = if settings.device.is_empty() || settings.device == "None" { tr("No device") } else { &settings.device }; + let device_menu = ui.menu_button(format!("{model} "), |ui| { + navigation_menu(ui, |ui| { + egui::ScrollArea::vertical().max_height(410.0).show(ui, |ui| { + if ui.selectable_label(settings.device.is_empty() || settings.device == "None", tr("No device")).clicked() { + device_choice = Some("None".to_owned()); + ui.close(); + } + ui.separator(); + for device in PrototypeSettings::DEVICES { + let label = format!("{} {}×{}", device.name, device.width as u32, device.height as u32); + if ui.selectable_label(settings.device == device.name, label).clicked() { + device_choice = Some(device.name.to_owned()); + ui.close(); + } + } + ui.separator(); + if ui.selectable_label(settings.device == PrototypeSettings::CUSTOM_DEVICE, tr("Custom size")).clicked() { + device_choice = Some(PrototypeSettings::CUSTOM_DEVICE.to_owned()); + ui.close(); + } + }); + }); + }); + paint_menu_chevron(ui.painter(), device_menu.response.rect, t.icon_secondary); + let scale_menu = ui.menu_button(format!("{} ", tr(p.view.scale.label(settings.device().is_some()))), |ui| { + navigation_menu(ui, |ui| { + for scale in PresentScale::options(settings.device().is_some()) { + let label = egui::RichText::new(tr(scale.label(settings.device().is_some()))).color(t.menu_text); + if ui.selectable_label(p.view.scale == *scale, label).clicked() { + p.set_scale(*scale); + ui.close(); + } + } + }); + }); + paint_menu_chevron(ui.painter(), scale_menu.response.rect, t.icon_secondary); + }); + }); + }); + if let Some(id) = navigate { + p.go(id, Transition::Instant, 0.0, Easing::Linear, now, true, false); + } + device_choice +} + +fn paint_menu_chevron(painter: &egui::Painter, button: Rect, color: Color32) { + let icon = Rect::from_center_size(pos2(button.right() - 11.0, button.center().y), vec2(17.0, 17.0)); + icons::paint(painter, icon, Icon::Chevron, color); +} + +fn navigation_menu(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + ui.visuals_mut().override_text_color = Some(t.menu_text); + egui::Frame::NONE.fill(t.menu).stroke(Stroke::new(1.0, t.border)).corner_radius(CornerRadius::same(7)).inner_margin(egui::Margin::same(6)).show( + ui, + |ui| { + ui.set_min_width(212.0); + add(ui); + }, + ); +} + /// How often the player polls the controllers while its prototype has gamepad triggers (seconds). const PAD_POLL_SECS: f64 = 1.0 / 30.0; @@ -2045,16 +2160,17 @@ fn inspect_hover(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context /// the right (text on the dark full-window bar, icons in the pane, which follows the theme). fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) -> Option { let t = Tokens::get(ui.ctx()); - let (fill, text, dim, hover) = if chrome.pane { - (t.panel, t.text, t.text_secondary, t.hover) - } else { - (Color32::from_rgb(0x2C, 0x2C, 0x2C), Color32::WHITE, Color32::from_gray(140), Color32::from_white_alpha(28)) - }; + let (fill, text, dim, hover) = (t.panel, t.text, t.text_secondary, t.hover); let r = chrome.round; ui.painter().rect_filled(bar, CornerRadius { nw: r, ne: r, sw: 0, se: 0 }, fill); if chrome.pane { ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border)); } + let source_key = p.source.as_ref().map_or(0, |s| Arc::as_ptr(s) as usize); + if !chrome.pane && p.bar_thumb.as_ref().is_none_or(|(id, source, _)| *id != p.current || *source != source_key) { + p.bar_thumb = + p.source.as_ref().and_then(|d| crate::assets_panel::thumbnail(ui.ctx(), d, p.current, 38.0)).map(|tex| (p.current, source_key, tex)); + } let doc = p.doc.as_ref()?; let name = doc.node(p.current).map(|n| n.name.clone()).unwrap_or_default(); let flows = match doc.page_of(p.start).and_then(|pg| doc.node(pg)).map(|n| &n.kind) { @@ -2117,19 +2233,33 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) // Left: the flow picker and the frame's name, clipped before the buttons. let left = ui.painter().with_clip_rect(Rect::from_min_max(bar.min, pos2(x, bar.max.y))); let mut lx = bar.min.x + if chrome.pane { 8.0 } else { 12.0 }; + if !chrome.pane { + let thumb = Rect::from_center_size(pos2(lx + 15.0, bar.center().y), vec2(28.0, 28.0)); + left.rect_filled(thumb, CornerRadius::same(4), t.field); + if let Some((_, _, tex)) = &p.bar_thumb { + let size = tex.size_vec2(); + if size.x > 0.0 && size.y > 0.0 { + let scaled = size * (26.0 / size.x.max(size.y)); + let dst = Rect::from_center_size(thumb.center(), scaled); + left.image(tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } + lx += 38.0; + } // The flow playing: its name on the picker, its description on hover; the menu lists each // flow with its description. let playing = flows.iter().find(|f| f.start == p.start); if !flows.is_empty() { - let label = format!("{} ▾", playing.map_or(tr("Flows"), |f| f.name.as_str())); + let label = playing.map_or(tr("Flows"), |f| f.name.as_str()).to_owned(); let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string(); let galley = left.layout_no_wrap(label, semibold(11.5), text); - let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); + let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 30.0, 24.0)); let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip); if resp.hovered() { left.rect_filled(fr, CornerRadius::same(5), hover); } left.galley(pos2(fr.min.x + 8.0, fr.center().y - galley.size().y / 2.0), galley, text); + paint_menu_chevron(&left, fr, t.icon_secondary); crate::right::menu_popup(&resp).width(240.0).show(|ui| { let t = Tokens::get(ui.ctx()); for f in &flows { @@ -2149,7 +2279,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, "/", regular(11.5), dim); lx += 14.0; } - left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), if chrome.pane { text } else { Color32::from_gray(220) }); + left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), text); action } diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 3571b2ad..8fdd5eb0 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -95,6 +95,28 @@ fn scale_options_match_the_command_and_cycle() { assert!(!PresentScale::options(true).contains(&PresentScale::FitWidth)); } +#[test] +fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() { + let (mut u, a, b, _) = setup(); + u.run("proto.present", json!({"start": a})); + u.key(pos2(720.0, 470.0), Key::ArrowRight); + assert_eq!(presenter(&u)["current"], json!(b)); + + let design_tab = pos2(100.0, 20.0); + u.button(design_tab, true); + u.button(design_tab, false); + assert!(u.app.ui.present.is_none()); + assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b)); + + let name_len = u.app.session.doc().unwrap().name.chars().count() as f32; + let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0); + let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0); + u.button(preview_tab, true); + u.button(preview_tab, false); + assert!(u.app.ui.paused_present.is_none()); + assert_eq!(presenter(&u)["current"], json!(b)); +} + /// No device: a big screen (2000×3000) and a small one, presenting the big one. fn big() -> (Ui, NodeId) { let mut s = Session::new(); @@ -117,10 +139,10 @@ fn num(v: &Value) -> f64 { fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); let at = pos2(720.0, 470.0); - // Fit: scaled down to the 1376×796 inside the padding; nothing pans. + // Fit: the tab strip leaves 1376×756 inside the presenter's padding; nothing pans. let p = presenter(&u); assert_eq!(p["scale"], "fit"); - assert!((num(&p["zoom"]) - 796.0 / 3000.0).abs() < 1e-3, "{p}"); + assert!((num(&p["zoom"]) - 756.0 / 3000.0).abs() < 1e-3, "{p}"); assert_eq!(p["panRange"], json!([0.0, 0.0])); // Z: fit width (the whole 1440 width; the rest of the height pans). u.key(at, Key::Z); @@ -128,14 +150,14 @@ fn presenter_scales_by_its_options_and_pans_what_overflows() { assert_eq!(p["scale"], "fitWidth"); assert!((num(&p["zoom"]) - 0.72).abs() < 1e-3, "{p}"); assert_eq!(p["panRange"][0], 0.0); - assert!((num(&p["panRange"][1]) - (2160.0 - 860.0)).abs() < 1.0, "{p}"); + assert!((num(&p["panRange"][1]) - (2160.0 - 820.0)).abs() < 1.0, "{p}"); // Z: fill (here the width decides too), then 100%: both ways overflow, centred across at the top. u.key(at, Key::Z); assert_eq!(presenter(&u)["scale"], "fill"); u.key(at, Key::Z); let p = presenter(&u); assert_eq!((p["scale"].as_str(), num(&p["zoom"])), (Some("actual"), 1.0)); - assert_eq!(p["panRange"], json!([624.0, 2204.0])); + assert_eq!(p["panRange"], json!([624.0, 2244.0])); assert_eq!(p["pan"], json!([312.0, 0.0])); // The wheel pans down; a drag pans with the pointer. let wheel = move || { @@ -192,7 +214,7 @@ fn devices_turn_and_their_frame_can_hide() { let with_body = num(&presenter(&u)["zoom"]); u.run("proto.presentOptions", json!({"deviceFrame": false})); let bare = num(&presenter(&u)["zoom"]); - assert!(bare > with_body && (bare - 796.0 / 852.0).abs() < 1e-3, "{with_body} {bare}"); + assert!(bare > with_body && (bare - 756.0 / 852.0).abs() < 1e-3, "{with_body} {bare}"); // A device offers no fit width: Z goes fit → fill → 100%. u.key(pos2(720.0, 470.0), Key::Z); assert_eq!(presenter(&u)["scale"], "fill"); diff --git a/crates/ui-egui/src/proto_responsive_tests.rs b/crates/ui-egui/src/proto_responsive_tests.rs index fc9ccda2..f0ead8e9 100644 --- a/crates/ui-egui/src/proto_responsive_tests.rs +++ b/crates/ui-egui/src/proto_responsive_tests.rs @@ -69,8 +69,8 @@ fn without_a_device_a_responsive_frame_fills_the_window_at_100_percent() { let mut u = Ui::new(s); u.run("proto.present", json!({"start": w})); let p = presenter(&u); - // The window is 1440×900; the bar takes 40. - assert_eq!(size(&p["frameSize"]), (1440.0, 860.0), "{p}"); + // The window is 1440×900; the title and tab bars take 80. + assert_eq!(size(&p["frameSize"]), (1440.0, 820.0), "{p}"); assert_eq!(p["zoom"], json!(1.0)); assert_eq!(size(&p["panRange"]), (0.0, 0.0)); // Z has nothing to cycle: the size options don't apply. diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 27ff085c..534881fc 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -33,7 +33,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { // Home. let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT)); let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).tip(tr("Home")); - let home_active = app.ui.home || !app.has_files(); + let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none(); crate::a11y::tab(&hresp, tr("Home"), home_active); if home_active { ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active); @@ -42,6 +42,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon); if hresp.clicked() { + if let Some(p) = app.ui.present.take() { + app.ui.paused_present = Some(p); + } app.ui.home = true; } x += 44.0; @@ -52,7 +55,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let mut detach = None; let several = !ui.ctx().embed_viewports(); for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) { - let active = i == app.session.active && !app.ui.home; + let active = i == app.session.active && !app.ui.home && app.ui.present.is_none(); let name = d.doc.name.clone(); // In a live session: a mark after the name says whether others get in. let live = live_mark(app, d.uid); @@ -132,6 +135,41 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + // Presentations live in their own tab. Switching back to the design keeps the player + // state (current screen, navigation history and scroll position) for the return trip. + if app.ui.present.is_some() || app.ui.paused_present.is_some() { + let w = 128.0; + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); + let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let active = app.ui.present.is_some(); + crate::a11y::tab(&resp, tr("Present"), active); + if active { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); + let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); + if resp.hovered() || active { + let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); + if close_resp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); + } + icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); + if close_resp.clicked() { + app.ui.present = None; + app.ui.paused_present = None; + app.ui.present_uid = None; + } else if resp.clicked() && !active { + resume_presentation(app); + } + } else if resp.clicked() && !active { + resume_presentation(app); + } + x += w; + ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); + } // New tab. let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new")); @@ -140,6 +178,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon); if nresp.clicked() { + if let Some(p) = app.ui.present.take() { + app.ui.paused_present = Some(p); + } app.run("file.new", json!({})); app.ui.home = false; } @@ -155,6 +196,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.run(cmd, json!({})); } if let Some(i) = switch { + if let Some(p) = app.ui.present.take() { + app.ui.paused_present = Some(p); + } app.run("doc.switch", json!({"index": i})); app.ui.home = false; } @@ -180,12 +224,29 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } else { app.run("file.close", json!({"index": i})); } + if app.ui.present_uid.is_some_and(|uid| !app.session.docs.iter().any(|d| d.uid == uid)) { + app.ui.present = None; + app.ui.paused_present = None; + app.ui.present_uid = None; + } } let _ = Color32::TRANSPARENT; let _ = Align2::LEFT_TOP; }); } +fn resume_presentation(app: &mut FigApp) { + let Some(uid) = app.ui.present_uid else { return }; + let Some(index) = app.session.docs.iter().position(|d| d.uid == uid) else { + app.ui.paused_present = None; + app.ui.present_uid = None; + return; + }; + app.run("doc.switch", json!({"index": index})); + app.ui.home = false; + app.ui.present = app.ui.paused_present.take(); +} + /// What the tab of document `uid` says about its live session: others get in (`true`: a session /// this app hosts or joined, a file shared online), only you do (`false`: a file of yours on a /// server, not shared now), or it has none. diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..95962a5c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (presenter navigation and device controls; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -138,6 +138,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, `theme_pages_tests`). +- **Landed (2026-10-15):** presentation keeps its own tab beside the design, preserves the + current screen when switching back to edit, shows a small current-screen preview and offers + screen, device and scale controls (`present_view_tests`). - **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). @@ -400,6 +403,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Gap 4: presenter tab, thumbnail, device picker and scale controls. Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | diff --git a/docs/images/shot-8-presenter-tab.png b/docs/images/shot-8-presenter-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..d5cee8e179bebf78e69afdb7311d460c4a5a072c GIT binary patch literal 75341 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 E02Al^oB#j- literal 0 HcmV?d00001 diff --git a/docs/images/shot-9-device-picker.png b/docs/images/shot-9-device-picker.png new file mode 100644 index 0000000000000000000000000000000000000000..2f4805154b9cef0c9e122acc1c8c7ed484c1d72e GIT binary patch literal 106019 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 ILUZqjTh)0r*Z=?k literal 0 HcmV?d00001 From 2cac36a4e22ca0b177c9852875a6f959f8966be7 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 12:31:50 +0300 Subject: [PATCH 054/245] Live font preview on hover in font picker (#653) Hovering a family in the Typography font dropdown previews it on the canvas via text.preview; closing without picking restores the original, clicking applies it as one undo step. --- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/lib.rs | 4 ++-- crates/engine/src/text_style_picker_tests.rs | 25 ++++++++++++++++++++ crates/engine/src/textpreview.rs | 4 ++-- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/style_picker.rs | 2 +- crates/ui-egui/src/typography.rs | 22 ++++++++++++----- 7 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 14ea1762..3b861895 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -332,7 +332,7 @@ pub fn specs() -> Vec { spec!( "text.preview", "Preview text change", - "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", false, |s, p| { let c = req_string("text.preview", p, "command")?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..8c6ca635 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -316,8 +316,8 @@ pub struct DocState { versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, - /// The document under a live text preview (a hovered text style or font instance, a dragged - /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. + /// The document under a live text preview (a hovered font family or style, text style or + /// font instance, a dragged axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. pub text_preview: Option>, /// The document as of the last version saved or restored (none yet): autosaves, restores and /// merges only add a checkpoint for changes since. diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs index 37c7dc5c..6ee8cc2c 100644 --- a/crates/engine/src/text_style_picker_tests.rs +++ b/crates/engine/src/text_style_picker_tests.rs @@ -212,6 +212,31 @@ fn previews_while_editing_keep_the_edit() { assert!(links(&s, t).iter().all(Option::is_none)); } +#[test] +fn previews_show_a_hovered_font_family() { + let mut s = new_session(); + let t = text(&mut s, "Preview me"); + s.ds_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + // Hovering a font family shows it on the canvas: no undo step, not dirty. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Arial"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Arial"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert!(!s.ds().unwrap().is_dirty()); + // Moving to another family replaces the preview, from the document itself. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Georgia"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + // Closing without picking puts the document back as it was. + assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(true)); + assert_eq!(*s.doc().unwrap(), before); + // Clicking commits it as one undo step. + s.execute("text.style", &json!({"ids": [t], "fontFamily": "Georgia"})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1); + assert!(s.ds().unwrap().text_preview.is_none()); +} + #[test] fn previews_refuse_what_they_cant_show() { let mut s = new_session(); diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/textpreview.rs index 507eccc3..da101d5e 100644 --- a/crates/engine/src/textpreview.rs +++ b/crates/engine/src/textpreview.rs @@ -1,6 +1,6 @@ //! Live text previews: what a text command would do, shown on the canvas without an undo step — -//! while the pointer is over a text style in the style browser or a font's named instance, or -//! while a variable font axis slider is dragged. The document under the preview is kept and comes +//! while the pointer is over a font family or style, a text style in the style browser or a +//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes //! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so //! committing (running the command for real) applies to the document itself as one undo step, and //! Escape just ends the preview. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..d98b9156 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -583,7 +583,7 @@ impl FigApp { } /// Show what `command params` would do on the canvas as a live preview (`text.preview`) while - /// a text style, a named instance or an axis slider is hovered or dragged. Call it every frame + /// a font family, font style, text style, named instance or axis slider is hovered or dragged. Call it every frame /// the preview should stay: it ends on the first frame nothing asks for it. pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) { self.ui.text_preview_wanted = true; diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11..1eec3e38 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { (family_resp, style_resp) }); crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).0 { set(out, &|d| { d.font_family = f.clone(); let styles = figcraft_text::family_styles(&f); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0..de304d6a 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -181,7 +181,12 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let w = ui.available_width(); let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) { + let (picked, hovered) = font_picker(ui, family.as_deref(), resp.clicked()); + if let Some(h) = hovered { + // The canvas shows the hovered family; closing without picking ends the preview. + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h}))); + } + if let Some(f) = picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); ui.close(); @@ -1049,8 +1054,9 @@ enum PickRow { Family(String), } -/// The searchable family list. Returns the family picked. -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { +/// The searchable family list. Returns the family picked and the family hovered (the caller +/// previews the hovered one on the canvas: closing without picking restores the original). +pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let q_id = Id::new("font_query"); let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); @@ -1066,7 +1072,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) let all = figcraft_text::family_names(); let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - return matches.into_iter().next(); + return (matches.into_iter().next(), None); } let mut rows: Vec = Vec::new(); if ql.is_empty() { @@ -1079,11 +1085,12 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } if matches.is_empty() { widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return None; + return (None, None); } rows.extend(matches.into_iter().map(PickRow::Family)); let mut budget = 4; let mut picked = None; + let mut hovered_family = None; egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| { for r in rows.get(range).unwrap_or(&[]) { let w = ui.available_width(); @@ -1125,11 +1132,14 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) if resp.clicked() { picked = Some(f.clone()); } + if resp.hovered() { + hovered_family = Some(f.clone()); + } } } } }); - picked + (picked, hovered_family) } #[cfg(test)] From 4484c2d1b023e7b058231dda20a157badd19f42b Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:24:37 +0300 Subject: [PATCH 055/245] perf(render): work out paint bounds once per render Culling a layer asked for its paint bounds, which walk its whole subtree, and then did the same for each child: every layer's subtree was walked again at each level above it, and once more in each parallel band. The bounds worked out during a render are now kept for it (layers with children only; bounds the 64-level cap cut short are not kept, and kept ones are read only where their levels still fit under the cap, so culling is exactly as before). Every render also looked through every layer of the document for the largest background blur, to size the overlap of its bands: 900 000 layers on one large file, on the main thread before the bands start. The document now works it out when first asked after a change, like whether it has masks. A 1440x900 render on a 900 000-layer file, page of 118 358 layers: at 100% 13.0 -> 0.6 ms, at 25% 16.5 -> 3.4 ms, fitted to the page 56 -> 31 ms; page of 246 204 layers: 12.6 -> 2.5 ms, 12.1 -> 2.0 ms, 69 -> 62 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/doc/src/lib.rs | 13 ++++-- crates/render/src/bounds_memo_tests.rs | 58 +++++++++++++++++++++++ crates/render/src/draw.rs | 65 ++++++++++++++++++++------ crates/render/src/lib.rs | 8 +++- crates/render/src/lod_tests.rs | 2 + crates/render/src/pattern.rs | 4 +- 6 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 crates/render/src/bounds_memo_tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 71c6da39..a757a6af 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -77,17 +77,19 @@ pub enum DocError { pub type DocResult = Result; -/// The set of touched node ids, and whether any layer is a mask (worked out when first asked -/// after a change: hit testing skips its mask checks without one). Never part of equality or +/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks +/// without one) and the largest background blur radius (renders keep that much overlap between +/// their bands), both worked out when first asked after a change. Never part of equality or /// serialisation. #[derive(Clone, Debug, Default)] -pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock); +pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock, std::sync::OnceLock); impl Dirty { /// Record node `id` as touched (edited, added or removed). fn touch(&mut self, id: NodeId) { self.0.insert(id); self.1 = std::sync::OnceLock::new(); + self.2 = std::sync::OnceLock::new(); } } @@ -254,6 +256,11 @@ impl Document { *self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask)) } + /// The largest background blur or glass radius of any layer's effects (0 without any). + pub fn max_backdrop_radius(&self) -> f64 { + *self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max)) + } + /// Node ids touched so far (without clearing). pub fn dirty(&self) -> &std::collections::BTreeSet { &self.dirty.0 diff --git a/crates/render/src/bounds_memo_tests.rs b/crates/render/src/bounds_memo_tests.rs new file mode 100644 index 00000000..d8539807 --- /dev/null +++ b/crates/render/src/bounds_memo_tests.rs @@ -0,0 +1,58 @@ +//! Paint bounds worked out once per render: the bounds kept for a layer are the ones worked out +//! without keeping any, and the largest background blur follows the document's edits. + +use super::*; +use figcraft_doc::{Effect, EffectKind}; + +#[test] +fn kept_paint_bounds_are_the_bounds_worked_out_afresh() { + // Unclipped frames nested past the 64-level walk, a rectangle at the bottom: bounds asked for + // deep first and then from the top, and the other way round, are those worked out alone. + let mut d = Document::new("deep"); + let p = d.first_page().unwrap(); + let mut chain = Vec::new(); + let mut parent = p; + for k in 0..80 { + let f = d.create(parent, NodeKind::Frame, &format!("F{k}"), 1.0, 1.0, 10.0, 10.0).unwrap(); + d.node_mut(f).unwrap().clip = false; + chain.push(f); + parent = f; + } + d.create(parent, NodeKind::Rectangle, "R", 500.0, 0.0, 10.0, 10.0).unwrap(); + for order in [chain.iter().rev().copied().collect::>(), chain.clone()] { + let memo = draw::Bounds::default(); + for id in &order { + let n = d.node(*id).unwrap(); + for depth in [0, 20] { + let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0; + assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name); + } + } + } +} + +#[test] +fn the_largest_background_blur_follows_edits() { + let mut d = Document::new("blur"); + let p = d.first_page().unwrap(); + let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert_eq!(max_backdrop_blur(&d), 0.0); + let mut blur = Effect::background_blur(); + if let EffectKind::BackgroundBlur(b) = &mut blur.kind { + b.radius = 12.0; + } + d.node_mut(r).unwrap().effects.push(blur); + assert_eq!(max_backdrop_blur(&d), 12.0); + // A copy of the document keeps it, and an edit of the copy changes only the copy's. + let mut copy = d.clone(); + if let Some(EffectKind::BackgroundBlur(b)) = copy.node_mut(r).unwrap().effects.first_mut().map(|e| &mut e.kind) { + b.radius = 30.0; + } + assert_eq!(max_backdrop_blur(©), 30.0); + assert_eq!(max_backdrop_blur(&d), 12.0); + copy.node_mut(r).unwrap().effects.clear(); + assert_eq!(max_backdrop_blur(©), 0.0); + // A deleted layer's blur no longer counts. + d.remove(r).unwrap(); + assert_eq!(max_backdrop_blur(&d), 0.0); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..0d53f390 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -33,8 +33,22 @@ pub struct Ctx<'a> { pub under: Option<&'a Under<'a>>, /// Seconds into animated GIFs (0: their first frame; only the presenter plays them). pub time: f64, + /// Paint bounds already worked out in this render. + pub bounds: &'a Bounds, } +/// The local paint bounds worked out during one render, by node and greeking scale. Culling asks +/// for a node's bounds and then for each of its children's, so without them every layer's subtree +/// was walked again at each level above it. +#[derive(Default)] +pub struct Bounds(std::cell::RefCell>); + +/// A layer, and the scale its text is greeked at (none: drawn in full). +type BoundsKey = (NodeId, Option); + +/// Deepest nesting the paint bounds walk follows. +const MAX_BOUNDS_DEPTH: usize = 64; + pub fn ts_transform(a: Affine) -> Transform { let c = a.as_coeffs(); Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) @@ -279,32 +293,57 @@ fn text_box_bounds(n: &Node) -> Rect { /// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children). /// Text that overflows its box counts where its glyphs are, so it isn't culled while visible; /// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`]. -fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option) -> Rect { +pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option) -> Rect { + bounds_within(doc, n, depth, greek_scale, None).0 +} + +/// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many +/// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on +/// the depth they were asked at and aren't kept, and kept bounds are read only where their levels +/// still fit under the cap. +pub(crate) fn bounds_within(doc: &Document, n: &Node, depth: usize, greek_scale: Option, memo: Option<&Bounds>) -> (Rect, Option) { + // A layer without children is quicker to work out again than to look up. + let memo = memo.filter(|_| !n.children.is_empty()); + let key = (n.id, greek_scale.map(f64::to_bits)); + if let Some((r, levels)) = memo.and_then(|m| m.0.borrow().get(&key).copied()) + && depth + levels <= MAX_BOUNDS_DEPTH + { + return (r, Some(levels)); + } let reach = n.paint_reach(); let mut r = n.local_rect().inflate(reach, reach); + let mut levels = Some(0); let clips = n.clip && n.kind.is_frame_like(); - if (!clips || n.kind.bounds_follow_children()) && depth < 64 && !n.hides_contents() { - for c in n.children.iter().filter_map(|c| doc.node(*c)) { - if !c.visible { - continue; + if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() { + if depth < MAX_BOUNDS_DEPTH { + let mut below = Some(0); + for c in n.children.iter().filter_map(|c| doc.node(*c)) { + if !c.visible { + continue; + } + let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo); + below = below.zip(c_levels).map(|(b, l)| b.max(l)); + r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb)); } - let cb = local_paint_bounds(doc, c, depth + 1, greek_scale); - r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb)); + levels = below.map(|b| b + 1); + } else if !n.children.is_empty() { + levels = None; } } // Text can overflow its box. if let NodeKind::Text(_) = n.kind { r = r.union(text_box_bounds(n).inflate(reach, reach)); - if greek_scale.is_some_and(|s| lod::greeked(n, s)) { - return r; - } - if let Some((l, dy)) = figcraft_text::layout_node(n) + if !greek_scale.is_some_and(|s| lod::greeked(n, s)) + && let Some((l, dy)) = figcraft_text::layout_node(n) && let Some(ink) = l.ink_bounds() { r = r.union(ink + figcraft_geom::kurbo::Vec2::new(0.0, dy)); } } - r + if let (Some(m), Some(l)) = (memo, levels) { + m.0.borrow_mut().insert(key, (r, l)); + } + (r, levels) } /// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass, @@ -385,7 +424,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } let xf = parent_xf * n.transform; let (pw, ph) = (pix.width(), pix.height()); - let local = local_paint_bounds(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale)); + let local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0; let dev = device_rect(xf, local); // Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or // blur of millions of pixels) keep a sane layer: what lies further off reads as empty. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9969a597..2a911076 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -90,7 +90,7 @@ fn new_pixmap(w: u32, h: u32) -> Option { /// The largest backdrop radius (background blur, glass: see [`figcraft_doc::Effect::backdrop_radius`]) /// in the document (0 without any). pub(crate) fn max_backdrop_blur(doc: &Document) -> f64 { - doc.nodes().flat_map(|n| n.effects.iter()).map(figcraft_doc::Effect::backdrop_radius).fold(0.0, f64::max) + doc.max_backdrop_radius() } /// How far (device pixels) a background blur of `radius` reads its backdrop at `scale`. @@ -170,6 +170,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti pix.fill(paint::ts_color(bg)); } let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE; + let bounds = draw::Bounds::default(); let ctx = draw::Ctx { doc, scale: view.scale, @@ -180,6 +181,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti lod, under: None, time: 0.0, + bounds: &bounds, }; let xf = view.transform(); let Some(p) = doc.node(page) else { return Some(pix) }; @@ -201,6 +203,7 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut /// [`render_nodes_into`] `time` seconds into animated GIFs. fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) { + let bounds = draw::Bounds::default(); let ctx = draw::Ctx { doc, scale: view.scale, @@ -211,6 +214,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim lod: false, under: None, time, + bounds: &bounds, }; for id in ids { draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0); @@ -434,6 +438,8 @@ mod band_tests; #[cfg(test)] mod boolean_cache_tests; #[cfg(test)] +mod bounds_memo_tests; +#[cfg(test)] mod effect_order_tests; #[cfg(test)] mod effects_behaviour_tests; diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index de8b52b4..a573ee7c 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -154,6 +154,7 @@ fn exports_never_use_lod() { crate::render_nodes_into(&d, &[card], &v, &mut exact); assert_eq!(png.data(), exact.data()); let mut preview = Pixmap::new(v.width, v.height).unwrap(); + let bounds = crate::draw::Bounds::default(); let ctx = crate::draw::Ctx { doc: &d, scale, @@ -164,6 +165,7 @@ fn exports_never_use_lod() { lod: true, under: None, time: 0.0, + bounds: &bounds, }; let parent = d.world_transform(p); crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index bfcf8e32..2489766c 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId}; use figcraft_geom::Affine; use tiny_skia::{FilterQuality, Pixmap, SpreadMode, Transform}; -use crate::draw::{Ctx, draw_node}; +use crate::draw::{Bounds, Ctx, draw_node}; use crate::paint::Prepared; /// The largest side of a cell's pixmap; a cell showing larger is drawn coarser. @@ -46,6 +46,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64 let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX)); let (kx, ky) = (pw / cell.size.0, ph / cell.size.1); let mut pix = Pixmap::new(pw as u32, ph as u32)?; + let bounds = Bounds::default(); let ctx = Ctx { doc, scale: kx.max(ky) * cell.scale, @@ -56,6 +57,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64 lod: false, under: None, time, + bounds: &bounds, }; let unrotated = src.transform.inverse(); nested(|| { From be3912f9868726e195c1fe9e3478405065e30386 Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 15:05:13 +0500 Subject: [PATCH 056/245] M7: Reflow presenter device screens and simplify tablet and desktop previews --- ROADMAP.md | 3 +- crates/ui-egui/src/present.rs | 96 +++++++++++++++++++++++++++++++++-- docs/gaps.md | 7 ++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 25439517..b54a43ec 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-16 · **Change:** minor (presenter tab, device picker and thumbnail; 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-16 · **Change:** minor (responsive presenter device sizing, bare tablet and computer screens; presenter tab, device picker and thumbnail; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Choosing a device in the presenter now enables responsive layout at that device's screen size, clears offsets from the previous screen, and lets No device restore the design's frame size. Tablet and computer presets show a bare screen at their resolution; phones keep their device body. The saved frame geometry is unchanged. Numbers unchanged. | | 2026-10-15 | minor | Presenter has a separate tab, keeps its current screen when returning to design, and adds a frame thumbnail, device picker and scale controls. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index bb611588..9fc85337 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -393,6 +393,18 @@ impl Presenter { self.stop_scrolling(); } + /// A new device changes the viewport and may relayout every frame. Start at its top instead + /// of carrying scroll offsets or a drag from the old screen into the new one. + fn reset_for_device(&mut self) { + self.pan = None; + self.scroll.clear(); + self.scroll_anim = None; + self.press = None; + self.dragged = false; + self.stop_scrolling(); + self.rev += 1; + } + /// The current offset of `t` (a frame's scroll, else the view's pan). fn offset(&self, t: Scroller) -> (f64, f64) { match t { @@ -1523,6 +1535,11 @@ fn body_outset(device: &str, size: Vec2, landscape: bool) -> (f32, f32, f32) { } } +/// Tablet and computer presets size the screen without drawing a hardware shell around it. +fn has_device_body(device: Device) -> bool { + !device.name.starts_with("iPad") && !matches!(device.name, "MacBook Air" | "Desktop") +} + /// Place the presented frame in `area`: on its device's screen (the whole device sized by the /// view's scale option), else the frame itself. Fit and 100% keep the chrome's padding from the /// edges; Fit never goes above 100%. What doesn't fit the window pans (`Presenter::pan`). The @@ -1538,7 +1555,7 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { let fb = disp.world_bounds(p.current)?; let view = if chrome.pane { PresentView::default() } else { p.view }; // Custom sizes are just a screen: no body. - let body = device.filter(|d| view.device_frame && !d.colors.is_empty()); + let body = device.filter(|d| view.device_frame && has_device_body(*d) && !d.colors.is_empty()); let pad = if !fills && matches!(view.scale, PresentScale::Fit | PresentScale::Actual) { chrome.pad } else { 0.0 }; let avail = area.shrink(pad); let (sw, sh) = device.map_or((fb.width(), fb.height()), |d| (d.width, d.height)); @@ -1684,7 +1701,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.run("proto.settings", json!({"responsive": on})); } if let Some(device) = picked_device { - app.run("proto.settings", json!({"device": device})); + choose_device(app, &mut pres, &device); } if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.present_uid = None; @@ -1693,6 +1710,15 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.present = Some(pres); } +/// Device choices in the presenter use the screen's real dimensions and reflow a copy of the +/// design through constraints and auto layout. "No device" returns to the original frame size. +fn choose_device(app: &mut FigApp, p: &mut Presenter, device: &str) { + let responsive = device != "None"; + if app.run("proto.settings", json!({"device": device, "responsive": responsive})).is_some() { + p.reset_for_device(); + } +} + /// Controls that stay in reach while viewing a prototype, including screen stepping, device and /// scale. The selected device is returned to the caller so it can go through the normal document /// command (and undo/save) rather than changing only the player's copy. @@ -2320,7 +2346,7 @@ fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option, responsi } ui.separator(); } - if device.is_some_and(|d| !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, tr("Show device frame"), "") { + if device.is_some_and(|d| has_device_body(d) && !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, tr("Show device frame"), "") { p.view.device_frame = !p.view.device_frame; ui.close(); } @@ -2815,6 +2841,70 @@ mod tests { use super::*; use figcraft_doc::NodeKind; + use crate::present_tests::{Ui, id}; + + #[test] + fn device_picker_reflows_without_resizing_the_saved_frame_and_can_revert() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + let right = id(&session + .execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 804, "y": 20, "width": 80, "height": 40})) + .unwrap()); + session.execute("node.set", &json!({"id": right, "props": {"constraints": {"horizontal": "right", "vertical": "top"}}})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + + let mut presenter = ui.app.ui.present.take().unwrap(); + presenter.pan = Some((100.0, 200.0)); + presenter.scroll.insert(frame, (75.0, 50.0)); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + assert!(presenter.pan.is_none() && presenter.scroll.is_empty()); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let shown = &ui.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(); + assert_eq!((shown.node(frame).unwrap().width, shown.node(frame).unwrap().height), (402.0, 874.0)); + assert_eq!(shown.world_bounds(right).unwrap().x0, 312.0, "right constraint follows the phone edge"); + assert_eq!(ui.app.inspect()["presenter"]["viewport"], json!([402.0, 874.0])); + assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0, "the saved design keeps its geometry"); + + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "None"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + assert_eq!(ui.app.inspect()["presenter"]["frameSize"], json!([894.0, 844.0])); + assert_eq!(ui.app.inspect()["presenter"]["responsive"], Value::Null); + } + + #[test] + fn tablet_and_computer_presets_show_only_their_screen_at_the_real_resolution() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 860.0)); + + for (name, width, height, body) in [ + ("iPad mini", 744.0, 1133.0, false), + ("MacBook Air", 1280.0, 832.0, false), + ("Desktop", 1440.0, 1024.0, false), + ("iPhone 17", 402.0, 874.0, true), + ] { + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, name); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let presenter = ui.app.ui.present.as_mut().unwrap(); + let laid = layout(presenter, area, FULL).unwrap(); + assert_eq!(laid.color.is_some(), body, "{name}: body visibility"); + assert!((f64::from(laid.screen.width()) / laid.scale - width).abs() < 0.1, "{name}: width"); + assert!((f64::from(laid.screen.height()) / laid.scale - height).abs() < 0.1, "{name}: height"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + } + } + fn doc_with_two_frames() -> (Document, NodeId, NodeId) { let mut d = Document::new("t"); let page = d.add_page("Page 1"); diff --git a/docs/gaps.md b/docs/gaps.md index 95962a5c..1e2cd114 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (presenter navigation and device controls; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (responsive presenter device sizing and bare tablet and computer screens; presenter navigation and device controls; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -141,6 +141,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-15):** presentation keeps its own tab beside the design, preserves the current screen when switching back to edit, shows a small current-screen preview and offers screen, device and scale controls (`present_view_tests`). +- **Landed (2026-10-16):** choosing a presenter device reflows a copy of the frame to its screen + size using constraints and auto layout, clears the previous screen's scroll and pan, and keeps + the file's frame geometry unchanged; No device restores the original size. Tablet and computer + presets show the screen at their resolution without a drawn hardware body. - **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). @@ -403,6 +407,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Gap 4: presenter device selection now uses responsive layout at the selected screen size and resets stale offsets; tablets and computers show bare screens, and the original frame is preserved. Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 4: presenter tab, thumbnail, device picker and scale controls. Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From 52eb736241b7b1d33c4c24b2dc2e93cf54928230 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:20:53 +0300 Subject: [PATCH 057/245] perf(render): look each layer up once per render Node lookups in the document's B-tree were still a third of a render on a 900 000-layer file: a small subtree was walked for its bounds, again to see whether it could be splatted, and again to splat it, and each child of a drawn layer was looked up by paint_runs, by the mask check and by draw_node. - The render's table also keeps the layers it looked up, keyed with a multiply hash instead of SipHash (ids are counters), and so do the bounds it keeps. - draw_children looks each child up once, works out the paint runs from those (Document::paint_runs_of) and draws the child it has. - Whether a layer can be splatted is kept like its bounds: layers with children only, answers the nesting cap decided aren't kept, and kept answers are read only where their levels fit under the cap. Ctx gets a second lifetime, for the document the kept layers come from. Single-threaded cycles per 1440x900 render fitted to the page: 0.39 -> 0.34 G on a page of 246 204 layers, 0.26 -> 0.22 G on one of 118 358. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/doc/src/lib.rs | 12 +++- crates/render/src/bounds_memo_tests.rs | 34 +++++++++ crates/render/src/draw.rs | 95 +++++++++++++++++++++----- crates/render/src/lod.rs | 39 +++++++++-- 4 files changed, 155 insertions(+), 25 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a757a6af..07700e8c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1124,8 +1124,14 @@ impl Document { /// mask with the layers above it up to the next mask, or a single layer. An auto layout frame /// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]). pub fn paint_runs(&self, n: &Node) -> Vec> { - let kids = &n.children; - let node = |i: usize| kids.get(i).and_then(|k| self.node(*k)); + let kids: Vec> = n.children.iter().map(|k| self.node(*k)).collect(); + Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z)) + } + + /// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run + /// first when `reverse_z`. + pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec> { + let node = |i: usize| kids.get(i).copied().flatten(); let mut runs = Vec::new(); let mut i = 0; while i < kids.len() { @@ -1138,7 +1144,7 @@ impl Document { runs.push(i..j); i = j; } - if n.layout.as_ref().is_some_and(|l| l.reverse_z) { + if reverse_z { runs.reverse(); } runs diff --git a/crates/render/src/bounds_memo_tests.rs b/crates/render/src/bounds_memo_tests.rs index d8539807..4d0b305b 100644 --- a/crates/render/src/bounds_memo_tests.rs +++ b/crates/render/src/bounds_memo_tests.rs @@ -56,3 +56,37 @@ fn the_largest_background_blur_follows_edits() { d.remove(r).unwrap(); assert_eq!(max_backdrop_blur(&d), 0.0); } + +#[test] +fn kept_splat_answers_are_the_answers_worked_out_afresh() { + use figcraft_color::{ImageScaleMode, Paint}; + // Groups nested past the 64-level walk, with an image-filled rectangle at the bottom of one + // branch and a plain one at the bottom of the other: answers asked for deep-first and + // top-first, at depths 0 and 20, are those worked out alone. + let mut d = Document::new("deep"); + let p = d.first_page().unwrap(); + let mut chain = Vec::new(); + for image in [false, true] { + let mut parent = p; + for k in 0..80 { + let g = d.create(parent, NodeKind::Frame, &format!("{image} {k}"), 0.0, 0.0, 10.0, 10.0).unwrap(); + d.node_mut(g).unwrap().clip = false; + chain.push(g); + parent = g; + } + let r = d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + if image { + d.node_mut(r).unwrap().fills = vec![Paint::image("missing", ImageScaleMode::Fill)]; + } + } + for order in [chain.iter().rev().copied().collect::>(), chain.clone()] { + let memo = draw::Bounds::default(); + for id in &order { + let n = d.node(*id).unwrap(); + for depth in [0, 20] { + let kept = lod::splattable_within(&d, n, depth, Some(&memo)).0; + assert_eq!(kept, lod::splattable(&d, n, depth), "{} at depth {depth}", n.name); + } + } + } +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 0d53f390..b1a81a56 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -16,8 +16,9 @@ use crate::{blur, grain, lod, shadow}; /// Shared state for one render. #[derive(Clone, Copy)] -pub struct Ctx<'a> { - pub doc: &'a Document, +/// `'d`: the document's (layers looked up for the render are kept that long), `'a`: the rest. +pub struct Ctx<'a, 'd> { + pub doc: &'d Document, /// Device pixels per document unit (for effect radii). pub scale: f64, pub outline_mode: bool, @@ -34,14 +35,59 @@ pub struct Ctx<'a> { /// Seconds into animated GIFs (0: their first frame; only the presenter plays them). pub time: f64, /// Paint bounds already worked out in this render. - pub bounds: &'a Bounds, + pub bounds: &'a Bounds<'d>, } -/// The local paint bounds worked out during one render, by node and greeking scale. Culling asks -/// for a node's bounds and then for each of its children's, so without them every layer's subtree -/// was walked again at each level above it. +/// What one render works out about layers with children, which a walk down their subtree finds: +/// their local paint bounds by greeking scale, and whether they can be splatted (see +/// [`lod::splattable`]). Culling asks for a node's and then for each of its children's, so without +/// them every layer's subtree was walked again at each level above it. Each answer keeps how many +/// levels its walk went down. +/// It also keeps the layers it looked up: the walks for culling, splatting and drawing go down the +/// same layers, and in a large document a lookup is a good part of a layer's cost. #[derive(Default)] -pub struct Bounds(std::cell::RefCell>); +pub struct Bounds<'a> { + paint: std::cell::RefCell>, + pub(crate) splat: std::cell::RefCell>, + nodes: std::cell::RefCell>>, +} + +impl<'a> Bounds<'a> { + /// Node `id` of `doc`, looked up once for the render. + pub(crate) fn node(&self, doc: &'a Document, id: NodeId) -> Option<&'a Node> { + if let Some(n) = self.nodes.borrow().get(&id) { + return *n; + } + let n = doc.node(id); + self.nodes.borrow_mut().insert(id, n); + n + } +} + +/// A hash map keyed by node ids, which need no SipHash: they are counters (or a peer's range of +/// them), and SipHash took a good part of a render. +type IdMap = HashMap>; + +/// Mixes each word in with a multiply (Fibonacci hashing), so ids that differ in low bits spread. +#[derive(Default)] +pub(crate) struct IdHash(u64); + +impl Hasher for IdHash { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for b in bytes { + self.write_u64(u64::from(*b)); + } + } + fn write_u64(&mut self, v: u64) { + self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x9E37_79B9_7F4A_7C15); + } + fn write_usize(&mut self, v: usize) { + self.write_u64(v as u64); + } +} /// A layer, and the scale its text is greeked at (none: drawn in full). type BoundsKey = (NodeId, Option); @@ -301,11 +347,17 @@ pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_s /// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on /// the depth they were asked at and aren't kept, and kept bounds are read only where their levels /// still fit under the cap. -pub(crate) fn bounds_within(doc: &Document, n: &Node, depth: usize, greek_scale: Option, memo: Option<&Bounds>) -> (Rect, Option) { +pub(crate) fn bounds_within<'a>( + doc: &'a Document, + n: &Node, + depth: usize, + greek_scale: Option, + memo: Option<&Bounds<'a>>, +) -> (Rect, Option) { // A layer without children is quicker to work out again than to look up. let memo = memo.filter(|_| !n.children.is_empty()); let key = (n.id, greek_scale.map(f64::to_bits)); - if let Some((r, levels)) = memo.and_then(|m| m.0.borrow().get(&key).copied()) + if let Some((r, levels)) = memo.and_then(|m| m.paint.borrow().get(&key).copied()) && depth + levels <= MAX_BOUNDS_DEPTH { return (r, Some(levels)); @@ -317,7 +369,7 @@ pub(crate) fn bounds_within(doc: &Document, n: &Node, depth: usize, greek_scale: if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() { if depth < MAX_BOUNDS_DEPTH { let mut below = Some(0); - for c in n.children.iter().filter_map(|c| doc.node(*c)) { + for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))) { if !c.visible { continue; } @@ -341,7 +393,7 @@ pub(crate) fn bounds_within(doc: &Document, n: &Node, depth: usize, greek_scale: } } if let (Some(m), Some(l)) = (memo, levels) { - m.0.borrow_mut().insert(key, (r, l)); + m.paint.borrow_mut().insert(key, (r, l)); } (r, levels) } @@ -406,10 +458,17 @@ const MAX_EFFECT_MARGIN_PX: f64 = 2048.0; /// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels. pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) { + if let Some(n) = ctx.doc.node(id) { + draw_layer(ctx, pix, n, parent_xf, depth); + } +} + +/// [`draw_node`] for a node already looked up. +fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: usize) { + let id = n.id; if depth > crate::MAX_EXPORT_DEPTH || ctx.skip.contains(&id) { return; } - let Some(n) = ctx.doc.node(id) else { return }; let outline = ctx.outline_mode || ctx.outlined.contains(&id); if n.is_mask || n.opacity <= 0.0 && n.visible { return; @@ -437,7 +496,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth); return; } - if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) { + if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable_within(ctx.doc, n, 0, Some(ctx.bounds)).0 { lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth); return; } @@ -711,13 +770,15 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { let kids = &n.children; - for run in ctx.doc.paint_runs(n) { - let Some(&c) = kids.get(run.start) else { continue }; - if ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) { + // Each child looked up once: in a large document a lookup is a good part of a layer's cost. + let nodes: Vec> = kids.iter().map(|c| ctx.bounds.node(ctx.doc, *c)).collect(); + for run in Document::paint_runs_of(&nodes, n.layout.as_ref().is_some_and(|l| l.reverse_z)) { + let (Some(&c), Some(Some(cn))) = (kids.get(run.start), nodes.get(run.start)) else { continue }; + if cn.is_mask && cn.visible { // Everything above the mask (until the next mask) is clipped by its alpha. draw_masked(ctx, pix, c, kids.get(run.start + 1..run.end).unwrap_or(&[]), xf, depth); } else { - draw_node(ctx, pix, c, xf, depth + 1); + draw_layer(ctx, pix, cn, xf, depth + 1); } } } diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index 6d742585..a5b3ddc1 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -60,18 +60,47 @@ fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator bool { + splattable_within(doc, n, depth, None).0 +} + +/// [`splattable`], kept in and read from `memo` when there is one (layers with children only). +/// Also returns how many levels the walk went down, or `None` when the nesting cap decided it: such +/// answers depend on the depth they were asked at and aren't kept, and kept answers are read only +/// where their levels still fit under the cap. +pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, memo: Option<&crate::draw::Bounds<'a>>) -> (bool, Option) { if depth > MAX_DEPTH { - return false; + return (false, None); + } + let memo = memo.filter(|_| !n.children.is_empty()); + if let Some((ok, levels)) = memo.and_then(|m| m.splat.borrow().get(&n.id).copied()) + && depth + levels <= MAX_DEPTH + { + return (ok, Some(levels)); } let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_)); let own = match &n.kind { NodeKind::Text(t) => text_paints(n, t).any(image), _ => n.fills.iter().any(image), }; - if own || n.strokes.iter().any(image) { - return false; + let (mut ok, mut below) = (!(own || n.strokes.iter().any(image)), Some(0)); + if ok { + for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))).filter(|c| c.visible) { + if c.is_mask { + ok = false; + break; + } + let (c_ok, c_levels) = splattable_within(doc, c, depth + 1, memo); + below = below.zip(c_levels).map(|(b, l)| b.max(l + 1)); + if !c_ok { + ok = false; + break; + } + } } - n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible).all(|c| !c.is_mask && splattable(doc, c, depth + 1)) + if let (Some(m), Some(l)) = (memo, below) { + m.splat.borrow_mut().insert(n.id, (ok, l)); + } + (ok, below) } /// A paint's average colour as straight RGBA (alpha times the paint's opacity); `None` for @@ -199,7 +228,7 @@ pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Op } else { clip }; - for c in n.paint_order().filter_map(|c| ctx.doc.node(c)) { + for c in n.paint_order().filter_map(|c| ctx.bounds.node(ctx.doc, c)) { splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1); } } From 7d7a7247644ee34247a263cf5b906e195ec137ab Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 13:11:13 +0200 Subject: [PATCH 058/245] Text: selection highlights after a line break start at the line's start, and the empty last line shows (fixes #665) --- crates/text/src/layout.rs | 23 +++++++++++++++++--- crates/text/src/tests.rs | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 37092da8..b2cc9ce1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1190,6 +1190,12 @@ impl TextLayout { Some(next.unwrap_or(if right != line.rtl { index + 1 } else { index.saturating_sub(1) })) } + /// Left edge of a left-to-right line's first char (its end when it has none). + fn line_start_x(&self, line: &Line) -> f64 { + let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); + gl.iter().find(|g| g.char_end > g.char_start).map_or_else(|| self.line_end_x(line), |g| g.x) + } + /// Where a line's text ends: its right edge, or its left edge for a right-to-left paragraph. fn line_end_x(&self, line: &Line) -> f64 { let glyphs = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start); @@ -1250,7 +1256,16 @@ impl TextLayout { let (a, b) = (start.min(end), start.max(end)); let mut out = Vec::new(); for (li, line) in self.lines.iter().enumerate() { - if b <= line.char_start || a > line.char_end || (a == line.char_end && li + 1 < self.lines.len()) { + let last = li + 1 == self.lines.len(); + // The empty line after a trailing break has no chars, but a selection reaching the + // end of the text covers it: it shows a paragraph-break mark at the line's start. + let trailing_empty = last && a < b && b >= line.char_start && line.char_start == line.char_end; + if (b <= line.char_start && !trailing_empty) || a > line.char_end || (a == line.char_end && !last) { + continue; + } + if trailing_empty { + let x = self.line_end_x(line); + out.push(Rect::new(x, line.top, x + 4.0, line.top + line.height)); continue; } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); @@ -1258,10 +1273,12 @@ impl TextLayout { out.extend(Self::mixed_selection(gl, a, b, line)); continue; } - let (x0, _) = self.caret(a.max(line.char_start)); + // From the line's own start: `caret(char_start)` after a hard break is the end of the + // line above. + let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. - self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e34f0110..f5eaab6b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -437,3 +437,47 @@ fn selection_through_text_end_stays_inside_auto_width_box() { let first = r.first().map_or(f64::NAN, |r| r.x1); assert!(first > l.caret(5).0 + 1.0, "{first}"); } + +/// The selection rect drawn on line `li` of `l`, if any. +fn rect_on(l: &TextLayout, rects: &[figcraft_geom::Rect], li: usize) -> Option { + let top = l.lines.get(li)?.top; + rects.iter().find(|r| (r.y0 - top).abs() < 1e-6).copied() +} + +#[test] +fn select_all_highlights_an_empty_last_line() { + if !have_fonts() { + return; + } + // "Hello\n": the trailing break leaves an empty second line (#665). + let l = layout(&TextData::new("Hello\n"), None, None); + assert_eq!(l.lines.len(), 2); + let Some(r) = rect_on(&l, &l.selection_rects(0, 6), 1) else { panic!("no highlight on the empty last line") }; + // A paragraph-break mark at the line's start, not at the end of "Hello" above. + assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}"); + assert!(rect_on(&l, &l.selection_rects(5, 6), 1).is_some()); + // Selecting part of the first line, or a caret, leaves the empty line alone. + assert!(l.selection_rects(0, 1).iter().all(|r| r.y0 < l.lines[1].top - 1e-6), "{:?}", l.selection_rects(0, 1)); + assert!(l.selection_rects(6, 6).is_empty()); + // A right-to-left paragraph marks its empty last line too. + let rtl = layout(&TextData::new("\u{5E9}\u{5DC}\u{5D5}\u{5DD}\n"), None, None); + let Some(r) = rect_on(&rtl, &rtl.selection_rects(0, 5), 1) else { panic!("no highlight on the empty right-to-left line") }; + assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}"); +} + +#[test] +fn selection_after_a_line_break_starts_at_the_line_start() { + if !have_fonts() { + return; + } + // Not at the end of the line above, where `caret` puts the index after the break. + let l = layout(&TextData::new("Hello\nworld"), None, None); + let Some(r) = rect_on(&l, &l.selection_rects(0, 11), 1) else { panic!("no highlight on the second line") }; + assert!(r.x0.abs() < 1e-6 && r.x1 > l.caret(10).0, "{r:?}"); + // An empty line in the middle shows its break mark at its start. + let m = layout(&TextData::new("Hello\n\nworld"), None, None); + let Some(r) = rect_on(&m, &m.selection_rects(0, 12), 1) else { panic!("no highlight on the empty line") }; + assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}"); + // A break followed by text still doesn't highlight the next line's start. + assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none()); +} From c5a8420089de30cc222786efae09db9423e44967 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 16 Oct 2026 16:27:05 +0500 Subject: [PATCH 059/245] Make the control server's connection-limit test pass on macOS `cargo xtask ci` was red on a clean main on macOS: the test's last shutdown runs after the server has answered an invalid request and closed the connection, and `shutdown(2)` on a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux returns success. The test is right about what it checks; it checked a return value that is not portable. Closing the client is what the worker thread waits for, and dropping the socket does that whether or not shutdown reported anything. The file's other six shutdowns run while the peer is still open, which is well defined on both platforms, so they stay checked. With no CI running the tests on pull requests, the local gate is the only signal a contributor gets, and it could not be green on macOS. --- apps/figcraft/src/control_server_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index d243fb91..008170fb 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { assert_eq!(workers.len(), 1); client.write_all(b"invalid\n").unwrap(); assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false); - client.shutdown(Shutdown::Both).unwrap(); + // The server closes this connection after answering an invalid request, and shutting down + // a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux + // returns Ok. Closing the client is what the worker is waiting for, so the result is moot: + // the other shutdowns in this file run while the peer is still open and stay checked. + let _ = client.shutdown(Shutdown::Both); workers.pop().unwrap().join().unwrap(); } From 805a121e1537a34ffb4705da387132ff1b255250 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 16 Oct 2026 16:30:58 +0500 Subject: [PATCH 060/245] Hold the duplicate step for a dragged and a nudged copy (#567) Duplicate repeats the last duplicate's offset, but of the ways to move the copy before the second Cmd+D only `node.set` and Alt-drag were covered. Dragging it with the mouse is how almost everyone does it, and is what #567 describes; an arrow-key nudge is how a small offset is usually made exact. Both work, because the step is measured from where the copy ended up rather than recorded from the gesture, but nothing held them there. The drag test also asserts the drag moved the copy rather than making another one, left it selected, and left the original in place: the ways the feature could break without the step arithmetic breaking. Both fail if the step lookup in `edit.duplicate` is stubbed out. --- crates/engine/src/duplicate_spacing_tests.rs | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs index b65fbb64..4c62179a 100644 --- a/crates/engine/src/duplicate_spacing_tests.rs +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() { cmd_d(&mut s); assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow"); } + +/// Drag `from` by `by` with no modifier: moving the selection, not copying it. +fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) { + let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0); + s.pointer(&ev(PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap(); + } + s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap(); +} + +/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests +/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done, +/// and the step is measured from where the copy ended up, whichever way it got there. +#[test] +fn duplicate_repeats_the_step_set_by_dragging_the_copy() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place"); + + drag_move(&mut s, (50.0, 50.0), (150.0, 20.0)); + assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one"); + assert_eq!(s.selection(), vec![c1], "and left it selected"); + assert_eq!(at(&s, a), (0.0, 0.0), "the original stays"); + + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again"); + let c3 = cmd_d(&mut s); + assert_eq!(at(&s, c3), (450.0, 60.0), "and again"); +} + +/// And a step set with the arrow keys, which is how a small offset is usually made exact. +#[test] +fn duplicate_repeats_a_nudged_step() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0})); + assert_eq!(at(&s, c1), (40.0, 0.0)); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step"); +} From 0f4c79b629b23d6a2bc792387c83d40d1dc639a4 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:59 +0800 Subject: [PATCH 061/245] M0.9: a script runs as one undo step and resolves `$step`, like command.batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `figcraft-cli run` ran each step of a script on its own, so a script's `{"$step": n}` reached `parent` unresolved and the layer landed on the page while every step reported success — the same commands through `command.batch` (the MCP `batch` tool, the control channel, plugins) put it inside the frame. One `edit.undo` took back one step of a script rather than the script, and a failing step left the earlier ones applied. `Session::batch_step` is now the one path both take — one step of a batch, its `$step` references resolved against the results before it — and `run_script` wraps its steps in `Session::grouped`: the whole script is one undo step, and a step that fails takes the script back. The result lines come out once the script is through, so none of them reports work a later failure took back; a failure prints one line with `rolledBack: true` and exits 1. `docs/cli.md` documents the reference and the output. --- apps/figcraft-cli/src/files.rs | 92 ++++++++++++++++++++++++++++++---- crates/engine/src/lib.rs | 13 ++++- docs/cli.md | 24 ++++++++- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 4fb485e1..46c8dd22 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -333,17 +333,31 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe } None => Session::new(), }; - for (i, (cmd, params)) in steps.iter().enumerate() { - let r = s.execute(cmd, params); - s.ui_requests.clear(); - match r { - Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})), - Err(e) => { - println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); - return Ok(ExitCode::from(1)); - } + // One undo step and all-or-nothing, the way the same commands behave through `command.batch` + // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and + // a step that fails takes the whole script back instead of leaving the earlier steps applied. + // The result lines are held back until the script is through, so none of them reports work that + // was rolled back. + let mut results: Vec = Vec::with_capacity(steps.len()); + let mut failed_at = 0; + let outcome = s.grouped(&format!("run {script}"), |s| { + for (i, (cmd, params)) in steps.iter().enumerate() { + failed_at = i; + let v = s.batch_step(&results, i, cmd, params)?; + s.ui_requests.clear(); + results.push(v); } + Ok(()) + }); + if let Err(e) = outcome { + let (i, (cmd, _)) = (failed_at, &steps[failed_at]); + println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); + eprintln!("error: step {i} ({cmd}) failed: {e}"); + eprintln!("the script's document changes were rolled back"); + return Ok(ExitCode::from(1)); + } + for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { + println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -422,4 +436,62 @@ mod tests { assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); std::fs::remove_dir_all(dir).unwrap(); } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + dir + } + + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step + /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it + /// as "no parent" and the layer landed on the page — with every step reporting success. + #[test] + fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() { + let dir = temp_dir("run-step"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + let top = doc.node(page).unwrap().children.clone(); + assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too"); + assert_eq!(doc.node(top[0]).unwrap().name, "Card"); + let inner = doc.node(top[0]).unwrap().children.clone(); + assert_eq!(inner.len(), 1, "the rectangle went inside the frame"); + assert_eq!(doc.node(inner[0]).unwrap().name, "child"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A step that fails takes the whole script back: nothing is written, and the failure says so. + #[test] + fn a_failed_step_leaves_no_output() { + let dir = temp_dir("run-rollback"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + // The failure is the script's, not the call's: the CLI reports it and exits non-zero. + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + assert!(!out.exists(), "a script that failed writes nothing"); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..27212f96 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1135,13 +1135,22 @@ impl Session { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; - results.push(s.execute(id, &p)?); + let v = s.batch_step(&results, step, id, p)?; + results.push(v); } Ok(results) }) } + /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of + /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one + /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps + /// resolve `$step` the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { + let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; + self.execute(cmd, &p) + } + /// Run `f` as one undo step called `label`: the commands it executes push no steps of their /// own, and when it fails the documents stay at their group-start state. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { diff --git a/docs/cli.md b/docs/cli.md index 9f5df40e..be54add2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -103,7 +103,29 @@ Runs commands in a headless session (a new document, or `--in`), printing one JS {"command":"frame.create","ok":true,"result":{"id":2},"step":0} ``` -A failing step prints `"ok": false` with the error and exits with code 1 (nothing is saved). +The whole script is **one undo step**, and it is **all-or-nothing**, exactly like running the same +commands through `command.batch` (the MCP tool `batch`, the control channel, plugins): `{"$step": n}` +in a step's params is the `id` in an earlier step's result, so a step can target a layer an earlier +step of the same script created: + +```json +[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Hi"}} +] +``` + +The result lines are printed once the script is through, so none of them reports work a later step +took back. + +A failing step prints one line with `"ok": false`, the error and `"rolledBack": true`, writes +nothing (`--out` is not saved, and the session's documents go back to how the script found them), +and exits with code 1: + +```text +{"step":1,"command":"node.set","ok":false,"error":"no such node 99999","rolledBack":true} +``` + `--out` saves the result by extension like `convert`. Script formats: a JSON array of `{"command": id, "params": {...}}`, an object From 9daf39e24ce55e369bdb3e1949927f72c18afd91 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:53:43 +0800 Subject: [PATCH 062/245] M1: a param that names a layer says so when it isn't one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node.create` read its `parent` as `id(p, "parent").unwrap_or(page)`, so a `parent` that isn't a layer id — a `{"$step": n}` nothing had resolved, a name, a negative number — quietly meant "no parent" and the layer landed on the page, with the call reporting success. The same shape sat on `image.place` and `instance.create`, on `index` (a string index appended where it was asked to insert), and on the `ids` of every command that targets layers (an entry that wasn't an id was dropped and the rest ran). A number that names nothing was reported; anything that isn't a number at all was not. `params::opt_id` and `params::opt_index` now say what the commands mean: missing and `null` are "none", and anything else has to be a layer id (or a whole, non-negative number) and is named when it isn't. `req_targets` refuses an `ids` entry that isn't an id, before falling back to the selection. A number that names no layer still reports `no such node`, and a boolean is still a number to `f64_of`, as it is for `x`, `width` and the rest. This turns a silently wrong result into an error on purpose, so a caller that passed something that isn't an id and relied on the fallback now hears about it. --- crates/engine/src/cmd/component.rs | 2 +- crates/engine/src/cmd/create.rs | 6 +- .../engine/src/id_params_behaviour_tests.rs | 151 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/params.rs | 38 ++++- 5 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/id_params_behaviour_tests.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0fb64816..e35c3f5b 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { let cmd = "instance.create"; let c = req_id(cmd, p, "component")?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc_mut()?; let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; let (x, y) = match (num(p, "x"), num(p, "y")) { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 2150701a..9b471290 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc()?; let pn = doc.get(parent)?; if !pn.kind.is_container() { @@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { node.width = tw; node.height = th; } - let index = num(p, "index").map(|i| i.max(0.0) as usize); + let index = crate::params::opt_index(cmd, p, "index")?; doc.insert(node, parent, index)?; if let Some(Value::Object(props)) = get(p, "props") { super::props::apply(cmd, doc, nid, props)?; @@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { _ => ImageScaleMode::Fill, }; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let ds = s.ds_mut()?; let doc = ds.doc_mut(); let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs new file mode 100644 index 00000000..ae77c8a1 --- /dev/null +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -0,0 +1,151 @@ +//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than +//! quietly meaning "none". +//! +//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id — +//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the +//! layer landed on the page while the call reported success. The same shape sat on `image.place` +//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets +//! layers (an entry that wasn't an id was dropped and the rest ran). + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +/// The page's children, by name (bottom first). +fn top(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect() +} + +/// The message of a failure, or `None` when the call went through. +fn failed(r: std::result::Result) -> Option { + match r { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } +} + +fn rect(parent: Value) -> Value { + json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10}) +} + +fn card(s: &mut Session) -> NodeId { + let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + id_of(&v["id"]).unwrap() +} + +#[test] +fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] { + let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused")); + assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}"); + } + assert_eq!(top(&s), ["Card"], "nothing landed on the page"); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame"); +} + +#[test] +fn leaving_the_parent_out_or_null_still_means_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [None, Some(json!(null))] { + let mut p = rect(json!(0)); + match parent { + None => _ = p.as_object_mut().unwrap().remove("parent"), + Some(v) => p["parent"] = v, + } + s.execute("node.create", &p).unwrap(); + } + assert_eq!(top(&s), ["Card", "child", "child"]); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty()); +} + +#[test] +fn a_parent_id_that_names_nothing_is_still_reported() { + let mut s = Session::new(); + card(&mut s); + let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail")); + assert!(msg.contains("no such node 99999"), "{msg}"); +} + +#[test] +fn an_index_that_is_not_a_whole_non_negative_number_is_refused() { + let mut s = Session::new(); + let card = card(&mut s); + // (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every + // other number, so it is deliberately not in this list.) + for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] { + let mut p = rect(json!(card)); + p["index"] = index.clone(); + let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused")); + assert!(msg.contains("`index`"), "{index}: {msg}"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted"); +} + +#[test] +fn a_whole_index_still_inserts_where_it_says() { + let mut s = Session::new(); + let card = card(&mut s); + for name in ["a", "b"] { + s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + } + s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10})) + .unwrap(); + let doc = s.doc().unwrap(); + let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect(); + assert_eq!(names, ["a", "mid", "b"]); +} + +#[test] +fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() { + let mut s = Session::new(); + let card = card(&mut s); + // The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame. + for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] { + let msg = + failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused")); + assert!(msg.contains("`ids`"), "{ids}: {msg}"); + } + let doc = s.doc().unwrap(); + assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed"); +} + +#[test] +fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() { + let mut s = Session::new(); + let card = card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail")); + assert!(msg.contains("`ids` must be an array"), "{msg}"); + // An explicit null says the same as leaving it out: the selection. + s.execute("select.set", &json!({"ids": [card]})).unwrap(); + s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap(); + assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected"); +} + +#[test] +fn a_single_id_that_is_not_an_id_is_refused() { + let mut s = Session::new(); + card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail")); + assert!(msg.contains("`id` must be a node id"), "{msg}"); +} + +#[test] +fn an_image_and_an_instance_parent_are_checked_the_same_way() { + let mut s = Session::new(); + card(&mut s); + let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}}))); + assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place"); + let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); + assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..fac5f477 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1548,6 +1548,8 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod id_params_behaviour_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index bbaa42a4..67a5b80a 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,25 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a +/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — +/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't +/// resolved yet silently becomes "no parent" and the layer lands on the page. +pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {v})"))), + } +} + +/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none". +pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(_) => req_index(cmd, p, key).map(Some), + } +} + /// `ids` (array), `id` (single), or the current selection. pub fn targets(s: &Session, p: &Value) -> Vec { if let Some(Value::Array(a)) = get(p, "ids") { @@ -99,8 +118,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec { s.selection() } -/// Like [`targets`] but an empty result is an error. +/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an +/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit +/// `null` says the same as leaving the key out. pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { + match get(p, "ids") { + Some(Value::Array(a)) => { + if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {v})"))); + } + } + Some(Value::Null) | None => { + if let Some(v) = get(p, "id").filter(|v| !v.is_null()) + && id_of(v).is_none() + { + return Err(bad(cmd, format!("`id` must be a node id (got {v})"))); + } + } + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {v})"))), + } let t = targets(s, p); if t.is_empty() { return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into())); From ac1a192a3aa6c4acdd3108956d2e7c4b1dc6076c Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 17:25:13 +0500 Subject: [PATCH 063/245] M7: keep mobile presenter fixed and confirm window close --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/present.rs | 90 ++++++++++++++++++++++++---- crates/ui-egui/src/tabs.rs | 10 +++- crates/ui-egui/src/titlebar_tests.rs | 19 +++++- crates/ui-egui/src/windows.rs | 9 ++- crates/ui-egui/src/windows_tests.rs | 19 ++++++ docs/gaps.md | 6 +- 8 files changed, 142 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b54a43ec..ce132520 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | The full presenter now scrolls content beyond a responsive phone or tablet screen in both directions. A drag that starts inside the screen continues when the pointer leaves it; the device body stays fixed. Computer presets keep their ordinary scrolling rules. The unsaved-changes dialog appears over presentation when closing a window. The saved design is unchanged. Numbers unchanged. | | 2026-10-16 | minor | Choosing a device in the presenter now enables responsive layout at that device's screen size, clears offsets from the previous screen, and lets No device restore the design's frame size. Tablet and computer presets show a bare screen at their resolution; phones keep their device body. The saved frame geometry is unchanged. Numbers unchanged. | | 2026-10-15 | minor | Presenter has a separate tab, keeps its current screen when returning to design, and adds a frame thumbnail, device picker and scale controls. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 91f7a56b..97b2be71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1312,9 +1312,14 @@ impl FigApp { self.end_pen_outside_design(); tabs::tab_bar(self, ui); if self.ui.present.is_some() { + // A close request can arrive while the presenter fills the window. Let its unsaved + // changes dialog take Escape and draw above the player just as it does in the editor. + dialogs::keys(self, &ctx); present::show(self, ui); + dialogs::show(self, &ctx); // What went wrong while presenting (a widget's plugin failing) shows over it. self.toast_ui(&ctx); + windows::requests(self, &ctx); return; } if !self.has_files() || self.ui.home { diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9fc85337..5a9d4bf3 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -541,7 +541,13 @@ impl Presenter { /// device's screen, other frames within themselves. fn range(&self, doc: &Document, frame: NodeId) -> (f64, f64) { let vp = if frame == self.current { viewport(doc, frame) } else { None }; - scroll_range_in(doc, frame, vp) + // In a responsive preview, make content outside the device reachable even if the source + // frame has no overflow mode. Its settings and geometry stay untouched in the file. + let mobile = frame == self.current && page_settings(doc, frame).device().is_some_and(|d| d.rotates); + let overflow = doc + .node(frame) + .map_or(Overflow::None, |n| if mobile && self.fit.is_some() && n.overflow == Overflow::None { Overflow::Both } else { n.overflow }); + scroll_range_with_overflow(doc, frame, vp, overflow) } /// What "Scroll to" `dest` moves: every scrolling frame around it, innermost first, brings the @@ -1171,8 +1177,13 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { /// frame taller than the screen scrolls through its whole height. Without a viewport the frame /// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { + let overflow = doc.node(frame).map_or(Overflow::None, |n| n.overflow); + scroll_range_with_overflow(doc, frame, viewport, overflow) +} + +fn scroll_range_with_overflow(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>, overflow: Overflow) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; - if n.overflow == Overflow::None { + if overflow == Overflow::None { return (0.0, 0.0); } let (mut x1, mut y1) = (0.0f64, 0.0f64); @@ -1197,8 +1208,8 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 } None => (n.width, n.height), }; - let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; - let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; + let rx = if matches!(overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; + let ry = if matches!(overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } @@ -2367,7 +2378,7 @@ fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, p let mut out: Vec = disp .subtree(root) .into_iter() - .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) + .filter(|f| disp.node(*f).is_some_and(|n| n.visible)) .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) .filter(|f| p.range(base, *f) != (0.0, 0.0)) .collect(); @@ -2574,14 +2585,21 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke } return; } - // Otherwise the drag scrolls the innermost frame under it that scrolls, else pans a view - // bigger than the window. + // A drag begun inside a mobile screen moves its content, even if the pointer leaves the + // screen. The device body stays in place even when the drag begins outside its screen. + // Panning the whole view remains available only without a mobile device. if let Some(base) = p.doc.clone() { - let target = scrollers_at(p, &base, doc, root, &place, start) - .first() + let horizontal = d.x.abs() >= d.y.abs(); + let scrolls = scrollers_at(p, &base, doc, root, &place, start); + let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 }; + let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates); + let target = scrolls + .iter() .copied() + .find(|f| on_axis(p.range(&base, *f))) + .or_else(|| scrolls.first().copied()) .map(Scroller::Frame) - .or((p.pan_range != (0.0, 0.0)).then_some(Scroller::View)); + .or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View)); if let Some(t) = target { p.fling = None; p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t)))); @@ -2869,6 +2887,14 @@ mod tests { assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0, "the saved design keeps its geometry"); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(pos2(720.0, 440.0), true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + ui.button(pos2(300.0, 300.0), false); + assert_eq!(ui.app.inspect()["presenter"]["scroll"], json!([0.0, 0.0]), "fitted content does not move"); + assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); + let mut presenter = ui.app.ui.present.take().unwrap(); choose_device(&mut ui.app, &mut presenter, "None"); ui.app.ui.present = Some(presenter); @@ -2905,6 +2931,50 @@ mod tests { } } + #[test] + fn responsive_device_scrolls_content_in_both_directions_without_changing_saved_overflow() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let range = &ui.app.inspect()["presenter"]["scrollRange"]; + assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}"); + let start = pos2(720.0, 440.0); + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(start - vec2(100.0, 120.0))]); + ui.button(start - vec2(100.0, 120.0), false); + let scroll = &ui.app.inspect()["presenter"]["scroll"]; + assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().overflow, Overflow::None); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + + // Like the phone preview, the content keeps following a drag after the pointer leaves + // the screen, while the device itself stays fixed on the stage. + ui.frames(60, Vec::new); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + let during = ui.app.inspect()["presenter"].clone(); + assert!(during["scroll"][0].as_f64().unwrap() > 0.0, "{during}"); + let screen_after = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + assert_eq!(screen_after, screen_before); + ui.button(pos2(300.0, 300.0), false); + + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "Desktop"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + let desktop = ui.app.inspect()["presenter"].clone(); + assert_eq!(desktop["scrollRange"], json!([0.0, 0.0])); + } + fn doc_with_two_frames() -> (Document, NodeId, NodeId) { let mut d = Document::new("t"); let page = d.add_page("Page 1"); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 534881fc..381b09dd 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -28,7 +28,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } } - let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(ui, rect) } else { 0.0 }; + let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 }; let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 }; // Home. let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT)); @@ -256,7 +256,7 @@ pub fn live_mark(app: &FigApp, uid: u64) -> Option { } /// Minimise, maximise/restore and close at the right end of the title bar; returns their width. -fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 { +fn window_buttons(app: &mut FigApp, ui: &mut Ui, bar: Rect) -> f32 { const W: f32 = 46.0; let t = Tokens::get(ui.ctx()); let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); @@ -284,7 +284,11 @@ fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 { } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, col); if resp.clicked() { - ui.ctx().send_viewport_cmd(cmd); + if close { + crate::windows::request_close(app, ui.ctx()); + } else { + ui.ctx().send_viewport_cmd(cmd); + } } } W * 3.0 diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index 796cb670..e9224404 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -4,6 +4,7 @@ use egui::{Event, PointerButton, Pos2, RawInput, Rect, ResizeDirection, ViewportCommand, ViewportId, pos2, vec2}; use figcraft_engine::Session; +use serde_json::json; use crate::{FigApp, NoServices, TitleBar}; @@ -76,7 +77,23 @@ fn window_buttons_minimise_maximise_and_close() { let mut u = Ui::new(TitleBar::Custom); assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); - assert!(u.click(window_button(2)).contains(&ViewportCommand::Close)); + u.click(window_button(2)); + assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); +} + +#[test] +fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() { + let mut u = Ui::new(TitleBar::Custom); + let frame = u.app.session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone(); + u.app.session.execute("proto.present", &json!({"start": frame})).unwrap(); + u.app.handle_ui_requests(); + u.frame(vec![]); + assert!(u.app.ui.present.is_some()); + + let commands = u.click(window_button(2)); + assert!(!commands.contains(&ViewportCommand::Close)); + assert!(u.app.ui.confirm_close); + assert!(u.ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some())); } #[test] diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 510c24f6..30b71f34 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -300,7 +300,12 @@ pub fn on_close_request(app: &mut FigApp, ctx: &Context) { if app.windows.current == 0 && (unsaved || !app.windows.list.is_empty()) { ctx.send_viewport_cmd(ViewportCommand::CancelClose); } - if unsaved { + request_close(app, ctx); +} + +/// The custom title bar's Close button uses the same unsaved-file check as a system close event. +pub(crate) fn request_close(app: &mut FigApp, ctx: &Context) { + if !app.unsaved().is_empty() { app.ui.confirm_close = true; ctx.request_repaint(); } else { @@ -325,7 +330,7 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { } let t = crate::theme::Tokens::get(ctx); let (mut open, mut discard, mut cancel) = (true, false, false); - crate::dialogs::window(tr("Unsaved changes"), ctx).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Unsaved changes"), ctx).order(egui::Order::Foreground).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text); ui.add_space(12.0); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 1a6e91bb..06e0a6a1 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -45,6 +45,25 @@ fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } +#[test] +fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() { + let mut session = Session::new(); + let frame_id = session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let mut app = FigApp::new(session, Box::new(NoServices)); + frame(&ctx, &mut app, false); + app.session.execute("proto.present", &json!({"start": frame_id})).unwrap(); + app.handle_ui_requests(); + frame(&ctx, &mut app, false); + assert!(app.ui.present.is_some()); + + frame(&ctx, &mut app, true); + assert!(app.ui.confirm_close); + assert!(ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some()), "dialog must be on screen"); + frame(&ctx, &mut app, false); + assert!(app.ui.present.is_some(), "asking to close does not dismiss the preview"); +} + #[test] fn windows_hold_tabs_and_close_with_their_files() { let mut s = Session::empty(); diff --git a/docs/gaps.md b/docs/gaps.md index 1e2cd114..94c387e5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -144,7 +144,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-16):** choosing a presenter device reflows a copy of the frame to its screen size using constraints and auto layout, clears the previous screen's scroll and pan, and keeps the file's frame geometry unchanged; No device restores the original size. Tablet and computer - presets show the screen at their resolution without a drawn hardware body. + presets show the screen at their resolution without a drawn hardware body. In the full presenter, + dragging inside a phone or tablet scrolls content in either direction when it exceeds the screen, + even when the pointer moves outside the device. The device remains fixed; computer presets keep + their ordinary scrolling rules. These gestures do not alter the file. Closing a window from + presentation shows the unsaved-changes dialog over the presenter. - **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). From 3065cd6da9e4bdd65d438b95b609690ff275f3b7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 14:49:23 +0200 Subject: [PATCH 064/245] M3: drag variant properties to reorder them in the Properties section --- crates/ui-egui/src/component_panel.rs | 17 ++- .../src/component_props_reorder_tests.rs | 125 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + docs/components-parity.md | 5 +- 4 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/component_props_reorder_tests.rs diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 646d6ecd..719c2588 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -20,7 +20,8 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::i18n::{tr, tr_noop, trn}; use crate::icons::{self, Icon}; -use crate::right::{menu_item, menu_popup, row}; +use crate::list_drag::ListDrag; +use crate::right::{menu_item, menu_popup, row, row_rect}; use crate::theme::{COMPONENT, Tokens, semibold}; use crate::widgets; @@ -241,9 +242,11 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } else { Default::default() }; - // Variant properties: name, values (the matrix), add value, delete. - for (k, vals) in &variants { - row(ui, 32.0, |ui| { + // Variant properties: name, values (the matrix), add value, delete. Dragged by their left + // margin to reorder them (only among themselves: the shared ones keep the engine's order). + let mut drag = ListDrag::in_order(ui, ("variant_props", owner)); + for (i, (k, vals)) in variants.iter().enumerate() { + let header = row_rect(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { @@ -284,6 +287,12 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { }); vi += 3; } + drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || k.clone()); + } + if let Some((from, to)) = drag.finish(ui) + && let Some((k, _)) = variants.get(from) + { + app.run("component.editProperty", json!({"id": owner, "name": k, "index": to})); } // Shared properties. for (k, d) in &defs { diff --git a/crates/ui-egui/src/component_props_reorder_tests.rs b/crates/ui-egui/src/component_props_reorder_tests.rs new file mode 100644 index 00000000..729185ce --- /dev/null +++ b/crates/ui-egui/src/component_props_reorder_tests.rs @@ -0,0 +1,125 @@ +//! Dragging variant properties in a component set's Properties section, driven through egui +//! headless: a property dragged by its left margin moves in one undo step (its values go with it, +//! instances follow), its fields don't start a drag, and Escape cancels. + +use egui::{Event, Pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::list_drag::grip_id; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A selected set whose variants list Size then State (two values each), a shared boolean "Icon", +/// and an instance of the set. Returns (ui, set, instance). +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let mut variants = Vec::new(); + for (k, name) in ["Size=Small, State=Default", "Size=Large, State=Default", "Size=Small, State=Hover"].iter().enumerate() { + let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap()); + variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap())); + } + let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap()); + s.execute("component.addProperty", &json!({"id": set, "name": "Icon", "type": "boolean", "default": true})).unwrap(); + let inst = id(s.execute("instance.create", &json!({"component": set, "x": 400, "y": 0})).unwrap()); + s.execute("select.set", &json!({"ids": [set]})).unwrap(); + (Ui::new(s), set, inst) +} + +/// Property names of `id` (a set or an instance), in the order they're listed. +fn names(u: &mut Ui, id: NodeId) -> Vec { + let props = u.app.session.execute("component.properties", &json!({"id": id})).unwrap(); + props["properties"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +fn undo_len(u: &Ui) -> usize { + u.app.session.ds().unwrap().history.undo.len() +} + +/// The middle of the grip of variant property `index` (as laid out last frame). +fn grip(u: &Ui, set: NodeId, index: usize) -> Option { + u.ctx.read_response(grip_id(("variant_props", set), index)).map(|r| r.rect.center()) +} + +/// Press at `from`, move to `to` in small steps, and leave the button down. +fn drag(u: &mut Ui, from: Pos2, to: Pos2) { + u.button(from, true); + for k in 1..=10 { + u.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))]); + } +} + +#[test] +fn dragging_a_variant_property_reorders_it_in_one_step() { + let (mut u, set, inst) = setup(); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); + let (size, state) = (grip(&u, set, 0).expect("variant properties have a grip"), grip(&u, set, 1).unwrap()); + assert!(size.y < state.y, "listed in order"); + let undo = undo_len(&u); + // State above Size. + let to = size - vec2(0.0, 12.0); + drag(&mut u, state, to); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"], "nothing moves before the drop"); + u.button(to, false); + assert_eq!(names(&mut u, set), ["State", "Size", "Icon"]); + assert_eq!(names(&mut u, inst), ["State", "Size", "Icon"], "the instance lists the new order"); + let doc = u.app.session.doc().unwrap(); + let first = doc.node(set).unwrap().children[0]; + assert!(doc.node(first).unwrap().name.starts_with("State="), "{}", doc.node(first).unwrap().name); + assert_eq!(undo_len(&u), undo + 1); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + // Dropped where it is: nothing changes. + u.frame(vec![]); + u.frame(vec![]); + let g = grip(&u, set, 0).unwrap(); + drag(&mut u, g, g + vec2(0.0, 6.0)); + u.button(g + vec2(0.0, 6.0), false); + assert_eq!(undo_len(&u), undo + 1); + assert!(u.app.session.undo().unwrap()); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); +} + +#[test] +fn a_property_dropped_under_the_last_ones_values_lands_last() { + let (mut u, set, _) = setup(); + let size = grip(&u, set, 0).unwrap(); + // State's header is 32 high and its value row 30 under it: 58 down is on the value row, below + // the header's half-row slack, so only an area that takes in the values counts it. + let state = u.ctx.read_response(grip_id(("variant_props", set), 1)).unwrap().rect; + let to = Pos2::new(size.x, state.min.y + 58.0); + drag(&mut u, size, to); + u.button(to, false); + assert_eq!(names(&mut u, set), ["State", "Size", "Icon"]); +} + +#[test] +fn pressing_the_name_field_does_not_start_a_drag() { + // A guard: on main there's no grip at all; it fails if the whole row becomes a drag handle. + let (mut u, set, _) = setup(); + let (size, state) = (grip(&u, set, 0).unwrap(), grip(&u, set, 1).unwrap()); + let undo = undo_len(&u); + let field = size + vec2(60.0, 0.0); + drag(&mut u, field, Pos2::new(field.x, state.y + 40.0)); + u.button(Pos2::new(field.x, state.y + 40.0), false); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); + assert_eq!(undo_len(&u), undo); +} + +#[test] +fn escape_cancels_a_property_drag_and_keeps_the_selection() { + // A guard: the cancel comes from the shared list drag. + let (mut u, set, _) = setup(); + let (size, state) = (grip(&u, set, 0).unwrap(), grip(&u, set, 1).unwrap()); + let undo = undo_len(&u); + let to = size - vec2(0.0, 12.0); + drag(&mut u, state, to); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + u.button(to, false); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); + assert_eq!(undo_len(&u), undo); + assert_eq!(u.app.session.ds().unwrap().selection, [set], "the Escape only cancels the drag"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..77ce7093 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -84,6 +84,8 @@ mod colorpicker_selection_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] +mod component_props_reorder_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod dev_mode_tests; diff --git a/docs/components-parity.md b/docs/components-parity.md index 659676fd..8ad788bf 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (variant properties reorder by dragging in the Properties section; variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -19,7 +19,7 @@ accounts). | Component sets / variants, add variant, switch | done | | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | -| Variant property order (names rewritten), duplicate combinations reported | done | reordering through `component.editProperty {index}`; no drag in the panel yet | +| Variant property order (names rewritten), duplicate combinations reported | done | drag a variant property by its left margin in the Properties section (or `component.editProperty {index}`); variant properties reorder among themselves; boolean, text, instance swap and slot properties keep a fixed (alphabetical) order | | Properties: boolean, text, instance swap (preferred values), variant | done | | | Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | | Exposed nested instances | done | hidden ones hide their properties | @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Variant properties reorder by dragging their left margin in a component or set's Properties section (insertion line, Escape cancels, one undo step; instances follow) | | 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | | 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | From 0e25cf4c2651db9395024cff75b7e1c5958bc7b8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:47:07 +0000 Subject: [PATCH 065/245] Refuse pages in edit.copy and edit.cut Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++-- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..ab503a02 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -499,7 +499,12 @@ fn bump_name(n: &str) -> String { pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; let doc = &ds.doc; - let ids = z_sorted(doc, &roots(doc, ids)); + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + } + let ids = z_sorted(doc, &roots(doc, &layers)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); for i in &ids { @@ -542,7 +547,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..6010559e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = sess(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let r = s.execute(cmd, &json!({"ids": [page.0]})); + if let Ok(v) = &r { + assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} From 554821afef53d20d7894eeddd9b48f7a265a9eb0 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Fri, 16 Oct 2026 17:31:11 +0300 Subject: [PATCH 066/245] M9: a damaged generation field no longer drops a log's flushed edits (#408) A log whose generation isn't its snapshot's was skipped as a leftover of a compaction cut short, but neither generation is under a checksum: one flipped bit made the server serve the older document, report nothing and overwrite the log. Replay such a log over the snapshot instead: replay skips what the snapshot already holds, so a genuine leftover changes nothing, while edits the snapshot lacks are recovered and the damage is reported. --- apps/figcraft-server/src/disk.rs | 6 +- apps/figcraft-server/src/store.rs | 30 +++- apps/figcraft-server/tests/header_recovery.rs | 139 ++++++++++++++++++ docs/server.md | 5 +- 4 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 apps/figcraft-server/tests/header_recovery.rs diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index f58f25b2..af6e0f99 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -8,8 +8,10 @@ //! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding. //! //! Compaction writes the next generation's snapshot, then an empty log of that generation. A log -//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it -//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short). +//! whose generation isn't the snapshot's is usually from before a compaction that was cut short +//! (what it holds is in the snapshot already), but no checksum covers either generation, so the +//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its +//! first damaged record (a write cut short). use std::fs::{self, File}; use std::io::{self, Read, Write}; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 538adeff..ea9bc626 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -440,7 +440,7 @@ impl Store { && let Ok(log) = disk::read_log(&bytes) && log.generation != generation { - log::info!("file at {}: its log predates the last compaction; it is ignored", path.display()); + log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display()); } let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum(); Ok(Entry { meta, links, log: None, bytes, forced: 0 }) @@ -1126,8 +1126,32 @@ impl Store { fresh_log = true; } } - // From before a compaction that was cut short: the snapshot has it all. - Ok(_) => {} + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), } for i in issues { diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/header_recovery.rs new file mode 100644 index 00000000..d728908d --- /dev/null +++ b/apps/figcraft-server/tests/header_recovery.rs @@ -0,0 +1,139 @@ +//! A generation field damaged after an abrupt stop must not make the server drop a log that still +//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the +//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short +//! still restores silently. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_collab::message::{Access, ClientMsg}; +use figcraft_collab::{Client, Outgoing, Replica, Server}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::Document; +use figcraft_server::config::Config; +use figcraft_server::disk::{self, LOG, SNAPSHOT}; +use figcraft_server::store::{FILES, Store}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap())); + fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn page_name(server: &Server) -> String { + let doc = server.document(); + doc.node(doc.first_page().unwrap()).unwrap().name.clone() +} + +/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then +/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state. +fn renamed_and_stopped(data: &Path) -> (String, Server) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap(); + let (info, _, _) = store.publish("Fixture", &snapshot).unwrap(); + let id = info.id; + let mut server = store.open(&id).unwrap(); + let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") }; + let (_, messages) = server.join_as(hello, Access::Edit).unwrap(); + let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") }; + let mut client = Client::join(welcome).unwrap(); + store.changed(&id, &mut server); + let mut edited = client.document().clone(); + let page = edited.first_page().unwrap(); + edited.node_mut(page).unwrap().name = "Kept".into(); + let change = client.edit(&edited).unwrap().unwrap(); + assert!(!server.receive(client.id(), change).is_empty()); + store.changed(&id, &mut server); + assert!(store.flush().is_empty(), "the edit must reach disk"); + assert_eq!(page_name(&server), "Kept"); + let dir = data.join(FILES).join(&id); + assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty()); + assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log"); + drop(store); + (id, server) +} + +/// Reopen the data directory: the file's first page name and the problems reported. +fn reopen(data: &Path, id: &str) -> (String, Vec) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let server = store.open(id).unwrap(); + (page_name(&server), store.problems().to_vec()) +} + +fn flip(path: &Path, at: usize) { + let mut bytes = fs::read(path).unwrap(); + bytes[at] ^= 1; + fs::write(path, bytes).unwrap(); +} + +/// The generation field's first byte in both headers (after the magic and the format byte). +const GENERATION_AT: usize = 5; + +#[test] +fn an_intact_log_restores_its_edits_without_a_report() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "{problems:?}"); +} + +#[test] +fn a_damaged_log_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let log = fixture.0.join(FILES).join(&id).join(LOG); + flip(&log, GENERATION_AT); + // The records themselves still read: only the header's generation changed. + let read = disk::read_log(&fs::read(&log).unwrap()).unwrap(); + assert!(read.damaged.is_none() && !read.entries.is_empty()); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + // And it stays: the recovered edit is now in the file's snapshot. + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT); + flip(&snapshot, GENERATION_AT); + assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum"); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_log_format_is_still_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + flip(&fixture.0.join(FILES).join(&id).join(LOG), 4); + let (_, problems) = reopen(&fixture.0, &id); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}"); +} + +#[test] +fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() { + let fixture = Fixture::new(); + let (id, server) = renamed_and_stopped(&fixture.0); + // The next snapshot was written (it holds the rename); the old log wasn't replaced yet. + let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap(); + fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap(); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}"); +} diff --git a/docs/server.md b/docs/server.md index f2e4820c..021d0e3f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -337,8 +337,9 @@ the address it came from and when it was last opened (for expiry). before the server tells anyone about the change. - **Compaction** writes the file's state as the next snapshot and starts an empty log, after `compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server - stops. A log left over from a compaction cut short is recognised and ignored (the snapshot holds - it all). + stops. A log left over from a compaction cut short is replayed over the snapshot, which already + holds it all, so nothing changes. If a damaged generation field made a current log look like such + a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported. - **Damage is skipped and reported, never deleted.** At startup a folder whose description, links or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged record loses only that end (logged when the file opens). Leftover temporary files are removed. From 8303cfa56f19fd11c13bb4f6933edd5717c7de03 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Fri, 16 Oct 2026 22:52:41 +0700 Subject: [PATCH 067/245] fix(engine): Shift-drag on a selected layer moves the selection (#498) Shift on a selected layer took it out of the selection on press, so the drag that followed had nothing to move, and with several layers selected the grabbed one stayed behind. It now leaves the selection on release, and only when the press didn't become a drag. Where no move can start (a version being viewed), it still leaves on press. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../engine/src/selection_behaviour_tests.rs | 89 +++++++++++++++++++ crates/engine/src/tools.rs | 38 +++++--- docs/ui-parity.md | 2 +- 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index b65b7936..bc961a4a 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -275,3 +275,92 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() { ex(&mut s, "object.toggleLock", json!({"ids": [k.g], "locked": true})); assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None); } + +#[test] +fn shift_drag_moves_the_selection() { + let mut s = Session::new(); + let k = scene(&mut s); + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + let home = (Point::new(500.0, 0.0), Point::new(600.0, 0.0)); + // (selection before, Shift-drag to (from r at 520,20), selection after, where r and empty end up, derivation) + let rows: Vec<(Vec, (f64, f64), Vec, (Point, Point), &str)> = vec![ + ( + vec![k.r], + (560.0, 22.0), + vec![k.r], + (Point::new(540.0, 0.0), Point::new(600.0, 0.0)), + "Shift-drag on the selected layer moves it along one axis and keeps it selected", + ), + ( + vec![k.r, k.empty], + (560.0, 22.0), + vec![k.r, k.empty], + (Point::new(540.0, 0.0), Point::new(640.0, 0.0)), + "Shift-drag on one of several selected layers moves them all", + ), + ( + vec![k.empty], + (560.0, 22.0), + vec![k.r, k.empty], + (Point::new(540.0, 0.0), Point::new(640.0, 0.0)), + "Shift-drag on an unselected layer adds it and moves the selection", + ), + (vec![k.r, k.empty], (521.0, 21.0), vec![k.empty], home, "a Shift-press that wobbles under the drag threshold is a click: the layer leaves"), + ]; + for (before, to, after, end, why) in rows { + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, (520.0, 20.0), to, SHIFT); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + assert_eq!((at(&s, k.r), at(&s, k.empty)), end, "{why}"); + if end != home { + assert!(s.undo().unwrap(), "{why}: one undo step"); + assert_eq!((at(&s, k.r), at(&s, k.empty)), home, "{why}: undone"); + } + } + // Shift let go before the button: still a Shift-click. + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).unwrap(); + s.pointer(&ev(PointerKind::Up, 520.0, 20.0, NONE)).unwrap(); + assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); +} + +#[test] +fn alt_shift_drag_on_a_selected_layer_duplicates_it() { + let mut s = Session::new(); + let k = scene(&mut s); + let layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let n = layers(&s); + let alt_shift = |kind, x, y| { + let mut e = ev(kind, x, y, SHIFT); + e.alt = true; + e + }; + ex(&mut s, "select.set", json!({"ids": [k.r]})); + s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Up, 520.0, 20.0)).unwrap(); + assert_eq!(s.selection(), Vec::::new(), "an Alt+Shift-click is a Shift-click: the layer leaves the selection"); + assert_eq!(layers(&s), n, "and nothing is copied"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Move, 560.0, 22.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Up, 560.0, 22.0)).unwrap(); + let sel = s.selection(); + assert_eq!(layers(&s), n + 1, "an Alt+Shift-drag copies the layer"); + assert!(sel.len() == 1 && sel[0] != k.r, "the copy is selected"); + let doc = s.doc().unwrap(); + assert_eq!(doc.world_bounds(sel[0]).unwrap().origin(), Point::new(540.0, 0.0), "the copy moves along one axis"); + assert_eq!(doc.world_bounds(k.r).unwrap().origin(), Point::new(500.0, 0.0), "the original stays"); +} + +#[test] +fn shift_click_takes_a_layer_out_where_nothing_can_move() { + let mut s = Session::new(); + let k = scene(&mut s); + // A version being viewed is read-only: the press can't start a move. + let v = ex(&mut s, "file.saveVersion", json!({"name": "v"})); + ex(&mut s, "version.preview", json!({"id": v["id"]})); + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + assert!(s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).is_err(), "a version being viewed can't be edited"); + s.pointer(&ev(PointerKind::Up, 520.0, 20.0, SHIFT)).unwrap(); + assert_eq!(s.selection(), vec![k.empty], "Shift-click still takes the layer out"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3d3b9acb..d074052f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -344,6 +344,9 @@ pub(crate) enum Gesture { targets: Targets, duplicate: bool, moved: bool, + /// A Shift-press on this selected layer: it leaves the selection on release, unless the + /// press became a drag. + deselect: Option, }, Resize { start: Point, @@ -1194,15 +1197,15 @@ impl Session { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { sel.clone() - } else if ev.shift { + } else if ev.shift && deselect.is_none() { let mut s2 = sel.clone(); - if let Some(pos) = s2.iter().position(|x| *x == target) { - s2.remove(pos); - } else { - s2.push(target); - } + s2.push(target); s2 } else if sel.contains(&target) { sel.clone() @@ -1210,12 +1213,12 @@ impl Session { vec![target] }; self.set_selection(new_sel.clone()); - if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) { - return Ok(()); - } // Locked layers selected in the layers panel stay put. let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); - if ids.is_empty() { + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } return Ok(()); } let mut targets = Targets::gather(&doc, page, &ids); @@ -1225,8 +1228,13 @@ impl Session { { targets.add_box(b); } - self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; - self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; } None => { @@ -1735,7 +1743,7 @@ impl Session { self.tool.overlay.drop_cell = None; let drop_target = self.tool.overlay.drop_target.take(); match g { - Gesture::Move { ids, moved, duplicate, start, .. } => { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { if moved { // A free move lands in the frame under the pointer (or the page) where it was // dropped; layers in an auto layout frame already sit in their slot. @@ -1777,6 +1785,10 @@ impl Session { } } else { self.cancel_interaction()?; + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } if !(ev.shift || ev.cmd || duplicate) { self.widget_click(start, ev.tol() * 2.0)?; } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..a8dfab69 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -48,7 +48,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | +| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped; Shift-click takes a selected layer out on release, so a Shift-drag on it moves the selection along one axis instead (`selection_behaviour_tests`) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | | Select layer menu (every layer under the click) | done | | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | From 40955aae7c97d48667c2a114bf31a260a257025c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Fri, 16 Oct 2026 23:09:59 +0700 Subject: [PATCH 068/245] fix(ui): Cmd/Ctrl+V pastes an image copied in another app (#663) egui-winit swallows the Cmd/Ctrl+V press and sends a paste event only when the clipboard holds text, so with an image on the clipboard only the key's release arrives and the image fallback, which waited for the press, never ran. Paste from a V release whose press never came and brought no paste event; the press and paste event are tracked from the raw events at the start of each frame. On Wayland arboard read the X11 clipboard, which the compositor does not fill for a native Wayland window, so every system paste found it empty. Enable arboard's wayland-data-control: it uses the Wayland clipboard when WAYLAND_DISPLAY is set and falls back to X11 otherwise. Right-click > Paste here now follows the same newest-wins rule, so a newer image beats copied layers there too, and lands at the point. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 66 +++++++ apps/figcraft/Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 11 +- crates/ui-egui/src/menus.rs | 23 +-- crates/ui-egui/src/paste_image_tests.rs | 234 ++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 44 ++++- 6 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/paste_image_tests.rs diff --git a/Cargo.lock b/Cargo.lock index aead39d1..bc60687e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1734,6 +1735,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2820,6 +2827,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3204,6 +3220,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -3264,6 +3290,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4574,6 +4611,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5726,6 +5774,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bafb6da1..fcc5d03a 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true, features = ["image-data"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } webbrowser = { workspace = true } image = { workspace = true } env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..bbc61149 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; #[cfg(test)] +mod paste_image_tests; +#[cfg(test)] mod pen_mode_tests; #[cfg(test)] mod perf_tests; @@ -494,6 +496,10 @@ pub struct UiState { /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste /// uses our copied layers; anything else there (an image, SVG, text) came from another app. pub last_copy: Option, + /// Since the last V key release: whether its press arrived, and whether a paste event did + /// (`shortcuts::swallowed_paste`). + pub v_pressed: bool, + pub paste_event: bool, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -587,6 +593,8 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + v_pressed: false, + paste_event: false, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1283,6 +1291,7 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + let paste_release = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. @@ -1398,7 +1407,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing); + shortcuts::handle(self, &ctx, typing, paste_release); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..fcbc1dad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -32,17 +32,18 @@ pub const UI_COMMANDS: &[&str] = &[ /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. /// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG /// or text from another app); otherwise our copied layers. `event_text` is the text the platform -/// handed over with the paste event (the browser has no other way to read it). -pub fn paste(app: &mut FigApp, event_text: Option<&str>) { +/// handed over with the paste event (the browser has no other way to read it). `at`: the page +/// point to paste at (Paste here); `None` pastes in the middle of the view. +pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; - let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(png); - // Centred in the view. - let (x, y) = ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()); + // Centred in the view; Paste here puts its top-left at the point, as it does layers. + let (x, y) = if at.is_some() { (cx, cy) } else { ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()) }; app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y})); return; } @@ -54,14 +55,14 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) { } else if figcraft_engine::looks_like_svg(&text) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true})); + app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": at.is_none()})); } else { app.run("text.create", json!({"text": text, "x": cx, "y": cy})); } return; } } - app.run("edit.paste", json!({})); + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); } /// The system clipboard's image as PNG bytes, with its size. @@ -143,7 +144,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.services.clipboard_set_text(&text); app.ui.last_copy = Some(text); } - "edit.paste" => paste(app, None), + "edit.paste" => paste(app, None, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or("")); app.toast(trf( @@ -532,11 +533,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { if item(ui, label, sc, available(id), false, &t) { if *id == "edit.paste" { let p = app.to_page(pos); - if app.session.clipboard.nodes.is_empty() { - invoke(app, id); - } else { - app.run("edit.paste", json!({"x": p.x, "y": p.y})); - } + paste(app, None, Some((p.x, p.y))); } else { invoke(app, id); } diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs new file mode 100644 index 00000000..6e202df5 --- /dev/null +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -0,0 +1,234 @@ +//! Pasting an image copied in another app (#663). On the desktop, egui-winit swallows Cmd/Ctrl+V's +//! press and sends a paste event only when the clipboard holds text: an image leaves just the +//! key's release. The browser sends the press and a paste event (`web_clipboard_tests`). + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services}; + +#[derive(Default)] +struct Clip { + text: Option, + image: Option<(u32, u32, Vec)>, +} + +/// The desktop clipboard. +struct Desktop(Rc>); + +impl Services for Desktop { + fn clipboard_set_text(&mut self, s: &str) { + *self.0.borrow_mut() = Clip { text: Some(s.into()), image: None }; + } + fn clipboard_get_text(&mut self) -> Option { + self.0.borrow().text.clone() + } + fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { + self.0.borrow().image.clone() + } +} + +struct Win { + ctx: egui::Context, + app: FigApp, + time: f64, + clip: Rc>, + shapes: Vec, +} + +impl Win { + fn new() -> Self { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + let clip = Rc::new(RefCell::new(Clip::default())); + let app = FigApp::new(s, Box::new(Desktop(clip.clone()))); + let mut w = Self { ctx, app, time: 0.0, clip, shapes: Vec::new() }; + for _ in 0..3 { + w.frame(vec![]); + } + w + } + + fn frame(&mut self, events: Vec) { + self.frame_with(events, false); + } + + /// `take_v`: something ahead of the app (a prototype's key trigger) consumes a V press. + fn frame_with(&mut self, events: Vec, take_v: bool) { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| { + if take_v { + ui.ctx().input_mut(|i| i.consume_key(Modifiers::NONE, Key::V)); + } + app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes.into_iter().map(|s| s.shape).collect(); + } + + fn copy_image_elsewhere(&mut self) { + *self.clip.borrow_mut() = Clip { text: None, image: Some((2, 2, vec![255; 16])) }; + } + + /// Cmd/Ctrl+V as egui-winit delivers it: the press brings a paste event when the clipboard + /// holds text and nothing otherwise; the release comes as a key event. + fn cmd_v(&mut self, m: Modifiers) { + let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty()); + self.frame(text.map(Event::Paste).into_iter().collect()); + self.frame(vec![key(Key::V, false, m)]); + self.frame(vec![]); + } + + fn named(&self, name: &str) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| n.name == name).count() + } + + fn texts(&self) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))).count() + } + + fn rects(&self) -> usize { + self.named("Rectangle") + } +} + +const CMD: Modifiers = Modifiers::COMMAND.plus(Modifiers::CTRL); + +fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } +} + +#[test] +fn cmd_v_pastes_an_image_copied_elsewhere() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1, "the image was pasted from the key's release"); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 2, "and again"); +} + +#[test] +fn cmd_released_before_v_still_pastes_the_image() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.cmd_v(Modifiers::NONE); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn text_pastes_once_from_its_paste_event() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("hello from another app".into()), image: None }; + w.cmd_v(CMD); + assert_eq!(w.texts(), 1, "the paste event pasted the text, the release nothing more"); + assert_eq!(w.rects(), 1); +} + +#[test] +fn text_pastes_once_when_another_key_is_let_go_first() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("hello".into()), image: None }; + // Cmd+A then Cmd+V typed fast: A comes up after V went down. + w.frame(vec![key(Key::A, true, CMD)]); + w.frame(vec![Event::Paste("hello".into())]); + w.frame(vec![key(Key::A, false, CMD)]); + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.texts(), 1); + assert_eq!(w.rects(), 1); +} + +#[test] +fn a_v_press_consumed_elsewhere_does_not_paste_on_release() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame_with(vec![key(Key::V, true, Modifiers::NONE)], true); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn the_browser_sequence_pastes_once() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, CMD)]); + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn v_alone_and_other_paste_commands_paste_nothing() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, Modifiers::NONE)]); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.cmd_v(CMD.plus(Modifiers::SHIFT)); + w.cmd_v(CMD.plus(Modifiers::ALT)); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn a_paste_event_from_another_key_does_not_block_the_next_cmd_v() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("text".into()), image: None }; + // Shift+Insert on Windows. + w.frame(vec![Event::Paste("text".into())]); + w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT)]); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn a_v_press_lost_to_another_window_does_not_block_the_next_cmd_v() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, Modifiers::NONE), Event::WindowFocused(false)]); + w.frame(vec![Event::WindowFocused(true)]); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn paste_here_prefers_a_newer_image_over_copied_layers() { + let mut w = Win::new(); + w.app.session.execute("select.all", &json!({})).unwrap(); + crate::menus::invoke(&mut w.app, "edit.copy"); + assert!(!w.app.session.clipboard.nodes.is_empty(), "layers copied"); + w.app.session.execute("select.none", &json!({})).unwrap(); + w.copy_image_elsewhere(); + let at = Pos2::new(900.0, 600.0); + for pressed in [true, false] { + w.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }, + ]); + } + w.frame(vec![]); + let item = crate::live_tests::text_center(&w.shapes, "Paste here"); + for pressed in [true, false] { + w.frame(vec![ + Event::PointerMoved(item), + Event::PointerButton { pos: item, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 1, "the newer image was pasted"); + assert_eq!(w.rects(), 1, "not the copied rectangle"); + let p = w.app.to_page(at); + let doc = w.app.session.doc().unwrap(); + let img = doc.nodes().find(|n| n.name == "Pasted image").unwrap(); + let b = doc.world_bounds(img.id).unwrap(); + assert!((b.x0 - p.x).abs() < 0.01 && (b.y0 - p.y).abs() < 0.01, "top-left where the menu opened: {b:?} vs {p:?}"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d5bfb156..1ac2824f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -94,7 +94,8 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame /// began. -pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { +/// `paste_release`: this frame released a Cmd/Ctrl+V the shell swallowed ([`swallowed_paste`]). +pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool) { note_platform(ctx); // Text fields in panels own the keyboard (also the key that ends their editing). if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { @@ -109,18 +110,19 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { text_keys(app, ctx, &events); return; } - // An image-only clipboard brings no paste event, just the key: paste from the key then. + // An image-only clipboard brings no paste event: paste from the key then. The browser sends + // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt)); - if paste_key && !paste_event { - crate::menus::paste(app, None); + if (paste_key && !paste_event) || paste_release { + crate::menus::paste(app, None, None); } let now = ctx.input(|i| i.time); for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), - Event::Paste(text) => crate::menus::paste(app, Some(text)), + Event::Paste(text) => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => { @@ -131,6 +133,38 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { } } +/// Whether this frame releases a Cmd/Ctrl+V whose press never arrived and that brought no paste +/// event: egui-winit swallows the press and sends a paste event only when the clipboard holds +/// text, so an image on the clipboard leaves just this release. Call at the start of every frame: +/// it reads the raw events, which keys consumed by panels or the presenter still carry. +pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { + let ui = &mut app.ui; + let mut paste = false; + ctx.input(|i| { + for ev in &i.raw.events { + match ev { + Event::Paste(_) => ui.paste_event = true, + Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, + Event::Key { key: Key::V, pressed: false, modifiers, .. } => { + // Shift or Alt with it is another command (paste over, paste properties). + paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt; + ui.v_pressed = false; + ui.paste_event = false; + } + // The other keys that paste (Shift+Insert, a Paste key) end their paste event. + Event::Key { key: Key::Insert | Key::Paste, pressed: false, .. } => ui.paste_event = false, + // Keys let go in another window never come back as releases. + Event::WindowFocused(false) => { + ui.v_pressed = false; + ui.paste_event = false; + } + _ => {} + } + } + }); + paste +} + /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out /// before it deselects, Delete ends a pen path, Enter edits the selected text or vector). pub fn run_binding(app: &mut FigApp, id: &str) { From 569a3b3669c409a0e90dcad6b2424e1134b35a6c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Fri, 16 Oct 2026 23:11:00 +0700 Subject: [PATCH 069/245] fix(engine): a Shift-click on a selected widget doesn't also click it (#498) With Shift let go before the button, the release took the layer out of the selection and also ran the widget's click. The take-out now skips the widget click, as a Shift-click always has. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/tools.rs | 2 +- crates/engine/src/widgets_tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index d074052f..dbda1894 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1789,7 +1789,7 @@ impl Session { let sel = self.selection().into_iter().filter(|x| *x != d).collect(); self.set_selection(sel); } - if !(ev.shift || ev.cmd || duplicate) { + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { self.widget_click(start, ev.tol() * 2.0)?; } } diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs index 7079905f..f228d35c 100644 --- a/crates/engine/src/widgets_tests.rs +++ b/crates/engine/src/widgets_tests.rs @@ -248,6 +248,19 @@ fn clicking_a_widget_on_the_canvas_calls_its_plugin_back() { assert!(after.x - before.x > 30.0 && state(&mut s, w) == json!({"n": 2}), "{before:?} → {after:?}"); } +#[test] +fn a_shift_click_on_a_selected_widget_only_deselects_it() { + let mut s = session(); + let (w, _) = insert(&mut s); + let target = s.doc().unwrap().world_bounds(node(&s, w).children[1]).unwrap().center(); + let mut down = PointerEvent::at(PointerKind::Down, target.x, target.y, 1.0); + down.shift = true; + // Shift let go before the button: still a Shift-click, not a click on the widget. + s.pointer(&down).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, target.x, target.y, 1.0)).unwrap(); + assert_eq!((s.selection(), state(&mut s, w)), (vec![], json!({"n": 1}))); +} + #[test] fn bad_widgets_and_clicks_are_refused_and_roll_back() { let mut s = session(); From 60fa813eb25a0ad4f8de6dfadbfbb61a1e3f30b0 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 00:27:34 +0700 Subject: [PATCH 070/245] fix(format): numbered lists from .fig files keep their start number (#686) Figma stores where a numbered list starts counting on each text line (listStartOffset; a list shown as "2." has offset 1). The import dropped it, so every list counted from 1. A paragraph's list settings now carry a start number (written only when set, so older files open and save unchanged). Numbering counts on from it where a list starts; items that go on counting ignore it. The .fig import reads it, the .fig export writes it on a list's first line, and plain paragraphs drop it. Toggling a list off ignores differing starts. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/text.rs | 46 ++++++++++-- crates/engine/src/text_tail_tests.rs | 21 +++++- crates/format/src/fig/export.rs | 4 +- crates/format/src/fig/export_tests.rs | 2 +- crates/format/src/fig/map.rs | 12 ++- crates/format/src/fig/tests.rs | 3 + crates/format/src/fig/text_list_tests.rs | 94 ++++++++++++++++++++++++ crates/format/src/rest/text.rs | 5 +- crates/text/src/layout.rs | 7 +- crates/text/src/tail_tests.rs | 27 ++++++- docs/file-format-parity.md | 8 +- 11 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 crates/format/src/fig/text_list_tests.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b294151b..161b207d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -215,6 +215,10 @@ pub struct ParaList { pub list: ListStyle, /// Nesting level (0 = outermost; plain paragraphs are always 0). pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start + 1` (items + /// that go on counting ignore it; plain paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start: u32, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -637,13 +641,13 @@ impl TextData { /// Paragraph `p`'s list settings. pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 }) + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) } /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). fn fill_paragraphs(&mut self) { let n = self.paragraph_count(); - let fill = ParaList { list: self.list, indent: 0 }; + let fill = ParaList { list: self.list, ..ParaList::default() }; self.paragraphs.resize(n, fill); } @@ -657,17 +661,20 @@ impl TextData { } /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with - /// no nesting folds back into `list`. + /// paragraphs stay at level 0 with no start, levels stop at [`MAX_LIST_INDENT`]); one style + /// for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); - pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) }; + if pl.list == ListStyle::None { + *pl = ParaList::default(); + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); } if let Some(first) = self.paragraphs.first().copied() - && first.indent == 0 + && first == (ParaList { list: first.list, ..ParaList::default() }) && self.paragraphs.iter().all(|p| *p == first) { self.list = first.list; @@ -675,11 +682,12 @@ impl TextData { } } - /// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ. + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. pub fn common_para_list(&self, start: usize, end: usize) -> Option { let (a, b) = self.para_span(start, end); let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p) == first).then_some(first) + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } /// Link chars `start..end` (all of them when the range is empty or the whole text) to text @@ -782,4 +790,26 @@ mod case_tests { assert_eq!(st.variations.keys().collect::>(), ["wdth"]); assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start, 1); + t.set_para_lists(5, 5, |p| p.start = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } } diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index b951bfc7..687c67b8 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -57,6 +57,21 @@ fn list_buttons_act_on_the_selected_paragraphs() { assert_eq!(data(&s, t).list, P, "toggled off"); } +#[test] +fn a_list_with_a_start_number_toggles_off() { + // An imported list that starts at 2 has the start on its first item only; it is still one + // numbered list, so the button turns it back to plain (#686). + let mut s = Session::new(); + let t = text(&mut s, "two\nthree\nfour"); + if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { + td.paragraphs = + vec![ParaList { list: N, indent: 0, start: 1 }, ParaList { list: N, indent: 0, start: 0 }, ParaList { list: N, indent: 0, start: 0 }]; + } + s.execute("text.numberList", &json!({"ids": [t]})).unwrap(); + assert_eq!(lists(&s, t), [(P, 0); 3]); + assert_eq!(data(&s, t).para_list(0).start, 0, "a plain paragraph has no start"); +} + #[test] fn typed_list_starts() { // Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one @@ -117,7 +132,7 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect(); + let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start: 0 }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -141,7 +156,7 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }]; + t.paragraphs = vec![ParaList { list: B, indent: 1, start: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start: 0 }]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -156,7 +171,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start: 0 }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..cb2b8bff 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1574,12 +1574,14 @@ impl<'d> Exporter<'d> { }; let listed = *l != ListStyle::None; let first = listed && (i == 0 || lists.get(i - 1) != Some(l)); + // Where a numbered list starts counting, less one. + let start = if first && *l == ListStyle::Numbered { td.para_list(i).start } else { 0 }; o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), ("sourceDirectionality", e("AUTO")), - ("listStartOffset", int(0)), + ("listStartOffset", int(i64::from(start))), ("isFirstLineOfList", b(first)), ]) }; diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 67215b0c..53cfaf91 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { for r in &mut td.runs { px(&mut r.style); } - td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect(); + td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect(); td.list = ListStyle::None; } NodeKind::Vector { network } => { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..cbde49d8 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -827,10 +827,8 @@ impl<'a> Mapper<'a> { o.insert("characterStyleOverrides".into(), json!(ids)); o.insert("styleOverrideTable".into(), Value::Object(table)); } - let lines: Vec<&str> = td - .get("lines") - .map(Kv::list) - .unwrap_or_default() + let raw = td.get("lines").map(Kv::list).unwrap_or_default(); + let lines: Vec<&str> = raw .iter() .map(|l| match l.get("lineType").and_then(Kv::str) { Some("ORDERED_LIST") => "ORDERED", @@ -841,6 +839,12 @@ impl<'a> Mapper<'a> { if lines.iter().any(|l| *l != "NONE") { o.insert("lineTypes".into(), json!(lines)); } + // Where a numbered list starts counting, less one (not a REST field). + let starts: Vec = + raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect(); + if starts.iter().any(|n| *n > 0) { + o.insert("listStartOffsets".into(), json!(starts)); + } } /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..1173dacd 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -873,6 +873,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { #[path = "tail_tests.rs"] mod tail; +#[path = "text_list_tests.rs"] +mod text_list; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs new file mode 100644 index 00000000..8d314ccd --- /dev/null +++ b/crates/format/src/fig/text_list_tests.rs @@ -0,0 +1,94 @@ +//! Numbered lists that start past 1 (#686): each text line says where its list starts counting, +//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers. + +use figcraft_doc::{ListStyle, NodeKind, ParaList}; + +use super::*; + +const LIST_SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS TEXT +enum LineType PLAIN ORDERED_LIST UNORDERED_LIST +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList +message TextData string characters, TextLineData[] lines +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData +message Message NodeChange[] nodeChanges +"; + +/// A list line: type, start offset, first of its list. +fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> { + let level = i64::from(ty != "PLAIN"); + o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))]) +} + +fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'static> { + let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6]; + node( + id, + Some((1, pos)), + "TEXT", + name, + vec![ + ("size", xy(200.0, 60.0)), + ("transform", at(0.0, 0.0)), + ("fontSize", f(16.0)), + ("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])), + ], + ) +} + +fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> { + let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; + (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start)).collect() +} + +#[test] +fn numbered_lists_keep_their_start() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]), + text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]), + text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]), + text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]), + text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]), + text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]), + ]; + let sc = Schema::parse(LIST_SCHEMA).unwrap(); + let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(starts(&doc, "From 1"), [(N, 0)]); + assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2."); + assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]); + assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]); + assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 2)]); + assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1"); +} + +#[test] +fn the_export_writes_where_a_list_starts() { + let mut doc = figcraft_doc::Document::new("Lists"); + let page = doc.first_page().unwrap(); + let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix"); + td.paragraphs = vec![ + ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, + ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, + ParaList::default(), + ParaList { list: ListStyle::Numbered, indent: 0, start: 5 }, + ]; + doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); + let (bytes, _) = super::super::export(&doc, None).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap(); + let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); + let offsets: Vec = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect(); + assert_eq!(offsets, [1.0, 0.0, 0.0, 5.0], "on the first line of each list only"); + let (back, _) = import(&bytes, "x").unwrap(); + assert_eq!(starts(&back, "List"), [(ListStyle::Numbered, 1), (ListStyle::Numbered, 0), (ListStyle::None, 0), (ListStyle::Numbered, 5)]); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index c5381bb2..ea66e390 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -210,10 +210,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { out } -/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`. +/// Each paragraph's list type, nesting level and start number from `lineTypes`, +/// `lineIndentations` and (from `.fig` files) `listStartOffsets`. fn lists(v: &Value, td: &mut TextData) { let types = list(v, "lineTypes"); let indents = list(v, "lineIndentations"); + let starts = list(v, "listStartOffsets"); td.paragraphs = types .iter() .enumerate() @@ -224,6 +226,7 @@ fn lists(v: &Value, td: &mut TextData) { _ => ListStyle::None, }, indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), + start: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), }) .collect(); // Clamp the levels and fold one style for all back into the layer's list style. diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 37092da8..5ba5dd48 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -827,8 +827,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } /// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at -/// one level, restarting after a plain paragraph, a shallower item or the other kind of list; -/// levels go 1. a. i. and • ◦ ▪, then round again. +/// one level, restarting after a plain paragraph, a shallower item or the other kind of list, +/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again. pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"]; let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1]; @@ -844,7 +844,8 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { *c = (ListStyle::None, 0); } let Some(c) = count.get_mut(d) else { return String::new() }; - *c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) }; + let first = usize::try_from(pl.start).unwrap_or(usize::MAX).saturating_add(1); + *c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) }; match (pl.list, d % 3) { (ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)), (ListStyle::Numbered, 2) => format!("{}.", roman(c.1)), diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 890b254d..b1854dfd 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -43,7 +43,32 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option)> { } fn pl(list: ListStyle, indent: u8) -> ParaList { - ParaList { list, indent } + ParaList { list, indent, start: 0 } +} + +fn from(list: ListStyle, indent: u8, start: u32) -> ParaList { + ParaList { list, indent, start } +} + +#[test] +fn numbered_lists_count_from_their_start() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let cases: Vec<(Vec, Vec<&str>, &str)> = vec![ + (vec![from(N, 0, 1)], vec!["2."], "a one-item list started at 2 (#686)"), + (vec![from(N, 0, 3), pl(N, 0), pl(N, 0)], vec!["4.", "5.", "6."], "the items after the first count on from it"), + (vec![from(N, 0, 3), from(N, 0, 3), from(N, 0, 3)], vec!["4.", "5.", "6."], "items that go on counting ignore their own start"), + (vec![from(N, 0, 1), pl(P, 0), from(N, 0, 1)], vec!["2.", "", "2."], "a list after a plain paragraph starts from its own start"), + (vec![pl(N, 0), from(N, 1, 1), pl(N, 1)], vec!["1.", "b.", "c."], "a nested level starts from its first item's start"), + (vec![from(B, 0, 4), pl(B, 0)], vec!["\u{2022}", "\u{2022}"], "bullets have no number"), + (vec![from(N, 0, 4_000), pl(N, 2)], vec!["4001.", "i."], "big starts"), + ]; + for (lists, want, why) in cases { + assert_eq!(list_markers(&lists), want, "{why}"); + } + // The largest start never overflows, on 32-bit targets too. + let max = list_markers(&[from(N, 0, u32::MAX), pl(N, 0), pl(N, 0)]); + assert_eq!(max.len(), 3); + assert!(max.iter().all(|m| m.starts_with("429496729")), "{max:?}"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..1b8cdcc4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` numbered list start numbers (#686); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -88,7 +88,8 @@ each. layers sit in it (fill, absolute, min/max sizes); layout grids; export settings; text style (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize, - truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed + truncation, paragraph spacing and indent, list types (with the number a numbered list starts + at; not yet checked in other software) and styled runs with their fills (indexed by UTF-16 unit); components (description, boolean, text and instance swap properties and the layers bound to them), component sets, instances (their main, property values, what the instance itself changes from its main, such as its size, name and paints, as an override addressed by the @@ -162,6 +163,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a text keeps it per paragraph (older files open unchanged), numbering counts on from it, and the export writes it on a list's first line. Nested list levels from `.fig` files are still read as the first level. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 4adbd6cee98b80c877ad31de57d3bf54ef13741c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 00:35:46 +0700 Subject: [PATCH 071/245] fix(ui): a V held while the window was away doesn't paste on release (#663) winit sends no press for a key held across a focus change (X11's synthetic presses are dropped by egui-winit), so letting go of V after coming back looked like a swallowed Cmd+V. After a focus gain, a V release only pastes with Cmd still held. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/paste_image_tests.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 8 ++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bbc61149..9fbd17cc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -500,6 +500,8 @@ pub struct UiState { /// (`shortcuts::swallowed_paste`). pub v_pressed: bool, pub paste_event: bool, + /// The window got the focus back since the last V release. + pub refocused: bool, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -595,6 +597,7 @@ impl Default for UiState { last_copy: None, v_pressed: false, paste_event: false, + refocused: false, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 6e202df5..fa797fb9 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -200,6 +200,30 @@ fn a_v_press_lost_to_another_window_does_not_block_the_next_cmd_v() { assert_eq!(w.named("Pasted image"), 1); } +#[test] +fn v_held_while_the_window_was_away_pastes_nothing_on_release() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + // Pressed in another window: only the focus and the release arrive here. + w.frame(vec![Event::WindowFocused(true)]); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1, "the next Cmd+V still pastes"); +} + +#[test] +fn cmd_v_in_a_panel_field_does_not_paste_on_the_canvas() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![]); + w.app.field_focus = true; + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); +} + #[test] fn paste_here_prefers_a_newer_image_over_copied_layers() { let mut w = Win::new(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ac2824f..f28a2433 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -146,10 +146,13 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { Event::Paste(_) => ui.paste_event = true, Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, Event::Key { key: Key::V, pressed: false, modifiers, .. } => { - // Shift or Alt with it is another command (paste over, paste properties). - paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt; + // Shift or Alt with it is another command (paste over, paste properties). A V + // held while the window was away comes back as a release alone: without Cmd + // held, that's no paste. + paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt && (modifiers.command || !ui.refocused); ui.v_pressed = false; ui.paste_event = false; + ui.refocused = false; } // The other keys that paste (Shift+Insert, a Paste key) end their paste event. Event::Key { key: Key::Insert | Key::Paste, pressed: false, .. } => ui.paste_event = false, @@ -158,6 +161,7 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { ui.v_pressed = false; ui.paste_event = false; } + Event::WindowFocused(true) => ui.refocused = true, _ => {} } } From 9aeb9fb75207e615e34a2dffcca778a77088280c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:58:00 +0200 Subject: [PATCH 072/245] Cap stroke width in the outliner so huge weights don't panic Found while testing crates/engine/src/cmd/props.rs:361 (strokeWeight is only clamped with .max(0.0)); the panic is raised when the renderer draws the stroke (render crate / tiny-skia stroker) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/huge_stroke_tests.rs | 28 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 ++ crates/pathops/src/lib.rs | 18 ++++++++++++++--- 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/huge_stroke_tests.rs diff --git a/crates/engine/src/huge_stroke_tests.rs b/crates/engine/src/huge_stroke_tests.rs new file mode 100644 index 00000000..7efabb48 --- /dev/null +++ b/crates/engine/src/huge_stroke_tests.rs @@ -0,0 +1,28 @@ +//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn huge_stroke_weight_with_round_caps_still_saves_and_exports() { + for weight in [1e6, 1e7, 1e10, 1e30] { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}})) + .unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id())); + let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err()); + let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"})); + assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..a68a78d9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1548,6 +1548,8 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod huge_stroke_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..d4071ca1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -258,11 +258,16 @@ pub(crate) fn kjoin(j: StrokeJoin) -> Join { } } -/// Plain centred stroke of `path` at `width`. +/// The widest stroke outlined: past this the rasteriser's fixed-point maths overflows and panics, +/// and nothing visible changes (a stroke this wide covers any canvas already). +pub const MAX_STROKE_WIDTH: f64 = 1e6; + +/// Plain centred stroke of `path` at `width` (capped at [`MAX_STROKE_WIDTH`]). pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath { if !(width.is_finite() && width > 0.0 && finite(path)) { return BezPath::new(); } + let width = width.min(MAX_STROKE_WIDTH); let mut style = kurbo::Stroke::new(width) .with_join(kjoin(props.join)) .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) @@ -284,10 +289,17 @@ pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) - /// [`stroke_outline`] of a shape whose stroke follows vector network `net` (`path` is its stroke /// path): points with a cap or join of their own draw them there. pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool, net: Option<&VectorNetwork>) -> BezPath { - let w = props.weight; - if !(w.is_finite() && w > 0.0) { + if !(props.weight.is_finite() && props.weight > 0.0) { return BezPath::new(); } + let capped; + let props = if props.weight > MAX_STROKE_WIDTH { + capped = StrokeProps { weight: MAX_STROKE_WIDTH, ..props.clone() }; + &capped + } else { + props + }; + let w = props.weight; let styled = net.and_then(|n| points::Styled::new(n, props)); // Centred at `width` (twice the weight for Inside/Outside, which clip half of it away). let centred = |width: f64| match (&styled, &props.brush, props.profile()) { From d27f45e06320e642e55324eb9bd01bb352153525 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 00:58:20 +0700 Subject: [PATCH 073/245] fix(format): write a list's start on every line; report it in get_node (#686) The .fig export now writes a numbered list's start on each of its lines, so a reader that takes it per line or per list counts the same. Bullets no longer keep a start. The field is named start_offset (it holds the offset: 1 shows "2."). get_node reports listStartOffsets. The parity doc lists what's still not read from .fig lists. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/text.rs | 30 ++++++++++++++---------- crates/engine/src/inspect.rs | 5 +++- crates/engine/src/text_tail_tests.rs | 15 +++++++----- crates/format/src/fig/export.rs | 21 +++++++++++++---- crates/format/src/fig/text_list_tests.rs | 21 ++++++++--------- crates/format/src/rest/text.rs | 2 +- crates/text/src/layout.rs | 2 +- crates/text/src/tail_tests.rs | 6 ++--- docs/file-format-parity.md | 6 ++--- docs/mcp.md | 3 ++- 10 files changed, 68 insertions(+), 43 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 161b207d..097dd189 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -215,10 +215,10 @@ pub struct ParaList { pub list: ListStyle, /// Nesting level (0 = outermost; plain paragraphs are always 0). pub indent: u8, - /// A numbered list that starts counting at this paragraph counts from `start + 1` (items - /// that go on counting ignore it; plain paragraphs are always 0). + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). #[serde(skip_serializing_if = "crate::node::is_default")] - pub start: u32, + pub start_offset: u32, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -661,15 +661,18 @@ impl TextData { } /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0 with no start, levels stop at [`MAX_LIST_INDENT`]); one style - /// for all with no nesting or start folds back into `list`. + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); if pl.list == ListStyle::None { - *pl = ParaList::default(); + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; } pl.indent = pl.indent.min(MAX_LIST_INDENT); } @@ -793,21 +796,24 @@ mod case_tests { #[test] fn list_starts_load_from_old_files_and_follow_edits() { - use ListStyle::{None as P, Numbered as N}; + use ListStyle::{Bulleted as B, None as P, Numbered as N}; // Files written before start numbers load with none, and write the same bytes. let old = r#"{"list":"numbered","indent":1}"#; let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start: 0 }); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); assert_eq!(serde_json::to_string(&pl).unwrap(), old); // One numbered paragraph with a start keeps it (it doesn't fold into `list`). let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start: 1 }); + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); // Enter copies it to the new item; different starts don't make the list mixed. t.replace_range(4, 4, "\nMore"); - assert_eq!(t.para_list(1).start, 1); - t.set_para_lists(5, 5, |p| p.start = 0); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); // A plain paragraph has none. t.set_para_lists(0, 0, |p| p.list = P); assert_eq!(t.para_list(0), ParaList::default()); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1f461c60..88bc015a 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); } - // Lists: one style for the layer, or each paragraph's type and nesting level. + // Lists: one style for the layer, or each paragraph's type, nesting level and start. if t.paragraphs.is_empty() { if t.list != figcraft_doc::ListStyle::None { o.insert("listStyle".into(), json!(t.list)); @@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } else { o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } } if t.list_spacing != 0.0 { o.insert("listSpacing".into(), json!(t.list_spacing)); diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index 687c67b8..81d05ed2 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -64,12 +64,15 @@ fn a_list_with_a_start_number_toggles_off() { let mut s = Session::new(); let t = text(&mut s, "two\nthree\nfour"); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { - td.paragraphs = - vec![ParaList { list: N, indent: 0, start: 1 }, ParaList { list: N, indent: 0, start: 0 }, ParaList { list: N, indent: 0, start: 0 }]; + let n = |start_offset| ParaList { list: N, indent: 0, start_offset }; + td.paragraphs = vec![n(1), n(0), n(0)]; } + let j = crate::inspect::node_json(s.doc().unwrap(), t, 0); + assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start"); s.execute("text.numberList", &json!({"ids": [t]})).unwrap(); assert_eq!(lists(&s, t), [(P, 0); 3]); - assert_eq!(data(&s, t).para_list(0).start, 0, "a plain paragraph has no start"); + assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start"); + assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none()); } #[test] @@ -132,7 +135,7 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start: 0 }).collect(); + let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -156,7 +159,7 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1, start: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start: 0 }]; + t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -171,7 +174,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2, start: 0 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index cb2b8bff..dc56ff79 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1565,6 +1565,20 @@ impl<'d> Exporter<'d> { data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]); } let lists: Vec = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect(); + let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l)); + // Where each line's numbered list starts counting, less one: its first item's start, on + // every line of the list. + let mut run = 0; + let starts: Vec = lists + .iter() + .enumerate() + .map(|(i, l)| { + if first(i, l) { + run = td.para_list(i).start_offset; + } + if *l == ListStyle::Numbered { run } else { 0 } + }) + .collect(); // One entry per paragraph, each with every field a reader expects. let line = |(i, l): (usize, &ListStyle)| { let ty = match l { @@ -1573,16 +1587,15 @@ impl<'d> Exporter<'d> { ListStyle::Bulleted => "UNORDERED_LIST", }; let listed = *l != ListStyle::None; - let first = listed && (i == 0 || lists.get(i - 1) != Some(l)); - // Where a numbered list starts counting, less one. - let start = if first && *l == ListStyle::Numbered { td.para_list(i).start } else { 0 }; + let begins = first(i, l); + let start = starts.get(i).copied().unwrap_or(0); o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), ("sourceDirectionality", e("AUTO")), ("listStartOffset", int(i64::from(start))), - ("isFirstLineOfList", b(first)), + ("isFirstLineOfList", b(begins)), ]) }; data.push(("lines", arr(lists.iter().enumerate().map(line).collect()))); diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs index 8d314ccd..eca7b0dc 100644 --- a/crates/format/src/fig/text_list_tests.rs +++ b/crates/format/src/fig/text_list_tests.rs @@ -42,7 +42,7 @@ fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'s fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> { let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; - (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start)).collect() + (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect() } #[test] @@ -65,7 +65,7 @@ fn numbered_lists_keep_their_start() { assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2."); assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]); assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]); - assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 2)]); + assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start"); assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1"); } @@ -73,13 +73,10 @@ fn numbered_lists_keep_their_start() { fn the_export_writes_where_a_list_starts() { let mut doc = figcraft_doc::Document::new("Lists"); let page = doc.first_page().unwrap(); - let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix"); - td.paragraphs = vec![ - ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, - ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, - ParaList::default(), - ParaList { list: ListStyle::Numbered, indent: 0, start: 5 }, - ]; + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot"); + let pl = |list, start_offset| ParaList { list, indent: 0, start_offset }; + td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)]; doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); let (bytes, _) = super::super::export(&doc, None).unwrap(); let a = Archive::open(&bytes).unwrap(); @@ -88,7 +85,9 @@ fn the_export_writes_where_a_list_starts() { let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap(); let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); let offsets: Vec = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect(); - assert_eq!(offsets, [1.0, 0.0, 0.0, 5.0], "on the first line of each list only"); + // Every line of a numbered list carries its first item's start (a reader that takes it per + // line or per list counts the same); bullets and plain lines carry none. + assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]); let (back, _) = import(&bytes, "x").unwrap(); - assert_eq!(starts(&back, "List"), [(ListStyle::Numbered, 1), (ListStyle::Numbered, 0), (ListStyle::None, 0), (ListStyle::Numbered, 5)]); + assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]); } diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index ea66e390..c8fd92ba 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -226,7 +226,7 @@ fn lists(v: &Value, td: &mut TextData) { _ => ListStyle::None, }, indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), - start: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), + start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), }) .collect(); // Clamp the levels and fold one style for all back into the layer's list style. diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5ba5dd48..e6c893b7 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -844,7 +844,7 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { *c = (ListStyle::None, 0); } let Some(c) = count.get_mut(d) else { return String::new() }; - let first = usize::try_from(pl.start).unwrap_or(usize::MAX).saturating_add(1); + let first = usize::try_from(pl.start_offset).unwrap_or(usize::MAX).saturating_add(1); *c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) }; match (pl.list, d % 3) { (ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)), diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index b1854dfd..2d0ad22b 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -43,11 +43,11 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option)> { } fn pl(list: ListStyle, indent: u8) -> ParaList { - ParaList { list, indent, start: 0 } + ParaList { list, indent, start_offset: 0 } } -fn from(list: ListStyle, indent: u8, start: u32) -> ParaList { - ParaList { list, indent, start } +fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList { + ParaList { list, indent, start_offset } } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1b8cdcc4..6f0cce80 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: nested list levels and where a new list begins right after another numbered one (`isFirstLineOfList`), connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -89,7 +89,7 @@ each. (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize, truncation, paragraph spacing and indent, list types (with the number a numbered list starts - at; not yet checked in other software) and styled runs with their fills (indexed + at, on every line of the list; not yet checked in other software) and styled runs with their fills (indexed by UTF-16 unit); components (description, boolean, text and instance swap properties and the layers bound to them), component sets, instances (their main, property values, what the instance itself changes from its main, such as its size, name and paints, as an override addressed by the @@ -163,7 +163,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a text keeps it per paragraph (older files open unchanged), numbering counts on from it, and the export writes it on a list's first line. Nested list levels from `.fig` files are still read as the first level. Numbers unchanged | +| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a numbered paragraph keeps it (older files open unchanged), numbering counts on from it, `get_node` reports it (`listStartOffsets`), and the export writes it on every line of the list. Nested list levels and a new list right after another numbered one are still not read from `.fig` files. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 4efee72a..331dc67d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -177,7 +177,8 @@ Or in a project's `.mcp.json`: - **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations` - per paragraph. `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected + per paragraph, plus `listStartOffsets` when a numbered list starts past 1 (from a `.fig` file; + 1 shows "2."). `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected paragraphs while text is edited. Layer props: `listSpacing` (px between list items, in place of `paragraphSpacing`), `hangingList` (markers outside the box) and `hangingPunctuation`. While editing, `key` Tab / Shift+Tab nest an item or move it out, Enter on an empty item and Backspace From 8c90ef962adb902a72c8127c9b0f63bd2df315bd Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 23:36:23 +0500 Subject: [PATCH 074/245] M7: open inputs for parameterized menu actions --- ROADMAP.md | 3 +- crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/src/dialogs.rs | 92 ++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menu_input_tests.rs | 74 +++++++++++++++++++++ crates/ui-egui/src/menus.rs | 29 ++++++++ docs/gaps.md | 6 +- 9 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/menu_input_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..c79f0ea7 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-16 · **Change:** minor (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-16 · **Change:** minor (menu and Actions parameter dialogs; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Affected menu and Actions commands that need values now open the relevant input or panel instead of showing a parameter error; Preferences offers language in a dropdown. Headline numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..5d978897 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -829,6 +829,7 @@ name = "English" "No model credits were built into this copy." = "No model credits were built into this copy." "No number variables fit here." = "No number variables fit here." "No number variables yet. Create them in Local variables." = "No number variables yet. Create them in Local variables." +"No other pages" = "No other pages" "No paints" = "No paints" "No plugins on {server}." = "No plugins on {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "No plugins yet. Import a development plugin from its figcraft-plugin.json." @@ -1603,4 +1604,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" \ No newline at end of file +"⌥ drag" = "⌥ drag" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..c0329964 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -830,6 +830,7 @@ status = "draft — needs native review" "No model credits were built into this copy." = "Esta copia no incluye créditos de modelos." "No number variables fit here." = "Ninguna variable numérica encaja aquí." "No number variables yet. Create them in Local variables." = "Aún no hay variables numéricas. Créalas en Variables locales." +"No other pages" = "No hay otras páginas" "No paints" = "Sin pinturas" "No plugins on {server}." = "No hay plugins en {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aún no hay plugins. Importa un plugin de desarrollo desde su figcraft-plugin.json." diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..4228f8bc 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -819,6 +819,7 @@ name = "Русский" "No model credits were built into this copy." = "В этой сборке нет сведений об использованных моделях." "No number variables fit here." = "Нет подходящих числовых переменных." "No number variables yet. Create them in Local variables." = "Числовых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No other pages" = "Других страниц нет" "No paints" = "Заливок нет" "No plugins on {server}." = "На {server} нет плагинов." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Плагинов пока нет. Импортируйте плагин для разработки из его figcraft-plugin.json." diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6e0ee3a6..31eb1adb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -71,6 +71,15 @@ pub enum AboutTab { Models, } +/// Values collected by a menu or Actions command before dispatching it to the engine. +#[derive(Clone, Debug, PartialEq)] +pub enum MenuInput { + RenameFile(String), + Link(String), + Offset(f64), + MoveToPage, +} + impl AboutTab { pub const ALL: [AboutTab; 3] = [AboutTab::About, AboutTab::Contributors, AboutTab::Models]; @@ -137,6 +146,7 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { pub fn keys(app: &mut FigApp, ctx: &Context) { let u = &mut app.ui; let open = u.color_profile.is_some() + || u.menu_input.is_some() || u.find.is_some() || u.batch_rename.is_some() || u.confirm_close @@ -147,7 +157,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { return; } - if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { + if u.menu_input.take().is_some() || u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { return; } for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] { @@ -265,12 +275,17 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.label(tr("Language")); // Each language by its own name; System follows the OS (the browser on the web). let langs = i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)); - for (code, name) in [("system", tr("System"))].into_iter().chain(langs) { - if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { - app.run("pref.language", json!({"language": code})); - ui.ctx().request_repaint(); + let choices: Vec<_> = [("system", tr("System"))].into_iter().chain(langs).collect(); + let selected = + choices.iter().find(|(code, _)| app.session.prefs.language.eq_ignore_ascii_case(code)).map_or(tr("System"), |(_, name)| *name); + egui::ComboBox::from_id_salt("pref_language").selected_text(selected).width(220.0).show_ui(ui, |ui| { + for (code, name) in choices { + if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { + app.run("pref.language", json!({"language": code})); + ui.ctx().request_repaint(); + } } - } + }); }); if let Some(lang) = i18n::current().filter(|l| l.draft) { let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]); @@ -321,6 +336,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.find = if open { Some((find, replace)) } else { None }; } batch_rename_dialog(app, ctx); + menu_input_dialog(app, ctx); crate::variables_panel::show(app, ctx); crate::dev_compare::show(app, ctx); crate::branch_ui::show(app, ctx); @@ -335,6 +351,70 @@ pub fn show(app: &mut FigApp, ctx: &Context) { crate::windows::confirm_close(app, ctx); } +/// Menus and Actions cannot pass a value themselves. Gather it here, then run the regular +/// command so the same validation and undo behaviour apply as for programmatic callers. +fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { + let Some(mut input) = app.ui.menu_input.take() else { return }; + let title = match &input { + MenuInput::RenameFile(_) => i18n::command("file.rename", "Rename file"), + MenuInput::Link(_) => i18n::command("text.link", "Create link"), + MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), + MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), + }; + let mut open = true; + let mut action: Option<(&str, serde_json::Value)> = None; + window(&title, ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + match &mut input { + MenuInput::RenameFile(name) => { + let field = ui.add(egui::TextEdit::singleline(name).desired_width(300.0)); + if (ui.button(tr("Rename")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + && !name.trim().is_empty() + { + action = Some(("file.rename", json!({"name": name.trim()}))); + } + } + MenuInput::Link(url) => { + let field = ui.add(egui::TextEdit::singleline(url).hint_text("https://").desired_width(300.0)); + if (ui.button(tr("Done")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) && !url.trim().is_empty() + { + action = Some(("text.link", json!({"url": url.trim()}))); + } + } + MenuInput::Offset(offset) => { + ui.add(egui::DragValue::new(offset).speed(1.0).range(-100_000.0..=100_000.0)); + if ui.button(tr("Offset")).clicked() { + action = Some(("vector.offset", json!({"offset": offset}))); + } + } + MenuInput::MoveToPage => { + let mut choices = 0; + if let Ok(ds) = app.session.ds() { + for &page in &ds.doc.pages { + if page == ds.page || ds.doc.is_divider(page) { + continue; + } + if let Some(node) = ds.doc.node(page) + && ui.button(&node.name).clicked() + { + action = Some(("object.moveToPage", json!({"page": page}))); + } + choices += 1; + } + } + if choices == 0 { + ui.label(tr("No other pages")); + } + } + } + }); + if let Some((id, params)) = action { + app.run(id, params); + } else if open { + app.ui.menu_input = Some(input); + } +} + /// The Rename layers dialog's fields. #[derive(Clone, Debug, Default, PartialEq)] pub struct BatchRenameUi { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..c4671391 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -132,6 +132,8 @@ mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] +mod menu_input_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -410,6 +412,8 @@ pub struct UiState { /// The tab the About window shows. pub about_tab: dialogs::AboutTab, pub preferences: bool, + /// A menu command that needs a value before the engine can run it. + pub menu_input: Option, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -537,6 +541,7 @@ impl Default for UiState { about: false, about_tab: dialogs::AboutTab::About, preferences: false, + menu_input: None, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs new file mode 100644 index 00000000..0de3757f --- /dev/null +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -0,0 +1,74 @@ +//! Menu and Actions entries that need an input must collect it in the UI, not send `{}` to the +//! engine (which would show a parameter error or silently use a default). + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dialogs::MenuInput; +use crate::layers_drag_tests::Ui; +use crate::menus; + +#[test] +fn preference_actions_open_preferences_without_changing_values() { + let mut ui = Ui::new(Session::new()); + ui.app.session.prefs.language = "en".into(); + ui.app.session.prefs.publish_server = "wss://example.test/".into(); + let before = ui.app.session.prefs.clone(); + for id in ["pref.language", "pref.userName", "pref.publishServer", "pref.nudge"] { + ui.app.ui.preferences = false; + menus::invoke(&mut ui.app, id); + assert!(ui.app.ui.preferences, "{id} did not open Preferences"); + assert!(ui.app.ui.last_error.is_none(), "{id} showed an error"); + assert_eq!(ui.app.session.prefs.language, before.language); + assert_eq!(ui.app.session.prefs.publish_server, before.publish_server); + assert_eq!(ui.app.session.prefs.nudge_small, before.nudge_small); + assert_eq!(ui.app.session.prefs.nudge_big, before.nudge_big); + } + // Programmatic callers still need the actual argument. + assert!(ui.app.session.execute("pref.language", &json!({})).is_err()); +} + +#[test] +fn menu_entries_collect_required_arguments_before_running() { + let mut ui = Ui::new(Session::new()); + let name = ui.app.session.doc().unwrap().name.clone(); + for (id, expected) in [ + ("file.rename", MenuInput::RenameFile(name)), + ("text.link", MenuInput::Link(String::new())), + ("vector.offset", MenuInput::Offset(0.0)), + ("object.moveToPage", MenuInput::MoveToPage), + ] { + ui.app.ui.menu_input = None; + menus::invoke(&mut ui.app, id); + assert_eq!(ui.app.ui.menu_input, Some(expected), "{id}"); + assert!(ui.app.ui.last_error.is_none(), "{id} showed an error"); + } + ui.frame(vec![]); + assert!(ui.app.ui.last_error.is_none()); +} + +#[test] +fn palette_entries_with_existing_pickers_show_them() { + let mut ui = Ui::new(Session::new()); + menus::invoke(&mut ui.app, "tool.set"); + assert_eq!(ui.app.ui.tool_flyout, Some("move")); + ui.app.ui.left_collapsed = true; + menus::invoke(&mut ui.app, "page.select"); + assert!(!ui.app.ui.left_collapsed); + assert!(ui.app.ui.pages_open); + menus::invoke(&mut ui.app, "file.openRecent"); + assert!(ui.app.ui.home); + assert_eq!(ui.app.ui.home_view.section, crate::home::Section::Recents); + assert!(ui.app.ui.last_error.is_none()); +} + +#[test] +fn batch_rename_opens_its_existing_dialog() { + let mut session = Session::new(); + let id = session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(); + session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut ui = Ui::new(session); + menus::invoke(&mut ui.app, "object.batchRename"); + assert_eq!(ui.app.ui.batch_rename.as_ref().map(|d| d.ids.len()), Some(1)); + assert!(ui.app.ui.last_error.is_none()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..ccdffa7f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -81,6 +81,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { "file.placeImage" => app.import_dialog(true), "file.save" => app.save(), "file.saveAs" => app.save_as_dialog(), + "file.openRecent" => { + app.ui.home = true; + app.ui.home_view.section = crate::home::Section::Recents; + } "file.saveVersion" => crate::versions::start_save(app), "branch.create" => crate::versions::start_branch(app), "file.export" | "file.exportSvg" => app.ui.export_preview = true, @@ -128,6 +132,31 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), + // The Actions palette invokes commands without parameters. These values are edited in + // Preferences; sending an empty object would either fail or silently reset a preference. + "pref.language" | "pref.userName" | "pref.publishServer" | "pref.nudge" => { + app.run("app.preferences", json!({})); + } + "file.rename" => { + if let Ok(doc) = app.session.doc() { + app.ui.menu_input = Some(crate::dialogs::MenuInput::RenameFile(doc.name.clone())); + } + } + "text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())), + "vector.offset" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Offset(0.0)), + "object.moveToPage" => app.ui.menu_input = Some(crate::dialogs::MenuInput::MoveToPage), + "object.batchRename" => { + let ids = app.session.selection(); + if !ids.is_empty() { + crate::dialogs::open_batch_rename(app, &ids); + } + } + "tool.set" => app.ui.tool_flyout = Some("move"), + "page.select" => { + app.ui.left_tab = crate::LeftTab::File; + app.ui.left_collapsed = false; + app.ui.pages_open = true; + } // From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly). "collab.join" => crate::live::open_join(app, ""), // So is the server address for publishing (it carries the server key). diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..c8ed80b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (menu and Actions parameter dialogs; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -138,6 +138,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, `theme_pages_tests`). +- **Landed (2026-10-16):** affected menu and Actions entries that need values now open their existing + controls or collect the values before dispatch. The language choice in Preferences uses a + dropdown that fits in the dialog (`menu_input_tests.rs`). - **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). @@ -400,6 +403,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Gap 4: affected menu and Actions entries with required values open an input or the relevant panel before running the command; the language selector fits inside Preferences. Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | From dc97118c6f0c8e3be88087d6e9a910d069be042e Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 22:58:36 +0300 Subject: [PATCH 075/245] Actions palette rework: Figma-shaped panel over the toolbar Tabs (All, Assets, Plugins & widgets), Recents kept in the preferences (newest first, capped at 5), commands grouped by top-level menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles. Rows carry right-side shortcut/kind hints; Enter runs the first visible row. The panel floats 8 px above the toolbar pill with its exact width and centre (toolbar::main_width, canvas-centred), at a fixed size so tab switches never resize it. Tabs keep their frames when idle so the active pill never moves its neighbours. Engine: Prefs.recent_actions (persisted, skipped when empty). UI: invoke/menu/plugin picks record recents; gone entries drop out. Locales: new keys in en + es, retired the old search hint everywhere. Docs: ui-parity row describes the new surface (palette_tests). --- .gitignore | 2 + crates/engine/src/lib.rs | 9 + crates/ui-egui/locales/cs.toml | 1 - crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 5 +- crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/id.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/ru.toml | 1 - crates/ui-egui/locales/sv.toml | 1 - crates/ui-egui/locales/tr.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menus.rs | 405 +++++++++++++++++++++++++--- crates/ui-egui/src/palette_tests.rs | 60 +++++ crates/ui-egui/src/plugins_ui.rs | 2 + crates/ui-egui/src/toolbar.rs | 15 +- docs/ui-parity.md | 2 +- 22 files changed, 467 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/src/palette_tests.rs diff --git a/.gitignore b/.gitignore index 928d6b49..3709c74a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ plans/ /log/ .DS_Store *.profraw + +plugins/target/ diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..09b34120 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -652,8 +652,16 @@ pub struct Prefs { pub home_sort: String, /// Which way: `"desc"` (newest first, Z to A) or `"asc"`. pub home_order: String, + /// Recently run actions (the Actions palette's Recents), newest first, at most + /// [`MAX_RECENT_ACTIONS`]. Entries are `command:`, `plugin:/` or + /// `widget:`. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_actions: Vec, } +/// Recently run actions kept for the Actions palette (newest first). +pub const MAX_RECENT_ACTIONS: usize = 5; + impl Default for Prefs { fn default() -> Self { Self { @@ -681,6 +689,7 @@ impl Default for Prefs { home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), + recent_actions: Vec::new(), } } } diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..e86da423 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1133,7 +1133,6 @@ status = "draft — needs native review" "Scroll with parent" = "Posouvat s nadřazeným prvkem" "Search plugins" = "Hledat pluginy" "Search" = "Hledat" -"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" "Search assets" = "Hledat zdroje" "Search comments" = "Hledat komentáře" "Search fonts" = "Hledat písma" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..862527e4 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1096,7 +1096,6 @@ status = "draft — needs native review" "Scroll to layer" = "Zur Ebene scrollen" "Scroll with parent" = "Mit übergeordnetem Element scrollen" "Search" = "Suchen" -"Search actions, layers and tools" = "Aktionen, Ebenen und Werkzeuge suchen" "Search assets" = "Assets suchen" "Search comments" = "Kommentare suchen" "Search fonts" = "Schriften suchen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..703f018c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -965,8 +965,10 @@ name = "English" "Place a shape or frame (its tool chosen)" = "Place a shape or frame (its tool chosen)" "Place automatically" = "Place automatically" "Place image" = "Place image" +"Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." "Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" "Point" = "Point" @@ -1148,7 +1150,7 @@ name = "English" "Scroll with parent" = "Scroll with parent" "Search plugins" = "Search plugins" "Search" = "Search" -"Search actions, layers and tools" = "Search actions, layers and tools" +"Search actions, assets and plugins" = "Search actions, assets and plugins" "Search assets" = "Search assets" "Search comments" = "Search comments" "Search fonts" = "Search fonts" @@ -1468,6 +1470,7 @@ name = "English" "White" = "White" "Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files" "Why it changed" = "Why it changed" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Width" "Width point" = "Width point" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..554d9983 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -966,8 +966,10 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "Colocar forma o marco (herramienta elegida)" "Place automatically" = "Colocar automáticamente" "Place image" = "Colocar imagen" +"Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza." "Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" "Point" = "Punto" @@ -1149,8 +1151,8 @@ status = "draft — needs native review" "Scroll with parent" = "Desplazar con el padre" "Search plugins" = "Buscar plugins" "Search" = "Buscar" -"Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" +"Search actions, assets and plugins" = "Buscar acciones, recursos y plugins" "Search comments" = "Buscar comentarios" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" @@ -1469,6 +1471,7 @@ status = "draft — needs native review" "White" = "Blanco" "Who runs it, its terms and how long it keeps files" = "Quién lo gestiona, sus condiciones y cuánto tiempo conserva los archivos" "Why it changed" = "Por qué cambió" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Ancho" "Width point" = "Punto de ancho" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..2c1fbc45 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1090,7 +1090,6 @@ status = "draft — needs native review" "Scroll to layer" = "Défiler jusqu’au calque" "Scroll with parent" = "Défiler avec le parent" "Search" = "Rechercher" -"Search actions, layers and tools" = "Rechercher des actions, des calques et des outils" "Search assets" = "Rechercher des ressources" "Search comments" = "Rechercher des commentaires" "Search fonts" = "Rechercher des polices" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..53825747 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1137,7 +1137,6 @@ status = "draft — needs native review" "Scroll with parent" = "Gulir bersama induk" "Search plugins" = "Cari plugin" "Search" = "Cari" -"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" "Search assets" = "Cari aset" "Search comments" = "Cari komentar" "Search fonts" = "Cari font" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..670f84fc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1140,7 +1140,6 @@ status = "draft — needs native review" "Scroll to layer" = "レイヤーまでスクロール" "Scroll with parent" = "親と一緒にスクロール" "Search" = "検索" -"Search actions, layers and tools" = "アクション、レイヤー、ツールを検索" "Search assets" = "アセットを検索" "Search comments" = "コメントを検索" "Search fonts" = "フォントを検索" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..1f7bed0c 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1090,7 +1090,6 @@ status = "draft — needs native review" "Scroll to layer" = "레이어로 스크롤" "Scroll with parent" = "부모와 함께 스크롤" "Search" = "검색" -"Search actions, layers and tools" = "액션, 레이어, 도구 검색" "Search assets" = "에셋 검색" "Search comments" = "댓글 검색" "Search fonts" = "글꼴 검색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..2fb61d12 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1089,7 +1089,6 @@ status = "draft — needs native review" "Scroll to layer" = "Rolar até a camada" "Scroll with parent" = "Rolar com o pai" "Search" = "Pesquisar" -"Search actions, layers and tools" = "Pesquisar ações, camadas e ferramentas" "Search assets" = "Pesquisar recursos" "Search comments" = "Pesquisar comentários" "Search fonts" = "Pesquisar fontes" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..78a8afd6 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1134,7 +1134,6 @@ name = "Русский" "Scroll with parent" = "Прокручивать с родителем" "Search plugins" = "Поиск плагинов" "Search" = "Поиск" -"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" "Search fonts" = "Поиск шрифтов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..a54c02e7 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1135,7 +1135,6 @@ name = "Svenska" "Scroll with parent" = "Rulla med överordnat element" "Search plugins" = "Sök plugin-program" "Search" = "Sök" -"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg" "Search assets" = "Sök tillgångar" "Search comments" = "Sök i kommentarer" "Search fonts" = "Sök typsnitt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..43603287 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1137,7 +1137,6 @@ name = "Türkçe" "Scroll with parent" = "Üst öğeyle birlikte kaydır" "Search plugins" = "Eklentilerde ara" "Search" = "Ara" -"Search actions, layers and tools" = "Eylem, katman ve araç ara" "Search assets" = "Varlıklarda ara" "Search comments" = "Yorumlarda ara" "Search fonts" = "Yazı tiplerinde ara" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..a42360e4 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1091,7 +1091,6 @@ status = "draft — needs native review" "Scroll to layer" = "滚动到图层" "Scroll with parent" = "随父级滚动" "Search" = "搜索" -"Search actions, layers and tools" = "搜索操作、图层和工具" "Search assets" = "搜索资源" "Search comments" = "搜索评论" "Search fonts" = "搜索字体" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7a196cf2..9dd8e2f4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -415,6 +415,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul } if let Some(h) = p.get("palette").and_then(Value::as_bool) { app.ui.palette = if h { Some(String::new()) } else { None }; + app.ui.palette_tab = crate::menus::PaletteTab::All; } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..46830316 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -136,6 +136,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod palette_tests; +#[cfg(test)] mod panel_remove_tests; #[cfg(test)] mod panel_reorder_tests; @@ -404,6 +406,8 @@ pub struct UiState { pub rename_page: Option<(NodeId, String)>, pub main_menu: bool, pub palette: Option, + /// The Actions palette's tab. + pub palette_tab: menus::PaletteTab, /// The Variables editor, when open. pub variables: Option, pub about: bool, @@ -533,6 +537,7 @@ impl Default for UiState { rename_page: None, main_menu: false, palette: None, + palette_tab: menus::PaletteTab::All, variables: None, about: false, about_tab: dialogs::AboutTab::About, @@ -788,7 +793,10 @@ impl FigApp { } "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, - "palette.actions" => self.ui.palette = Some(String::new()), + "palette.actions" => { + self.ui.palette = Some(String::new()); + self.ui.palette_tab = menus::PaletteTab::All; + } "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..0e079313 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,9 +7,9 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::i18n::{self, tr, tr_noop, trf}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular}; +use crate::i18n::{self, tr, tr_noop, trf}; +use crate::theme::{Tokens, regular}; use crate::toolbar::pretty_shortcut; /// Commands the UI layer implements itself (dialogs and platform actions). @@ -73,7 +73,28 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } +/// The Actions palette's tab. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum PaletteTab { + #[default] + All, + Assets, + Plugins, +} + +/// Remember a ran action for the palette's Recents (newest first, capped). +pub fn record_recent(app: &mut FigApp, key: &str) { + let r = &mut app.session.prefs.recent_actions; + if r.first().is_some_and(|f| f == key) { + return; + } + r.retain(|k| k != key); + r.insert(0, key.to_string()); + r.truncate(figcraft_engine::MAX_RECENT_ACTIONS); +} + pub fn invoke(app: &mut FigApp, id: &str) { + record_recent(app, &format!("command:{id}")); match id { "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), @@ -612,14 +633,25 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) } } -/// The Actions palette: fuzzy search over every command. +/// The Actions palette's content height (the frame adds its own 8 px margins). +const PALETTE_H: f32 = 450.0; + +/// The Actions palette: search over commands, assets and plugins, in Figma's shape (tabs, +/// recents, grouped sections). Rows run on click; Enter runs the first visible row. fn palette(app: &mut FigApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let t = Tokens::get(ctx); - let screen = ctx.content_rect(); + // Same centre and width as the toolbar pill (it centres on the canvas, not the window), + // floating 8 px above it. Fixed size: switching tabs never resizes the panel. + let bar_w = crate::toolbar::main_width(); + let canvas = app.canvas.rect; + let area = Rect::from_min_size( + pos2(canvas.center().x - bar_w / 2.0, canvas.max.y - 68.0 - (PALETTE_H + 16.0)), + vec2(bar_w, PALETTE_H + 16.0), + ); let mut run: Option = None; let mut close = false; - egui::Area::new(Id::new("palette")).fixed_pos(pos2(screen.center().x - 260.0, screen.min.y + 120.0)).order(egui::Order::Foreground).show( + egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( ctx, |ui| { egui::Frame::NONE @@ -629,64 +661,353 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { - ui.set_width(520.0); + // Same outer width as the toolbar pill, and a fixed height: switching tabs + // never resizes the panel. + let inner = crate::toolbar::main_width() - 16.0; + ui.set_width(inner); + ui.set_height(PALETTE_H); let r = ui.add( egui::TextEdit::singleline(&mut q) - .hint_text(tr("Search actions, layers and tools")) - .desired_width(500.0) + .hint_text(tr("Search actions, assets and plugins")) + .desired_width(inner - 16.0) .font(regular(14.0)) .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), ); r.request_focus(); crate::widgets::select_all_on_focus(ui, &r, &q); ui.add_space(4.0); - let ql = q.to_lowercase(); - // Labels match in the UI language and in English. - let matches = - |label: &str, english: &str, id: &str| ql.is_empty() || [label, english, id].iter().any(|s| s.to_lowercase().contains(&ql)); - let mut hits: Vec<(&str, std::borrow::Cow, &str)> = Vec::new(); - let menu_items = MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)).map(|m| (m.id, m.label)); - for (id, english) in figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)).chain(menu_items) { - let label = i18n::command(id, english); - if matches(&label, english, id) { - hits.push((id, label, crate::shortcuts::shortcut(id).unwrap_or(""))); - } - } - hits.sort_by_key(|h| (!h.1.to_lowercase().starts_with(&ql), h.1.len())); - hits.dedup_by(|a, b| a.0 == b.0); - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (i, (id, label, sc)) in hits.iter().take(60).enumerate() { - let w = ui.available_width(); - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); - if resp.hovered() || i == 0 { - ui.painter().rect_filled(r, CornerRadius::same(5), if resp.hovered() { ACCENT } else { t.hover }); - } - let col = if resp.hovered() { Color32::WHITE } else { t.text }; - ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, label.as_ref(), regular(12.0), col); - ui.painter().text( - pos2(r.max.x - 10.0, r.center().y), - Align2::RIGHT_CENTER, - pretty_shortcut(sc), - regular(11.0), - if resp.hovered() { Color32::WHITE } else { t.text_secondary }, + // Tabs: All, Assets, Plugins & widgets. + let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + for (tab, label) in tabs { + let active = app.ui.palette_tab == tab; + // The frame stays on for every tab (transparent when idle) so the + // active pill never moves its neighbours. + let resp = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) + .frame(true) + .fill(if active { t.hover } else { Color32::TRANSPARENT }) + .corner_radius(CornerRadius::same(6)), ); - if resp.clicked() || (enter && i == 0) { - run = Some(id.to_string()); + if resp.clicked() { + app.ui.palette_tab = tab; } } }); + ui.add_space(4.0); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let mut first: Option = None; + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + let tab = app.ui.palette_tab; + if tab == PaletteTab::All { + let recents: Vec = app + .session + .prefs + .recent_actions + .clone() + .iter() + .filter_map(|key| recent_row(app, key, &q)) + .collect(); + if !recents.is_empty() { + section(ui, &t, tr("Recents"), |ui| { + for row in &recents { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, row) { + run = Some(row.key.clone()); + } + } + }); + } + } + if tab != PaletteTab::Plugins { + for (group, rows) in command_sections(&q) { + if tab == PaletteTab::Assets { + continue; + } + section(ui, &t, &group, |ui| { + for row in rows { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + if tab != PaletteTab::Assets { + let plugins = plugin_rows(app, &q); + let widgets = widget_rows(app, &q); + if !plugins.is_empty() { + section(ui, &t, tr("Plugins"), |ui| { + for row in plugins { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + if !widgets.is_empty() { + section(ui, &t, tr("Widgets"), |ui| { + for row in widgets { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + if tab == PaletteTab::Assets { + for (group, rows) in asset_sections(app, &q) { + section(ui, &t, &group, |ui| { + for row in rows { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + }); + if enter { + if let Some(key) = first { + run = Some(key); + } + } }); }, ); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } - if let Some(id) = run { + if let Some(key) = run { app.ui.palette = None; - invoke(app, &id); + run_action(app, &key); return; } app.ui.palette = if close { None } else { Some(q) }; let _ = StrokeKind::Inside; } + +/// Run a palette row: `command:`, `plugin:/`, `widget:`, +/// `component:` or `style:`. +fn run_action(app: &mut FigApp, key: &str) { + if let Some(id) = key.strip_prefix("command:") { + invoke(app, id); + } else if let Some(rest) = key.strip_prefix("plugin:") { + if let Some((plugin, command)) = rest.split_once('/') { + record_recent(app, key); + crate::plugins_ui::start(app, plugin, command); + } + } else if let Some(plugin) = key.strip_prefix("widget:") { + record_recent(app, key); + app.run("widget.insert", json!({ "plugin": plugin })); + } else if let Some(node) = key.strip_prefix("component:") { + if let Ok(n) = node.parse::() { + let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + app.run("instance.place", json!({ "component": n, "x": cx, "y": cy })); + } + } else if let Some(style) = key.strip_prefix("style:") { + if app.session.selection().is_empty() { + app.toast(tr("Select layers to apply the style to")); + } else { + app.run("style.apply", json!({ "style": style })); + } + } +} + +/// One pickable palette row. +pub(crate) struct ActionRow { + pub(crate) key: String, + pub(crate) label: String, + hint: String, + /// A letter tile instead of an icon (plugins carry no artwork). + tile: Option, +} + +/// A section header with its rows. +fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { + ui.add_space(6.0); + ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 7.0), Align2::LEFT_TOP, name, regular(11.0), t.text_secondary); + ui.add_space(18.0); + rows(ui); +} + +/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint. +/// True when clicked. +fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + } + let mut label_x = r.min.x + 10.0; + if let Some(letter) = row.tile { + let tile = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(18.0, 18.0)); + ui.painter().rect_filled(tile, CornerRadius::same(5), t.hover); + ui.painter().text(tile.center(), Align2::CENTER_CENTER, letter.to_string(), regular(11.0), t.text); + label_x = r.min.x + 38.0; + } + ui.painter().text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, &row.label, regular(13.0), t.text); + ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, &row.hint, regular(11.0), t.text_secondary); + resp.clicked() +} + +/// Whether `label` (or `extra`) matches the query, in any case. +fn matches(q: &str, label: &str, extra: &str) -> bool { + q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q) +} + +/// Commands grouped by top-level menu, in catalog order, filtered by the query. +pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec)> { + let ql = q.to_lowercase(); + let mut groups: Vec<(String, Vec)> = Vec::new(); + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for m in figcraft_engine::catalog::MENU.iter() { + if m.label == figcraft_engine::catalog::SEP || m.menu.starts_with("Plugins") || !available(m.id) || !seen.insert(m.id) { + continue; + } + let english = m.label; + let label = i18n::menu_item(m.id, english).into_owned(); + if !matches(&ql, &label, &format!("{english} {}", m.id)) { + continue; + } + let group = m.menu.split('/').next().unwrap_or(&m.menu); + let group = tr(group).to_string(); + let row = ActionRow { + key: format!("command:{}", m.id), + label, + hint: crate::shortcuts::shortcut(m.id).map(pretty_shortcut).unwrap_or_default(), + tile: None, + }; + match groups.iter_mut().find(|(g, _)| *g == group) { + Some((_, rows)) => rows.push(row), + None => groups.push((group, vec![row])), + } + } + groups +} + +/// Installed plugins' commands, filtered by the query. +fn plugin_rows(app: &FigApp, q: &str) -> Vec { + let ql = q.to_lowercase(); + let mut rows = Vec::new(); + for p in &app.session.prefs.plugins { + let m = &p.manifest; + for c in &m.commands { + let label = if m.commands.len() == 1 { m.name.clone() } else { format!("{} — {}", m.name, c.label) }; + if !matches(&ql, &label, &m.id) { + continue; + } + rows.push(ActionRow { + key: format!("plugin:{}/{}", m.id, c.id), + label, + hint: if c.params.is_empty() { tr("Plugin").to_string() } else { c.label.clone() }, + tile: m.name.chars().next(), + }); + } + } + rows +} + +/// Installed widgets, filtered by the query. +fn widget_rows(app: &FigApp, q: &str) -> Vec { + let ql = q.to_lowercase(); + app.session + .prefs + .plugins + .iter() + .filter(|p| p.manifest.widget) + .filter(|p| matches(&ql, &p.manifest.name, &p.manifest.id)) + .map(|p| ActionRow { + key: format!("widget:{}", p.manifest.id), + label: p.manifest.name.clone(), + hint: tr("Widget").to_string(), + tile: p.manifest.name.chars().next(), + }) + .collect() +} + +/// This file's components and styles, filtered by the query. +fn asset_sections(app: &FigApp, q: &str) -> Vec<(String, Vec)> { + let ql = q.to_lowercase(); + let Ok(doc) = app.session.doc() else { return Vec::new() }; + let components: Vec = figcraft_engine::library::local_components(doc) + .iter() + .filter_map(|id| doc.node(*id)) + .filter(|n| matches(&ql, &n.name, "")) + .map(|n| ActionRow { + key: format!("component:{}", n.id.0), + label: n.name.clone(), + hint: tr("Component").to_string(), + tile: None, + }) + .collect(); + let styles: Vec = figcraft_engine::library::local_styles(doc) + .filter(|s| matches(&ql, &s.name, "")) + .map(|s| ActionRow { + key: format!("style:{}", s.id), + label: s.name.clone(), + hint: tr("Style").to_string(), + tile: None, + }) + .collect(); + let mut out = Vec::new(); + if !components.is_empty() { + out.push((tr("Components").to_string(), components)); + } + if !styles.is_empty() { + out.push((tr("Styles").to_string(), styles)); + } + out +} + +/// A recent action back as a row (gone commands, plugins or assets drop out). +pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option { + let ql = q.to_lowercase(); + if let Some(id) = key.strip_prefix("command:") { + let spec = figcraft_engine::find_command(id); + let menu_label = figcraft_engine::catalog::MENU.iter().find(|m| m.id == id); + let english = menu_label.map(|m| m.label).or_else(|| spec.map(|s| s.label)).unwrap_or(id); + if !available(id) || !matches(&ql, english, id) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label: i18n::command(id, english).into_owned(), + hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), + tile: None, + }); + } + if let Some(rest) = key.strip_prefix("plugin:") { + let (pid, cid) = rest.split_once('/')?; + let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid)?; + let c = p.manifest.commands.iter().find(|c| c.id == cid)?; + let label = if p.manifest.commands.len() == 1 { p.manifest.name.clone() } else { format!("{} — {}", p.manifest.name, c.label) }; + if !matches(&ql, &label, pid) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label, + hint: tr("Plugin").to_string(), + tile: p.manifest.name.chars().next(), + }); + } + if let Some(pid) = key.strip_prefix("widget:") { + let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid && p.manifest.widget)?; + if !matches(&ql, &p.manifest.name, pid) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label: p.manifest.name.clone(), + hint: tr("Widget").to_string(), + tile: p.manifest.name.chars().next(), + }); + } + None +} diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs new file mode 100644 index 00000000..e496cb77 --- /dev/null +++ b/crates/ui-egui/src/palette_tests.rs @@ -0,0 +1,60 @@ +//! The Actions palette, driven headless: invoking records recents (newest first, capped), +//! sections group commands by menu, the query filters, and gone entries drop out of Recents. + +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; + +#[test] +fn invoke_records_recents_newest_first_and_capped() { + let mut u = Ui::new(Session::new()); + for id in [ + "view.zoomIn", + "view.zoomOut", + "edit.copyAsText", + "style.list", + "edit.findReplace", + "edit.copyAsCss", + "view.zoomIn", + ] { + crate::menus::invoke(&mut u.app, id); + } + let r = &u.app.session.prefs.recent_actions; + assert_eq!(r.len(), figcraft_engine::MAX_RECENT_ACTIONS, "recents are capped: {r:?}"); + assert_eq!(r[0], "command:view.zoomIn", "a re-run moves to the front: {r:?}"); + assert_eq!(r.iter().filter(|k| k.as_str() == "command:view.zoomIn").count(), 1, "no duplicates: {r:?}"); + assert!(!r.contains(&"command:view.zoomOut".to_string()), "the oldest drops out: {r:?}"); +} + +#[test] +fn sections_group_by_menu_in_catalog_order() { + let groups = crate::menus::command_sections(""); + assert!(!groups.is_empty(), "the catalog has commands"); + assert_eq!(groups[0].0, "File", "catalog order starts at File"); + for (group, rows) in &groups { + assert!(!rows.is_empty(), "{group} has no rows"); + for row in rows { + assert!(row.key.starts_with("command:"), "unexpected key {}", row.key); + } + } + let names: Vec<&str> = groups.iter().map(|(g, _)| g.as_str()).collect(); + assert!(names.contains(&"File") && names.contains(&"Edit"), "menus group: {names:?}"); + assert!(!names.iter().any(|g| g.starts_with("Plugins")), "plugins have their own tab"); +} + +#[test] +fn query_filters_to_matches() { + let groups = crate::menus::command_sections("save"); + let labels: Vec<&str> = groups.iter().flat_map(|(_, rows)| rows.iter().map(|r| r.label.as_str())).collect(); + assert!(!labels.is_empty(), "something matches 'save'"); + assert!(labels.iter().all(|l| l.to_lowercase().contains("save")), "only matches: {labels:?}"); + assert!(crate::menus::command_sections("zzz-no-such-action").iter().all(|(_, r)| r.is_empty())); +} + +#[test] +fn gone_entries_drop_out_of_recents() { + let u = Ui::new(Session::new()); + assert!(crate::menus::recent_row(&u.app, "command:nope.nope", "").is_none(), "gone commands drop out"); + assert!(crate::menus::recent_row(&u.app, "plugin:nope.nope/nope", "").is_none(), "gone plugins drop out"); + assert!(crate::menus::recent_row(&u.app, "widget:nope", "").is_none(), "gone widgets drop out"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..99df5d6f 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -171,10 +171,12 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { .flatten(); crate::menus::separator(ui); if let Some(plugin) = widget { + crate::menus::record_recent(app, &format!("widget:{plugin}")); app.run("widget.insert", json!({"plugin": plugin})); return true; } let Some((id, command)) = chosen else { return false }; + crate::menus::record_recent(app, &format!("plugin:{id}/{command}")); start(app, &id, &command); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a921cc29..0aef7555 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -72,9 +72,18 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { builder_toolbar(app, ui); return; } - let cell = 32.0; - let gap = 4.0; - let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0; + main_toolbar(app, ui, canvas, &t); +} + +/// The main toolbar pill's width: the Actions palette matches it so the two read as one bar. +pub(crate) fn main_width() -> f32 { + let (cell, gap) = (32.0, 4.0); + GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 +} + +fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { + let (cell, gap) = (32.0, 4.0); + let width = main_width(); let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); let current = app.session.tool.tool; let mut x = rect.min.x + 8.0; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..4a7bf45b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,7 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | -| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | +| Actions palette (Cmd+K, Cmd+/) | done | tabs (All, Assets, Plugins & widgets), Recents kept in the preferences, commands grouped by menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles; rows carry icons and shortcut hints (`palette_tests`) | | Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | From b0f56ed1698b94d31ec6e6b9f2dcf0a1b21a40a5 Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:19:32 +0300 Subject: [PATCH 076/245] fix(server): let message limits follow max_file_mb so files above 64 MiB publish and open --- apps/figcraft-server/src/config.rs | 26 ++++++++++++++---------- apps/figcraft-server/src/public_tests.rs | 18 +++++++++++++++- crates/collab-net/src/limits.rs | 6 ++++-- docs/server.md | 4 ++-- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index bd587b76..3e951e87 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -253,8 +253,9 @@ impl Config { } } - /// Largest message a client may send when open to the public: a whole file (or plugin bundle) - /// and a margin, not the core's 64 MiB. + /// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the + /// public this is also the cap (strangers' files stay small); a private server never goes below + /// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it. fn open_message(&self) -> usize { usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX) } @@ -265,9 +266,9 @@ impl Config { fn core(&self) -> figcraft_collab::Limits { let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }; if !self.open_publish { - return base; + return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } - let max = self.open_message().min(base.max_message_bytes); + let max = self.open_message(); let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX); figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base } } @@ -279,21 +280,24 @@ impl Config { pub fn net(&self) -> NetLimits { let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() }; if !self.open_publish { - return base; + let max = base.max_client_message.max(self.open_message()); + return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base }; } - let max = self.open_message().min(base.max_client_message); + let max = self.open_message(); // Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an - // eighth of each, and its biggest consumers are dropped first. - let (queued, reading) = (256usize << 20, 192usize << 20); + // eighth of each, and its biggest consumers are dropped first. Each still fits one whole + // message, or a file as large as `max_file_mb` could never arrive. + let one = max.saturating_add(1 << 20); + let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8))); NetLimits { max_per_address: self.max_connections_per_ip, max_client_message: max, - max_queued_bytes: max.saturating_add(1 << 20), + max_queued_bytes: one, max_queued_block: queued / 8, max_queued_total: queued, - max_reading_block: max.saturating_add(1 << 20).min(reading / 8), + max_reading_block: one.min(reading / 8), max_reading_total: reading, - max_in_flight: 128 << 20, + max_in_flight: (128usize << 20).max(one), reserve_for_sessions: self.max_connections / 8, request_idle: std::time::Duration::from_secs(20), ..base diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index fea2667e..d93dbe3e 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() { assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly"); assert_eq!(open.net().reserve_for_sessions, 512 / 8); assert_eq!(open.net().max_client_message, 17 << 20); - assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private"); + assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin"); let w = open.warnings().join("\n"); assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}"); let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open }; @@ -493,3 +493,19 @@ fn open_connections_never_reach_what_needs_the_key() { assert!(!format!("{info:?}").contains(&k)); server.stop(); } + +#[test] +fn files_above_64_mib_fit_every_limit() { + // `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must + // follow it, or the upload is dropped by the transport before the server can say why. + for open_publish in [false, true] { + let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() }; + let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20); + assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message); + assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}"); + assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); + assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); + } + // A client takes the largest file any server can keep. + assert!(NetLimits::default().max_server_message > 4096 << 20); +} diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index c3111876..42756b1e 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,7 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document). + /// Largest message a client accepts from the host (a welcome carries the whole document): the + /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, + /// so a client can open every file a server takes. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -61,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: figcraft_collab::Limits::default().max_message_bytes, + max_server_message: 4097 << 20, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/docs/server.md b/docs/server.md index f2e4820c..55d784f4 100644 --- a/docs/server.md +++ b/docs/server.md @@ -282,7 +282,7 @@ are errors): | `data` | `figcraft-data` | The data directory | | `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) | | `max_files` | 1000 | Most files kept | -| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB | +| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server) | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | | `max_people` | 256 | Most people in one file at once | @@ -489,7 +489,7 @@ are larger. | Resource | Limit | |---|---| -| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). The core's 64 MiB otherwise | +| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). On a private server, the larger of that and the core's 64 MiB | | A file as it grows | Every batch is checked first (*always*): past `max_file_mb`, past its publisher's `max_mb_per_ip` or past `max_total_mb`, it is refused with the reason and the app rolls it back. A long log is compacted to make room at most once a minute per file (a compaction rewrites the whole file). A file's snapshot also holds the recent batches kept for reconnecting clients, at most a quarter of `max_file_mb` | | Recent batches in memory | A quarter of `max_file_mb` (256 KiB at least) per open file; older ones are dropped (a client that missed them catches up with the whole file) | | Bytes queued for slow clients | The largest message and 1 MiB per connection, 32 MiB per block of addresses, 256 MiB for everyone. A message sent to many people counts once for everyone and once per block (twenty colleagues behind one office address cost their block one copy of a broadcast), and welcomes (bounded by the file's size) don't count against blocks, so a whole office can join a file at once. A client that doesn't read its answers is dropped; when everyone's budget runs out, the connection holding the most is dropped | From 3b5c5d563b18c2d329ed2f4e92b8c3de87071d72 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 23:27:45 +0300 Subject: [PATCH 077/245] Palette follows the bar: toggle, dismiss, no invented icons The Actions button toggles (re-click and Cmd+K close), reads on in accent blue with a white icon while open, and a press anywhere outside closes the panel in one click. The opening press is spared once (the spare expires on press-free frames); presses on the button itself never dismiss, so the release toggles cleanly shut instead of reopening. Rows drop the per-family icons (labels + right-side hints only); plugins and widgets keep their letter tiles. The panel anchors to the canvas centre at the toolbar pill's exact width (toolbar::main_width), at a fixed size, with inset hover pills and larger section titles. Tests: one-click dismiss pinned (palette_tests). --- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 15 ++++++-- crates/ui-egui/src/menus.rs | 22 +++++++++--- crates/ui-egui/src/palette_tests.rs | 53 ++++++++++++++++++++++++++++- crates/ui-egui/src/toolbar.rs | 20 +++++++++-- 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9dd8e2f4..6e55c8a4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -416,6 +416,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(h) = p.get("palette").and_then(Value::as_bool) { app.ui.palette = if h { Some(String::new()) } else { None }; app.ui.palette_tab = crate::menus::PaletteTab::All; + app.ui.palette_armed = true; } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 46830316..34f6ac0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,6 +408,10 @@ pub struct UiState { pub palette: Option, /// The Actions palette's tab. pub palette_tab: menus::PaletteTab, + /// The palette ignores one outside click after opening (the click that opened it). + pub palette_armed: bool, + /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. + pub actions_rect: Option, /// The Variables editor, when open. pub variables: Option, pub about: bool, @@ -538,6 +542,8 @@ impl Default for UiState { main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, + palette_armed: false, + actions_rect: None, variables: None, about: false, about_tab: dialogs::AboutTab::About, @@ -794,8 +800,13 @@ impl FigApp { "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => { - self.ui.palette = Some(String::new()); - self.ui.palette_tab = menus::PaletteTab::All; + if self.ui.palette.is_some() { + self.ui.palette = None; + } else { + self.ui.palette = Some(String::new()); + self.ui.palette_tab = menus::PaletteTab::All; + self.ui.palette_armed = true; + } } "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0e079313..a95fe2fd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -651,6 +651,18 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { ); let mut run: Option = None; let mut close = false; + // A press anywhere outside closes the panel. The opening press is spared once, and the + // spare expires the first frame without a press (so the next outside press always closes). + let pressed = ctx.input(|i| i.pointer.any_pressed()); + if pressed && !std::mem::replace(&mut app.ui.palette_armed, false) { + let on_toggle = app.ui.actions_rect.is_some_and(|b| ctx.input(|i| i.pointer.hover_pos().is_some_and(|p| b.contains(p)))); + let outside = !on_toggle && ctx.input(|i| i.pointer.hover_pos().map(|p| !area.contains(p)).unwrap_or(false)); + if outside { + close = true; + } + } else if !pressed { + app.ui.palette_armed = false; + } egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( ctx, |ui| { @@ -830,9 +842,9 @@ pub(crate) struct ActionRow { /// A section header with its rows. fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { - ui.add_space(6.0); - ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 7.0), Align2::LEFT_TOP, name, regular(11.0), t.text_secondary); - ui.add_space(18.0); + ui.add_space(10.0); + ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 8.0), Align2::LEFT_TOP, name, regular(12.5), t.text_secondary); + ui.add_space(22.0); rows(ui); } @@ -841,8 +853,10 @@ fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); + // The hover pill stays inside the row: it never touches the panel's edges. + let pill = r.shrink2(vec2(4.0, 1.0)); if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + ui.painter().rect_filled(pill, CornerRadius::same(6), t.hover); } let mut label_x = r.min.x + 10.0; if let Some(letter) = row.tile { diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index e496cb77..06e9fdf5 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -1,7 +1,10 @@ //! The Actions palette, driven headless: invoking records recents (newest first, capped), -//! sections group commands by menu, the query filters, and gone entries drop out of Recents. +//! sections group commands by menu, the query filters, gone entries drop out of Recents, a used +//! plugin resolves back to a row, and a press outside the panel closes it. +use egui::Pos2; use figcraft_engine::Session; +use serde_json::json; use crate::layers_drag_tests::Ui; @@ -58,3 +61,51 @@ fn gone_entries_drop_out_of_recents() { assert!(crate::menus::recent_row(&u.app, "plugin:nope.nope/nope", "").is_none(), "gone plugins drop out"); assert!(crate::menus::recent_row(&u.app, "widget:nope", "").is_none(), "gone widgets drop out"); } + +/// A used plugin (API 4, several commands) resolves back to a Recents row. +#[test] +fn used_plugin_resolves_to_a_recent_row() { + let mut u = Ui::new(Session::new()); + let plugin: figcraft_engine::plugins::InstalledPlugin = serde_json::from_value(json!({ + "manifest": { + "id": "dev.local.solar-icons", + "name": "Solar icons", + "main": "solar_icons.wasm", + "api": 4, + "commands": [ + {"id": "panel", "label": "Panel"}, + {"id": "grid", "label": "Grid"}, + ], + }, + })) + .unwrap(); + u.app.session.prefs.plugins.push(plugin); + crate::menus::record_recent(&mut u.app, "plugin:dev.local.solar-icons/panel"); + let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "") + .expect("a used plugin stays in Recents"); + assert_eq!(row.label, "Solar icons — Panel"); + assert!(crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "zzz").is_none(), "the query filters recents"); +} + +/// A press outside the panel closes it; the opening press is spared once. +#[test] +fn outside_press_closes_the_panel() { + let mut u = Ui::new(Session::new()); + u.app.ui.palette = Some(String::new()); + u.app.ui.palette_armed = true; + // The opening press: spared, and disarmed. + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_some(), "the opening press is spared"); + // The next press outside: closed. + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_none(), "an outside press closes the panel"); + // One click is enough: the spare expires on frames without a press. + u.app.ui.palette = Some(String::new()); + u.app.ui.palette_armed = true; + u.frame(vec![]); + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_none(), "a single outside press closes the panel"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0aef7555..962b7497 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -57,6 +57,7 @@ fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { pub fn show(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let canvas = app.canvas.rect; + app.ui.actions_rect = None; // A file you can't edit now (view only, or on its way to a server): moving around only. if let Some(why) = app.session.active_read_only() { view_only_toolbar(app, ui, why); @@ -138,13 +139,26 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { ] { let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); let resp = ui.interact(r, ui.id().with(id), Sense::click()).tip(tip); - if resp.hovered() { + if id == "actions" { + app.ui.actions_rect = Some(r); + } + // The Actions button reads on while its panel is open; clicking it again closes it. + let actions_open = id == "actions" && app.ui.palette.is_some(); + if actions_open { + ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, t.icon); + icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { - app.ui.palette = Some(String::new()); + if app.ui.palette.is_some() { + app.ui.palette = None; + } else { + app.ui.palette = Some(String::new()); + app.ui.palette_tab = crate::menus::PaletteTab::All; + app.ui.palette_armed = true; + } } else { app.ui.left_tab = crate::LeftTab::Assets; app.ui.left_collapsed = false; From 1b4db719576965d13e8ec39e319ef2e4ae67dc85 Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:37:55 +0300 Subject: [PATCH 078/245] fix(collab-net): keep the client's message cap in range on 32-bit targets --- apps/figcraft-server/src/public_tests.rs | 6 ++++-- crates/collab-net/src/limits.rs | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index d93dbe3e..6403212c 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -506,6 +506,8 @@ fn files_above_64_mib_fit_every_limit() { assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); } - // A client takes the largest file any server can keep. - assert!(NetLimits::default().max_server_message > 4096 << 20); + // A client takes the largest file any server can keep, or as much as a 32-bit target can address + // (computed in u64: `4097 << 20` in a 32-bit usize wraps to 1 MiB). + let client = NetLimits::default().max_server_message as u64; + assert!(client > 4096 << 20 || client == u64::from(u32::MAX), "{client}"); } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index 42756b1e..02167770 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -14,7 +14,8 @@ pub struct NetLimits { pub max_hello: usize, /// Largest message a client accepts from the host (a welcome carries the whole document): the /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, - /// so a client can open every file a server takes. + /// so a client can open every file a server takes; on a 32-bit target (the browser) as much as + /// `usize` holds. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -63,7 +64,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: 4097 << 20, + max_server_message: usize::try_from(4097u64 << 20).unwrap_or(usize::MAX), rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), From 9e2596e97524951b6c93e038d5a03c252322fed2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:09:53 +0000 Subject: [PATCH 079/245] Skip layers with children in vector.offset Found while testing crates/engine/src/cmd/structure.rs:575 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/structure.rs | 4 ++++ crates/engine/src/tests.rs | 38 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..6829595c 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -556,6 +556,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { for i in req_targets("vector.offset", s, p)? { let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); + // A vector can't hold children: leave containers (frames, groups, instances) alone. + if !n.children.is_empty() { + continue; + } let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..746f417e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,41 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +/// No node that holds children may be a non-container. +fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { + let doc = s.doc().unwrap(); + for &i in ids { + let n = doc.node(i).unwrap(); + assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); + } +} + +#[test] +fn offset_path_leaves_containers_with_children_alone() { + let mut s = sess(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); + for target in [f, g] { + s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); + assert_children_only_in_containers(&s, &[f, g, inner, a, b]); + } + let doc = s.doc().unwrap(); + assert!(doc.node(f).unwrap().kind.is_container()); + assert_eq!(doc.node(f).unwrap().children, vec![inner]); + assert!(doc.node(g).unwrap().kind.is_container()); + assert_eq!(doc.node(g).unwrap().children.len(), 2); +} + +#[test] +fn offset_path_still_converts_a_plain_rectangle() { + let mut s = sess(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Vector { .. })); + assert!(n.width > 10.0); +} From a9bcc48df7bc96f8428c312b77aff9c53740e36b Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:45:08 +0300 Subject: [PATCH 080/245] feat(web): join a live session from ?join= in the page URL --- apps/figcraft-web/src/main.rs | 9 ++++++++- apps/figcraft-web/src/query.rs | 10 ++++++++++ apps/figcraft-web/src/query_tests.rs | 21 +++++++++++++++++++++ apps/figcraft-web/src/web.rs | 8 +++++++- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 apps/figcraft-web/src/query.rs create mode 100644 apps/figcraft-web/src/query_tests.rs diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 587fd60b..35aebb02 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,8 +13,15 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! URL query: `?webgl` forces the WebGL2 backend instead of WebGPU; `?join=` joins that live session once the page has loaded, so a bookmark opens a shared file. +//! `join` takes the rest of the query: put it last. The link carries the session's secret, so the +//! URL (and the browser history) is as private as the link itself. +#[cfg(any(target_arch = "wasm32", test))] +mod query; +#[cfg(test)] +mod query_tests; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs new file mode 100644 index 00000000..be68f598 --- /dev/null +++ b/apps/figcraft-web/src/query.rs @@ -0,0 +1,10 @@ +//! The page's URL query: which flags it sets and the invite link it names. + +/// The invite link in `?join=` (`search` is `location.search`, with or without its `?`), still +/// escaped. `join` takes the rest of the query, so a pasted `figcraft://join?host=…&file=…&token=…` +/// keeps its own `&`s; put it last. +pub fn join_link(search: &str) -> Option<&str> { + let query = search.strip_prefix('?').unwrap_or(search); + let start = if query.starts_with("join=") { 0 } else { query.find("&join=")? + 1 }; + query.get(start + "join=".len()..).filter(|link| !link.is_empty()) +} diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs new file mode 100644 index 00000000..9582682f --- /dev/null +++ b/apps/figcraft-web/src/query_tests.rs @@ -0,0 +1,21 @@ +//! `?join=` in the page's URL. + +use crate::query::join_link; + +#[test] +fn join_takes_the_rest_of_the_query() { + let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; + assert_eq!(join_link(&format!("?join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("?webgl&join={link}")), Some(link), "after other flags"); + assert_eq!(join_link("?join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading ?"); +} + +#[test] +fn no_join_without_the_parameter() { + assert_eq!(join_link(""), None); + assert_eq!(join_link("?webgl"), None); + assert_eq!(join_link("?join="), None); + assert_eq!(join_link("?rejoin=x"), None, "only the whole parameter name"); + assert_eq!(join_link("?xjoin=x&a=1"), None); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 8cadb013..d3c16441 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,13 @@ pub fn start() { // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); - let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + // `?join=`: join that session, as opening an invite link does on the desktop. + let search = query(); + if let Some(link) = crate::query::join_link(&search) { + let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); + figcraft_ui_egui::live::open_invite(&mut fig, &link); + } Ok(Box::new(WebShell { fig })) }), ) From ce472a251cf8e739ce2756594600fb8ca030e30f Mon Sep 17 00:00:00 2001 From: Ismael Trejo Date: Fri, 16 Oct 2026 16:40:27 -0700 Subject: [PATCH 081/245] fix(doc): thin strokes take a double-click a few pixels away (#646) A 1 px Line or stroke-only path only counted as hit within 2 screen pixels, so a double-click slightly off it cleared the selection and never entered vector edit mode, and Cmd/Ctrl could not bend it. Strokes with nothing filled now reach 5 screen pixels; filled shapes keep the plain tolerance. --- crates/doc/src/geometry.rs | 10 ++++- crates/engine/src/vector_behaviour_tests.rs | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 06596ddc..2a7b42a6 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -51,6 +51,10 @@ pub fn is_closed(n: &Node) -> bool { } } +/// How much further than `tol` a stroke with nothing filled reaches: a thin line or stroke-only path +/// is hard to land on with the plain tolerance, so it gets a wider band (5 screen px for the usual 2 px tolerance). +const STROKE_REACH: f64 = 2.5; + /// 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 { @@ -62,7 +66,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false, NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp), NodeKind::Line => { - let half = (n.stroke.weight / 2.0).max(tol_l); + let half = (n.stroke.weight / 2.0).max(tol_l * STROKE_REACH); lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half } NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { @@ -78,7 +82,9 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) { return true; } - let reach = (n.stroke.max_weight() / 2.0).max(tol); + // Only a stroke with nothing filled gets the wider band: a filled shape's edge keeps the plain one. + let stroke_only = n.has_visible_stroke() && !n.fills.iter().any(|f| f.visible); + let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol * STROKE_REACH } else { tol }); if !path.bounding_box().inflate(reach, reach).contains(lp) { return false; } diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 0c472ecc..8899dc4e 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -133,6 +133,50 @@ fn entering_and_leaving_vector_edit_mode() { assert!(matches!(s.doc().unwrap().node(id).unwrap().kind, NodeKind::Rectangle), "converting is one undo step"); } +#[test] +fn thin_strokes_are_easy_to_enter_and_bend() { + use crate::tools::ToolId; + // A diagonal line from (100, 100) to (200, 50); `off` steps away from its middle, square to it. + let at = |off: f64| (150.0 + off * 50.0 / 125f64.sqrt() / 10.0, 75.0 + off * 100.0 / 125f64.sqrt() / 10.0); + // (layer, screen px off the stroke, in edit mode after a double-click, derivation) + let rows: Vec<(&str, f64, bool, &str)> = vec![ + ("line", 0.0, true, "double-clicking a selected line edits it as a vector"), + ("line", 4.0, true, "a 1 px line reaches a few pixels, so a double-click near it still lands (#646)"), + ("line", 8.0, false, "a double-click well away from it does not"), + ("path", 4.0, true, "a stroke-only path reaches as far as a line does"), + ("path", 8.0, false, "and no further"), + ]; + for (layer, off, after, why) in rows { + let mut s = Session::new(); + let id = if layer == "line" { + s.set_tool(ToolId::Line); + drag(&mut s, (100.0, 100.0), (200.0, 50.0), NONE); + *s.selection().first().unwrap() + } else { + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 50]], "closed": false}))["id"]).unwrap(); + // Styled like a pen stroke: 1 px, no fill. + ex(&mut s, "node.set", json!({"ids": [id], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + s.set_selection(vec![id]); + id + }; + assert!(!s.doc().unwrap().node(id).unwrap().fills.iter().any(|f| f.visible), "{why}: the {layer} only has a stroke"); + let p = at(off); + click(&mut s, p, NONE); + s.pointer(&ev(PointerKind::Down, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, p, NONE)).unwrap(); + assert_eq!(editing(&s).is_some(), after, "{why}"); + if after { + // Cmd/Ctrl then bends it through the pointer. + drag(&mut s, at(0.0), (170.0, 115.0), Mods { cmd: true, ..NONE }); + let w = s.doc().unwrap().world_transform(id); + let n = net(&s, id); + assert!(!n.segments[0].is_straight(), "{why}: Cmd/Ctrl bends it"); + assert!((w * n.cubic(0).unwrap().eval(0.5) - Point::new(170.0, 115.0)).hypot() < 1.0, "{why}: through the pointer"); + } + } +} + #[test] fn the_pen_follows_the_documented_rules() { let mut s = Session::new(); From 9f52819fe9877421876fe42e401ceed7c3c97fc6 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 08:35:03 +0700 Subject: [PATCH 082/245] fix(ui): Shift+Cmd/Ctrl+V pastes over the selection and Alt+Cmd/Ctrl+V pastes properties (#716) egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and sends a paste event only for text. Shift+Cmd+V and Alt+Cmd+V became a plain paste (text) or did nothing (image). The keymap now decides what a swallowed chord means, from the modifiers tracked in the raw events: its paste event or its release runs paste over or paste properties instead. In the browser the key press runs the command, and the paste event no longer pastes its text as well. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/paste_image_tests.rs | 152 ++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 73 ++++++++--- crates/ui-egui/src/web_clipboard_tests.rs | 66 ++++++++++ 4 files changed, 279 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9fbd17cc..a64ef6a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,6 +502,8 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// The modifiers held, as of the last raw event that told: a paste event doesn't carry them. + pub held: egui::Modifiers, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -598,6 +600,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1294,7 +1297,7 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); - let paste_release = shortcuts::swallowed_paste(self, &ctx); + let swallowed = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. @@ -1410,7 +1413,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing, paste_release); + shortcuts::handle(self, &ctx, typing, swallowed); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index fa797fb9..23cec8ea 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -6,11 +6,25 @@ use std::cell::RefCell; use std::rc::Rc; use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, vec2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; use crate::{FigApp, Services}; +/// What the system clipboard holds when a paste chord is pressed (#716). +#[derive(Clone, Copy, Debug)] +enum Held { + /// FigCraft's own copy (its SVG text). + Ours, + /// Text copied in another app. + Text, + /// An image copied in another app. + Image, +} + +const HELD: [Held; 3] = [Held::Ours, Held::Text, Held::Image]; + #[derive(Default)] struct Clip { text: Option, @@ -87,6 +101,55 @@ impl Win { self.frame(vec![]); } + /// A Cmd/Ctrl+V chord with its modifiers, as egui-winit delivers it: they go down first, the V + /// press brings a paste event only for text, its release comes as a key event, then they're + /// let go. + fn chord(&mut self, m: Modifiers) { + let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty()); + self.frame(vec![Event::ModifiersChanged(m)]); + self.frame(text.map(Event::Paste).into_iter().collect()); + self.frame(vec![key(Key::V, false, m)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// A red rectangle A copied in FigCraft, and a grey rectangle B selected; then `held` on the + /// system clipboard. Returns (A, B). + fn copy_a_select_b(&mut self, held: Held) -> (NodeId, NodeId) { + let s = &mut self.app.session; + let a = s.doc().unwrap().nodes().find(|n| n.name == "Rectangle").unwrap().id; + s.execute("fill.set", &json!({"ids": [a.0], "color": "#e53935"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + let b = NodeId(b["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + crate::menus::invoke(&mut self.app, "edit.copy"); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + match held { + Held::Ours => {} + Held::Text => self.clip.borrow_mut().text = Some("hello from another app".into()), + Held::Image => self.copy_image_elsewhere(), + } + (a, b) + } + + fn fill(&self, id: NodeId) -> String { + let n = figcraft_engine::inspect::node_json(self.app.session.doc().unwrap(), id, 0); + n["fills"][0]["color"].as_str().unwrap_or_default().to_string() + } + + /// Top-lefts of the rectangles other than `b`. + fn rects_besides(&self, b: NodeId) -> Vec<(i64, i64)> { + let doc = self.app.session.doc().unwrap(); + let mut at: Vec<_> = doc + .nodes() + .filter(|n| n.name == "Rectangle" && n.id != b) + .filter_map(|n| doc.world_bounds(n.id)) + .map(|r| (r.x0.round() as i64, r.y0.round() as i64)) + .collect(); + at.sort(); + at + } + fn named(&self, name: &str) -> usize { self.app.session.doc().unwrap().nodes().filter(|n| n.name == name).count() } @@ -256,3 +319,92 @@ fn paste_here_prefers_a_newer_image_over_copied_layers() { let b = doc.world_bounds(img.id).unwrap(); assert!((b.x0 - p.x).abs() < 0.01 && (b.y0 - p.y).abs() < 0.01, "top-left where the menu opened: {b:?} vs {p:?}"); } + +#[test] +fn shift_cmd_v_pastes_over_the_selection_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::SHIFT)); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "{held:?}: A's copy at B's top-left, nothing else"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::ALT)); + assert_eq!(w.fill(b), "#E53935", "{held:?}: B took A's fill"); + assert_eq!(w.rects_besides(b), [(10, 10)], "{held:?}: no layer pasted"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn a_chord_let_go_within_one_frame_still_reads_its_modifiers() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![ + Event::ModifiersChanged(m), + Event::Paste("hello from another app".into()), + key(Key::V, false, m), + Event::ModifiersChanged(Modifiers::NONE), + ]); + w.frame(vec![]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn cmd_let_go_before_v_still_pastes_over() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Image); + w.frame(vec![Event::ModifiersChanged(CMD.plus(Modifiers::SHIFT))]); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + w.frame(vec![key(Key::V, false, Modifiers::SHIFT)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "pasted over, not flipped or a plain paste"); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn a_held_chord_pastes_over_once() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![Event::ModifiersChanged(m)]); + // Auto-repeat: egui-winit sends a paste event for every repeated press. + for _ in 0..3 { + w.frame(vec![Event::Paste("hello from another app".into())]); + } + w.frame(vec![key(Key::V, false, m)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn shift_insert_still_pastes_text() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT), Event::Paste("hello from another app".into())]); + w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT), Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.texts(), 1); + assert_eq!(w.rects_besides(b), [(10, 10)]); +} + +#[test] +fn cmd_v_after_a_paste_chord_is_a_plain_paste_again() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.chord(CMD.plus(Modifiers::SHIFT)); + w.chord(CMD); + assert_eq!(w.texts(), 1, "the text was pasted by the plain Cmd+V"); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "Shift+Cmd+V pasted over once"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f28a2433..57295c22 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -94,8 +94,8 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame /// began. -/// `paste_release`: this frame released a Cmd/Ctrl+V the shell swallowed ([`swallowed_paste`]). -pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool) { +/// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). +pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); // Text fields in panels own the keyboard (also the key that ends their editing). if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { @@ -113,16 +113,18 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool // An image-only clipboard brings no paste event: paste from the key then. The browser sends // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); - let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt)); - if (paste_key && !paste_event) || paste_release { - crate::menus::paste(app, None, None); + let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && v_binding(*modifiers) == Some("edit.paste"))); + match swallowed.run.or((paste_key && !paste_event).then_some("edit.paste")) { + Some("edit.paste") => crate::menus::paste(app, None, None), + Some(id) => run_binding(app, id), + None => {} } let now = ctx.input(|i| i.time); for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), - Event::Paste(text) => crate::menus::paste(app, Some(text), None), + Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => { @@ -133,23 +135,59 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool } } -/// Whether this frame releases a Cmd/Ctrl+V whose press never arrived and that brought no paste -/// event: egui-winit swallows the press and sends a paste event only when the clipboard holds -/// text, so an image on the clipboard leaves just this release. Call at the start of every frame: -/// it reads the raw events, which keys consumed by panels or the presenter still carry. -pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { +/// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), +/// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, +/// whether the key press arrived (browser) or only a paste event and the release (desktop). +fn v_binding(m: Modifiers) -> Option<&'static str> { + lookup(keymap::Context::Canvas, Key::V, Some(Key::V), Modifiers { command: true, ..m }).map(|b| b.id) +} + +/// What this frame's Cmd/Ctrl+V chords ask for when the desktop shell swallowed their press +/// ([`swallowed_paste`]). +#[derive(Clone, Copy, Default)] +pub struct Swallowed { + /// The command to run ([`v_binding`]). + pub run: Option<&'static str>, + /// This frame's paste event belongs to another command or an unbound chord: its text isn't + /// pasted. + pub drop_text: bool, +} + +/// egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and sends a paste event +/// only when the clipboard holds text. So a chord brings a paste event (text) or just its release +/// (an image, or nothing). The browser sends the press as well, which runs the command itself. +/// Call at the start of every frame: it reads the raw events, which keys consumed by panels or the +/// presenter still carry. +pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { let ui = &mut app.ui; - let mut paste = false; + let mut out = Swallowed::default(); ctx.input(|i| { for ev in &i.raw.events { + if let Event::Key { modifiers, .. } = ev { + ui.held = *modifiers; + } match ev { + Event::ModifiersChanged(m) => ui.held = *m, + // Without Cmd/Ctrl held it's another paste key (Shift+Insert, a Paste key). + Event::Paste(_) if ui.held.command => { + let id = v_binding(ui.held); + if id != Some("edit.paste") { + out.drop_text = true; + // Held down, the chord repeats its paste event: run once, as keys do. + if !ui.v_pressed && !ui.paste_event { + out.run = out.run.or(id); + } + } + ui.paste_event = true; + } Event::Paste(_) => ui.paste_event = true, Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, Event::Key { key: Key::V, pressed: false, modifiers, .. } => { - // Shift or Alt with it is another command (paste over, paste properties). A V - // held while the window was away comes back as a release alone: without Cmd - // held, that's no paste. - paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt && (modifiers.command || !ui.refocused); + // Cmd was down when V went down, maybe let go first. A V held while the window + // was away comes back as a release alone: without Cmd held, that's no paste. + if !ui.v_pressed && !ui.paste_event && (modifiers.command || !ui.refocused) { + out.run = out.run.or(v_binding(*modifiers)); + } ui.v_pressed = false; ui.paste_event = false; ui.refocused = false; @@ -160,13 +198,14 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { Event::WindowFocused(false) => { ui.v_pressed = false; ui.paste_event = false; + ui.held = Modifiers::NONE; } Event::WindowFocused(true) => ui.refocused = true, _ => {} } } }); - paste + out } /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index a7787120..26389ff4 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -86,6 +86,26 @@ impl Page { self.frame(vec![Self::key(Key::V, false)]); } + /// Cmd+V with `m` added, as the browser delivers it: the key press, then the paste event. + fn chord(&mut self, m: Modifiers) { + let m = m | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let text = self.clipboard.clone(); + self.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(text)]); + self.frame(vec![v(false)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// Copy the selected rectangle A (made red) and its properties, then select a new rectangle B. + fn copy_a_select_b(&mut self) -> figcraft_doc::NodeId { + self.app.session.execute("fill.set", &json!({"color": "#e53935"})).unwrap(); + self.copy(); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + let b = self.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b["id"]]})).unwrap(); + figcraft_doc::NodeId(b["id"].as_u64().unwrap()) + } + fn count(&self, kind: fn(&NodeKind) -> bool) -> usize { self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count() } @@ -121,6 +141,52 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); } +#[test] +fn shift_cmd_v_pastes_over_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::SHIFT); + assert_eq!(p.count(rect), 3, "foreign text {foreign}: one copy pasted over B"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let doc = p.app.session.doc().unwrap(); + let over = doc.nodes().filter(|n| n.id != b).filter_map(|n| doc.world_bounds(n.id)).any(|r| r.x0 == 400.0 && r.y0 == 250.0); + assert!(over, "foreign text {foreign}: the copy sits at B's top-left"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::ALT); + assert_eq!(p.count(rect), 2, "foreign text {foreign}: no layer pasted"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let fill = figcraft_engine::inspect::node_json(p.app.session.doc().unwrap(), b, 0)["fills"][0]["color"].clone(); + assert_eq!(fill, "#E53935", "foreign text {foreign}: B took A's fill"); + } +} + +#[test] +fn a_chord_tapped_within_one_frame_runs_once() { + let mut p = Page::new(); + p.copy_a_select_b(); + let m = Modifiers::SHIFT | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let clip = p.clipboard.clone(); + p.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(clip), v(false), Event::ModifiersChanged(Modifiers::NONE)]); + p.frame(vec![]); + assert_eq!(p.count(rect), 3, "one copy pasted over B"); + assert_eq!(p.count(text), 0); +} + #[test] fn copy_as_png_puts_an_image_on_the_clipboard() { let mut p = Page::new(); From 18a7a063fe29627f7d02cbf8aab6ba36fc63f45a Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 09:00:56 +0700 Subject: [PATCH 083/245] fix(ui): paste chords read modifiers from modifier changes only (#716) Every shell reports a modifier change before the key it goes with, so key events needn't update the held modifiers too. The old test that pressed Shift+Cmd+V and Alt+Cmd+V expecting "nothing" now covers plain V only: those chords run their commands, covered by their own tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/paste_image_tests.rs | 4 +--- crates/ui-egui/src/shortcuts.rs | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a64ef6a8..e32e8e6e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,7 +502,8 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, - /// The modifiers held, as of the last raw event that told: a paste event doesn't carry them. + /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't + /// carry them. pub held: egui::Modifiers, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 23cec8ea..48bd142e 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -231,13 +231,11 @@ fn the_browser_sequence_pastes_once() { } #[test] -fn v_alone_and_other_paste_commands_paste_nothing() { +fn v_alone_pastes_nothing() { let mut w = Win::new(); w.copy_image_elsewhere(); w.frame(vec![key(Key::V, true, Modifiers::NONE)]); w.frame(vec![key(Key::V, false, Modifiers::NONE)]); - w.cmd_v(CMD.plus(Modifiers::SHIFT)); - w.cmd_v(CMD.plus(Modifiers::ALT)); assert_eq!(w.named("Pasted image"), 0); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 57295c22..0a291a7c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -163,9 +163,6 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { let mut out = Swallowed::default(); ctx.input(|i| { for ev in &i.raw.events { - if let Event::Key { modifiers, .. } = ev { - ui.held = *modifiers; - } match ev { Event::ModifiersChanged(m) => ui.held = *m, // Without Cmd/Ctrl held it's another paste key (Shift+Insert, a Paste key). From df674d0d53749572250809226ac607cd523e4034 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Sat, 17 Oct 2026 08:17:28 +0530 Subject: [PATCH 084/245] #710: Resources and Shift+I open the Assets panel from a collapsed sidebar or the version history Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 8 +++- crates/ui-egui/src/resources_tests.rs | 67 +++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 3 +- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/resources_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..f2f74c1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,6 +162,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod resources_tests; +#[cfg(test)] mod select_all_tests; #[cfg(test)] mod shortcut_keys_tests; @@ -810,7 +812,11 @@ impl FigApp { } "focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32), "panel.layers" => self.ui.left_tab = LeftTab::File, - "panel.assets" => self.ui.left_tab = LeftTab::Assets, + "panel.assets" => { + self.ui.left_tab = LeftTab::Assets; + self.ui.left_collapsed = false; + self.ui.versions = Default::default(); + } "panel.versionHistory" => versions::open(self), "view.devCompare" => { self.ui.dev.compare = r.data.get("id").and_then(figcraft_engine::params::id_of).map(dev_compare::CompareView::new); diff --git a/crates/ui-egui/src/resources_tests.rs b/crates/ui-egui/src/resources_tests.rs new file mode 100644 index 00000000..42869098 --- /dev/null +++ b/crates/ui-egui/src/resources_tests.rs @@ -0,0 +1,67 @@ +//! The toolbar's Resources button and Shift+I open the Assets panel (#710), from a collapsed +//! sidebar and from the version history too. + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use figcraft_engine::Session; + +use crate::LeftTab; +use crate::layers_drag_tests::Ui; + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn shift_i(u: &mut Ui) { + let modifiers = Modifiers::SHIFT; + u.frame(vec![ + Event::Key { key: Key::I, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::I, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); + u.frame(vec![]); +} + +/// The middle of the toolbar's Resources button, found in the AccessKit tree. +fn resources_button(u: &mut Ui) -> 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("Resources")).expect("Resources"); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn assets_shown(u: &Ui) -> bool { + u.app.ui.left_tab == LeftTab::Assets && !u.app.ui.left_collapsed && !u.app.ui.versions.open +} + +/// The three states the sidebar can be in when Resources is asked for. +fn starts() -> Vec<(&'static str, Ui)> { + let shown = Ui::new(Session::new()); + let mut collapsed = Ui::new(Session::new()); + collapsed.app.ui.left_collapsed = true; + let mut versions = Ui::new(Session::new()); + crate::versions::open(&mut versions.app); + vec![("layers", shown), ("collapsed", collapsed), ("version history", versions)] +} + +#[test] +fn the_resources_button_opens_the_assets_panel() { + for (name, mut u) in starts() { + let at = resources_button(&mut u); + click(&mut u, at); + assert!(assets_shown(&u), "{name}: the Assets panel is open"); + } +} + +#[test] +fn shift_i_opens_the_assets_panel() { + for (name, mut u) in starts() { + u.frame(vec![]); + shift_i(&mut u); + assert!(assets_shown(&u), "{name}: the Assets panel is open"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a921cc29..c2f947f8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -137,8 +137,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if id == "actions" { app.ui.palette = Some(String::new()); } else { - app.ui.left_tab = crate::LeftTab::Assets; - app.ui.left_collapsed = false; + app.run("view.assets", json!({})); } } x += cell + 4.0; From 40c744da1092fce41803da7f7ef884cac8d68aab Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 13:03:49 +0700 Subject: [PATCH 085/245] M1: convert frames to sections and sections to frames (#719) (#721) Frames on the page or in a section convert to sections (object.convertToSection) and sections back to frames (object.convertToFrame), in place, one undo step each, from the canvas context menu; nested frames, instances, main components and sections holding sections are refused. --- ROADMAP.md | 4 +- crates/engine/src/cmd/structure.rs | 62 ++++++++++++++++++-- crates/engine/src/convert_container_tests.rs | 52 +++++++++++++++- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 3 + crates/ui-egui/src/menus.rs | 21 +++++++ docs/mcp.md | 2 + docs/target-app-parity.md | 3 +- 8 files changed, 140 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..bbde8bc5 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-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **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. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..5643c058 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -26,10 +26,17 @@ pub fn specs() -> Vec { spec!( "object.convertToFrame", "Convert to frame", - "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)", true, |s, p| convert(s, p, "object.convertToFrame", true) ), + spec!( + "object.convertToSection", + "Convert to section", + "{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)", + true, + convert_to_section + ), spec!( "object.wrapInSection", "Wrap in new section", @@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res let n = doc.get(i)?; let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); match (&n.kind, to_frame) { + // A frame can't hold sections. + (NodeKind::Section { .. }, true) + if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) => + { + return refuse("holds sections, which can't go inside a frame: convert those first"); + } + (NodeKind::Section { .. }, true) => todo.push(i), (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { @@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res } for i in &todo { if to_frame { - // The group's box as it is; nothing painted or clipped, so the look stays. + // The box as it is and nothing clipped, so the look stays: a group has nothing painted, a + // section keeps its fill. let n = doc.get_mut(*i)?; + if matches!(n.kind, NodeKind::Group) { + n.fills.clear(); + n.strokes.clear(); + } n.kind = NodeKind::Frame; n.clip = false; - n.fills.clear(); - n.strokes.clear(); continue; } let n = doc.get_mut(*i)?; @@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res Ok(json!({"ids": todo})) } +/// Frames on the page or in a section become sections in place (sections only sit there). +fn convert_to_section(s: &mut Session, p: &Value) -> Result { + let cmd = "object.convertToSection"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })); + match &n.kind { + NodeKind::Section { .. } => {} + NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"), + NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"), + NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"), + NodeKind::Frame => todo.push(i), + _ => return refuse("is not a frame"), + } + } + for i in &todo { + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Section { contents_hidden: false }; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.grids.clear(); + n.guides.clear(); + n.styles.grid = None; + n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + } + Ok(json!({"ids": todo})) +} + fn ungroup(s: &mut Session, p: &Value) -> Result { let ids = req_targets("object.ungroup", s, p)?; let doc = s.doc_mut()?; diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 7c25a620..620388d2 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -1,5 +1,5 @@ -//! Frames and groups converted into each other in place: the layers stay where they are on the -//! page, what only a frame has goes with it, what can't convert is refused, one undo step. +//! Frames, groups and sections converted into each other in place: the layers stay where they are on +//! the page, what only a frame has goes with it, what can't convert is refused, one undo step. use serde_json::{Value, json}; @@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() { #[test] fn junk_parameters_are_refused_not_fatal() { let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])]; - for cmd in ["object.convertToGroup", "object.convertToFrame"] { + for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] { for j in &junk { let mut s = Session::new(); let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))); @@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() { } } } + +#[test] +fn a_frame_on_the_page_becomes_a_section_and_back() { + let mut s = Session::new(); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}}))); + let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0)); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}})); + let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)]; + assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false })); + assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill"); + assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section"); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves"); + // And back: a frame that keeps the fill and doesn't clip, so the look stays. + assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before); + // Each conversion is one undo step. + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Section { .. })); + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Frame)); + assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back"); +} + +#[test] +fn only_frames_on_the_page_or_in_a_section_become_sections() { + let mut s = Session::new(); + let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300}))); + let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100}))); + let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string(); + assert!(err.contains("inside a frame"), "{err}"); + let r = rect(&mut s, outer, 150.0, 150.0); + assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame"); + // A frame inside a section converts; a section holding a section can't become a frame. + let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400}))); + let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100}))); + ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]})); + assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. })); + let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string(); + assert!(err.contains("holds sections"), "{err}"); + assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..c9b6df62 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -201,6 +201,8 @@ name = "English" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" +"Convert to frame" = "Convert to frame" +"Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" "Can edit" = "Can edit" "Can't read this library: {e}" = "Can't read this library: {e}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..ff3e5a79 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -302,6 +302,8 @@ status = "draft — needs native review" "Copied as SVG" = "Copiado como SVG" "Copied as text" = "Copiado como texto" "Copied {value}" = "Copiado: {value}" +"Convert to frame" = "Convertir en marco" +"Convert to section" = "Convertir en sección" "Copy" = "Copiar" "Copy as" = "Copiar como" "Copy as CSS" = "Copiar como CSS" @@ -1839,6 +1841,7 @@ status = "draft — needs native review" "object.batchRename" = "Cambiar nombre en lote" "object.convertToFrame" = "Convertir en marco" "object.convertToGroup" = "Convertir en grupo" +"object.convertToSection" = "Convertir en sección" "object.flipH" = "Voltear horizontalmente" "object.flipV" = "Voltear verticalmente" "object.frameSelection" = "Enmarcar selección" diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..498dc8de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -467,6 +467,22 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .collect(); (!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { tr("Show section contents") } else { tr("Hide section contents") }) }); + // Frames on the page (or in a section) convert to sections, and sections to frames. + let convert = app.session.doc().ok().and_then(|doc| { + let nodes: Vec<_> = ids.iter().filter_map(|i| doc.node(*i)).collect(); + let on_board = |n: &figcraft_doc::Node| { + n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, figcraft_doc::NodeKind::Section { .. })) + }; + if nodes.is_empty() { + None + } else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Section { .. })) { + Some((tr("Convert to frame"), "object.convertToFrame")) + } else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame) && on_board(n)) { + Some((tr("Convert to section"), "object.convertToSection")) + } else { + None + } + }); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -519,6 +535,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "section.toggleContents")); } + if let Some(entry) = convert + && let Some(at) = entries.iter().position(|e| e.1 == "object.wrapInSection") + { + entries.insert(at + 1, entry); + } // 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"), ("-", "")]); diff --git a/docs/mcp.md b/docs/mcp.md index 4efee72a..64228ea7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -114,6 +114,8 @@ Or in a project's `.mcp.json`: columns (vertical layouts wrap top to bottom into new columns). - `object.convertToGroup` / `object.convertToFrame` turn a frame into a group or back in place (layers stay put; a group has no auto layout, fill, stroke, corner radius, clipping or grids). + - `object.convertToSection` turns a frame on the page or in a section into a section in place, and + `object.convertToFrame` turns a section back into a frame (keeping its fill, not clipping). - `contentAlign` (wrapping layouts): `auto` (default) packs the lines `crossGap` apart at the start, centre or end `crossAlign` names (lines of nothing but fill children stretch instead); `spaceBetween` puts the free space between the lines (one line, or no free space: like `auto`). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e15ddaed..7c89f239 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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-17 · **Change:** minor (frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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 @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | From fa4821bcea03a2a0eae40609c9ffcbda3383f132 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:11 +0700 Subject: [PATCH 086/245] #627: settle bound paints once per open, not also in the native loader DocState::new already settles every opened document (native files, branches and live joins included); the .fig/REST path keeps its settlement in link_instances for file.import into an open document. --- crates/engine/src/cmd/file.rs | 1 - crates/engine/src/variable_tests.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f9175b9c..28124b7c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -520,7 +520,6 @@ fn load_with( if loaded.doc.name.is_empty() { loaded.doc.name = file_stem(name_hint); } - super::styles::apply_paint_bindings_on_load(&mut loaded.doc); Ok((loaded, false)) } } diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index f5808b2c..dcdd04e3 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -53,7 +53,10 @@ fn initial_color_bindings_settle_in_constructor_and_loaders() { let native = figcraft_format::save_bytes(&stale, None).unwrap(); let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap(); - let loaded_id = assert_dark_bound_color(&loaded, name_transform); + // A native file settles when it opens as a document, not in the loader. + let opened = Session::with_document(loaded); + let loaded = opened.doc().unwrap(); + let loaded_id = assert_dark_bound_color(loaded, name_transform); assert_eq!(loaded_id, id); assert_eq!(loaded.node(id).unwrap().source, stale.first_page()); assert!(loaded.node(id).unwrap().overrides.contains("sentinel")); From 6e15e30790acc1b8d062b31fb4ce9168eba09207 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:12 +0700 Subject: [PATCH 087/245] #627: remove the dead network::decode shim --- crates/format/src/fig/network.rs | 9 ++------- crates/format/src/fig/tail_tests.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..92ad4898 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -64,14 +64,9 @@ impl Reader<'_> { } } -/// The network in blob `b`; `None` when it's truncated, out of range or not finite. -#[allow(dead_code, reason = "for readers that don't need the count of styled segments")] -pub(super) fn decode(b: &[u8]) -> Option { - decode_styled(b).map(|(net, _)| net) -} - /// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` as for `decode`. +/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of +/// range or not finite. pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..19618044 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -355,9 +355,9 @@ fn unreadable_conditions_skip_the_conditional() { #[test] fn damaged_network_blobs_never_panic() { let good = square_blob(); - assert!(network::decode(&good).is_some()); + assert!(network::decode_styled(&good).is_some()); for cut in 0..good.len() { - assert!(network::decode(&good[..cut]).is_none(), "{cut}"); + assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}"); } let mut r = Lcg(5); for _ in 0..5000 { @@ -366,7 +366,7 @@ fn damaged_network_blobs_never_panic() { let i = (r.next() as usize) % m.len(); m[i] ^= 1 << (r.next() % 8); } - if let Some(net) = network::decode(&m) { + if let Some((net, _)) = network::decode_styled(&m) { assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len())); assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); @@ -375,13 +375,13 @@ fn damaged_network_blobs_never_panic() { // Counts larger than the blob, and indices out of range. let mut huge = vec![0xff; 12]; huge.extend_from_slice(&[0; 64]); - assert!(network::decode(&huge).is_none()); + assert!(network::decode_styled(&huge).is_none()); let mut bad = arrow_blob(); bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes()); - assert!(network::decode(&bad).is_none()); + assert!(network::decode_styled(&bad).is_none()); for _ in 0..2000 { let junk: Vec = (0..(r.next() % 120)).map(|_| r.next() as u8).collect(); - let _ = network::decode(&junk); + let _ = network::decode_styled(&junk); } } From b4d58a362e3350fd7416d8dc528d70dc6e372007 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:13 +0700 Subject: [PATCH 088/245] #627: leave the progress docs to the maintainer --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e56eda19..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, per-segment stroke styles, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 51e02a6b1a31b75e7b686a410b2f40e41d3f6fed Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:28 +0700 Subject: [PATCH 089/245] #695: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6f0cce80..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` numbered list start numbers (#686); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: nested list levels and where a new list begins right after another numbered one (`isFirstLineOfList`), connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -88,8 +88,7 @@ each. layers sit in it (fill, absolute, min/max sizes); layout grids; export settings; text style (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize, - truncation, paragraph spacing and indent, list types (with the number a numbered list starts - at, on every line of the list; not yet checked in other software) and styled runs with their fills (indexed + truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed by UTF-16 unit); components (description, boolean, text and instance swap properties and the layers bound to them), component sets, instances (their main, property values, what the instance itself changes from its main, such as its size, name and paints, as an override addressed by the @@ -163,7 +162,6 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a numbered paragraph keeps it (older files open unchanged), numbering counts on from it, `get_node` reports it (`listStartOffsets`), and the export writes it on every line of the list. Nested list levels and a new list right after another numbered one are still not read from `.fig` files. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 7dcd7fd05bfa820bf6adb0fe4e607f1e9ee5c89f Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:27:42 +0300 Subject: [PATCH 090/245] fix(format): one kiwi value per compressed canvas byte One value per two bytes of the compressed canvas was too tight for a public design system: a 39.5 MB canvas holding 29.7 million values got a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75 values per compressed byte; the budget is now one per byte, at least the old 20 million and at most 128 million. A small file still gets main's 20 million, however far it unpacks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 10 +++++----- crates/format/src/fig/tests.rs | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 46a9daac..6496062b 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -11,11 +11,11 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; /// Most values decoded from one message (every scalar, array element and field counts): at least -/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, -/// so a large real file decodes while a small one can't expand into a huge tree, however far its -/// chunks inflate. +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a large +/// real file decodes while a small one can't expand into a huge tree, however far its chunks +/// inflate. Real files hold 0.37 to 0.75 values per compressed byte. const MIN_VALUES: usize = 20_000_000; -const PACKED_BYTES_PER_VALUE: usize = 2; +const MAX_VALUES: usize = 128 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -302,7 +302,7 @@ impl Schema { /// Most values decoded from a file whose compressed canvas is `packed` bytes. pub(crate) fn value_budget(packed: usize) -> usize { - MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) + packed.clamp(MIN_VALUES, MAX_VALUES) } struct Decoder<'s, 'b> { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 574f860c..c2fae249 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -629,8 +629,11 @@ fn large_files_get_a_larger_value_budget() { // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds // 30.5 million values was refused as "too many values". assert!(kiwi::value_budget(83_491_232) > 30_514_899); - // Still bounded by the largest canvas read. - assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); + // A public design system packs more densely: a 39.5 MB canvas holding 29.7 million values, + // which one value per two compressed bytes refused. + assert!(kiwi::value_budget(39_500_000) > 29_703_730); + // Never more than the ceiling, however large the canvas. + assert_eq!(kiwi::value_budget(MAX_CANVAS as usize), 128 << 20); } #[test] From 69806c9edd85d8fac2a76eda32c75bc6ede262d6 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 13:35:00 +0700 Subject: [PATCH 091/245] Plugins: refuse file and network access through path params and collab (#723) Plugins can no longer reach the disk or network through path parameters (variable.export/codeConnect.export/component.playground writes, codeConnect.import/variable.import reads) or through collab.* and window.*; found and fixed by @lucasmartins-ai, split out of #696. --- crates/engine/src/plugins.rs | 15 +++++++++++++++ crates/engine/src/plugins_tests.rs | 7 +++++++ docs/plugins.md | 6 +++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075e..ce5aa0c6 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -111,6 +111,10 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Network sessions, online files and their recovery copies on disk. + "collab.", + // Opens files in new windows. + "window.", "view.theme", ]; /// Read-only exceptions to [`DENIED`]. @@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + // `path` here is a file on disk (elsewhere it is a vector path or an action's place). + if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { + return Some("plugins can't write files: leave out `path` to get the result back".into()); + } + if command == "codeConnect.import" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the connections as `json`".into()); + } + // Variables JSON may be given as a path: only objects and JSON text for plugins. + if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) { + return Some("plugins can't read files: pass the variables JSON itself".into()); + } if !m.permissions.allows(command) { return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name)); } diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 102d675a..657b6825 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("variable.export", json!({"format": "css", "path": target})), + ("codeConnect.export", json!({"path": target})), + ("codeConnect.import", json!({"path": "/etc/passwd"})), + ("component.playground", json!({"id": a, "path": target})), + ("variable.import", json!({"variables": "/etc/passwd"})), + ("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})), + ("window.new", json!({"path": "C:/Windows/win.ini"})), ("doc.switch", json!({"index": 0})), ("pref.keepTool", json!({})), // Without a name it would open inline renaming in the layers panel: a UI request. diff --git a/docs/plugins.md b/docs/plugins.md index 6f23e6ec..5867bd25 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -237,7 +237,11 @@ Whatever its manifest says, FigCraft refuses these calls (the plugin gets the re it can handle): - files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`, - `image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`; + `image.place` with a `path` (pass the image as base64 `data` instead), `variable.export`, + `codeConnect.export` and `component.playground` with a `path` (leave it out to get the result + back), `codeConnect.import` with a `path` and `variable.import` with a file path instead of JSON, + `window.*` (new windows open files), `view.theme*`; +- live sessions and online files: `collab.*`; - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and `version.list`); - the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is From 0ef8966844fd5b174b23f0e66de6b4af13cd799f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:40:30 +0700 Subject: [PATCH 092/245] #639: drop the ignored test that needs a fixture outside the repo --- .../format/src/fig/connector_label_tests.rs | 68 +------------------ 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs index 56cea368..2aa6d893 100644 --- a/crates/format/src/fig/connector_label_tests.rs +++ b/crates/format/src/fig/connector_label_tests.rs @@ -1,4 +1,4 @@ -use super::{Archive, decode_canvas, kiwi::Kv, map}; +use super::{kiwi::Kv, map}; fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { Kv::Object(fields) @@ -202,10 +202,6 @@ fn synthetic_connector_authored_hidden_background_is_retained() { assert_eq!(background["fills"].as_array().unwrap().len(), 1); } -fn connector<'a>(layers: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value { - layers.iter().find(|layer| layer.get("name").and_then(serde_json::Value::as_str) == Some(name)).expect("connector in fixture") -} - fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> { let Kv::Object(fields) = value else { return None }; fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value) @@ -244,65 +240,3 @@ fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'s fields.push((key, value)); } } - -#[test] -#[ignore = "set FIGCRAFT_CONNECTOR_LABEL_FIXTURE to the retained synthetic source-initial.zip"] -fn synthetic_export_keeps_saved_vector_gap_and_composed_label_position() { - let path = std::env::var_os("FIGCRAFT_CONNECTOR_LABEL_FIXTURE").expect("fixture path is required when running this test"); - let bytes = std::fs::read(path).expect("read synthetic connector export"); - let archive = Archive::open(&bytes).expect("open synthetic connector export"); - let (schema, raw_message) = decode_canvas(&archive.canvas).expect("decode synthetic canvas"); - let root = schema.find("Message").expect("Message definition"); - let message = schema.decode_message(root, &raw_message).expect("decode synthetic message"); - let (rest, warnings) = map::to_rest(&message, "connector labels"); - let layers = rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); - - let plain = connector(layers, "Connector line"); - assert_eq!(plain["type"], "VECTOR", "an unlabelled connector stays one vector"); - assert_eq!(plain["strokeCapStart"], "ROUND"); - assert_eq!(plain["strokeCapEnd"], "LINE_ARROW"); - assert!(plain.get("children").is_none()); - - let alpha = connector(layers, "STRAIGHT alpha"); - let alpha_children = alpha["children"].as_array().expect("snapshot children"); - let alpha_vector = alpha_children.iter().find(|child| child["type"] == "VECTOR").expect("saved connector vector"); - let segments = alpha_vector.pointer("/vectorNetwork/segments").and_then(serde_json::Value::as_array).expect("editable vector network"); - assert_eq!(segments.len(), 4, "network preserves the label gap as two disconnected runs"); - assert_eq!(alpha_children.iter().filter(|child| child["type"] == "TEXT").count(), 1); - let alpha_text = alpha_children.iter().find(|child| child["type"] == "TEXT").expect("label text"); - assert_eq!(alpha_text["characters"], "STRAIGHT alpha"); - assert_eq!(alpha_text["relativeTransform"], serde_json::json!([[1.0, 0.0, 201.5], [0.0, 1.0, 44.0]])); - assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); - - let mut malformed = message.clone(); - let alpha_node = connector_mut(&mut malformed, 127).expect("alpha node in decoded message"); - let derived = field_mut(alpha_node, "derivedImmutableFrameData").expect("derived frame data"); - let overrides = field_mut(derived, "overrides").expect("derived overrides"); - let Kv::Array(overrides) = overrides else { panic!("derived overrides array") }; - let text = overrides - .iter_mut() - .find(|row| { - row.get("guidPath") - .and_then(|path| path.get("guids")) - .and_then(|ids| ids.list().first()) - .and_then(|id| id.get("localID")) - .and_then(Kv::f64) - == Some(1.0) - }) - .expect("text template record"); - let transform = field_mut(text, "transform").expect("text transform"); - *transform = Kv::Object(vec![ - ("m00", Kv::Float(1.0)), - ("m01", Kv::Float(0.0)), - ("m02", Kv::Float(f32::NAN)), - ("m10", Kv::Float(0.0)), - ("m11", Kv::Float(1.0)), - ("m12", Kv::Float(0.0)), - ]); - let (malformed_rest, malformed_warnings) = map::to_rest(&malformed, "connector labels"); - let malformed_layers = malformed_rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); - let malformed_alpha = connector(malformed_layers, "STRAIGHT alpha"); - assert_eq!(malformed_alpha["children"].as_array().map(Vec::len), Some(1)); - assert_eq!(malformed_alpha["children"][0]["type"], "VECTOR"); - assert!(malformed_warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); -} From 9b4e45aa0bc8327673c4fc6e97915fca6b8cca68 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:40:55 +0700 Subject: [PATCH 093/245] #711: move the vector.offset tests into their own module --- crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 38 ------------------ crates/engine/src/vector_offset_tests.rs | 49 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/vector_offset_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..8609fb21 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1710,6 +1710,8 @@ mod vector_behaviour_tests; #[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] +mod vector_offset_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 746f417e..dbf432d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,41 +621,3 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } - -/// No node that holds children may be a non-container. -fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { - let doc = s.doc().unwrap(); - for &i in ids { - let n = doc.node(i).unwrap(); - assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); - } -} - -#[test] -fn offset_path_leaves_containers_with_children_alone() { - let mut s = sess(); - let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); - let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); - let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); - let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); - let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); - for target in [f, g] { - s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); - assert_children_only_in_containers(&s, &[f, g, inner, a, b]); - } - let doc = s.doc().unwrap(); - assert!(doc.node(f).unwrap().kind.is_container()); - assert_eq!(doc.node(f).unwrap().children, vec![inner]); - assert!(doc.node(g).unwrap().kind.is_container()); - assert_eq!(doc.node(g).unwrap().children.len(), 2); -} - -#[test] -fn offset_path_still_converts_a_plain_rectangle() { - let mut s = sess(); - let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); - s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); - let n = s.doc().unwrap().node(r).unwrap().clone(); - assert!(matches!(n.kind, NodeKind::Vector { .. })); - assert!(n.width > 10.0); -} diff --git a/crates/engine/src/vector_offset_tests.rs b/crates/engine/src/vector_offset_tests.rs new file mode 100644 index 00000000..e47158f2 --- /dev/null +++ b/crates/engine/src/vector_offset_tests.rs @@ -0,0 +1,49 @@ +//! `vector.offset` on layers with children: containers are left alone. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// No node that holds children may be a non-container. +fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { + let doc = s.doc().unwrap(); + for &i in ids { + let n = doc.node(i).unwrap(); + assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); + } +} + +#[test] +fn offset_path_leaves_containers_with_children_alone() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); + for target in [f, g] { + s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); + assert_children_only_in_containers(&s, &[f, g, inner, a, b]); + } + let doc = s.doc().unwrap(); + assert!(doc.node(f).unwrap().kind.is_container()); + assert_eq!(doc.node(f).unwrap().children, vec![inner]); + assert!(doc.node(g).unwrap().kind.is_container()); + assert_eq!(doc.node(g).unwrap().children.len(), 2); +} + +#[test] +fn offset_path_still_converts_a_plain_rectangle() { + let mut s = Session::new(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Vector { .. })); + assert!(n.width > 10.0); +} From ee3e3f0bd423c3fc713f8e2a4672124313332931 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:44:34 +0700 Subject: [PATCH 094/245] #637: skip ink only counts under an underline The public docs don't give a default for skip ink; they say the value is unset when the text isn't underlined, and the exporter always wrote it on. So the export keeps writing on for text without an underline and the real value under one (that's what the canvas draws), and the import leaves a value on text without an underline out of the decoration look, so plain text keeps the default look and round-trips unchanged. The native default stays off, so existing documents draw as before. --- crates/format/src/fig/export.rs | 7 ++- crates/format/src/fig/interop_tests.rs | 3 +- crates/format/src/fig/text_skip_ink_tests.rs | 45 +++++++++++++++++--- crates/format/src/rest/text.rs | 7 +++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index dd3f2e88..475f8ceb 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -2392,13 +2392,14 @@ fn key_codes(key: &str) -> Option<(Vec, bool)> { Some((codes, false)) } -/// The text style fields of a layer or of an override table entry. +/// Whether the decoration look has more than skip ink, which the file can't hold. fn unsupported_decoration_look(st: &TextStyle) -> bool { let mut look = st.look(); look.skip_ink = false; look != Default::default() } +/// The text style fields of a layer or of an override table entry. fn style_fields(st: &TextStyle, f: &mut Fields) { let line_height = match st.line_height { // Percent units count from the font's own line height (100 % is "auto"); a share of the @@ -2430,7 +2431,9 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { ("letterSpacing", letter_spacing), ("textCase", e(case)), ("textDecoration", e(decoration)), - ("textDecorationSkipInk", b(st.look().skip_ink)), + // Skip ink only shows on an underline; other text gets the files' default (on), the value + // the import leaves out again. + ("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)), ]); if let Some(url) = &st.link { f.push(("hyperlink", o(vec![("url", s(url))]))); diff --git a/crates/format/src/fig/interop_tests.rs b/crates/format/src/fig/interop_tests.rs index 56391975..b51760c5 100644 --- a/crates/format/src/fig/interop_tests.rs +++ b/crates/format/src/fig/interop_tests.rs @@ -187,8 +187,7 @@ fn text_carries_its_layout_settings_and_a_line_per_paragraph() { with_changes(&sample(), |of, _| { let t = of("TEXT"); assert!(!flag(&t, "autoRename")); - assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures")); - assert!(!flag(&t, "textDecorationSkipInk"), "default native text exports its actual false value"); + assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk")); assert_eq!(name(&t, "fontVersion"), ""); assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0)); assert_eq!(num(&t, "textTracking"), 0.0); diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs index 8d1cd295..2a1ff61d 100644 --- a/crates/format/src/fig/text_skip_ink_tests.rs +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -1,8 +1,9 @@ -//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. +//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. Skip +//! ink only shows on an underline, so the fixtures are underlined. use super::kiwi::Kv; use super::{export, import}; -use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextStyle}; +use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextDecoration, TextStyle}; use serde_json::Value; fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { @@ -30,6 +31,7 @@ fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec< let mut layer = vec![ ("fontName", font()), ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), ( "textData", object(vec![ @@ -44,7 +46,8 @@ fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec< } let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; if let Some(shared) = shared { - let mut fields = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0))]; + let mut fields = + vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0)), ("textDecoration", Kv::Enum("UNDERLINE"))]; if let Some(value) = shared { fields.push(("textDecorationSkipInk", value)); } @@ -125,6 +128,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() { vec![ ("fontName", font()), ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), ("textDecorationSkipInk", Kv::Bool(true)), ("textData", object(vec![("characters", Kv::Str("A".into()))])), ], @@ -172,6 +176,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() { fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() { let mut doc = Document::new("Skip ink export"); let mut text = TextData::new("abcd"); + text.style.decoration = TextDecoration::Underline; text.style.set_look(|look| look.skip_ink = false); let mut skipped = text.style.clone(); skipped.set_look(|look| look.skip_ink = true); @@ -209,9 +214,9 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let (_, warnings) = export(&variable_doc, None).unwrap(); assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}"); - let mut shared = TextStyle::default(); + let mut shared = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; shared.set_look(|look| look.skip_ink = true); - let mut local = TextStyle::default(); + let mut local = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; local.set_look(|look| look.skip_ink = false); let mut linked_doc = Document::new("Shared skip ink"); linked_doc.styles.push(figcraft_doc::Style { @@ -235,3 +240,33 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; assert!(style.look().skip_ink, "local false must not alter the shared definition"); } + +#[test] +fn skip_ink_without_an_underline_stays_out_of_the_look_and_exports_the_default() { + // Plain text with the files' default value imports with the default look... + let mut value = raw(Some(Kv::Bool(true)), vec![], "ab", vec![0, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!(label(&doc).style.decoration, TextDecoration::None); + assert!(label(&doc).style.decoration_look.is_none()); + + // ...an underlined run of it keeps the layer's value... + let mut value = + raw(Some(Kv::Bool(true)), vec![object(vec![("styleID", Kv::Uint(1)), ("textDecoration", Kv::Enum("UNDERLINE"))])], "ab", vec![1, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!((0..2).map(|i| skip_at(label(&doc), i)).collect::>(), vec![true, false]); + + // ...and default native text writes the default, which reads back as the default look. + let mut doc = Document::new("Plain"); + doc.create(doc.first_page().unwrap(), NodeKind::Text(TextData::new("x")), "Label", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let archive = super::Archive::open(&bytes).unwrap(); + let (schema, body) = super::decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap(); + let value = super::debug_json(&message); + let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(layer["textDecorationSkipInk"], true); + let (back, _) = import(&bytes, "Plain").unwrap(); + assert!(label(&back).style.decoration_look.is_none()); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..f607c292 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -27,6 +27,13 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { let mut td = TextData { characters, style: base, ..TextData::default() }; paragraph(style_json, &mut td); td.runs = runs(cx, v, &td); + // Skip ink only shows on an underline. A file can carry it on any text, so elsewhere it stays out + // of the look (runs have inherited the base value by now). + for st in std::iter::once(&mut td.style).chain(td.runs.iter_mut().map(|r| &mut r.style)) { + if st.decoration != TextDecoration::Underline && st.look().skip_ink { + st.set_look(|l| l.skip_ink = false); + } + } td.normalize(); lists(v, &mut td); td From 6023d3547a2a8b056e2f3c0c68cefc45607cbdc9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:45:15 +0700 Subject: [PATCH 095/245] #617: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dcd75691..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, per-point stroke joins (strokes that are dashed or variable-width still draw with the layer's join, with a warning), end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 7fe0770b9d74a072bdccae449edc347819617231 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:45:16 +0700 Subject: [PATCH 096/245] #617: point joins equal to the layer's no longer count as point styles (SVG keeps a plain stroke) --- crates/doc/src/vector.rs | 6 +++--- crates/pathops/src/points.rs | 3 +-- crates/render/src/draw.rs | 2 +- crates/svg/src/lib.rs | 2 +- crates/svg/src/tests.rs | 25 +++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7e..1165bd60 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -468,9 +468,9 @@ impl VectorNetwork { start_dir(&self.outward(seg, at_a)?) } - /// Whether any point has a stroke cap or join of its own. - pub fn has_point_styles(&self) -> bool { - self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some()) + /// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`. + pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool { + self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join)) } /// The fill path: every region's loops. diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index 9c161b7c..05f58216 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -20,8 +20,7 @@ impl Styled { /// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for. /// Joins equal to the layer do not require styled runs; stored overrides remain unchanged. pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option { - let styled = net.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|join| join != props.join)); - if !styled || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { + if !net.has_point_styles(props.join) || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { return None; } let mut net = net.rounded().unwrap_or_else(|| net.clone()); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..021af92d 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -155,7 +155,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { props.dashes.len().hash(&mut h); // Points' own caps and joins (vector networks). let net = match &n.kind { - NodeKind::Vector { network } if network.has_point_styles() => Some(network), + NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network), _ => None, }; for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a6813017..1433b942 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -638,7 +638,7 @@ impl W<'_> { || k.brush.is_some() || k.cap_start.is_arrow() || k.cap_end.is_arrow() - || matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles()) + || matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles(k.join)) || !(w.is_finite() && w > 0.0) { return None; diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index b15cd999..5a333d07 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -115,3 +115,28 @@ fn text_run_fill_survives_live_text_export() { assert!(svg.contains("#000000"), "{svg}"); assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b") && svg.contains(">a"), "{svg}"); } + +#[test] +fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { + use figcraft_doc::{StrokeJoin, VectorNetwork}; + use figcraft_geom::kurbo::Point; + let svg_with = |join: StrokeJoin| { + let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 50.0), Point::new(100.0, 0.0)], false); + for v in &mut network.vertices { + v.join = Some(StrokeJoin::Miter); + } + if let Some(v) = network.vertices.get_mut(1) { + v.join = Some(join); + } + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let v = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 50.0).unwrap(); + let n = d.get_mut(v).unwrap(); + n.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())]; + n.stroke.weight = 2.0; + n.stroke.join = StrokeJoin::Miter; + export_svg(&d, &[v], &SvgOptions::default()).unwrap() + }; + assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke"); + assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline"); +} From d9e79d604b8d34fbea7eff367e90afdc67868c51 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:34:42 +0200 Subject: [PATCH 097/245] M1: distinguish unresolved vector segment style metadata in .fig import The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model. --- crates/format/src/fig/export.rs | 8 +++- crates/format/src/fig/map.rs | 25 ++++++++--- crates/format/src/fig/network.rs | 11 ++--- crates/format/src/fig/tail_tests.rs | 64 ++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..7443c8de 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1669,7 +1669,13 @@ impl<'d> Exporter<'d> { let segments = net .segments .iter() - .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) .collect(); let regions = net .regions diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..36f45f31 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,12 +1192,29 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some((net, styled_segments)) = network::decode_styled(blob) else { + let Some((net, _)) = network::decode_styled(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; - if styled_segments > 0 { - self.warn("stroke styles of single vector network segments were left out"); + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + } + if has_missing { + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = @@ -1205,8 +1222,6 @@ impl<'a> Mapper<'a> { if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } - let styles: BTreeMap = - vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..4e8c3ad7 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -17,6 +17,7 @@ pub(super) struct Vertex { } pub(super) struct Segment { + pub style: u32, pub a: usize, pub ta: (f64, f64), pub b: usize, @@ -70,8 +71,7 @@ pub(super) fn decode(b: &[u8]) -> Option { decode_styled(b).map(|(net, _)| net) } -/// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` as for `decode`. +/// The network in blob `b` and how many of its segments have a nonzero style id; `None` as for `decode`. pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); @@ -84,10 +84,11 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } let mut styled = 0usize; for _ in 0..ns { - if r.u32()? != 0 { + let style = r.u32()?; + if style != 0 { styled += 1; } - net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; for _ in 0..nr { @@ -120,7 +121,7 @@ pub(super) fn encode(net: &Network) -> Vec { fl(v.y, &mut out); } for s in &net.segments { - u(0, &mut out); + u(s.style, &mut out); u(s.a as u32, &mut out); fl(s.ta.0, &mut out); fl(s.ta.1, &mut out); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -9,7 +9,7 @@ use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { - Segment { a, ta, b, tb } + Segment { style: 0, a, ta, b, tb } } fn vtx(style: u32, x: f64, y: f64) -> Vertex { @@ -264,21 +264,33 @@ fn vectors_import_as_editable_networks() { #[test] fn per_segment_stroke_styles_are_reported() { - let mut blob = square_blob(); - // The first segment's style id follows the counts and the vertices (12 bytes each). - blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&7u32.to_le_bytes()); - let (net, styled) = network::decode_styled(&blob).unwrap(); - assert_eq!((net.segments.len(), styled), (4, 1)); + let styled_import = |segment_style: u32| { + let mut blob = square_blob(); + // The first segment's style id follows the counts and the vertices (12 bytes each). + blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + let mut message = tail_message(); + let Kv::Object(fields) = &mut message else { panic!() }; + let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; + blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); + assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + warnings + }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("stroke styles of single")), "{plain:?}"); - let mut message = tail_message(); - let Kv::Object(fields) = &mut message else { panic!() }; - let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; - blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); - let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); - assert!(warnings.iter().any(|w| w.contains("stroke styles of single vector network segments were left out")), "{warnings:?}"); - assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); + // Style 1 has a row in the layer's override table; its contents are not applied. + let known = styled_import(1); + assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); + assert!(!known.iter().any(missing), "{known:?}"); + // Style 7 names no row at all. + let unknown = styled_import(7); + assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}"); + assert!(!unknown.iter().any(uninterpreted), "{unknown:?}"); } #[test] @@ -385,6 +397,30 @@ fn damaged_network_blobs_never_panic() { } } +#[test] +fn segment_styles_round_trip_without_changing_geometry() { + let original = Network { + vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)], + segments: vec![ + seg(0, 1, (1.0, 2.0), (3.0, 4.0)), + Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) }, + Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) }, + ], + regions: vec![], + }; + let bytes = network::encode(&original); + let (decoded, styled) = network::decode_styled(&bytes).unwrap(); + assert_eq!(styled, 2); + assert_eq!(decoded.segments.len(), 3); + for (got, want) in decoded.segments.iter().zip(&original.segments) { + assert_eq!(got.style, want.style); + assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); + } + for cut in 0..bytes.len() { + assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + } +} + #[test] fn mangled_tail_files_never_panic() { let c = tail_canvas(); From 22bdad22fe84f793a3b7b0b9491d31b61cebadfd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:46:21 +0700 Subject: [PATCH 098/245] #656: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c9e5da2f..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` import: images carried in the message (`image.dataBlob`); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills (from the archive, or carried in the message itself as `image.dataBlob`, the way copies written by plugins and tools carry them) come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -162,7 +162,6 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | `.fig` import: image fills whose bytes travel in the message itself (`image.dataBlob`, an index into the message's blobs) come across. Copies written by plugins and tools carry their images this way, and a bare canvas, the shape the clipboard holds, has no `images/`: they were grey placeholders. Blobs over the image size cap and indexes past the blobs are skipped (placeholder, as before). Checked on a bare canvas generated in code and on copies written by an HTML-to-Figma browser extension. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 99dc199bb89d2c0f7dfb6f508169e3cf78280073 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:46:22 +0700 Subject: [PATCH 099/245] #656: borrow embedded image bytes and copy only the ones a paint uses --- crates/format/src/fig/mod.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 091492da..90cdd05d 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -66,7 +66,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings) = map::to_rest(&msg, file_name); let embedded = embedded_images(&msg); - let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); + let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec())); let (doc, more) = crate::rest::import(&json, &images)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) @@ -100,8 +100,9 @@ fn corrupt(msg: impl Into) -> FormatError { /// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's /// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed /// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The -/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. -fn embedded_images(msg: &Kv) -> BTreeMap> { +/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The +/// bytes stay borrowed from the message; only images a paint asks for are copied. +fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap { let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); let mut found = BTreeMap::new(); let mut pending = vec![msg]; @@ -109,7 +110,7 @@ fn embedded_images(msg: &Kv) -> BTreeMap> { match value { Kv::Object(fields) => { if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { - found.entry(hash).or_insert_with(|| bytes.to_vec()); + found.entry(hash).or_insert(bytes); } pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); } From 5fba7e5dba98a275e858713289ed4f9fd3363672 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:48:56 +0700 Subject: [PATCH 100/245] #524: one kiwi value per compressed canvas byte (up to 128M), lower value and document.json ceilings on 32-bit targets --- crates/format/src/fig/kiwi.rs | 14 +++++++++----- crates/format/src/fig/tests.rs | 2 ++ crates/format/src/large_tests.rs | 4 +++- crates/format/src/lib.rs | 4 ++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 46a9daac..5c735b28 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -11,11 +11,15 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; /// Most values decoded from one message (every scalar, array element and field counts): at least -/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, -/// so a large real file decodes while a small one can't expand into a huge tree, however far its -/// chunks inflate. +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a +/// large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is +/// lower: past it the file is refused cleanly instead of running out of memory. const MIN_VALUES: usize = 20_000_000; -const PACKED_BYTES_PER_VALUE: usize = 2; +#[cfg(not(target_pointer_width = "32"))] +const MAX_VALUES: usize = 128 << 20; +#[cfg(target_pointer_width = "32")] +const MAX_VALUES: usize = 32 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -302,7 +306,7 @@ impl Schema { /// Most values decoded from a file whose compressed canvas is `packed` bytes. pub(crate) fn value_budget(packed: usize) -> usize { - MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) + packed.clamp(MIN_VALUES, MAX_VALUES) } struct Decoder<'s, 'b> { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 574f860c..ad9c85d0 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -629,6 +629,8 @@ fn large_files_get_a_larger_value_budget() { // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds // 30.5 million values was refused as "too many values". assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values. + assert!(kiwi::value_budget(39_528_420) > 29_703_730); // Still bounded by the largest canvas read. assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); } diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 2dc9eb4c..889074f1 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -211,7 +211,9 @@ fn document_json_may_unpack_in_proportion_to_its_packed_size() { assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and // didn't open again under the 512 MB cap. - assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + if cfg!(not(target_pointer_width = "32")) { + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + } // The packed size an archive claims counts only up to the archive's own size. assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); // Never more than the ceiling. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index dd96b932..9f7e51bc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -42,8 +42,12 @@ const MAX_MEMBER: u64 = 512 * 1024 * 1024; /// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within /// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs /// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +/// 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is lower. const DOCUMENT_RATIO: u64 = 16; +#[cfg(not(target_pointer_width = "32"))] const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). From c605bd5781157431b756768e58add61a200d9fb6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:49:46 +0700 Subject: [PATCH 101/245] #631: drop the styled-segment count nobody reads --- crates/format/src/fig/map.rs | 2 +- crates/format/src/fig/network.rs | 12 +++--------- crates/format/src/fig/tail_tests.rs | 23 +++++++++++------------ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 36f45f31..94678e25 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,7 +1192,7 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some((net, _)) = network::decode_styled(blob) else { + let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index 3adb0abf..ccb5c291 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -65,10 +65,8 @@ impl Reader<'_> { } } -/// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of -/// range or not finite. -pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { +/// The network in blob `b`; `None` when it's truncated, out of range or not finite. +pub(super) fn decode(b: &[u8]) -> Option { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); if nv > MAX_ITEMS || ns > MAX_ITEMS || nr > MAX_ITEMS || nv * 12 + ns * 28 + nr * 8 > b.len().saturating_sub(12) { @@ -78,12 +76,8 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { for _ in 0..nv { net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? }); } - let mut styled = 0usize; for _ in 0..ns { let style = r.u32()?; - if style != 0 { - styled += 1; - } net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; @@ -100,7 +94,7 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } net.regions.push(Region { style: sw >> 1, non_zero: sw & 1 == 1, loops }); } - Some((net, styled)) + Some(net) } /// The blob of `net` (the export writes it; so do test fixtures). Callers pass finite values. diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 363d2bdf..a15334a5 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -268,8 +268,8 @@ fn per_segment_stroke_styles_are_reported() { let mut blob = square_blob(); // The first segment's style id follows the counts and the vertices (12 bytes each). blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes()); - let (net, styled) = network::decode_styled(&blob).unwrap(); - assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + let net = network::decode(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style), (4, segment_style)); let mut message = tail_message(); let Kv::Object(fields) = &mut message else { panic!() }; let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; @@ -278,7 +278,7 @@ fn per_segment_stroke_styles_are_reported() { assert_eq!(network_of(&doc, "Wave").segments.len(), 4); warnings }; - assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); + assert!(network::decode(&square_blob()).unwrap().segments.iter().all(|s| s.style == 0)); let plain = import(&tail_canvas(), "Tail").unwrap().1; assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); @@ -367,9 +367,9 @@ fn unreadable_conditions_skip_the_conditional() { #[test] fn damaged_network_blobs_never_panic() { let good = square_blob(); - assert!(network::decode_styled(&good).is_some()); + assert!(network::decode(&good).is_some()); for cut in 0..good.len() { - assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}"); + assert!(network::decode(&good[..cut]).is_none(), "{cut}"); } let mut r = Lcg(5); for _ in 0..5000 { @@ -378,7 +378,7 @@ fn damaged_network_blobs_never_panic() { let i = (r.next() as usize) % m.len(); m[i] ^= 1 << (r.next() % 8); } - if let Some((net, _)) = network::decode_styled(&m) { + if let Some(net) = network::decode(&m) { assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len())); assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); @@ -387,13 +387,13 @@ fn damaged_network_blobs_never_panic() { // Counts larger than the blob, and indices out of range. let mut huge = vec![0xff; 12]; huge.extend_from_slice(&[0; 64]); - assert!(network::decode_styled(&huge).is_none()); + assert!(network::decode(&huge).is_none()); let mut bad = arrow_blob(); bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes()); - assert!(network::decode_styled(&bad).is_none()); + assert!(network::decode(&bad).is_none()); for _ in 0..2000 { let junk: Vec = (0..(r.next() % 120)).map(|_| r.next() as u8).collect(); - let _ = network::decode_styled(&junk); + let _ = network::decode(&junk); } } @@ -409,15 +409,14 @@ fn segment_styles_round_trip_without_changing_geometry() { regions: vec![], }; let bytes = network::encode(&original); - let (decoded, styled) = network::decode_styled(&bytes).unwrap(); - assert_eq!(styled, 2); + let decoded = network::decode(&bytes).unwrap(); assert_eq!(decoded.segments.len(), 3); for (got, want) in decoded.segments.iter().zip(&original.segments) { assert_eq!(got.style, want.style); assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); } for cut in 0..bytes.len() { - assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + assert!(network::decode(&bytes[..cut]).is_none(), "truncated at {cut}"); } } From 6233b90ba7684fc58af66804176c3e54dfa954fe Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:36:24 +0200 Subject: [PATCH 102/245] M1: import editable text on a path from .fig files Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path. The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged. --- crates/format/src/fig/map.rs | 95 ++++++- crates/format/src/fig/mod.rs | 3 + crates/format/src/fig/text_path.rs | 315 +++++++++++++++++++++++ crates/format/src/fig/text_path_tests.rs | 211 +++++++++++++++ crates/format/src/rest/text.rs | 12 +- docs/gaps.md | 4 + 6 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_path.rs create mode 100644 crates/format/src/fig/text_path_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 94678e25..9a2b696d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -759,7 +759,7 @@ impl<'a> Mapper<'a> { "VARIABLE" | "VARIABLE_SET" => return None, t => stand.map_or(t, |s| s.0), }; - if let Some((_, what)) = stand { + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } self.count += 1; @@ -774,7 +774,12 @@ impl<'a> Mapper<'a> { copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); match ty { - "TEXT" => self.text(&mut o, &view), + "TEXT" => { + self.text(&mut o, &view); + if own == "TEXT_PATH" { + self.text_path(&mut o, &view); + } + } "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), "ELLIPSE" => { if let Some(a) = view.get("arcData") { @@ -1155,6 +1160,92 @@ impl<'a> Mapper<'a> { } } + /// Carry supported text-path data through the private .fig REST ingress only. + fn text_path(&mut self, o: &mut Obj, v: &View<'a>) { + let fail = |this: &mut Self, reason: &str| { + this.warn(format!("text path {reason}; text imports off its path")); + }; + let Some(start) = v.get("textPathStart") else { + fail(self, "has no start data"); + return; + }; + let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else { + fail(self, "has invalid start data"); + return; + }; + let Some(vd) = v.get("vectorData") else { + fail(self, "has no vector data"); + return; + }; + let Some(blob) = num(vd, "vectorNetworkBlob") + .filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0) + .and_then(|i| usize::try_from(i as u64).ok()) + .and_then(|i| self.blobs.get(i)) + .copied() + else { + fail(self, "has no vector network"); + return; + }; + let Some((net, _)) = network::decode_styled(blob) else { + fail(self, "has damaged vector data"); + return; + }; + let Some(norm) = vd.get("normalizedSize") else { + fail(self, "has invalid normalized size"); + return; + }; + let Some(size) = v.get("size") else { + fail(self, "has invalid dimensions"); + return; + }; + let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else { + fail(self, "has invalid dimensions"); + return; + }; + let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option { + if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 { + return None; + } + if normalized > 1e-9 { + if size <= 0.0 { + return None; + } + return Some(size / normalized); + } + (size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0) + }; + let xs: Vec = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect(); + let sx = axis_scale(w, nx, &xs); + let sy = axis_scale(h, ny, &ys); + let (Some(sx), Some(sy)) = (sx, sy) else { + fail(self, "has invalid path scale"); + return; + }; + if v.str("textAlignVertical").is_some_and(|x| x != "TOP") { + fail(self, "uses unsupported vertical placement"); + return; + } + if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) { + fail(self, "contains multiple lines"); + return; + } + let path = match super::text_path::from_network(&net, sx, sy, t, forward) { + Ok(path) => path, + Err(reason) => { + fail(self, reason); + return; + } + }; + let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE"); + let decorated = o.get("style").is_some_and(has_decoration) + || o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration); + if decorated { + self.warn("text path underline or strikethrough decoration is not rendered"); + } + o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip})); + } + /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. fn geometry(&mut self, o: &mut Obj, v: &View<'a>) { for key in ["fillGeometry", "strokeGeometry"] { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..2ad6e276 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -24,6 +24,9 @@ mod network; mod source_identity_tests; #[cfg(test)] mod tests; +mod text_path; +#[cfg(test)] +mod text_path_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs new file mode 100644 index 00000000..8a93f7e4 --- /dev/null +++ b/crates/format/src/fig/text_path.rs @@ -0,0 +1,315 @@ +//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path. + +use figcraft_doc::{ + TextPath, + geom::{ + BezPath, + kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point}, + }, +}; + +use super::network::{Network, Segment}; + +const START_SEGMENT_STYLE: u32 = 0x01ff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Use { + segment: usize, + forward: bool, +} + +/// Build a native path from a validated connected chain or cycle. The marked source segment is +/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space. +pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result { + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return Err("invalid start parameter"); + } + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + return Err("invalid path scale"); + } + let markers: Vec = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect(); + let [marked] = markers.as_slice() else { + return Err("missing or ambiguous start segment marker"); + }; + + let (uses, closed) = ordered_chain(net, *marked)?; + let mut d = BezPath::new(); + let mut total = 0.0; + let mut prefix = 0.0; + let mut start_segment_length = 0.0; + for use_ in &uses { + let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?; + if d.elements().is_empty() { + d.move_to((p0.0, p0.1)); + } + let line = p1 == p0 && p2 == p3; + let (length, partial) = if line { + d.line_to((p3.0, p3.1)); + let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1)); + (line.arclen(1e-9), line.arclen(1e-9) * t) + } else { + d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) }; + (curve.arclen(1e-9), partial) + }; + if !length.is_finite() || length <= 1e-10 { + return Err("zero-length path segment"); + } + if use_.segment == *marked { + start_segment_length = partial; + if !start_segment_length.is_finite() { + return Err("invalid path length"); + } + prefix = total; + } + total += length; + } + if closed { + d.close_path(); + } + if !total.is_finite() || total <= 1e-10 { + return Err("zero-length path"); + } + let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0); + let flip = !forward; + if flip { + start = 1.0 - start; + } + let path = TextPath { d: d.to_svg(), start, flip }; + let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?; + if lines.first().is_none_or(|line| line.length() <= 1e-9) { + return Err("cannot be measured by text renderer"); + } + Ok(path) +} + +/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b; +/// cycles start at the marked segment's a vertex and traverse it first. +fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::new(); net.vertices.len()]; + for (i, s) in net.segments.iter().enumerate() { + if s.a >= adjacency.len() || s.b >= adjacency.len() { + return Err("invalid path vertex index"); + } + if s.a == s.b { + return Err("path contains a segment that starts and ends at the same vertex"); + } + adjacency[s.a].push(i); + adjacency[s.b].push(i); + } + let mut endpoints = Vec::new(); + for edges in &adjacency { + if edges.len() > 2 { + return Err("branched path network"); + } + if edges.len() == 1 { + endpoints.push(edges[0]); + } + } + if !endpoints.is_empty() && endpoints.len() != 2 { + return Err("disconnected or malformed path network"); + } + let cycle = endpoints.is_empty(); + let mut used = vec![false; net.segments.len()]; + let mut out = Vec::with_capacity(net.segments.len()); + if cycle { + out.push(Use { segment: marked, forward: true }); + used[marked] = true; + let mut at = marker.b; + let mut visited = vec![false; net.vertices.len()]; + visited[marker.a] = true; + visited[marker.b] = true; + while at != marker.a { + let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?; + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + if at != marker.a && std::mem::replace(&mut visited[at], true) { + return Err("path cycle is not simple"); + } + out.push(Use { segment: next, forward: is_forward }); + if out.len() > net.segments.len() { + return Err("invalid path cycle"); + } + } + } else { + let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?; + let mut at = endpoint; + while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) { + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + out.push(Use { segment: next, forward: is_forward }); + } + let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") }; + if !marker_use.forward { + out.reverse(); + for u in &mut out { + u.forward = !u.forward; + } + } + } + if !used.iter().all(|x| *x) { + return Err("disconnected path network"); + } + Ok((out, cycle)) +} + +type P = (f64, f64); + +fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> { + let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?; + let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?; + let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?; + let p0 = (a.x * sx, a.y * sy); + let p3 = (b.x * sx, b.y * sy); + let ta = (s.ta.0 * sx, s.ta.1 * sy); + let tb = (s.tb.0 * sx, s.tb.1 * sy); + if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) { + return Err("invalid path coordinates"); + } + let (p0, p1, p2, p3) = + if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) }; + if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fig::network::{Segment, Vertex}; + use figcraft_doc::geom::kurbo::PathEl; + + fn vertex(x: f64, y: f64) -> Vertex { + Vertex { style: 0, x, y } + } + + fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { + Segment { style, a, ta, b, tb } + } + + fn curved() -> Network { + Network { + vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))], + ..Network::default() + } + } + + #[test] + fn local_cubic_parameter_converts_to_path_distance() { + let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap(); + assert!((path.start - 0.004744078).abs() < 0.00001); + // Cubic t is not its fraction of total path length. + assert!((path.start - 0.0086954795).abs() > 0.003); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5)); + assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..)))); + } + + #[test] + fn second_segment_marker_and_t_are_respected() { + let mut net = curved(); + net.segments[0].style = 0; + net.segments[1].style = START_SEGMENT_STYLE; + let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap(); + assert!((path.start - 0.7490379757).abs() < 0.00002); + } + + #[test] + fn reverse_direction_inverts_normalized_start() { + let net = Network { + vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap(); + assert!(path.flip); + assert!((path.start - 0.16013986).abs() < 1e-8); + assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0)); + } + + #[test] + fn rejects_missing_duplicate_and_zero_length_segments() { + let mut net = curved(); + net.segments[0].style = 0; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + net.segments[1].style = START_SEGMENT_STYLE; + net.segments[0].style = START_SEGMENT_STYLE; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + let zero = Network { + vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment")); + } + + #[test] + fn rejects_branching_and_disconnected_segments() { + let branch = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network")); + let disconnected = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network")); + } + + #[test] + fn scales_geometry_and_accepts_closed_cycles() { + let cycle = Network { + vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap(); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0)); + assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0)); + let total = 200.0 + 300.0 + 130_000.0_f64.sqrt(); + assert!((path.start - 100.0 / total).abs() < 1e-8); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + } + + #[test] + fn rejects_paths_the_native_renderer_cannot_measure() { + let count = 40_000usize; + let many_lines = Network { + vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(), + segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(), + ..Network::default() + }; + assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let huge = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let tiny = Network { + vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + } +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs new file mode 100644 index 00000000..66148930 --- /dev/null +++ b/crates/format/src/fig/text_path_tests.rs @@ -0,0 +1,211 @@ +//! Mapper → private REST ingress tests for source text-path fields. + +use super::kiwi::Kv; + +const SCHEMA: &str = " +enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH +enum StyleType NONE TEXT +enum TextDecoration NONE UNDERLINE STRIKETHROUGH +struct GUID uint sessionID, uint localID +struct StyleId GUID guid +struct ParentIndex GUID guid, string position +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct FontName string family, string style +message TextOverride uint styleID, FontName fontName, float fontSize +message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable +message TextPathStart float tValue, bool forward +message VectorData uint vectorNetworkBlob, Vector normalizedSize +message Blob byte[] bytes +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration +message Message NodeChange[] nodeChanges, Blob[] blobs +"; +use figcraft_doc::{NodeKind, TextPath}; + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn a(values: Vec>) -> Kv<'static> { + Kv::Array(values) +} + +fn f(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn u(value: u64) -> Kv<'static> { + Kv::Uint(value) +} + +fn s(value: &'static str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} + +fn guid(id: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(id))]) +} + +fn node(id: u64, parent: Option, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut node = vec![("guid", guid(id)), ("type", s(ty))]; + if let Some(parent) = parent { + node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))]))); + } + node.extend(fields); + o(node) +} + +fn blob() -> Vec { + let mut bytes = Vec::new(); + let u32le = |out: &mut Vec, n: u32| out.extend_from_slice(&n.to_le_bytes()); + let f32le = |out: &mut Vec, n: f32| out.extend_from_slice(&n.to_le_bytes()); + u32le(&mut bytes, 2); // vertices + u32le(&mut bytes, 1); // segments + u32le(&mut bytes, 0); // regions + for (x, y) in [(0.0, 0.0), (100.0, 0.0)] { + u32le(&mut bytes, 0); + f32le(&mut bytes, x); + f32le(&mut bytes, y); + } + u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker + u32le(&mut bytes, 0); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + u32le(&mut bytes, 1); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + bytes +} + +fn canvas() -> Vec { + let schema = super::kiwi::Schema::parse(SCHEMA).unwrap(); + let message = message(true); + let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap(); + let mut out = b"fig-kiwi\0\0\0\0".to_vec(); + for raw in [super::kiwi::encode::schema(&schema), message] { + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &raw).unwrap(); + let compressed = encoder.finish().unwrap(); + out.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); + out.extend_from_slice(&compressed); + } + out +} + +fn message(with_path: bool) -> Kv<'static> { + let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])]; + let mut blobs = Vec::new(); + if with_path { + blobs.push(o(vec![("bytes", Kv::Bytes(blob()))])); + let text = o(vec![ + ("characters", s("alpha")), + ("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])), + ( + "styleOverrideTable", + a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]), + ), + ]); + nodes.push(node( + 2, + Some(1), + "TEXT_PATH", + vec![ + ("name", s("alpha path")), + ("size", point(100.0, 0.0)), + ("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])), + ("textData", text), + ("fontSize", f(24.0)), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])), + ("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])), + ], + )); + } else { + nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))])); + } + o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))]) +} + +fn linked_underline_message() -> Kv<'static> { + let Kv::Object(mut root) = message(true) else { panic!("expected message object") }; + let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") }; + let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap(); + let Kv::Object(fields) = text else { panic!("expected text node object") }; + fields.push(("styleIdForText", o(vec![("guid", guid(3))]))); + nodes.push(node( + 3, + Some(1), + "TEXT", + vec![ + ("name", s("Shared style")), + ("styleType", s("TEXT")), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("fontSize", f(24.0)), + ("textDecoration", s("UNDERLINE")), + ], + )); + Kv::Object(root) +} + +#[test] +fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { + let message = message(true); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert_eq!(text.runs.len(), 1); + assert_eq!(text.runs[0].start, 3); + assert_eq!(text.runs[0].end, 5); +} + +#[test] +fn public_rest_import_ignores_private_fig_text_path_field() { + let message = message(true); + let (json, _) = super::map::to_rest(&message, "Text path"); + // The mapper's private field is accepted only by import_fig, never by public REST import. + let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone(); + assert!(path.is_object()); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + assert!(text.path.is_none()); +} + +#[test] +fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() { + let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); +} + +#[test] +fn malformed_text_path_falls_back_with_specific_warning() { + let message = message(false); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}"); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "broken path").unwrap(); + assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none())); +} + +#[test] +fn linked_text_style_decoration_warns_from_effective_mapped_style() { + let message = linked_underline_message(); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE"); + assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}"); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..9b8e9be4 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextStyle, + TextData, TextDecoration, TextPath, TextStyle, }; use serde_json::Value; @@ -29,6 +29,16 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { td.runs = runs(cx, v, &td); td.normalize(); lists(v, &mut td); + if cx.fig_sources.is_some() + && let Some(path) = v.get("_figTextPath") + { + match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) { + (Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => { + td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip })); + } + _ => cx.warn("invalid internal .fig text path data was ignored"), + } + } td } diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..a5cd6a3b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,6 +41,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail +- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks + to existing editable native text paths, preserving the start segment, direction and styled + runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST + text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob 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, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From a96222012b437ae7f47403dcd3fd514d50eb73e1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:51:00 +0700 Subject: [PATCH 103/245] #619: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c25e33ea..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -60,13 +60,6 @@ export limit, not a native document nesting limit. ### Export to `.fig` (experimental) -File ▸ Save as offers `.figcraft` and `.fig` as separate file types in the system dialog. A `.fig` -destination writes the same experimental export copy of the document that is shown (`file.save` and -`file.saveAs` with a path ending in `.fig` do the same) and reports what was left out. The file's own -path, its unsaved changes, versions, branches and undo history are not touched, and a shown earlier -version stays shown. Other apps may not open every exported `.fig` file. A plain Save never writes over -a file whose saved path ends in `.fig`; it asks for a destination instead. - `file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental), `figcraft-cli export -o .fig` (and `convert`) and MCP's `export {format: "fig"}` write the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format From 6baeb53b200ba6c6ec8f5ca39bee4799e0346904 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:51:01 +0700 Subject: [PATCH 104/245] #619: the Save as messages in every catalog --- crates/ui-egui/locales/cs.toml | 2 ++ crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 ++ crates/ui-egui/locales/id.toml | 2 ++ crates/ui-egui/locales/ja.toml | 2 ++ crates/ui-egui/locales/ko.toml | 2 ++ crates/ui-egui/locales/pt-BR.toml | 2 ++ crates/ui-egui/locales/ru.toml | 2 ++ crates/ui-egui/locales/sv.toml | 2 ++ crates/ui-egui/locales/tr.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 2 ++ 11 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..e8fc0330 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -13,6 +13,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Soubor .figcraft s tímto názvem už existuje. Nahrazení potvrdíte výběrem jeho úplného názvu." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci" "A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení" @@ -1393,6 +1394,7 @@ status = "draft — needs native review" "Use as mask" = "Použít jako masku" "Use auto layout" = "Použít automatické rozvržení" "Use number keys for opacity" = "Nastavovat krytí číselnými klávesami" +"Use the .figcraft or .fig extension." = "Použijte příponu .figcraft nebo .fig." "Use the layer's fill" = "Použít výplň vrstvy" "Used in {files} · {instances}" = "Použití – {files} · {instances}" "Username" = "Uživatelské jméno" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 0f2e3391..47000119 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -9,7 +9,6 @@ status = "draft — needs native review" [ui] "Couldn't download the variables" = "No se pudieron descargar las variables" -"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo." "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" @@ -19,6 +18,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Una capa sola se alinea con su marco: selecciona más capas o una dentro de un marco" "A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..3f2e12be 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée" "About" = "À propos" @@ -1338,6 +1339,7 @@ status = "draft — needs native review" "Use as mask" = "Utiliser comme masque" "Use auto layout" = "Utiliser la mise en page auto" "Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité" +"Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig." "Use the layer's fill" = "Utiliser le remplissage du calque" "Used in {files} · {instances}" = "Utilisé dans {files} · {instances}" "Username" = "Nom d’utilisateur" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..c56d9fc7 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{bin_added} / −{bin_deleted} aset biner, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Koleksi file ini dengan mode dan variabel tersebut: timpa nilainya per mode dan tambahkan variabel sendiri" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "File .figcraft dengan nama ini sudah ada. Pilih nama lengkapnya untuk mengonfirmasi penggantian." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Satu layer saja diratakan ke frame-nya: pilih lebih banyak layer, atau satu layer di dalam frame" "A responsive product page: hero, features, figures, a quote and a footer" = "Halaman produk responsif: hero, fitur, angka, kutipan, dan footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Spring berjalan hingga stabil: massa, kekakuan, dan redamannya menentukan durasinya" @@ -1397,6 +1398,7 @@ status = "draft — needs native review" "Use as mask" = "Gunakan sebagai mask" "Use auto layout" = "Gunakan auto layout" "Use number keys for opacity" = "Gunakan tombol angka untuk opasitas" +"Use the .figcraft or .fig extension." = "Gunakan ekstensi .figcraft atau .fig." "Use the layer's fill" = "Gunakan fill layer" "Used in {files} · {instances}" = "Digunakan di {files} · {instances}" "Username" = "Nama pengguna" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..c04e07ca 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "この名前の .figcraft ファイルは既に存在します。置き換えるには、拡張子まで含む名前を選択。" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択" "A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" @@ -1406,6 +1407,7 @@ status = "draft — needs native review" "Use as mask" = "マスクとして使用" "Use auto layout" = "オートレイアウトを使用" "Use number keys for opacity" = "数字キーで不透明度を変更" +"Use the .figcraft or .fig extension." = "拡張子は .figcraft または .fig を使用。" "Use the layer's fill" = "レイヤーの塗りを使用" "Used in {files} · {instances}" = "{files}で使用 · {instances}" "Username" = "ユーザー名" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..c57e4279 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요" "A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨" "About" = "정보" @@ -1338,6 +1339,7 @@ status = "draft — needs native review" "Use as mask" = "마스크로 사용" "Use auto layout" = "자동 레이아웃 사용" "Use number keys for opacity" = "숫자 키로 불투명도 변경" +"Use the .figcraft or .fig extension." = ".figcraft 또는 .fig 확장자를 사용하세요." "Use the layer's fill" = "레이어의 채우기 사용" "Used in {files} · {instances}" = "{files}에서 사용 · {instances}" "Username" = "사용자 이름" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..0bd03b84 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -16,6 +16,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Já existe um arquivo .figcraft com este nome. Escolha o nome completo dele para confirmar a substituição." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração" "About" = "Sobre" @@ -1337,6 +1338,7 @@ status = "draft — needs native review" "Use as mask" = "Usar como máscara" "Use auto layout" = "Usar layout automático" "Use number keys for opacity" = "Usar teclas numéricas para a opacidade" +"Use the .figcraft or .fig extension." = "Use a extensão .figcraft ou .fig." "Use the layer's fill" = "Usar o preenchimento da camada" "Used in {files} · {instances}" = "Usado em {files} · {instances}" "Username" = "Nome de usuário" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..2725a7ab 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -15,6 +15,7 @@ name = "Русский" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя." "A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование" "A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд" @@ -1394,6 +1395,7 @@ name = "Русский" "Use as mask" = "Использовать как маску" "Use auto layout" = "Использовать автолейаут" "Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами" +"Use the .figcraft or .fig extension." = "Используйте расширение .figcraft или .fig." "Use the layer's fill" = "Использовать заливку слоя" "Used in {files} · {instances}" = "Используется в {files} · {instances}" "Username" = "Имя пользователя" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..b2d7b7a5 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -16,6 +16,7 @@ name = "Svenska" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts." "A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot" "A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge" "A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild" @@ -1395,6 +1396,7 @@ name = "Svenska" "Use as mask" = "Använd som mask" "Use auto layout" = "Använd automatisk layout" "Use number keys for opacity" = "Använd siffertangenterna för opacitet" +"Use the .figcraft or .fig extension." = "Använd filändelsen .figcraft eller .fig." "Use the layer's fill" = "Använd lagrets fyllning" "Used in {files} · {instances}" = "Används i {files} · {instances}" "Username" = "Användarnamn" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..b6021987 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -18,6 +18,7 @@ name = "Türkçe" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Bu adda bir .figcraft dosyası zaten var. Değiştirmeyi onaylamak için tam adını seçin." "A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler" "A title, an agenda, a big figure, a chart and a closing slide" = "Bir başlık, bir gündem, büyük bir rakam, bir grafik ve bir kapanış slaydı" @@ -1397,6 +1398,7 @@ name = "Türkçe" "Use as mask" = "Maske olarak kullan" "Use auto layout" = "Otomatik yerleşim kullan" "Use number keys for opacity" = "Opaklık için rakam tuşlarını kullan" +"Use the .figcraft or .fig extension." = ".figcraft veya .fig uzantısını kullanın." "Use the layer's fill" = "Katmanın dolgusunu kullan" "Used in {files} · {instances}" = "Kullanıldığı yer: {files} · {instances}" "Username" = "Kullanıcı adı" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..3bf2ee9b 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层" "A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定" "About" = "关于" @@ -1339,6 +1340,7 @@ status = "draft — needs native review" "Use as mask" = "用作蒙版" "Use auto layout" = "使用自动布局" "Use number keys for opacity" = "用数字键设置不透明度" +"Use the .figcraft or .fig extension." = "请使用 .figcraft 或 .fig 扩展名。" "Use the layer's fill" = "使用图层的填充" "Used in {files} · {instances}" = "用于 {files} · {instances}" "Username" = "用户名" From 3fadbf4ba9613f1fcc9f11c770f927c5088524f4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:54:25 +0700 Subject: [PATCH 105/245] #632: read the network with the decoder #631 left --- crates/format/src/fig/map.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 9a2b696d..9e388cc4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1186,7 +1186,7 @@ impl<'a> Mapper<'a> { fail(self, "has no vector network"); return; }; - let Some((net, _)) = network::decode_styled(blob) else { + let Some(net) = network::decode(blob) else { fail(self, "has damaged vector data"); return; }; From ca94b5fe6f144b31092731d056fb4046c450476c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:54:27 +0700 Subject: [PATCH 106/245] #632: leave the progress docs to the maintainer --- docs/gaps.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index a5cd6a3b..e8b111a8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,10 +41,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail -- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks - to existing editable native text paths, preserving the start segment, direction and styled - runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST - text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob 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, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From d84aa7f1e1f14a41fc2d0591064a8a220616d245 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:38:33 +0200 Subject: [PATCH 107/245] M1: import image adjustments from .fig files Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once. --- crates/format/src/fig/image_filter_tests.rs | 83 +++++++++++++++++++ crates/format/src/fig/map.rs | 74 +++++++++++++++-- .../format/src/fig/map_image_filter_tests.rs | 27 ++++++ crates/format/src/fig/tests.rs | 3 + crates/format/src/rest/props.rs | 23 ++++- crates/format/src/rest/tests.rs | 18 ++++ 6 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 crates/format/src/fig/image_filter_tests.rs create mode 100644 crates/format/src/fig/map_image_filter_tests.rs diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs new file mode 100644 index 00000000..b8d64694 --- /dev/null +++ b/crates/format/src/fig/image_filter_tests.rs @@ -0,0 +1,83 @@ +//! Image paint filters read from `.fig` files, on fixtures built in code with the parent module's +//! helpers. + +use super::*; + +/// The shared schema plus the image filter containers only these tests use. +fn image_filter_schema() -> Schema { + let text = SCHEMA + .replace( + "message Paint ", + "message FilterColorAdjust float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance\n\ + message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ + message Paint ", + ) + .replace("ImageScaleMode imageScaleMode\n", "ImageScaleMode imageScaleMode, PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust\n"); + Schema::parse(&text).unwrap() +} + +#[test] +fn image_filters_import_from_current_container_and_skip_ambiguous_data() { + let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields); + let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| { + let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))]; + paint.append(&mut extra); + node(id, Some((1, "!")), "RECTANGLE", name, vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![o(paint)]))]) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + image_paint( + 2, + "Current", + vec![( + "paintFilter", + filter(vec![ + ("exposure", f(0.25)), + ("contrast", f(0.3)), + ("vibrance", f(-0.4)), + ("temperature", f(0.5)), + ("tint", f(1.0)), + ("highlights", f(-0.6)), + ("shadows", f(0.7)), + ]), + )], + ), + image_paint(3, "Negative tint", vec![("paintFilter", filter(vec![("tint", f(-1.0)), ("contrast", f(-0.3))]))]), + image_paint(4, "Neutral", vec![]), + image_paint(5, "Explicit neutral", vec![("paintFilter", filter(vec![("exposure", f(0.0)), ("vibrance", f(0.0))]))]), + image_paint(6, "Legacy", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))]))]), + image_paint(7, "Both", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))])), ("paintFilter", filter(vec![("contrast", f(0.2))]))]), + image_paint( + 8, + "Unsupported", + vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))], + ), + ]; + let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))]))); + let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap(); + let get = |name: &str| { + let node = doc.nodes().find(|node| node.name == name).unwrap(); + let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + let current = get("Current"); + assert!((current.filters.exposure - 0.25).abs() < 1e-6); + assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); + assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); + assert!((current.filters.temperature - 0.5).abs() < 1e-6); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "positive Figma tint converts once at the REST boundary"); + assert!((current.filters.highlights + 0.6).abs() < 1e-6); + assert!((current.filters.shadows - 0.7).abs() < 1e-6); + assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); + assert!((get("Negative tint").filters.contrast + 0.3).abs() < 1e-6); + assert_eq!(get("Neutral").filters, Default::default()); + assert_eq!(get("Explicit neutral").filters, Default::default()); + assert_eq!(doc.images.get(¤t.image).map(|image| image.bytes.as_slice()), Some(png().as_slice())); + assert!(warnings.iter().any(|w| w.contains("legacy image filter container is unsupported")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("both filter containers are present")), "{warnings:?}"); + for field in ["brightness", "detail", "vignette", "mystery"] { + assert!(warnings.iter().any(|w| w.contains(field)), "missing {field} warning: {warnings:?}"); + } + assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..62d10d71 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -55,6 +55,10 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { (out, m.warnings) } +#[cfg(test)] +#[path = "map_image_filter_tests.rs"] +mod image_filter_tests; + /// REST importer warnings reworded for a `.fig` import (`None`: drop it). pub(super) fn adapt_warning(w: String) -> Option { if w.contains("geometry=paths") { @@ -73,6 +77,16 @@ fn num(n: &Kv, k: &str) -> Option { n.get(k).and_then(Kv::f64) } +fn filter_number(value: &Kv) -> Option { + let number = match value { + Kv::Float(value) => f64::from(*value), + Kv::Int(value) => *value as f64, + Kv::Uint(value) => *value as f64, + _ => return None, + }; + number.is_finite().then_some(number) +} + fn guid(v: &Kv) -> Option { Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) } @@ -964,11 +978,11 @@ impl<'a> Mapper<'a> { let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); let fills = own("fillPaints") .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect())); + o.insert("fills".into(), Value::Array(fills.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); let strokes = own("strokePaints") .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect())); + o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default()); if !effects.is_empty() { @@ -1138,7 +1152,7 @@ impl<'a> Mapper<'a> { } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; - char_styles(o, td, size); + char_styles(o, td, size, &mut |w| self.warn(w)); let lines: Vec<&str> = td .get("lines") .map(Kv::list) @@ -1333,7 +1347,7 @@ impl<'a> Mapper<'a> { o.remove("styleOverrideTable"); if let Some(tv) = val.get("textValue") { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); - char_styles(o, tv, size); + char_styles(o, tv, size, &mut |w| self.warn(w)); } } } @@ -1920,7 +1934,7 @@ fn paint_bound(p: &Kv) -> bool { alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) } -fn paint(p: &Kv) -> Option { +fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option { let ty = p.get("type").and_then(Kv::str)?; let mut o = Obj::new(); o.insert("type".into(), json!(ty)); @@ -1989,6 +2003,52 @@ fn paint(p: &Kv) -> Option { if let Some(r) = num(p, "rotation") { o.insert("rotation".into(), json!(r)); } + match (p.get("filterColorAdjust"), p.get("paintFilter")) { + (Some(_), Some(_)) => { + warn("image filters are ambiguous because both filter containers are present; filter conversion was skipped".into()) + } + (Some(_), None) => warn("legacy image filter container is unsupported; filter conversion was skipped".into()), + (None, Some(filters)) => { + let mut mapped = Obj::new(); + let fields = [ + ("exposure", "exposure"), + ("contrast", "contrast"), + ("vibrance", "saturation"), + ("temperature", "temperature"), + ("tint", "tint"), + ("highlights", "highlights"), + ("shadows", "shadows"), + ]; + if let Kv::Object(values) = filters { + for (source, target) in fields { + if let Some(value) = filters.get(source) { + match filter_number(value) { + Some(value) => { + mapped.insert(target.into(), json!(value)); + } + None => warn(format!("malformed image filter `{source}` was skipped")), + } + } + } + for (name, value) in values { + if fields.iter().any(|(known, _)| *known == *name) { + continue; + } + match filter_number(value) { + Some(0.0) => {} + Some(_) => warn(format!("nonzero unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + None => warn(format!("malformed unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + } + } + } else { + warn("malformed image paintFilter container was skipped".into()); + } + if !mapped.is_empty() { + o.insert("filters".into(), Value::Object(mapped)); + } + } + (None, None) => {} + } } _ => {} } @@ -2046,7 +2106,7 @@ fn variable_value(d: &Kv) -> Option { /// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST /// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size. -fn char_styles(o: &mut Obj, td: &Kv, size: Option) { +fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(String)) { let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); if ids.iter().all(|i| *i == 0) { return; @@ -2056,7 +2116,7 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option) { let Some(sid) = num(e, "styleID") else { continue }; let mut s = text_style(&View::of(e), size); if let Some(fills) = e.get("fillPaints") { - s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect())); } table.insert((sid as u64).to_string(), Value::Object(s)); } diff --git a/crates/format/src/fig/map_image_filter_tests.rs b/crates/format/src/fig/map_image_filter_tests.rs new file mode 100644 index 00000000..a8db39a7 --- /dev/null +++ b/crates/format/src/fig/map_image_filter_tests.rs @@ -0,0 +1,27 @@ +//! Malformed image paint filters are skipped with a warning. + +use super::*; + +#[test] +fn malformed_non_finite_filter_is_skipped_with_warning() { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}"); +} + +#[test] +fn boolean_filter_values_are_malformed_even_when_false() { + for (field, value, expected_warning) in [ + ("exposure", Kv::Bool(true), "malformed image filter `exposure`"), + ("exposure", Kv::Bool(false), "malformed image filter `exposure`"), + ("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"), + ] { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}"); + } +} diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..e98a1f34 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -873,6 +873,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { #[path = "tail_tests.rs"] mod tail; +#[path = "image_filter_tests.rs"] +mod image_filters; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 67ec8063..ac4d3519 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -178,6 +178,24 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { .filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9); let f = p.get("filters"); let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32; + let filter_fields = ["exposure", "contrast", "saturation", "temperature", "tint", "highlights", "shadows"]; + let mut has_filters = false; + if let Some(filters) = f.and_then(Value::as_object) { + for (key, value) in filters { + match value.as_f64().filter(|value| value.is_finite()) { + Some(value) if filter_fields.contains(&key.as_str()) => has_filters |= value != 0.0, + Some(0.0) => {} + Some(_) => cx.warn(format!("nonzero unsupported image filter `{}` was skipped", clip_str(key))), + None => cx.warn(format!("malformed image filter `{}` was skipped", clip_str(key))), + } + } + } else if f.is_some_and(|filters| !filters.is_null()) { + cx.warn("malformed image filters container was skipped"); + } + let fig = cx.fig_sources.is_some(); + if has_filters && fig { + cx.warn("image filters use FigCraft's approximate renderer"); + } ImagePaint { image: cx.image_key(text(p, "imageRef").unwrap_or("")), scale_mode, @@ -189,7 +207,8 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - tint: filter("tint"), + // A positive tint in .fig sources moves toward magenta; native positive tint adds green. + tint: if fig { -filter("tint") } else { filter("tint") }, highlights: filter("highlights"), shadows: filter("shadows"), }, @@ -550,6 +569,6 @@ pub(super) fn export_settings(v: &Value) -> Vec { } /// At most 200 chars of `s` (names and labels from the file stay readable in warnings). -pub(super) fn clip_str(s: &str) -> &str { +pub(crate) fn clip_str(s: &str) -> &str { s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s) } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..282476ef 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -186,6 +186,24 @@ fn paints_gradients_and_image_placeholders() { assert!(warnings.iter().any(|w| w.starts_with("1 image without image data shows")), "{warnings:?}"); } +#[test] +fn public_rest_image_filter_tint_keeps_its_sign() { + let rect = |name: &str, tint: f64| { + json!({ + "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] + }) + }; + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5)])); + let image = |name: &str| { + let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + assert!(close(f64::from(image("Magenta").filters.tint), 0.75)); + assert!(close(f64::from(image("Green").filters.tint), -0.5)); + assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); +} + #[test] fn image_bytes_come_from_the_resolver() { let png = { From 2ac4ce3a730f02fef392c0f6fcb28ada4dfcb10e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:58:41 +0700 Subject: [PATCH 108/245] #635: rebase onto #628: the filter tests add their paint fields themselves The test schema extended the image scale-mode field that only #634 adds; it now adds the filter containers to the paint message itself, so it works with and without #634. --- crates/format/src/fig/image_filter_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index b8d64694..3216b2b3 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -12,7 +12,7 @@ fn image_filter_schema() -> Schema { message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ message Paint ", ) - .replace("ImageScaleMode imageScaleMode\n", "ImageScaleMode imageScaleMode, PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust\n"); + .replace("message Paint PaintType type,", "message Paint PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust, PaintType type,"); Schema::parse(&text).unwrap() } From baa58b21faa220a9920586bff2106e84286025f0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:59:03 +0700 Subject: [PATCH 109/245] #620: file.open with base64 data returns the summary too --- crates/engine/src/cmd/file.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ab459e13..af2ac56d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -698,9 +698,10 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active}); + let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } From 48d74df226894457390f4968ab76fec276526846 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:03:57 +0700 Subject: [PATCH 110/245] #635: one tint sign for REST file JSON and .fig Both carry the same image filter value, which the public docs describe as green when lower and magenta when higher; a positive native tint adds green. Both readers flip it now (the .fig path did, the REST path didn't). The .fig test message no longer names the product. --- crates/format/src/fig/image_filter_tests.rs | 2 +- crates/format/src/rest/props.rs | 5 +++-- crates/format/src/rest/tests.rs | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index 3216b2b3..83792123 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -66,7 +66,7 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() { assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); assert!((current.filters.temperature - 0.5).abs() < 1e-6); - assert!((current.filters.tint + 1.0).abs() < 1e-6, "positive Figma tint converts once at the REST boundary"); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "a positive (magenta) tint in the file is a negative native tint"); assert!((current.filters.highlights + 0.6).abs() < 1e-6); assert!((current.filters.shadows - 0.7).abs() < 1e-6); assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index ac4d3519..0ee2d687 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -207,8 +207,9 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - // A positive tint in .fig sources moves toward magenta; native positive tint adds green. - tint: if fig { -filter("tint") } else { filter("tint") }, + // The files' tint runs from green (negative) to magenta (positive), in REST file JSON and + // .fig alike; a positive native tint adds green. + tint: -filter("tint"), highlights: filter("highlights"), shadows: filter("shadows"), }, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 282476ef..97e5d828 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -187,7 +187,7 @@ fn paints_gradients_and_image_placeholders() { } #[test] -fn public_rest_image_filter_tint_keeps_its_sign() { +fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { let rect = |name: &str, tint: f64| { json!({ "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, @@ -199,8 +199,9 @@ fn public_rest_image_filter_tint_keeps_its_sign() { let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; image }; - assert!(close(f64::from(image("Magenta").filters.tint), 0.75)); - assert!(close(f64::from(image("Green").filters.tint), -0.5)); + // The same sign as a `.fig` import (`fig::tests::image_filters`). + assert!(close(f64::from(image("Magenta").filters.tint), -0.75)); + assert!(close(f64::from(image("Green").filters.tint), 0.5)); assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); } From 9e28e119548745adf9dbf09e96588ce79a9d83d9 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 14:03:59 +0700 Subject: [PATCH 111/245] M3: number variables in the text content picker (#629) (#725) The Typography section's variable picker also lists number variables for a text layer's content (shown as the number), as the engine already accepted; other text fields keep their own types (#629). --- ROADMAP.md | 4 +++- crates/ui-egui/src/variable_ui.rs | 28 +++++++++++++++++++++++++++- docs/target-app-parity.md | 3 ++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bbde8bc5..006b81b9 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 (frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **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. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. @@ -314,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 04507c0c..e6cc6860 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -361,6 +361,12 @@ pub fn bound_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kin }); } +/// Whether a variable of type `have` belongs in a picker for `want` on `field`: its own type, and +/// numbers for text content too (shown as text). +fn kind_fits(want: Option, field: Option<&str>, have: VariableType) -> bool { + want.is_none_or(|k| have == k || (field == Some("characters") && have == VariableType::Number)) +} + /// The picker popover (call once per frame). pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let Some(p) = ctx.data(|d| d.get_temp::(picker_id())) else { return }; @@ -379,8 +385,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { }; // Prototypes and styles use this file's variables. let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. }); + let field = match &p.target { + Target::Bind { field, .. } => Some(field.as_str()), + _ => None, + }; let keep = |lib: Option<&PublishedLibrary>, v: &Variable| { - p.kind.is_none_or(|k| v.kind == k) + kind_fits(p.kind, field, v.kind) && !(local_only && lib.is_some()) && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) @@ -476,3 +486,19 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { fn groups_any_kind(doc: &Document, libs: &[Arc], kind: Option) -> bool { !groups(doc, libs, |_, v| kind.is_none_or(|k| v.kind == k)).is_empty() } + +#[cfg(test)] +mod kind_tests { + use super::*; + + #[test] + fn text_content_offers_numbers_too() { + use VariableType::*; + assert!(kind_fits(Some(String), Some("characters"), Number), "a number shows as text"); + assert!(kind_fits(Some(String), Some("characters"), String)); + assert!(!kind_fits(Some(String), Some("characters"), Color)); + assert!(!kind_fits(Some(String), Some("fontFamily"), Number), "only text content"); + assert!(!kind_fits(Some(Number), Some("width"), String)); + assert!(kind_fits(None, None, Boolean)); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c89f239..c3842d4f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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 @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | From 1e85d549523a7908d173c61d18524c9a9a15451f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:04:00 +0700 Subject: [PATCH 112/245] #635: cap the .fig import warnings like the REST ones Image filter warnings name fields of the file, so a crafted schema could add any number of distinct warnings (each checked against all earlier ones). The mapper keeps at most as many as the REST importer does. --- crates/format/src/fig/image_filter_tests.rs | 21 +++++++++++++++++++++ crates/format/src/fig/map.rs | 3 ++- crates/format/src/rest/mod.rs | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index 83792123..3fa50a20 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -81,3 +81,24 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() { } assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); } + +#[test] +fn warnings_naming_unknown_filter_fields_are_capped() { + // A schema can name any number of fields; each nonzero unknown one is a warning of its own. + let names: Vec<&'static str> = (0..300).map(|i| &*Box::leak(format!("unknown{i}").into_boxed_str())).collect(); + let fields: Vec = names.iter().map(|n| format!("float {n}")).collect(); + let text = SCHEMA + .replace("message Paint ", &format!("message ManyFilters {}\nmessage Paint ", fields.join(", "))) + .replace("message Paint PaintType type,", "message Paint ManyFilters paintFilter, PaintType type,"); + let filters = o(names.iter().map(|n| (*n, f(0.5))).collect()); + let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("paintFilter", filters)]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node(2, Some((1, "!")), "RECTANGLE", "Photo", vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![paint]))]), + ]; + let bytes = zip_file(&canvas(&Schema::parse(&text).unwrap(), &o(vec![("nodeChanges", arr(nodes))]))); + let (_, warnings) = import(&bytes, "ManyFilters.fig").unwrap(); + let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count(); + assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len()); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 62d10d71..9dc238dc 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -431,7 +431,8 @@ impl<'a> Mapper<'a> { fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Capped like the REST importer's list: some warnings name fields of the file (image filters). + if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..d42b04f3 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -66,7 +66,7 @@ const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. const MAX_PATH_ELEMENTS: usize = 200_000; /// Most distinct warnings reported. -const MAX_WARNINGS: usize = 200; +pub(crate) const MAX_WARNINGS: usize = 200; /// Most shared style definitions read. const MAX_STYLES: usize = 10_000; /// Image key used for image fills without an `imageRef`. From d0539b83e9893329995c922926ff94fe5514e741 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:06:43 +0700 Subject: [PATCH 113/245] #631: follow the decoder rename in #639's connector check #639 lands first and reads networks with decode_styled; once #631 drops the count it calls network::decode. Needed whenever #631 is rebased onto a main that has #639. --- crates/format/src/fig/map.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 3ccf3a97..7a3dab5c 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1074,8 +1074,8 @@ impl<'a> Mapper<'a> { .get("vectorData") .and_then(|data| num(data, "vectorNetworkBlob")) .and_then(|index| self.blobs.get(index as usize)) - .and_then(|blob| network::decode_styled(blob)) - .is_some_and(|(network, _)| !network.segments.is_empty()); + .and_then(|blob| network::decode(blob)) + .is_some_and(|network| !network.segments.is_empty()); (geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some() } From f757044d8cf91e63277481018d381716f7a76b80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:08:05 +0700 Subject: [PATCH 114/245] fix/all: rustfmt the merged module list --- crates/format/src/fig/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ca01611f..0d341307 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -28,12 +28,12 @@ mod slot_tests; mod source_identity_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_paragraph_tests; mod text_path; #[cfg(test)] mod text_path_tests; #[cfg(test)] -mod text_paragraph_tests; -#[cfg(test)] mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; From d40728d0b8ca7aadaddd1b25735c1e0536de8d12 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:21:29 +0700 Subject: [PATCH 115/245] #620: import notes come from the importers' own warn() texts, translated once when the report opens --- crates/ui-egui/src/i18n.rs | 11 ++- crates/ui-egui/src/i18n_tests.rs | 37 +++++++-- crates/ui-egui/src/import_report.rs | 94 +++-------------------- crates/ui-egui/src/import_report_tests.rs | 4 +- crates/ui-egui/src/menus.rs | 8 +- 5 files changed, 57 insertions(+), 97 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d30130fb..22ea1a72 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -108,9 +108,14 @@ fn lookup(lang: usize, key: &str) -> Option<&'static Text> { /// The UI string for `key` (its English text) in the current language. One English text that needs /// two translations gets a context after `|` ("Fill|sizing"); the English catalog maps it back. pub fn tr(key: &str) -> &str { - match lookup(CURRENT.load(Ordering::Relaxed), key) { - Some(Text::Plain(s) | Text::Plural { other: s, .. }) => s, - None => key, + try_tr(key).unwrap_or(key) +} + +/// [`tr`] for text that comes from data and may have no catalog entry (an importer's notes, say): +/// `None` when the catalog doesn't have it. +pub fn try_tr(key: &str) -> Option<&str> { + match lookup(CURRENT.load(Ordering::Relaxed), key)? { + Text::Plain(s) | Text::Plural { other: s, .. } => Some(s), } } diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 07f32037..16a06a90 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -160,15 +160,38 @@ fn used_keys() -> BTreeSet { if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "i18n.rs" { continue; } - keys.extend(literals(&std::fs::read_to_string(&path).unwrap())); + keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), LOOKUPS)); } keys } -/// The first-argument string literals of the lookup functions in `src`. -fn literals(src: &str) -> Vec { +/// The importers' fixed notes: the string literals passed to `warn` in the `.fig` and REST import +/// sources. The import report shows a note translated when the catalog has it, so an entry for one +/// counts as used, and an entry for a note the importers no longer give shows up as unused. +fn importer_notes() -> BTreeSet { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../format/src"); + let mut keys = BTreeSet::new(); + for dir in ["fig", "rest"] { + for entry in std::fs::read_dir(src.join(dir)).unwrap().flatten() { + let path = entry.path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + // export.rs warns about what a `.fig` export leaves out, not about an import. + if !name.ends_with(".rs") || name.contains("tests") || name == "export.rs" { + continue; + } + keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), &["warn("])); + } + } + keys +} + +/// The lookup functions whose first argument is a catalog key. +const LOOKUPS: &[&str] = &["tr(", "trf(", "trn(", "tr_noop("]; + +/// The first-argument string literals of the functions `fns` (each with its `(`) in `src`. +fn literals(src: &str, fns: &[&str]) -> Vec { let mut out = Vec::new(); - for f in ["tr(", "trf(", "trn(", "tr_noop("] { + for f in fns { let mut from = 0; while let Some(i) = src.get(from..).and_then(|s| s.find(f)) { let at = from + i; @@ -229,7 +252,8 @@ fn every_key_in_the_code_is_in_the_english_catalog_and_no_other() { let used: BTreeSet = used_keys().into_iter().chain(dynamic_keys()).collect(); let en: BTreeSet = english().ui.iter().map(|(k, _)| k.to_string()).collect(); let missing: Vec<_> = used.difference(&en).collect(); - let unused: Vec<_> = en.difference(&used).collect(); + let notes = importer_notes(); + let unused: Vec<_> = en.difference(&used).filter(|k| !notes.contains(*k)).collect(); assert!( missing.is_empty(), "keys missing from locales/en.toml:\n{}", @@ -416,7 +440,8 @@ fn the_key_scanner_reads_rust_literals() { let src = r#"x(tr("A \"b\"")); trf( "C {n}", &[]); attr("no"); tr_noop("D\ E"); tr(label);"#; - assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]); + assert_eq!(literals(src, LOOKUPS), vec!["A \"b\"", "C {n}", "DE"]); + assert_eq!(literals(r#"self.warn("a note"); s.warn(format!("{n}")); forewarn("no")"#, &["warn("]), vec!["a note"]); } #[test] diff --git a/crates/ui-egui/src/import_report.rs b/crates/ui-egui/src/import_report.rs index 0540a2fc..5b524727 100644 --- a/crates/ui-egui/src/import_report.rs +++ b/crates/ui-egui/src/import_report.rs @@ -5,85 +5,13 @@ use egui::RichText; use serde_json::{Value, json}; use crate::FigApp; -use crate::i18n::{tr, tr_noop, trn}; +use crate::i18n::{tr, trn, try_tr}; use crate::theme::{Tokens, regular}; /// A report stays small even when a damaged input produces many or very long notes. pub const MAX_NOTES: usize = 500; pub const MAX_NOTE_CHARS: usize = 2_000; -/// The fixed notes of the `.fig` and REST importers, translated in the report. `.fig` imports name -/// "the file" where REST imports name the JSON they read. Other notes (SVG's, say) are shown as they are. -const IMPORT_NOTES: &[&str] = &[ - tr_noop("conditional prototype actions nested too deeply were skipped"), - tr_noop("conditional prototype actions with conditions that can't be evaluated were skipped"), - tr_noop("connector labels were skipped"), - tr_noop("connectors whose ends can't be placed are empty"), - tr_noop("damaged vector geometry was skipped"), - tr_noop("damaged vector networks import as outlines"), - tr_noop("document children other than pages were skipped"), - tr_noop("elbowed and curved connectors without a saved path import as straight lines"), - tr_noop("entries without a node type were skipped"), - tr_noop("filled areas of a vector with their own fill use the layer's fill"), - tr_noop("gamepad triggers without exactly one known controller button were skipped"), - tr_noop("grid tracks of fixed or content size import as equal tracks"), - tr_noop("instance swap properties whose default component isn't in the file were skipped"), - tr_noop("instances inside their own main component were imported as frames"), - tr_noop("instances of components that aren't in the file show no layers"), - tr_noop("instances of components that aren't in the file were imported as frames"), - tr_noop("instances that would nest a component inside itself were imported as frames"), - tr_noop("instances without a main component were imported as empty frames"), - tr_noop("invalid or oversized vector networks import as outlines"), - tr_noop("invalid or oversized vector path data was skipped"), - tr_noop("key triggers without a supported key were skipped"), - tr_noop("key up triggers import as key presses"), - tr_noop("layers without a position were placed at their parent's origin"), - tr_noop("layers without a type were skipped"), - tr_noop("points with their own stroke join use the layer's"), - tr_noop("prototype actions of unknown types were skipped"), - tr_noop("prototype actions without a destination in the file were skipped"), - tr_noop("prototype expressions on variables that aren't in the variables JSON were skipped"), - tr_noop("prototype expressions with unsupported functions were skipped"), - tr_noop("prototype interactions without supported actions were skipped"), - tr_noop("prototype navigations of unknown types were skipped"), - tr_noop("prototype transitions of unknown types play instantly"), - tr_noop("prototype triggers of unknown types were skipped"), - tr_noop("set-variable actions on variables that aren't in the variables JSON were skipped"), - tr_noop("set-variable actions with values that can't be evaluated were skipped"), - tr_noop("set-variable actions with values that don't fit the variable were skipped"), - tr_noop("set-variable actions without a value were skipped"), - tr_noop("set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"), - tr_noop("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"), - tr_noop("some vector segments refer to style settings missing from the file; they use the layer's stroke"), - tr_noop("stars and polygons without point counts or geometry use the default point count"), - tr_noop("style settings of some vector segments aren't supported; those segments use the layer's stroke"), - tr_noop("text style overrides refer to missing table entries"), - tr_noop("the file has no pages"), - tr_noop("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"), - tr_noop("unknown component property types were skipped"), - tr_noop("variable aliases to variables that aren't in the variables JSON (or of another type) were skipped"), - tr_noop("variable bindings to properties the layer doesn't have were skipped"), - tr_noop("variable bindings to variables that aren't in the variables JSON (library variables) were skipped"), - tr_noop( - "variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)", - ), - tr_noop("variable bindings whose variable type doesn't fit the property were skipped"), - tr_noop("variable collections without modes were skipped"), - tr_noop("variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped"), - tr_noop("variable modes of collections that aren't in the file were skipped"), - tr_noop("variable modes of collections that aren't in the variables JSON were skipped"), - tr_noop("variable values that don't match their type (or composed colours) were skipped"), - tr_noop("variables of unknown types were skipped"), - tr_noop("variables whose collection isn't in the variables JSON were skipped"), - tr_noop("variables without readable values were skipped"), - tr_noop("vectors without geometry are empty"), - tr_noop("vectors without geometry are empty (export the file JSON with geometry=paths)"), - tr_noop("very long text was cut to 1,048,576 characters"), - tr_noop("very long vector paths were skipped"), - tr_noop("video actions are not supported and were skipped"), - tr_noop("video triggers are not supported and were skipped"), -]; - #[derive(Clone, Debug)] pub struct Report { pub name: String, @@ -97,6 +25,8 @@ pub struct Report { /// The document the report belongs to: it goes when that document closes. pub uid: u64, pub notes: Vec, + /// `notes` as the window shows them, in the UI language (worked out once, not every frame). + pub shown: Vec, pub note_count: usize, } @@ -136,6 +66,7 @@ impl Report { placed: command == "file.import" && !appended, uid: 0, notes: Vec::new(), + shown: Vec::new(), note_count: 0, }; for note in warnings.iter().filter_map(Value::as_str) { @@ -151,6 +82,7 @@ impl Report { if note.chars().nth(MAX_NOTE_CHARS).is_some() { text.push('…'); } + self.shown.push(note_text(&text)); self.notes.push(text); } } @@ -161,15 +93,13 @@ impl Report { } } -/// `note` in the UI language when it is a known import note; a repeat count (` (×N)`) is kept. The -/// stored notes are lower-case fragments; a known note is shown as a sentence, so its first letter -/// is capitalised here (unknown notes are left as they are). +/// `note` in the UI language when the catalog has it (the importers' fixed notes: `.fig` imports +/// name "the file" where REST imports name the JSON they read); a repeat count (` (×N)`) is kept. +/// The stored notes are lower-case fragments; a known note is shown as a sentence, so its first +/// letter is capitalised here. Other notes (SVG's, say) are shown as they are. pub(crate) fn note_text(note: &str) -> String { let translated = |base: &str| { - if !IMPORT_NOTES.contains(&base) { - return base.to_owned(); - } - let text = tr(base); + let Some(text) = try_tr(base) else { return base.to_owned() }; let mut chars = text.chars(); chars.next().map_or_else(String::new, |first| first.to_uppercase().chain(chars).collect()) }; @@ -230,8 +160,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ); ui.add_space(4.0); egui::ScrollArea::vertical().id_salt("import_report_notes").max_height(260.0).show(ui, |ui| { - for note in &report.notes { - ui.label(note_text(note)); + for note in &report.shown { + ui.label(note); ui.add_space(4.0); } }); diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 37b51642..bc5fc8bb 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -63,8 +63,8 @@ fn note_text_keeps_the_repeat_suffix_and_unknown_notes() { fn vector_style_notes_are_known_import_notes() { for note in [ "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners", - "some vector segments refer to style settings missing from the file; they use the layer's stroke", - "style settings of some vector segments aren't supported; those segments use the layer's stroke", + "vector points with unknown stroke joins use the layer join", + "point stroke joins were preserved; dashed or variable-width strokes render with the layer join", ] { let shown = note_text(note); assert!(shown.chars().next().is_some_and(char::is_uppercase), "{shown}"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dd7ec509..31b53429 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -239,20 +239,20 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned()); let exists = path.exists(); if item(ui, &name, "", exists, false, t) && exists { - picked = Some(("file.openRecent", json!({"path": p}))); + picked = Some(json!({"path": p})); } } if !recents.is_empty() { separator(ui); } if item(ui, tr("Clear recent"), "", !recents.is_empty(), false, t) && !recents.is_empty() { - picked = Some(("file.openRecent", json!({"clear": true}))); + picked = Some(json!({"clear": true})); } picked }); - let Some((command, params)) = picked else { return false }; + let Some(params) = picked else { return false }; // Through the user path, so an import report shows; `file.openRecent` also drops a file that is gone. - if app.run_user(command, params).is_some_and(|r| r.get("index").is_some()) { + if app.run_user("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) { app.ui.home = false; } true From 01c3f0bca6b78f86c8b27474b8bf852e108a4c5b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:21:29 +0700 Subject: [PATCH 116/245] #620: the import report and its notes in every catalog; #617's notes added, stale and unshipped ones dropped --- crates/ui-egui/locales/cs.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/de.toml | 7 ++- crates/ui-egui/locales/en.toml | 5 +- crates/ui-egui/locales/es-419.toml | 3 ++ crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/id.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ja.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ko.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/pt-BR.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ru.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/sv.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/tr.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/zh-Hans.toml | 74 +++++++++++++++++++++++++++++ 14 files changed, 750 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..a13d12db 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -266,6 +266,7 @@ status = "draft — needs native review" "Commits" = "Commity" "Company" = "Společnost" "Compare changes" = "Porovnat změny" +"Compatibility notes" = "Poznámky ke kompatibilitě" "Completed" = "Dokončeno" "Component" = "Komponenta" "Component name" = "Název komponenty" @@ -472,6 +473,7 @@ status = "draft — needs native review" "False" = "Nepravda" "Feb" = "února" "Fetching from the server…" = "Stahování ze serveru…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft zatím nepodporuje všechny funkce těchto souborů. Poznámky uvádějí, co bylo v importované kopii vynecháno nebo změněno; původní soubor zůstává beze změny." "File" = "Soubor" "File name" = "Název souboru" "File not found" = "Soubor nenalezen" @@ -595,11 +597,14 @@ status = "draft — needs native review" "If {condition}" = "Pokud {condition}" "Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)" "Image" = "Obrázek" +"Images" = "Obrázky" "Import" = "Import" "Import path" = "Cesta importu" "Import plugin from manifest" = "Importovat plugin z manifestu" "Import plugin from manifest…" = "Importovat plugin z manifestu…" +"Import report" = "Zpráva o importu" "Import variables" = "Importovat proměnné" +"Imported document" = "Importovaný dokument" "Imported {name}" = "Importováno: {name}" "Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", few = "Importovány {n} nové proměnné, aktualizováno: {updated}", other = "Importováno {n} nových proměnných, aktualizováno: {updated}" } "Import…" = "Importovat…" @@ -801,6 +806,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Žádné vhodné barevné proměnné." "No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných." "No comments yet" = "Zatím žádné komentáře" +"No compatibility warnings reported" = "Nebyla hlášena žádná upozornění na kompatibilitu" "No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}." "No conflicts." = "Žádné konflikty." "No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích." @@ -892,6 +898,7 @@ status = "draft — needs native review" "Open overlay" = "Otevřít překryv" "Open prototype preview" = "Otevřít náhled prototypu" "Open variables ({count})" = "Otevřít proměnné ({count})" +"Opened as a new document" = "Otevřeno jako nový dokument" "Opened “{name}” in a new tab" = "Soubor „{name}“ otevřen v nové kartě" "Open|comments" = "Otevřené" "Options" = "Možnosti" @@ -916,6 +923,8 @@ status = "draft — needs native review" "Padding" = "Vnitřní odsazení" "Page" = "Stránka" "Pages" = "Stránky" +"Pages added to the document" = "Stránky přidány do dokumentu" +"Placed on the current page" = "Umístěno na aktuální stránku" "Pages: {pages}" = "Stránky: {pages}" "Paint bucket" = "Plechovka barvy" "Pan the canvas" = "Posunout plátno" @@ -1485,26 +1494,90 @@ status = "draft — needs native review" "call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními" "call only: {commands}" = "volat pouze: {commands}" "can edit the document" = "může upravovat dokument" +"conditional prototype actions nested too deeply were skipped" = "podmíněné akce prototypu vnořené příliš hluboko byly přeskočeny" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "podmíněné akce prototypu s podmínkami, které nelze vyhodnotit, byly přeskočeny" +"connector labels were skipped" = "popisky spojnic byly přeskočeny" +"connectors whose ends can't be placed are empty" = "spojnice, jejichž konce nelze umístit, jsou prázdné" "crop marks" = "ořezové značky" +"damaged vector geometry was skipped" = "poškozená vektorová geometrie byla přeskočena" +"damaged vector networks import as outlines" = "poškozené vektorové sítě se importují jako obrysy" "development: {path}" = "vývoj: {path}" +"document children other than pages were skipped" = "potomci dokumentu, kteří nejsou stránkami, byli přeskočeni" "e.g. count + 1" = "např. count + 1" "e.g. count >= 3 and not done" = "např. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky" +"entries without a node type were skipped" = "položky bez typu uzlu byly přeskočeny" "figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci." +"filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy" "from {server}" = "ze serveru {server}" +"gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny" +"grid tracks of fixed or content size import as equal tracks" = "stopy mřížky s pevnou velikostí nebo velikostí podle obsahu se importují jako stejné stopy" "hidden" = "skryté" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "vlastnosti výměny instance, jejichž výchozí komponenta není v souboru, byly přeskočeny" +"instances inside their own main component were imported as frames" = "instance uvnitř vlastní hlavní komponenty byly importovány jako rámce" +"instances of components that aren't in the file show no layers" = "instance komponent, které nejsou v souboru, nezobrazují žádné vrstvy" +"instances of components that aren't in the file were imported as frames" = "instance komponent, které nejsou v souboru, byly importovány jako rámce" +"instances that would nest a component inside itself were imported as frames" = "instance, které by vnořily komponentu samu do sebe, byly importovány jako rámce" +"instances without a main component were imported as empty frames" = "instance bez hlavní komponenty byly importovány jako prázdné rámce" +"invalid or oversized vector networks import as outlines" = "neplatné nebo příliš velké vektorové sítě se importují jako obrysy" +"invalid or oversized vector path data was skipped" = "neplatná nebo příliš velká data vektorových cest byla přeskočena" "just now" = "právě teď" +"key triggers without a supported key were skipped" = "spouštěče kláves bez podporované klávesy byly přeskočeny" +"key up triggers import as key presses" = "spouštěče uvolnění klávesy se importují jako stisky klávesy" +"layers without a position were placed at their parent's origin" = "vrstvy bez pozice byly umístěny do počátku nadřazeného prvku" +"layers without a type were skipped" = "vrstvy bez typu byly přeskočeny" "locked" = "zamknuté" "no bleed" = "bez spadávky" "nobody else here yet" = "zatím tu není nikdo další" "now" = "nyní" "or" = "nebo" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "spojení tahu v bodech byla zachována; přerušované tahy a tahy s proměnnou šířkou se kreslí se spojením vrstvy" +"prototype actions of unknown types were skipped" = "akce prototypu neznámých typů byly přeskočeny" +"prototype actions without a destination in the file were skipped" = "akce prototypu bez cíle v souboru byly přeskočeny" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "výrazy prototypu s proměnnými, které nejsou v JSON proměnných, byly přeskočeny" +"prototype expressions with unsupported functions were skipped" = "výrazy prototypu s nepodporovanými funkcemi byly přeskočeny" +"prototype interactions without supported actions were skipped" = "interakce prototypu bez podporovaných akcí byly přeskočeny" +"prototype navigations of unknown types were skipped" = "navigace prototypu neznámých typů byly přeskočeny" +"prototype transitions of unknown types play instantly" = "přechody prototypu neznámých typů proběhnou okamžitě" +"prototype triggers of unknown types were skipped" = "spouštěče prototypu neznámých typů byly přeskočeny" "put a widget on the canvas" = "umístit widget na plátno" "read and edit the document" = "číst a upravovat dokument" "read the document" = "číst dokument" "reads the document" = "čte dokument" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "akce nastavení proměnné u proměnných, které nejsou v JSON proměnných, byly přeskočeny" +"set-variable actions with values that can't be evaluated were skipped" = "akce nastavení proměnné s hodnotami, které nelze vyhodnotit, byly přeskočeny" +"set-variable actions with values that don't fit the variable were skipped" = "akce nastavení proměnné s hodnotami, které neodpovídají proměnné, byly přeskočeny" +"set-variable actions without a value were skipped" = "akce nastavení proměnné bez hodnoty byly přeskočeny" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akce nastavení režimu proměnných u kolekcí nebo režimů, které nejsou v JSON proměnných, byly přeskočeny" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "některé vektorové body odkazují na nastavení bodů, která v souboru chybí; používají spojení vrstvy a nezaoblené rohy" +"stars and polygons without point counts or geometry use the default point count" = "hvězdy a mnohoúhelníky bez počtu bodů nebo geometrie používají výchozí počet bodů" +"text style overrides refer to missing table entries" = "přepsání textových stylů odkazují na chybějící položky tabulky" +"the file has no pages" = "soubor nemá žádné stránky" "the plugin" = "plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON proměnných nemá `variableCollections` (očekávala se odpověď s místními proměnnými) a byl ignorován" "this session only" = "pouze v této relaci" +"unknown component property types were skipped" = "neznámé typy vlastností komponent byly přeskočeny" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliasy na proměnné, které nejsou v JSON proměnných (nebo jsou jiného typu), byly přeskočeny" +"variable bindings to properties the layer doesn't have were skipped" = "vazby proměnných na vlastnosti, které vrstva nemá, byly přeskočeny" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "vazby na proměnné, které nejsou v JSON proměnných (proměnné knihovny), byly přeskočeny" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "vazby proměnných byly přeskočeny: JSON souboru nemá definice proměnných (předejte JSON místních proměnných jako `variables` nebo vložte jeho `meta` vedle JSON souboru)" +"variable bindings whose variable type doesn't fit the property were skipped" = "vazby proměnných, jejichž typ neodpovídá vlastnosti, byly přeskočeny" +"variable collections without modes were skipped" = "kolekce proměnných bez režimů byly přeskočeny" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "vyhledání režimu proměnné ve výrazech prototypu, které nejsou proměnnou a jedním z jejích režimů, byla přeskočena" +"variable modes of collections that aren't in the file were skipped" = "režimy proměnných kolekcí, které nejsou v souboru, byly přeskočeny" +"variable modes of collections that aren't in the variables JSON were skipped" = "režimy proměnných kolekcí, které nejsou v JSON proměnných, byly přeskočeny" +"variable values that don't match their type (or composed colours) were skipped" = "hodnoty proměnných, které neodpovídají svému typu (nebo složené barvy), byly přeskočeny" +"variables of unknown types were skipped" = "proměnné neznámých typů byly přeskočeny" +"variables whose collection isn't in the variables JSON were skipped" = "proměnné, jejichž kolekce není v JSON proměnných, byly přeskočeny" +"variables without readable values were skipped" = "proměnné bez čitelných hodnot byly přeskočeny" +"vector points with unknown stroke joins use the layer join" = "vektorové body s neznámým spojením tahu používají spojení vrstvy" +"vectors without geometry are empty" = "vektory bez geometrie jsou prázdné" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektory bez geometrie jsou prázdné (exportujte JSON souboru s geometry=paths)" +"very long text was cut to 1,048,576 characters" = "velmi dlouhý text byl zkrácen na 1 048 576 znaků" +"very long vector paths were skipped" = "velmi dlouhé vektorové cesty byly přeskočeny" +"video actions are not supported and were skipped" = "akce videa nejsou podporovány a byly přeskočeny" +"video triggers are not supported and were skipped" = "spouštěče videa nejsou podporovány a byly přeskočeny" "yesterday" = "včera" "{instances} in {files}" = "{instances} · {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: návrh překladu, čeká na kontrolu rodilým mluvčím." @@ -1549,6 +1622,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" } "{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" } "{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" } +"{n} more notes omitted from this report" = { one = "{n} další poznámka v této zprávě vynechána", few = "{n} další poznámky v této zprávě vynechány", other = "{n} dalších poznámek v této zprávě vynecháno" } "{n} of {total}" = "{n} z {total}" "{n} people joined" = { one = "Připojila se {n} osoba", few = "Připojily se {n} osoby", other = "Připojilo se {n} osob" } "{n} preferred components" = { one = "{n} preferovaná komponenta", few = "{n} preferované komponenty", other = "{n} preferovaných komponent" } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 520d4ba6..4a28a7fc 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1481,7 +1481,7 @@ status = "draft — needs native review" "nobody else here yet" = "noch niemand sonst hier" "now" = "jetzt" "or" = "oder" -"points with their own stroke join use the layer's" = "Punkte mit eigener Konturecke verwenden die der Ebene" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "die Konturecken der Punkte wurden übernommen; gestrichelte Konturen und Konturen mit variabler Breite werden mit der Ecke der Ebene gezeichnet" "prototype actions of unknown types were skipped" = "Prototyp-Aktionen unbekannter Art wurden übersprungen" "prototype actions without a destination in the file were skipped" = "Prototyp-Aktionen ohne Ziel in der Datei wurden übersprungen" "prototype expressions on variables that aren't in the variables JSON were skipped" = "Prototyp-Ausdrücke mit Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" @@ -1499,10 +1499,8 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" "set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen" -"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie verwenden die Konturecken der Ebene und einfache Ecken" -"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "einige Vektorsegmente verweisen auf Stileinstellungen, die in der Datei fehlen; sie verwenden die Kontur der Ebene" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie übernehmen die Ecken der Ebenenkontur und bleiben ungerundet" "stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl" -"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "Stileinstellungen einiger Vektorsegmente werden nicht unterstützt; diese Segmente verwenden die Kontur der Ebene" "text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge" "the file has no pages" = "die Datei hat keine Seiten" "the plugin" = "das Plugin" @@ -1522,6 +1520,7 @@ status = "draft — needs native review" "variables of unknown types were skipped" = "Variablen unbekannter Typen wurden übersprungen" "variables whose collection isn't in the variables JSON were skipped" = "Variablen, deren Sammlung nicht im Variablen-JSON ist, wurden übersprungen" "variables without readable values were skipped" = "Variablen ohne lesbare Werte wurden übersprungen" +"vector points with unknown stroke joins use the layer join" = "Vektorpunkte mit unbekannten Konturecken verwenden die Ecke der Ebene" "vectors without geometry are empty" = "Vektoren ohne Geometrie sind leer" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "Vektoren ohne Geometrie sind leer (exportieren Sie das Datei-JSON mit geometry=paths)" "very long text was cut to 1,048,576 characters" = "sehr langer Text wurde auf 1.048.576 Zeichen gekürzt" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 50045c1e..de22405d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1555,7 +1555,7 @@ name = "English" "nobody else here yet" = "nobody else here yet" "now" = "now" "or" = "or" -"points with their own stroke join use the layer's" = "points with their own stroke join use the layer's" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" "prototype actions of unknown types were skipped" = "prototype actions of unknown types were skipped" "prototype actions without a destination in the file were skipped" = "prototype actions without a destination in the file were skipped" "prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype expressions on variables that aren't in the variables JSON were skipped" @@ -1574,9 +1574,7 @@ name = "English" "set-variable actions without a value were skipped" = "set-variable actions without a value were skipped" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" -"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" "stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count" -"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke" "text style overrides refer to missing table entries" = "text style overrides refer to missing table entries" "the file has no pages" = "the file has no pages" "the plugin" = "the plugin" @@ -1596,6 +1594,7 @@ name = "English" "variables of unknown types were skipped" = "variables of unknown types were skipped" "variables whose collection isn't in the variables JSON were skipped" = "variables whose collection isn't in the variables JSON were skipped" "variables without readable values were skipped" = "variables without readable values were skipped" +"vector points with unknown stroke joins use the layer join" = "vector points with unknown stroke joins use the layer join" "vectors without geometry are empty" = "vectors without geometry are empty" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectors without geometry are empty (export the file JSON with geometry=paths)" "very long text was cut to 1,048,576 characters" = "very long text was cut to 1,048,576 characters" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 6b42ac20..74ce97a8 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -66,6 +66,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y presiona {keys}." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Agrega un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo." "PDF print settings" = "Configuración de impresión PDF" +"Pages added to the document" = "Páginas agregadas al documento" "Press a key…" = "Presiona una tecla…" "Prototype settings" = "Configuración del prototipo" "Remove export setting" = "Quitar configuración de exportación" @@ -80,6 +81,8 @@ status = "draft — needs native review" "Type settings" = "Configuración de texto" "With the layers' export settings" = "Con la configuración de exportación de las capas" "You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editarán en conjunto." +"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de control sin exactamente un botón de control conocido" +"key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como presiones de tecla" "{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en esta computadora; el archivo se queda en {server}" "{n} added" = { one = "{n} agregada", other = "{n} agregadas" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 8abaa332..3f08968b 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1556,7 +1556,7 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "now" = "ahora" "or" = "o" -"points with their own stroke join use the layer's" = "los puntos con su propia unión de trazo usan la de la capa" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" "prototype actions of unknown types were skipped" = "se omitieron las acciones de prototipo de tipos desconocidos" "prototype actions without a destination in the file were skipped" = "se omitieron las acciones de prototipo sin destino en el archivo" "prototype expressions on variables that aren't in the variables JSON were skipped" = "se omitieron las expresiones de prototipo con variables que no están en el JSON de variables" @@ -1575,9 +1575,7 @@ status = "draft — needs native review" "set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples" -"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa" "stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado" -"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "los ajustes de estilo de algunos segmentos vectoriales no son compatibles; esos segmentos usan el trazo de la capa" "text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan" "the file has no pages" = "el archivo no tiene páginas" "the plugin" = "el plugin" @@ -1597,6 +1595,7 @@ status = "draft — needs native review" "variables of unknown types were skipped" = "se omitieron las variables de tipos desconocidos" "variables whose collection isn't in the variables JSON were skipped" = "se omitieron las variables cuya colección no está en el JSON de variables" "variables without readable values were skipped" = "se omitieron las variables sin valores legibles" +"vector points with unknown stroke joins use the layer join" = "los puntos vectoriales con uniones de trazo desconocidas usan la unión de la capa" "vectors without geometry are empty" = "los vectores sin geometría están vacíos" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "los vectores sin geometría están vacíos (exporta el JSON del archivo con geometry=paths)" "very long text was cut to 1,048,576 characters" = "el texto muy largo se recortó a 1.048.576 caracteres" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..e1961711 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -260,6 +260,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Entreprise" "Compare changes" = "Comparer les modifications" +"Compatibility notes" = "Notes de compatibilité" "Completed" = "Terminé" "Component" = "Composant" "Component name" = "Nom du composant" @@ -460,6 +461,7 @@ status = "draft — needs native review" "False" = "Faux" "Fetching from the server…" = "Récupération depuis le serveur…" "FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé." "File" = "Fichier" "File name" = "Nom du fichier" "File published" = "Fichier publié" @@ -576,11 +578,14 @@ status = "draft — needs native review" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" "Image" = "Image" +"Images" = "Images" "Import" = "Importer" "Import path" = "Chemin d’importation" "Import plugin from manifest" = "Importer un plugin depuis un manifeste" "Import plugin from manifest…" = "Importer un plugin depuis un manifeste…" +"Import report" = "Rapport d’importation" "Import variables" = "Importer des variables" +"Imported document" = "Document importé" "Imported {name}" = "{name} importé" "Imported {n} new variables, updated {updated}" = { one = "{n} nouvelle variable importée, {updated} mises à jour", other = "{n} nouvelles variables importées, {updated} mises à jour" } "Import…" = "Importer…" @@ -770,6 +775,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Aucune variable de couleur ne convient ici." "No colour variables yet. Create them in Local variables." = "Aucune variable de couleur pour l’instant. Créez-en dans Variables locales." "No comments yet" = "Aucun commentaire pour l’instant" +"No compatibility warnings reported" = "Aucun avertissement de compatibilité signalé" "No components yet. Select layers and press {keys}." = "Aucun composant pour l’instant. Sélectionnez des calques et appuyez sur {keys}." "No conflicts." = "Aucun conflit." "No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs." @@ -856,6 +862,7 @@ status = "draft — needs native review" "Open overlay" = "Ouvrir une superposition" "Open prototype preview" = "Ouvrir l’aperçu du prototype" "Open variables ({count})" = "Ouvrir les variables ({count})" +"Opened as a new document" = "Ouvert comme nouveau document" "Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet" "Open|comments" = "Ouverts" "Options" = "Options" @@ -879,6 +886,8 @@ status = "draft — needs native review" "Padding" = "Marge intérieure" "Page" = "Page" "Pages" = "Pages" +"Pages added to the document" = "Pages ajoutées au document" +"Placed on the current page" = "Placé sur la page actuelle" "Pages: {pages}" = "Pages : {pages}" "Paint bucket" = "Pot de peinture" "Pan the canvas" = "Faire défiler le canevas" @@ -1426,25 +1435,89 @@ status = "draft — needs native review" "call any command its access allows" = "appeler toute commande que son accès autorise" "call only: {commands}" = "appeler seulement : {commands}" "can edit the document" = "peut modifier le document" +"conditional prototype actions nested too deeply were skipped" = "les actions de prototype conditionnelles trop profondément imbriquées ont été ignorées" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "les actions de prototype conditionnelles dont les conditions ne peuvent pas être évaluées ont été ignorées" +"connector labels were skipped" = "les libellés des connecteurs ont été ignorés" +"connectors whose ends can't be placed are empty" = "les connecteurs dont les extrémités ne peuvent pas être placées sont vides" "crop marks" = "traits de coupe" +"damaged vector geometry was skipped" = "la géométrie vectorielle endommagée a été ignorée" +"damaged vector networks import as outlines" = "les réseaux vectoriels endommagés sont importés comme tracés" "development: {path}" = "développement : {path}" +"document children other than pages were skipped" = "les enfants du document autres que des pages ont été ignorés" "e.g. count + 1" = "p. ex. count + 1" "e.g. count >= 3 and not done" = "p. ex. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites" +"entries without a node type were skipped" = "les entrées sans type de nœud ont été ignorées" "figcraft:// links from a browser, chat or email open this app and join the session." = "Les liens figcraft:// d’un navigateur, d’une messagerie ou d’un e-mail ouvrent cette application et rejoignent la session." +"filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque" "from {server}" = "depuis {server}" +"gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés" +"grid tracks of fixed or content size import as equal tracks" = "les pistes de grille de taille fixe ou ajustée au contenu sont importées comme des pistes égales" "hidden" = "masqué" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "les propriétés de permutation d’instance dont le composant par défaut n’est pas dans le fichier ont été ignorées" +"instances inside their own main component were imported as frames" = "les instances placées dans leur propre composant principal ont été importées comme cadres" +"instances of components that aren't in the file show no layers" = "les instances de composants absents du fichier n’affichent aucun calque" +"instances of components that aren't in the file were imported as frames" = "les instances de composants absents du fichier ont été importées comme cadres" +"instances that would nest a component inside itself were imported as frames" = "les instances qui imbriqueraient un composant dans lui-même ont été importées comme cadres" +"instances without a main component were imported as empty frames" = "les instances sans composant principal ont été importées comme cadres vides" +"invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés" +"invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées" +"key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés" +"key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche" +"layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent" +"layers without a type were skipped" = "les calques sans type ont été ignorés" "locked" = "verrouillé" "no bleed" = "sans fond perdu" "nobody else here yet" = "personne d’autre pour l’instant" "now" = "maintenant" "or" = "ou" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "les jointures des points ont été conservées ; les contours en pointillés ou à largeur variable sont dessinés avec la jointure du calque" +"prototype actions of unknown types were skipped" = "les actions de prototype de types inconnus ont été ignorées" +"prototype actions without a destination in the file were skipped" = "les actions de prototype sans destination dans le fichier ont été ignorées" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "les expressions de prototype portant sur des variables absentes du JSON des variables ont été ignorées" +"prototype expressions with unsupported functions were skipped" = "les expressions de prototype utilisant des fonctions non prises en charge ont été ignorées" +"prototype interactions without supported actions were skipped" = "les interactions de prototype sans action prise en charge ont été ignorées" +"prototype navigations of unknown types were skipped" = "les navigations de prototype de types inconnus ont été ignorées" +"prototype transitions of unknown types play instantly" = "les transitions de prototype de types inconnus sont instantanées" +"prototype triggers of unknown types were skipped" = "les déclencheurs de prototype de types inconnus ont été ignorés" "put a widget on the canvas" = "placer un widget sur le canevas" "read and edit the document" = "lire et modifier le document" "read the document" = "lire le document" "reads the document" = "lit le document" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "les actions de définition de variable portant sur des variables absentes du JSON des variables ont été ignorées" +"set-variable actions with values that can't be evaluated were skipped" = "les actions de définition de variable dont les valeurs ne peuvent pas être évaluées ont été ignorées" +"set-variable actions with values that don't fit the variable were skipped" = "les actions de définition de variable dont les valeurs ne correspondent pas à la variable ont été ignorées" +"set-variable actions without a value were skipped" = "les actions de définition de variable sans valeur ont été ignorées" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "les actions de définition du mode de variable portant sur des collections ou des modes absents du JSON des variables ont été ignorées" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "certains points vectoriels renvoient à des réglages de point absents du fichier ; ils utilisent les jointures du calque et des angles simples" +"stars and polygons without point counts or geometry use the default point count" = "les étoiles et polygones sans nombre de pointes ni géométrie utilisent le nombre de pointes par défaut" +"text style overrides refer to missing table entries" = "les remplacements de style de texte renvoient à des entrées de table manquantes" +"the file has no pages" = "le fichier n’a aucune page" "the plugin" = "le plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "le JSON des variables n’a pas de `variableCollections` (la réponse des variables locales était attendue) et a été ignoré" "this session only" = "cette session uniquement" +"unknown component property types were skipped" = "les types de propriété de composant inconnus ont été ignorés" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "les alias vers des variables absentes du JSON des variables (ou d’un autre type) ont été ignorés" +"variable bindings to properties the layer doesn't have were skipped" = "les liaisons de variables à des propriétés que le calque n’a pas ont été ignorées" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "les liaisons à des variables absentes du JSON des variables (variables de bibliothèque) ont été ignorées" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "les liaisons de variables ont été ignorées : le JSON du fichier n’a aucune définition de variable (transmettez le JSON des variables locales comme `variables`, ou intégrez son `meta` à côté du JSON du fichier)" +"variable bindings whose variable type doesn't fit the property were skipped" = "les liaisons de variables dont le type de variable ne convient pas à la propriété ont été ignorées" +"variable collections without modes were skipped" = "les collections de variables sans mode ont été ignorées" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "les recherches de mode de variable dans les expressions de prototype qui ne sont pas une variable et l’un de ses modes ont été ignorées" +"variable modes of collections that aren't in the file were skipped" = "les modes de variables de collections absentes du fichier ont été ignorés" +"variable modes of collections that aren't in the variables JSON were skipped" = "les modes de variables de collections absentes du JSON des variables ont été ignorés" +"variable values that don't match their type (or composed colours) were skipped" = "les valeurs de variables qui ne correspondent pas à leur type (ou les couleurs composées) ont été ignorées" +"variables of unknown types were skipped" = "les variables de types inconnus ont été ignorées" +"variables whose collection isn't in the variables JSON were skipped" = "les variables dont la collection est absente du JSON des variables ont été ignorées" +"variables without readable values were skipped" = "les variables sans valeurs lisibles ont été ignorées" +"vector points with unknown stroke joins use the layer join" = "les points vectoriels aux jointures inconnues utilisent la jointure du calque" +"vectors without geometry are empty" = "les vecteurs sans géométrie sont vides" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "les vecteurs sans géométrie sont vides (exportez le JSON du fichier avec geometry=paths)" +"very long text was cut to 1,048,576 characters" = "le texte très long a été coupé à 1 048 576 caractères" +"very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés" +"video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées" +"video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés" "{instances} in {files}" = "{instances} dans {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif." "{length} bleed" = "fond perdu de {length}" @@ -1487,6 +1560,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} résultat", other = "{n} résultats" } "{n} minutes ago" = { one = "il y a {n} minute", other = "il y a {n} minutes" } "{n} months ago" = { one = "il y a {n} mois", other = "il y a {n} mois" } +"{n} more notes omitted from this report" = { one = "{n} autre note omise de ce rapport", other = "{n} autres notes omises de ce rapport" } "{n} of {total}" = "{n} sur {total}" "{n} people joined" = { one = "{n} personne a rejoint la session", other = "{n} personnes ont rejoint la session" } "{n} preferred components" = { one = "{n} composant préféré", other = "{n} composants préférés" } diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..89475579 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -270,6 +270,7 @@ status = "draft — needs native review" "Commits" = "Commit" "Company" = "Perusahaan" "Compare changes" = "Bandingkan perubahan" +"Compatibility notes" = "Catatan kompatibilitas" "Completed" = "Selesai" "Component" = "Component" "Component name" = "Nama component" @@ -476,6 +477,7 @@ status = "draft — needs native review" "False" = "Salah" "Feb" = "Feb" "Fetching from the server…" = "Mengambil dari server…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft belum mendukung semua fitur file ini. Catatan berisi apa yang dihilangkan atau diubah pada salinan yang diimpor; file aslinya tidak berubah." "File" = "File" "File name" = "Nama file" "File not found" = "File tidak ditemukan" @@ -599,11 +601,14 @@ status = "draft — needs native review" "If {condition}" = "Jika {condition}" "Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)" "Image" = "Gambar" +"Images" = "Gambar" "Import" = "Impor" "Import path" = "Jalur impor" "Import plugin from manifest" = "Impor plugin dari manifest" "Import plugin from manifest…" = "Impor plugin dari manifest…" +"Import report" = "Laporan impor" "Import variables" = "Impor variabel" +"Imported document" = "Dokumen yang diimpor" "Imported {name}" = "{name} diimpor" "Imported {n} new variables, updated {updated}" = { other = "{n} variabel baru diimpor, {updated} diperbarui" } "Import…" = "Impor…" @@ -805,6 +810,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini." "No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal." "No comments yet" = "Belum ada komentar" +"No compatibility warnings reported" = "Tidak ada peringatan kompatibilitas" "No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}." "No conflicts." = "Tidak ada konflik." "No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini." @@ -896,6 +902,7 @@ status = "draft — needs native review" "Open overlay" = "Buka overlay" "Open prototype preview" = "Buka pratinjau prototype" "Open variables ({count})" = "Buka variabel ({count})" +"Opened as a new document" = "Dibuka sebagai dokumen baru" "Opened “{name}” in a new tab" = "“{name}” dibuka di tab baru" "Open|comments" = "Terbuka" "Options" = "Opsi" @@ -920,6 +927,8 @@ status = "draft — needs native review" "Padding" = "Padding" "Page" = "Halaman" "Pages" = "Halaman" +"Pages added to the document" = "Halaman ditambahkan ke dokumen" +"Placed on the current page" = "Ditempatkan di halaman saat ini" "Pages: {pages}" = "Halaman: {pages}" "Paint bucket" = "Ember cat" "Pan the canvas" = "Geser kanvas" @@ -1489,26 +1498,90 @@ status = "draft — needs native review" "call any command its access allows" = "memanggil perintah apa pun yang diizinkan aksesnya" "call only: {commands}" = "hanya memanggil: {commands}" "can edit the document" = "dapat mengedit dokumen" +"conditional prototype actions nested too deeply were skipped" = "tindakan prototype kondisional yang bersarang terlalu dalam dilewati" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "tindakan prototype kondisional dengan kondisi yang tidak dapat dievaluasi dilewati" +"connector labels were skipped" = "label konektor dilewati" +"connectors whose ends can't be placed are empty" = "konektor yang ujungnya tidak dapat ditempatkan kosong" "crop marks" = "tanda potong" +"damaged vector geometry was skipped" = "geometri vector yang rusak dilewati" +"damaged vector networks import as outlines" = "jaringan vector yang rusak diimpor sebagai outline" "development: {path}" = "pengembangan: {path}" +"document children other than pages were skipped" = "anak dokumen selain halaman dilewati" "e.g. count + 1" = "mis. count + 1" "e.g. count >= 3 and not done" = "mis. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus" +"entries without a node type were skipped" = "entri tanpa tipe node dilewati" "figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi." +"filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer" "from {server}" = "dari {server}" +"gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati" +"grid tracks of fixed or content size import as equal tracks" = "track grid berukuran tetap atau mengikuti konten diimpor sebagai track yang sama besar" "hidden" = "tersembunyi" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "properti tukar instance yang component bawaannya tidak ada di file dilewati" +"instances inside their own main component were imported as frames" = "instance di dalam main component-nya sendiri diimpor sebagai frame" +"instances of components that aren't in the file show no layers" = "instance dari component yang tidak ada di file tidak menampilkan layer" +"instances of components that aren't in the file were imported as frames" = "instance dari component yang tidak ada di file diimpor sebagai frame" +"instances that would nest a component inside itself were imported as frames" = "instance yang akan menyarangkan component di dalam dirinya sendiri diimpor sebagai frame" +"instances without a main component were imported as empty frames" = "instance tanpa main component diimpor sebagai frame kosong" +"invalid or oversized vector networks import as outlines" = "jaringan vector yang tidak valid atau terlalu besar diimpor sebagai outline" +"invalid or oversized vector path data was skipped" = "data jalur vector yang tidak valid atau terlalu besar dilewati" "just now" = "baru saja" +"key triggers without a supported key were skipped" = "pemicu tombol tanpa tombol yang didukung dilewati" +"key up triggers import as key presses" = "pemicu saat tombol dilepas diimpor sebagai penekanan tombol" +"layers without a position were placed at their parent's origin" = "layer tanpa posisi ditempatkan di titik asal induknya" +"layers without a type were skipped" = "layer tanpa tipe dilewati" "locked" = "terkunci" "no bleed" = "tanpa bleed" "nobody else here yet" = "belum ada orang lain di sini" "now" = "sekarang" "or" = "atau" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "sambungan stroke per titik dipertahankan; stroke putus-putus atau berlebar variabel digambar dengan sambungan layer" +"prototype actions of unknown types were skipped" = "tindakan prototype dengan tipe yang tidak dikenal dilewati" +"prototype actions without a destination in the file were skipped" = "tindakan prototype tanpa tujuan di file dilewati" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "ekspresi prototype pada variabel yang tidak ada di JSON variabel dilewati" +"prototype expressions with unsupported functions were skipped" = "ekspresi prototype dengan fungsi yang tidak didukung dilewati" +"prototype interactions without supported actions were skipped" = "interaksi prototype tanpa tindakan yang didukung dilewati" +"prototype navigations of unknown types were skipped" = "navigasi prototype dengan tipe yang tidak dikenal dilewati" +"prototype transitions of unknown types play instantly" = "transisi prototype dengan tipe yang tidak dikenal berjalan instan" +"prototype triggers of unknown types were skipped" = "pemicu prototype dengan tipe yang tidak dikenal dilewati" "put a widget on the canvas" = "menaruh widget di kanvas" "read and edit the document" = "membaca dan mengedit dokumen" "read the document" = "membaca dokumen" "reads the document" = "membaca dokumen" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "tindakan atur variabel pada variabel yang tidak ada di JSON variabel dilewati" +"set-variable actions with values that can't be evaluated were skipped" = "tindakan atur variabel dengan nilai yang tidak dapat dievaluasi dilewati" +"set-variable actions with values that don't fit the variable were skipped" = "tindakan atur variabel dengan nilai yang tidak cocok dengan variabelnya dilewati" +"set-variable actions without a value were skipped" = "tindakan atur variabel tanpa nilai dilewati" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "tindakan atur mode variabel pada koleksi atau mode yang tidak ada di JSON variabel dilewati" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "beberapa titik vector merujuk ke pengaturan titik yang tidak ada di file; titik tersebut memakai sambungan layer dan sudut biasa" +"stars and polygons without point counts or geometry use the default point count" = "bintang dan poligon tanpa jumlah titik atau geometri memakai jumlah titik bawaan" +"text style overrides refer to missing table entries" = "penimpaan gaya teks merujuk ke entri tabel yang tidak ada" +"the file has no pages" = "file tidak memiliki halaman" "the plugin" = "plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON variabel tidak memiliki `variableCollections` (yang diharapkan adalah respons variabel lokal) dan diabaikan" "this session only" = "hanya sesi ini" +"unknown component property types were skipped" = "tipe properti component yang tidak dikenal dilewati" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "alias ke variabel yang tidak ada di JSON variabel (atau bertipe lain) dilewati" +"variable bindings to properties the layer doesn't have were skipped" = "binding variabel ke properti yang tidak dimiliki layer dilewati" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "binding ke variabel yang tidak ada di JSON variabel (variabel pustaka) dilewati" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "binding variabel dilewati: JSON file tidak memiliki definisi variabel (berikan JSON variabel lokal sebagai `variables`, atau sematkan `meta`-nya di samping JSON file)" +"variable bindings whose variable type doesn't fit the property were skipped" = "binding variabel yang tipe variabelnya tidak cocok dengan properti dilewati" +"variable collections without modes were skipped" = "koleksi variabel tanpa mode dilewati" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "pencarian mode variabel dalam ekspresi prototype yang bukan berupa variabel dan salah satu modenya dilewati" +"variable modes of collections that aren't in the file were skipped" = "mode variabel dari koleksi yang tidak ada di file dilewati" +"variable modes of collections that aren't in the variables JSON were skipped" = "mode variabel dari koleksi yang tidak ada di JSON variabel dilewati" +"variable values that don't match their type (or composed colours) were skipped" = "nilai variabel yang tidak sesuai dengan tipenya (atau warna gabungan) dilewati" +"variables of unknown types were skipped" = "variabel dengan tipe yang tidak dikenal dilewati" +"variables whose collection isn't in the variables JSON were skipped" = "variabel yang koleksinya tidak ada di JSON variabel dilewati" +"variables without readable values were skipped" = "variabel tanpa nilai yang dapat dibaca dilewati" +"vector points with unknown stroke joins use the layer join" = "titik vector dengan sambungan stroke yang tidak dikenal memakai sambungan layer" +"vectors without geometry are empty" = "vector tanpa geometri kosong" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vector tanpa geometri kosong (ekspor JSON file dengan geometry=paths)" +"very long text was cut to 1,048,576 characters" = "teks yang sangat panjang dipotong menjadi 1.048.576 karakter" +"very long vector paths were skipped" = "jalur vector yang sangat panjang dilewati" +"video actions are not supported and were skipped" = "tindakan video tidak didukung dan dilewati" +"video triggers are not supported and were skipped" = "pemicu video tidak didukung dan dilewati" "yesterday" = "kemarin" "{instances} in {files}" = "{instances} di {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: terjemahan draf, menunggu tinjauan penutur asli." @@ -1553,6 +1626,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n} kecocokan" } "{n} minutes ago" = { other = "{n} menit lalu" } "{n} months ago" = { other = "{n} bulan lalu" } +"{n} more notes omitted from this report" = { other = "{n} catatan lainnya tidak dicantumkan dalam laporan ini" } "{n} of {total}" = "{n} dari {total}" "{n} people joined" = { other = "{n} orang bergabung" } "{n} preferred components" = { other = "{n} component pilihan" } diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..c337eb38 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -272,6 +272,7 @@ status = "draft — needs native review" "Commits" = "コミット" "Company" = "会社" "Compare changes" = "変更を比較" +"Compatibility notes" = "互換性に関する注記" "Completed" = "完了" "Component" = "コンポーネント" "Component name" = "コンポーネント名" @@ -480,6 +481,7 @@ status = "draft — needs native review" "Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" "FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft はこれらのファイルの機能をまだすべてサポートしていない。注記には、インポートしたコピーで省略または変更された内容を示す。元のファイルは変更されない。" "File" = "ファイル" "File name" = "ファイル名" "File not found" = "ファイルが見つからない" @@ -604,11 +606,14 @@ status = "draft — needs native review" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" "Image" = "画像" +"Images" = "画像" "Import" = "インポート" "Import path" = "インポートパス" "Import plugin from manifest" = "マニフェストからプラグインをインポート" "Import plugin from manifest…" = "マニフェストからプラグインをインポート…" +"Import report" = "インポートレポート" "Import variables" = "変数をインポート" +"Imported document" = "インポートしたドキュメント" "Imported {name}" = "{name} をインポート済み" "Imported {n} new variables, updated {updated}" = { other = "新しい変数を {n} 個インポートし、{updated} 個を更新" } "Import…" = "インポート…" @@ -810,6 +815,7 @@ status = "draft — needs native review" "No colour variables fit here." = "ここに使えるカラー変数はない。" "No colour variables yet. Create them in Local variables." = "カラー変数はまだない。ローカル変数で作成。" "No comments yet" = "コメントはまだない" +"No compatibility warnings reported" = "互換性の警告はない" "No components yet. Select layers and press {keys}." = "コンポーネントはまだない。レイヤーを選択して {keys} を押す。" "No conflicts." = "競合なし。" "No contributor data was built into this copy." = "このコピーには貢献者データが含まれていない。" @@ -901,6 +907,7 @@ status = "draft — needs native review" "Open overlay" = "オーバーレイを開く" "Open prototype preview" = "プロトタイプのプレビューを開く" "Open variables ({count})" = "変数を開く({count})" +"Opened as a new document" = "新しいドキュメントとして開いた" "Opened “{name}” in a new tab" = "「{name}」を新しいタブで開いた" "Open|comments" = "未解決" "Options" = "オプション" @@ -925,6 +932,8 @@ status = "draft — needs native review" "Padding" = "パディング" "Page" = "ページ" "Pages" = "ページ" +"Pages added to the document" = "ページをドキュメントに追加した" +"Placed on the current page" = "現在のページに配置した" "Pages: {pages}" = "ページ:{pages}" "Paint bucket" = "塗りつぶし" "Pan the canvas" = "キャンバスをパン" @@ -1499,26 +1508,90 @@ status = "draft — needs native review" "call any command its access allows" = "アクセス権で許可された任意のコマンドの呼び出し" "call only: {commands}" = "次のコマンドのみの呼び出し:{commands}" "can edit the document" = "ドキュメントを編集できる" +"conditional prototype actions nested too deeply were skipped" = "入れ子が深すぎる条件付きプロトタイプアクションをスキップ" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "評価できない条件を持つ条件付きプロトタイプアクションをスキップ" +"connector labels were skipped" = "コネクタのラベルをスキップ" +"connectors whose ends can't be placed are empty" = "端点を配置できないコネクタは空" "crop marks" = "トンボ" +"damaged vector geometry was skipped" = "破損したベクタージオメトリをスキップ" +"damaged vector networks import as outlines" = "破損したベクターネットワークはアウトラインとしてインポート" "development: {path}" = "開発:{path}" +"document children other than pages were skipped" = "ページ以外のドキュメントの子をスキップ" "e.g. count + 1" = "例:count + 1" "e.g. count >= 3 and not done" = "例:count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート" +"entries without a node type were skipped" = "ノードタイプのない項目をスキップ" "figcraft:// links from a browser, chat or email open this app and join the session." = "ブラウザー、チャット、メールからの figcraft:// リンクでこのアプリが開き、セッションに参加する。" +"filled areas of a vector with their own fill use the layer's fill" = "独自の塗りを持つベクターの塗りつぶし領域にはレイヤーの塗りを使用" "from {server}" = "{server} から" +"gamepad triggers without exactly one known controller button were skipped" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ" +"grid tracks of fixed or content size import as equal tracks" = "固定サイズまたはコンテンツサイズのグリッドトラックは均等なトラックとしてインポート" "hidden" = "非表示" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "デフォルトのコンポーネントがファイルにないインスタンスの入れ替えプロパティをスキップ" +"instances inside their own main component were imported as frames" = "自身のメインコンポーネント内にあるインスタンスはフレームとしてインポート" +"instances of components that aren't in the file show no layers" = "ファイルにないコンポーネントのインスタンスにはレイヤーが表示されない" +"instances of components that aren't in the file were imported as frames" = "ファイルにないコンポーネントのインスタンスはフレームとしてインポート" +"instances that would nest a component inside itself were imported as frames" = "コンポーネントを自身の中に入れ子にしてしまうインスタンスはフレームとしてインポート" +"instances without a main component were imported as empty frames" = "メインコンポーネントのないインスタンスは空のフレームとしてインポート" +"invalid or oversized vector networks import as outlines" = "無効または大きすぎるベクターネットワークはアウトラインとしてインポート" +"invalid or oversized vector path data was skipped" = "無効または大きすぎるベクターパスデータをスキップ" "just now" = "たった今" +"key triggers without a supported key were skipped" = "サポートされているキーのないキートリガーをスキップ" +"key up triggers import as key presses" = "キーを離すトリガーはキー押下としてインポート" +"layers without a position were placed at their parent's origin" = "位置のないレイヤーは親の原点に配置" +"layers without a type were skipped" = "タイプのないレイヤーをスキップ" "locked" = "ロック中" "no bleed" = "裁ち落としなし" "nobody else here yet" = "まだ他に誰もいない" "now" = "たった今" "or" = "または" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "ポイントごとの角の形状は保持した。破線や可変幅の線はレイヤーの角の形状で描画" +"prototype actions of unknown types were skipped" = "不明なタイプのプロトタイプアクションをスキップ" +"prototype actions without a destination in the file were skipped" = "ファイル内に移動先のないプロトタイプアクションをスキップ" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "変数 JSON にない変数を使うプロトタイプの式をスキップ" +"prototype expressions with unsupported functions were skipped" = "サポートされていない関数を使うプロトタイプの式をスキップ" +"prototype interactions without supported actions were skipped" = "サポートされているアクションのないプロトタイプインタラクションをスキップ" +"prototype navigations of unknown types were skipped" = "不明なタイプのプロトタイプナビゲーションをスキップ" +"prototype transitions of unknown types play instantly" = "不明なタイプのプロトタイプトランジションは即時に再生" +"prototype triggers of unknown types were skipped" = "不明なタイプのプロトタイプトリガーをスキップ" "put a widget on the canvas" = "キャンバスへのウィジェットの配置" "read and edit the document" = "ドキュメントの読み取りと編集" "read the document" = "ドキュメントの読み取り" "reads the document" = "ドキュメントを読み取る" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "変数 JSON にない変数に対する変数設定アクションをスキップ" +"set-variable actions with values that can't be evaluated were skipped" = "評価できない値を持つ変数設定アクションをスキップ" +"set-variable actions with values that don't fit the variable were skipped" = "変数に合わない値を持つ変数設定アクションをスキップ" +"set-variable actions without a value were skipped" = "値のない変数設定アクションをスキップ" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "変数 JSON にないコレクションまたはモードに対する変数モード設定アクションをスキップ" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "一部のベクターポイントがファイルにないポイント設定を参照している。それらのポイントはレイヤーの角の形状と丸めのない角を使用" +"stars and polygons without point counts or geometry use the default point count" = "ポイント数もジオメトリもない星と多角形はデフォルトのポイント数を使用" +"text style overrides refer to missing table entries" = "テキストスタイルのオーバーライドが存在しないテーブル項目を参照している" +"the file has no pages" = "ファイルにページがない" "the plugin" = "このプラグイン" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "変数 JSON に `variableCollections` がない(ローカル変数のレスポンスを想定)ため無視した" "this session only" = "このセッションのみ" +"unknown component property types were skipped" = "不明なタイプのコンポーネントプロパティをスキップ" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "変数 JSON にない(または型が異なる)変数へのエイリアスをスキップ" +"variable bindings to properties the layer doesn't have were skipped" = "レイヤーにないプロパティへの変数バインディングをスキップ" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "変数 JSON にない変数(ライブラリ変数)へのバインディングをスキップ" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "変数バインディングをスキップ:ファイル JSON に変数の定義がない(ローカル変数 JSON を `variables` として渡すか、その `meta` をファイル JSON の隣に埋め込む)" +"variable bindings whose variable type doesn't fit the property were skipped" = "変数の型がプロパティに合わない変数バインディングをスキップ" +"variable collections without modes were skipped" = "モードのない変数コレクションをスキップ" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "変数とそのモードの 1 つになっていない、プロトタイプの式内の変数モード参照をスキップ" +"variable modes of collections that aren't in the file were skipped" = "ファイルにないコレクションの変数モードをスキップ" +"variable modes of collections that aren't in the variables JSON were skipped" = "変数 JSON にないコレクションの変数モードをスキップ" +"variable values that don't match their type (or composed colours) were skipped" = "型に合わない変数値(または合成カラー)をスキップ" +"variables of unknown types were skipped" = "不明なタイプの変数をスキップ" +"variables whose collection isn't in the variables JSON were skipped" = "コレクションが変数 JSON にない変数をスキップ" +"variables without readable values were skipped" = "読み取れる値のない変数をスキップ" +"vector points with unknown stroke joins use the layer join" = "不明な角の形状を持つベクターポイントはレイヤーの角の形状を使用" +"vectors without geometry are empty" = "ジオメトリのないベクターは空" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "ジオメトリのないベクターは空(geometry=paths を指定してファイル JSON を書き出す)" +"very long text was cut to 1,048,576 characters" = "非常に長いテキストを 1,048,576 文字に切り詰めた" +"very long vector paths were skipped" = "非常に長いベクターパスをスキップ" +"video actions are not supported and were skipped" = "ビデオアクションはサポートされていないためスキップ" +"video triggers are not supported and were skipped" = "ビデオトリガーはサポートされていないためスキップ" "yesterday" = "昨日" "{instances} in {files}" = "{files}内の{instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。" @@ -1563,6 +1636,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n} 件一致" } "{n} minutes ago" = { other = "{n} 分前" } "{n} months ago" = { other = "{n} か月前" } +"{n} more notes omitted from this report" = { other = "このレポートで省略した注記がさらに {n} 件" } "{n} of {total}" = "{n} / {total}" "{n} people joined" = { other = "{n} 人が参加済み" } "{n} preferred components" = { other = "優先コンポーネント {n} 個" } diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..58192efb 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -261,6 +261,7 @@ status = "draft — needs native review" "Commits" = "커밋" "Company" = "회사" "Compare changes" = "변경 사항 비교" +"Compatibility notes" = "호환성 참고 사항" "Completed" = "완료됨" "Component" = "컴포넌트" "Component name" = "컴포넌트 이름" @@ -460,6 +461,7 @@ status = "draft — needs native review" "False" = "거짓" "Fetching from the server…" = "서버에서 가져오는 중…" "FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft는 아직 이 파일의 모든 기능을 지원하지 않습니다. 참고 사항에는 가져온 사본에서 빠지거나 바뀐 내용이 나와 있으며, 원본 파일은 바뀌지 않습니다." "File" = "파일" "File name" = "파일 이름" "File published" = "파일 게시됨" @@ -576,11 +578,14 @@ status = "draft — needs native review" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" "Image" = "이미지" +"Images" = "이미지" "Import" = "가져오기" "Import path" = "가져오기 경로" "Import plugin from manifest" = "매니페스트에서 플러그인 가져오기" "Import plugin from manifest…" = "매니페스트에서 플러그인 가져오기…" +"Import report" = "가져오기 보고서" "Import variables" = "변수 가져오기" +"Imported document" = "가져온 문서" "Imported {name}" = "{name} 가져옴" "Imported {n} new variables, updated {updated}" = { other = "새 변수 {n}개를 가져오고 {updated}개를 업데이트함" } "Import…" = "가져오기…" @@ -770,6 +775,7 @@ status = "draft — needs native review" "No colour variables fit here." = "여기에 맞는 색상 변수가 없음." "No colour variables yet. Create them in Local variables." = "아직 색상 변수가 없음. 로컬 변수에서 만드세요." "No comments yet" = "아직 댓글 없음" +"No compatibility warnings reported" = "보고된 호환성 경고 없음" "No components yet. Select layers and press {keys}." = "아직 컴포넌트가 없음. 레이어를 선택하고 {keys}를 누르세요." "No conflicts." = "충돌 없음." "No contributor data was built into this copy." = "이 사본에는 기여자 데이터가 포함되지 않음." @@ -856,6 +862,7 @@ status = "draft — needs native review" "Open overlay" = "오버레이 열기" "Open prototype preview" = "프로토타입 미리 보기 열기" "Open variables ({count})" = "변수 열기({count})" +"Opened as a new document" = "새 문서로 열림" "Opened “{name}” in a new tab" = "\"{name}\"을(를) 새 탭에서 열었음" "Open|comments" = "미해결" "Options" = "옵션" @@ -879,6 +886,8 @@ status = "draft — needs native review" "Padding" = "패딩" "Page" = "페이지" "Pages" = "페이지" +"Pages added to the document" = "문서에 페이지 추가됨" +"Placed on the current page" = "현재 페이지에 배치됨" "Pages: {pages}" = "페이지: {pages}" "Paint bucket" = "페인트 통" "Pan the canvas" = "캔버스 이동" @@ -1426,25 +1435,89 @@ status = "draft — needs native review" "call any command its access allows" = "접근 권한이 허용하는 모든 명령 호출" "call only: {commands}" = "다음 명령만 호출: {commands}" "can edit the document" = "문서를 편집할 수 있음" +"conditional prototype actions nested too deeply were skipped" = "너무 깊게 중첩된 조건부 프로토타입 액션을 건너뜀" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "평가할 수 없는 조건이 있는 조건부 프로토타입 액션을 건너뜀" +"connector labels were skipped" = "커넥터 레이블을 건너뜀" +"connectors whose ends can't be placed are empty" = "끝점을 배치할 수 없는 커넥터는 비어 있음" "crop marks" = "재단선 표시" +"damaged vector geometry was skipped" = "손상된 벡터 지오메트리를 건너뜀" +"damaged vector networks import as outlines" = "손상된 벡터 네트워크는 윤곽선으로 가져옴" "development: {path}" = "개발: {path}" +"document children other than pages were skipped" = "페이지가 아닌 문서 하위 항목을 건너뜀" "e.g. count + 1" = "예: count + 1" "e.g. count >= 3 and not done" = "예: count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴" +"entries without a node type were skipped" = "노드 유형이 없는 항목을 건너뜀" "figcraft:// links from a browser, chat or email open this app and join the session." = "브라우저, 채팅, 이메일의 figcraft:// 링크로 이 앱을 열고 세션에 참여함." +"filled areas of a vector with their own fill use the layer's fill" = "자체 채우기가 있는 벡터의 채워진 영역은 레이어의 채우기를 사용함" "from {server}" = "{server}에서" +"gamepad triggers without exactly one known controller button were skipped" = "알려진 컨트롤러 버튼이 정확히 하나가 아닌 게임패드 트리거를 건너뜀" +"grid tracks of fixed or content size import as equal tracks" = "고정 크기 또는 콘텐츠 크기의 그리드 트랙은 균등한 트랙으로 가져옴" "hidden" = "숨김" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "기본 컴포넌트가 파일에 없는 인스턴스 교체 속성을 건너뜀" +"instances inside their own main component were imported as frames" = "자체 메인 컴포넌트 안에 있는 인스턴스는 프레임으로 가져옴" +"instances of components that aren't in the file show no layers" = "파일에 없는 컴포넌트의 인스턴스에는 레이어가 표시되지 않음" +"instances of components that aren't in the file were imported as frames" = "파일에 없는 컴포넌트의 인스턴스는 프레임으로 가져옴" +"instances that would nest a component inside itself were imported as frames" = "컴포넌트를 자기 안에 중첩하게 되는 인스턴스는 프레임으로 가져옴" +"instances without a main component were imported as empty frames" = "메인 컴포넌트가 없는 인스턴스는 빈 프레임으로 가져옴" +"invalid or oversized vector networks import as outlines" = "잘못되었거나 너무 큰 벡터 네트워크는 윤곽선으로 가져옴" +"invalid or oversized vector path data was skipped" = "잘못되었거나 너무 큰 벡터 경로 데이터를 건너뜀" +"key triggers without a supported key were skipped" = "지원되는 키가 없는 키 트리거를 건너뜀" +"key up triggers import as key presses" = "키를 뗄 때 트리거는 키 누름으로 가져옴" +"layers without a position were placed at their parent's origin" = "위치가 없는 레이어는 상위 요소의 원점에 배치됨" +"layers without a type were skipped" = "유형이 없는 레이어를 건너뜀" "locked" = "잠김" "no bleed" = "도련 없음" "nobody else here yet" = "아직 다른 사람이 없음" "now" = "방금" "or" = "또는" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "점별 선 연결을 유지함. 파선이나 가변 너비 선은 레이어의 선 연결로 그려짐" +"prototype actions of unknown types were skipped" = "알 수 없는 유형의 프로토타입 액션을 건너뜀" +"prototype actions without a destination in the file were skipped" = "파일에 대상이 없는 프로토타입 액션을 건너뜀" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "변수 JSON에 없는 변수에 대한 프로토타입 수식을 건너뜀" +"prototype expressions with unsupported functions were skipped" = "지원되지 않는 함수가 있는 프로토타입 수식을 건너뜀" +"prototype interactions without supported actions were skipped" = "지원되는 액션이 없는 프로토타입 인터랙션을 건너뜀" +"prototype navigations of unknown types were skipped" = "알 수 없는 유형의 프로토타입 탐색을 건너뜀" +"prototype transitions of unknown types play instantly" = "알 수 없는 유형의 프로토타입 전환은 즉시 재생됨" +"prototype triggers of unknown types were skipped" = "알 수 없는 유형의 프로토타입 트리거를 건너뜀" "put a widget on the canvas" = "캔버스에 위젯 배치" "read and edit the document" = "문서 읽기 및 편집" "read the document" = "문서 읽기" "reads the document" = "문서를 읽음" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "변수 JSON에 없는 변수에 대한 변수 설정 액션을 건너뜀" +"set-variable actions with values that can't be evaluated were skipped" = "평가할 수 없는 값이 있는 변수 설정 액션을 건너뜀" +"set-variable actions with values that don't fit the variable were skipped" = "변수에 맞지 않는 값이 있는 변수 설정 액션을 건너뜀" +"set-variable actions without a value were skipped" = "값이 없는 변수 설정 액션을 건너뜀" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "변수 JSON에 없는 컬렉션이나 모드에 대한 변수 모드 설정 액션을 건너뜀" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "일부 벡터 점이 파일에 없는 점 설정을 참조함. 해당 점은 레이어의 선 연결과 둥글리지 않은 모서리를 사용함" +"stars and polygons without point counts or geometry use the default point count" = "점 개수나 지오메트리가 없는 별과 다각형은 기본 점 개수를 사용함" +"text style overrides refer to missing table entries" = "텍스트 스타일 재정의가 없는 테이블 항목을 참조함" +"the file has no pages" = "파일에 페이지가 없음" "the plugin" = "이 플러그인" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "변수 JSON에 `variableCollections`가 없어(로컬 변수 응답이어야 함) 무시함" "this session only" = "이 세션만" +"unknown component property types were skipped" = "알 수 없는 컴포넌트 속성 유형을 건너뜀" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "변수 JSON에 없거나 유형이 다른 변수에 대한 별칭을 건너뜀" +"variable bindings to properties the layer doesn't have were skipped" = "레이어에 없는 속성에 대한 변수 바인딩을 건너뜀" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "변수 JSON에 없는 변수(라이브러리 변수)에 대한 바인딩을 건너뜀" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "변수 바인딩을 건너뜀: 파일 JSON에 변수 정의가 없음(로컬 변수 JSON을 `variables`로 전달하거나 파일 JSON 옆에 해당 `meta`를 포함하세요)" +"variable bindings whose variable type doesn't fit the property were skipped" = "변수 유형이 속성에 맞지 않는 변수 바인딩을 건너뜀" +"variable collections without modes were skipped" = "모드가 없는 변수 컬렉션을 건너뜀" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "변수와 그 모드 중 하나가 아닌, 프로토타입 수식의 변수 모드 조회를 건너뜀" +"variable modes of collections that aren't in the file were skipped" = "파일에 없는 컬렉션의 변수 모드를 건너뜀" +"variable modes of collections that aren't in the variables JSON were skipped" = "변수 JSON에 없는 컬렉션의 변수 모드를 건너뜀" +"variable values that don't match their type (or composed colours) were skipped" = "유형과 맞지 않는 변수 값(또는 합성 색상)을 건너뜀" +"variables of unknown types were skipped" = "알 수 없는 유형의 변수를 건너뜀" +"variables whose collection isn't in the variables JSON were skipped" = "컬렉션이 변수 JSON에 없는 변수를 건너뜀" +"variables without readable values were skipped" = "읽을 수 있는 값이 없는 변수를 건너뜀" +"vector points with unknown stroke joins use the layer join" = "알 수 없는 선 연결이 있는 벡터 점은 레이어의 선 연결을 사용함" +"vectors without geometry are empty" = "지오메트리가 없는 벡터는 비어 있음" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "지오메트리가 없는 벡터는 비어 있음(geometry=paths로 파일 JSON을 내보내세요)" +"very long text was cut to 1,048,576 characters" = "매우 긴 텍스트를 1,048,576자로 잘랐음" +"very long vector paths were skipped" = "매우 긴 벡터 경로를 건너뜀" +"video actions are not supported and were skipped" = "비디오 액션은 지원되지 않아 건너뜀" +"video triggers are not supported and were skipped" = "비디오 트리거는 지원되지 않아 건너뜀" "{instances} in {files}" = "{files}의 {instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: 초안 번역, 원어민 검토 대기 중." "{length} bleed" = "도련 {length}" @@ -1487,6 +1560,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n}개 일치" } "{n} minutes ago" = { other = "{n}분 전" } "{n} months ago" = { other = "{n}개월 전" } +"{n} more notes omitted from this report" = { other = "참고 사항 {n}개가 이 보고서에서 더 생략됨" } "{n} of {total}" = "{n} / {total}" "{n} people joined" = { other = "{n}명 참여함" } "{n} preferred components" = { other = "선호 컴포넌트 {n}개" } diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..a6f53dc8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -259,6 +259,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar alterações" +"Compatibility notes" = "Notas de compatibilidade" "Completed" = "Concluído" "Component" = "Componente" "Component name" = "Nome do componente" @@ -459,6 +460,7 @@ status = "draft — needs native review" "False" = "Falso" "Fetching from the server…" = "Buscando no servidor…" "FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "O FigCraft ainda não oferece suporte a todos os recursos desses arquivos. As notas listam o que foi omitido ou alterado na cópia importada; o arquivo original não é alterado." "File" = "Arquivo" "File name" = "Nome do arquivo" "File published" = "Arquivo publicado" @@ -575,11 +577,14 @@ status = "draft — needs native review" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" "Image" = "Imagem" +"Images" = "Imagens" "Import" = "Importar" "Import path" = "Caminho de importação" "Import plugin from manifest" = "Importar plugin a partir do manifesto" "Import plugin from manifest…" = "Importar plugin a partir do manifesto…" +"Import report" = "Relatório de importação" "Import variables" = "Importar variáveis" +"Imported document" = "Documento importado" "Imported {name}" = "{name} importado" "Imported {n} new variables, updated {updated}" = { one = "{n} variável nova importada, {updated} atualizadas", other = "{n} variáveis novas importadas, {updated} atualizadas" } "Import…" = "Importar…" @@ -769,6 +774,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Nenhuma variável de cor se encaixa aqui." "No colour variables yet. Create them in Local variables." = "Ainda não há variáveis de cor. Crie-as em Variáveis locais." "No comments yet" = "Ainda não há comentários" +"No compatibility warnings reported" = "Nenhum aviso de compatibilidade relatado" "No components yet. Select layers and press {keys}." = "Ainda não há componentes. Selecione camadas e pressione {keys}." "No conflicts." = "Nenhum conflito." "No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores." @@ -855,6 +861,7 @@ status = "draft — needs native review" "Open overlay" = "Abrir sobreposição" "Open prototype preview" = "Abrir visualização do protótipo" "Open variables ({count})" = "Abrir variáveis ({count})" +"Opened as a new document" = "Aberto como novo documento" "Opened “{name}” in a new tab" = "“{name}” aberto em uma nova aba" "Open|comments" = "Abertos" "Options" = "Opções" @@ -878,6 +885,8 @@ status = "draft — needs native review" "Padding" = "Espaçamento interno" "Page" = "Página" "Pages" = "Páginas" +"Pages added to the document" = "Páginas adicionadas ao documento" +"Placed on the current page" = "Colocado na página atual" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Balde de tinta" "Pan the canvas" = "Mover a tela" @@ -1425,25 +1434,89 @@ status = "draft — needs native review" "call any command its access allows" = "chamar qualquer comando que seu acesso permita" "call only: {commands}" = "chamar apenas: {commands}" "can edit the document" = "pode editar o documento" +"conditional prototype actions nested too deeply were skipped" = "ações condicionais de protótipo aninhadas profundamente demais foram ignoradas" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "ações condicionais de protótipo com condições que não podem ser avaliadas foram ignoradas" +"connector labels were skipped" = "rótulos de conectores foram ignorados" +"connectors whose ends can't be placed are empty" = "conectores cujas pontas não podem ser posicionadas ficam vazios" "crop marks" = "marcas de corte" +"damaged vector geometry was skipped" = "geometria vetorial danificada foi ignorada" +"damaged vector networks import as outlines" = "redes vetoriais danificadas são importadas como contornos" "development: {path}" = "desenvolvimento: {path}" +"document children other than pages were skipped" = "filhos do documento que não são páginas foram ignorados" "e.g. count + 1" = "por ex. count + 1" "e.g. count >= 3 and not done" = "por ex. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas" +"entries without a node type were skipped" = "entradas sem tipo de nó foram ignoradas" "figcraft:// links from a browser, chat or email open this app and join the session." = "Links figcraft:// de um navegador, chat ou e-mail abrem este app e entram na sessão." +"filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada" "from {server}" = "de {server}" +"gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados" +"grid tracks of fixed or content size import as equal tracks" = "trilhas de grade de tamanho fixo ou pelo conteúdo são importadas como trilhas iguais" "hidden" = "oculta" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "propriedades de troca de instância cujo componente padrão não está no arquivo foram ignoradas" +"instances inside their own main component were imported as frames" = "instâncias dentro do próprio componente principal foram importadas como quadros" +"instances of components that aren't in the file show no layers" = "instâncias de componentes que não estão no arquivo não mostram camadas" +"instances of components that aren't in the file were imported as frames" = "instâncias de componentes que não estão no arquivo foram importadas como quadros" +"instances that would nest a component inside itself were imported as frames" = "instâncias que aninhariam um componente dentro dele mesmo foram importadas como quadros" +"instances without a main component were imported as empty frames" = "instâncias sem componente principal foram importadas como quadros vazios" +"invalid or oversized vector networks import as outlines" = "redes vetoriais inválidas ou grandes demais são importadas como contornos" +"invalid or oversized vector path data was skipped" = "dados de caminho vetorial inválidos ou grandes demais foram ignorados" +"key triggers without a supported key were skipped" = "gatilhos de tecla sem uma tecla compatível foram ignorados" +"key up triggers import as key presses" = "gatilhos ao soltar uma tecla são importados como pressionamentos de tecla" +"layers without a position were placed at their parent's origin" = "camadas sem posição foram colocadas na origem do elemento pai" +"layers without a type were skipped" = "camadas sem tipo foram ignoradas" "locked" = "bloqueada" "no bleed" = "sem sangria" "nobody else here yet" = "ainda não há mais ninguém" "now" = "agora" "or" = "ou" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "as junções de traço por ponto foram mantidas; traços tracejados ou de largura variável são desenhados com a junção da camada" +"prototype actions of unknown types were skipped" = "ações de protótipo de tipos desconhecidos foram ignoradas" +"prototype actions without a destination in the file were skipped" = "ações de protótipo sem destino no arquivo foram ignoradas" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "expressões de protótipo com variáveis que não estão no JSON de variáveis foram ignoradas" +"prototype expressions with unsupported functions were skipped" = "expressões de protótipo com funções sem suporte foram ignoradas" +"prototype interactions without supported actions were skipped" = "interações de protótipo sem ações compatíveis foram ignoradas" +"prototype navigations of unknown types were skipped" = "navegações de protótipo de tipos desconhecidos foram ignoradas" +"prototype transitions of unknown types play instantly" = "transições de protótipo de tipos desconhecidos são instantâneas" +"prototype triggers of unknown types were skipped" = "gatilhos de protótipo de tipos desconhecidos foram ignorados" "put a widget on the canvas" = "colocar um widget na tela" "read and edit the document" = "ler e editar o documento" "read the document" = "ler o documento" "reads the document" = "lê o documento" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ações de definir variável em variáveis que não estão no JSON de variáveis foram ignoradas" +"set-variable actions with values that can't be evaluated were skipped" = "ações de definir variável com valores que não podem ser avaliados foram ignoradas" +"set-variable actions with values that don't fit the variable were skipped" = "ações de definir variável com valores que não cabem na variável foram ignoradas" +"set-variable actions without a value were skipped" = "ações de definir variável sem valor foram ignoradas" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ações de definir modo de variável em coleções ou modos que não estão no JSON de variáveis foram ignoradas" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "alguns pontos vetoriais se referem a configurações de ponto ausentes no arquivo; eles usam as junções da camada e cantos simples" +"stars and polygons without point counts or geometry use the default point count" = "estrelas e polígonos sem número de pontas nem geometria usam o número de pontas padrão" +"text style overrides refer to missing table entries" = "substituições de estilo de texto se referem a entradas de tabela ausentes" +"the file has no pages" = "o arquivo não tem páginas" "the plugin" = "o plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "o JSON de variáveis não tem `variableCollections` (era esperada a resposta de variáveis locais) e foi ignorado" "this session only" = "somente nesta sessão" +"unknown component property types were skipped" = "tipos desconhecidos de propriedade de componente foram ignorados" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliases para variáveis que não estão no JSON de variáveis (ou de outro tipo) foram ignorados" +"variable bindings to properties the layer doesn't have were skipped" = "vínculos de variáveis a propriedades que a camada não tem foram ignorados" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "vínculos a variáveis que não estão no JSON de variáveis (variáveis de biblioteca) foram ignorados" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "vínculos de variáveis foram ignorados: o JSON do arquivo não tem definições de variáveis (passe o JSON de variáveis locais como `variables` ou incorpore o `meta` dele ao lado do JSON do arquivo)" +"variable bindings whose variable type doesn't fit the property were skipped" = "vínculos de variáveis cujo tipo de variável não combina com a propriedade foram ignorados" +"variable collections without modes were skipped" = "coleções de variáveis sem modos foram ignoradas" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "consultas de modo de variável em expressões de protótipo que não são uma variável e um de seus modos foram ignoradas" +"variable modes of collections that aren't in the file were skipped" = "modos de variáveis de coleções que não estão no arquivo foram ignorados" +"variable modes of collections that aren't in the variables JSON were skipped" = "modos de variáveis de coleções que não estão no JSON de variáveis foram ignorados" +"variable values that don't match their type (or composed colours) were skipped" = "valores de variáveis que não correspondem ao seu tipo (ou cores compostas) foram ignorados" +"variables of unknown types were skipped" = "variáveis de tipos desconhecidos foram ignoradas" +"variables whose collection isn't in the variables JSON were skipped" = "variáveis cuja coleção não está no JSON de variáveis foram ignoradas" +"variables without readable values were skipped" = "variáveis sem valores legíveis foram ignoradas" +"vector points with unknown stroke joins use the layer join" = "pontos vetoriais com junções de traço desconhecidas usam a junção da camada" +"vectors without geometry are empty" = "vetores sem geometria ficam vazios" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vetores sem geometria ficam vazios (exporte o JSON do arquivo com geometry=paths)" +"very long text was cut to 1,048,576 characters" = "textos muito longos foram cortados em 1.048.576 caracteres" +"very long vector paths were skipped" = "caminhos vetoriais muito longos foram ignorados" +"video actions are not supported and were skipped" = "ações de vídeo não têm suporte e foram ignoradas" +"video triggers are not supported and were skipped" = "gatilhos de vídeo não têm suporte e foram ignorados" "{instances} in {files}" = "{instances} em {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: tradução em rascunho, aguardando revisão de um falante nativo." "{length} bleed" = "sangria de {length}" @@ -1486,6 +1559,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} resultado", other = "{n} resultados" } "{n} minutes ago" = { one = "há {n} minuto", other = "há {n} minutos" } "{n} months ago" = { one = "há {n} mês", other = "há {n} meses" } +"{n} more notes omitted from this report" = { one = "{n} nota a mais omitida deste relatório", other = "{n} notas a mais omitidas deste relatório" } "{n} of {total}" = "{n} de {total}" "{n} people joined" = { one = "{n} pessoa entrou", other = "{n} pessoas entraram" } "{n} preferred components" = { one = "{n} componente preferido", other = "{n} componentes preferidos" } diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..fb090d36 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -267,6 +267,7 @@ name = "Русский" "Commits" = "Коммиты" "Company" = "Компания" "Compare changes" = "Сравнить изменения" +"Compatibility notes" = "Замечания о совместимости" "Completed" = "Завершено" "Component" = "Компонент" "Component name" = "Имя компонента" @@ -473,6 +474,7 @@ name = "Русский" "False" = "Ложь" "Feb" = "февр." "Fetching from the server…" = "Загрузка с сервера…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft пока поддерживает не все возможности этих файлов. В замечаниях перечислено, что было пропущено или изменено в импортированной копии; исходный файл не меняется." "File" = "Файл" "File name" = "Имя файла" "File not found" = "Файл не найден" @@ -596,11 +598,14 @@ name = "Русский" "If {condition}" = "Если {condition}" "Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" "Image" = "Изображение" +"Images" = "Изображения" "Import" = "Импорт" "Import path" = "Путь импорта" "Import plugin from manifest" = "Импортировать плагин из манифеста" "Import plugin from manifest…" = "Импортировать плагин из манифеста…" +"Import report" = "Отчёт об импорте" "Import variables" = "Импортировать переменные" +"Imported document" = "Импортированный документ" "Imported {name}" = "Импортировано: {name}" "Imported {n} new variables, updated {updated}" = { one = "Импортирована {n} новая переменная, обновлено: {updated}", few = "Импортированы {n} новые переменные, обновлено: {updated}", many = "Импортировано {n} новых переменных, обновлено: {updated}", other = "Импортировано {n} новой переменной, обновлено: {updated}" } "Import…" = "Импорт…" @@ -802,6 +807,7 @@ name = "Русский" "No colour variables fit here." = "Нет подходящих цветовых переменных." "No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»." "No comments yet" = "Комментариев пока нет" +"No compatibility warnings reported" = "Предупреждений о совместимости нет" "No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}." "No conflicts." = "Конфликтов нет." "No contributor data was built into this copy." = "В этой сборке нет данных об участниках." @@ -893,6 +899,7 @@ name = "Русский" "Open overlay" = "Открыть оверлей" "Open prototype preview" = "Открыть предпросмотр прототипа" "Open variables ({count})" = "Открыть переменные ({count})" +"Opened as a new document" = "Открыт как новый документ" "Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке" "Open|comments" = "Открытые" "Options" = "Параметры" @@ -917,6 +924,8 @@ name = "Русский" "Padding" = "Внутренние отступы" "Page" = "Страница" "Pages" = "Страницы" +"Pages added to the document" = "Страницы добавлены в документ" +"Placed on the current page" = "Размещено на текущей странице" "Pages: {pages}" = "Страницы: {pages}" "Paint bucket" = "Заливка области" "Pan the canvas" = "Перемещение по холсту" @@ -1486,26 +1495,90 @@ name = "Русский" "call any command its access allows" = "вызывать любые команды в рамках разрешений" "call only: {commands}" = "вызывать только: {commands}" "can edit the document" = "может редактировать документ" +"conditional prototype actions nested too deeply were skipped" = "условные действия прототипа со слишком глубокой вложенностью пропущены" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "условные действия прототипа с условиями, которые нельзя вычислить, пропущены" +"connector labels were skipped" = "подписи соединительных линий пропущены" +"connectors whose ends can't be placed are empty" = "соединительные линии, концы которых нельзя разместить, пусты" "crop marks" = "метки обреза" +"damaged vector geometry was skipped" = "повреждённая векторная геометрия пропущена" +"damaged vector networks import as outlines" = "повреждённые векторные сети импортируются как контуры" "development: {path}" = "разработка: {path}" +"document children other than pages were skipped" = "дочерние элементы документа, не являющиеся страницами, пропущены" "e.g. count + 1" = "например, count + 1" "e.g. count >= 3 and not done" = "например, count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые" +"entries without a node type were skipped" = "записи без типа узла пропущены" "figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии." +"filled areas of a vector with their own fill use the layer's fill" = "залитые области вектора с собственной заливкой используют заливку слоя" "from {server}" = "с {server}" +"gamepad triggers without exactly one known controller button were skipped" = "триггеры геймпада без ровно одной известной кнопки контроллера пропущены" +"grid tracks of fixed or content size import as equal tracks" = "дорожки сетки фиксированного размера или по содержимому импортируются как равные дорожки" "hidden" = "скрыто" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "свойства замены экземпляра, компонент по умолчанию которых отсутствует в файле, пропущены" +"instances inside their own main component were imported as frames" = "экземпляры внутри собственного основного компонента импортированы как фреймы" +"instances of components that aren't in the file show no layers" = "экземпляры компонентов, которых нет в файле, не показывают слоёв" +"instances of components that aren't in the file were imported as frames" = "экземпляры компонентов, которых нет в файле, импортированы как фреймы" +"instances that would nest a component inside itself were imported as frames" = "экземпляры, которые вложили бы компонент в самого себя, импортированы как фреймы" +"instances without a main component were imported as empty frames" = "экземпляры без основного компонента импортированы как пустые фреймы" +"invalid or oversized vector networks import as outlines" = "недопустимые или слишком большие векторные сети импортируются как контуры" +"invalid or oversized vector path data was skipped" = "недопустимые или слишком большие данные векторных путей пропущены" "just now" = "только что" +"key triggers without a supported key were skipped" = "триггеры клавиш без поддерживаемой клавиши пропущены" +"key up triggers import as key presses" = "триггеры отпускания клавиши импортируются как нажатия клавиши" +"layers without a position were placed at their parent's origin" = "слои без положения размещены в начале координат родителя" +"layers without a type were skipped" = "слои без типа пропущены" "locked" = "заблокировано" "no bleed" = "без вылета" "nobody else here yet" = "здесь пока больше никого нет" "now" = "сейчас" "or" = "или" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя" +"prototype actions of unknown types were skipped" = "действия прототипа неизвестных типов пропущены" +"prototype actions without a destination in the file were skipped" = "действия прототипа без цели в файле пропущены" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "выражения прототипа с переменными, которых нет в JSON переменных, пропущены" +"prototype expressions with unsupported functions were skipped" = "выражения прототипа с неподдерживаемыми функциями пропущены" +"prototype interactions without supported actions were skipped" = "взаимодействия прототипа без поддерживаемых действий пропущены" +"prototype navigations of unknown types were skipped" = "действия навигации прототипа неизвестных типов пропущены" +"prototype transitions of unknown types play instantly" = "переходы прототипа неизвестных типов выполняются мгновенно" +"prototype triggers of unknown types were skipped" = "триггеры прототипа неизвестных типов пропущены" "put a widget on the canvas" = "размещать виджет на холсте" "read and edit the document" = "читать и редактировать документ" "read the document" = "читать документ" "reads the document" = "читает документ" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "действия «Задать переменную» для переменных, которых нет в JSON переменных, пропущены" +"set-variable actions with values that can't be evaluated were skipped" = "действия «Задать переменную» со значениями, которые нельзя вычислить, пропущены" +"set-variable actions with values that don't fit the variable were skipped" = "действия «Задать переменную» со значениями, не подходящими переменной, пропущены" +"set-variable actions without a value were skipped" = "действия «Задать переменную» без значения пропущены" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы" +"stars and polygons without point counts or geometry use the default point count" = "звёзды и многоугольники без числа вершин или геометрии используют число вершин по умолчанию" +"text style overrides refer to missing table entries" = "переопределения текстовых стилей ссылаются на отсутствующие записи таблицы" +"the file has no pages" = "в файле нет страниц" "the plugin" = "плагин" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "в JSON переменных нет `variableCollections` (ожидался ответ с локальными переменными), он проигнорирован" "this session only" = "только эта сессия" +"unknown component property types were skipped" = "неизвестные типы свойств компонентов пропущены" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "псевдонимы переменных, которых нет в JSON переменных (или другого типа), пропущены" +"variable bindings to properties the layer doesn't have were skipped" = "привязки переменных к свойствам, которых нет у слоя, пропущены" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "привязки к переменным, которых нет в JSON переменных (переменные библиотеки), пропущены" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "привязки переменных пропущены: в JSON файла нет определений переменных (передайте JSON локальных переменных как `variables` или вложите его `meta` рядом с JSON файла)" +"variable bindings whose variable type doesn't fit the property were skipped" = "привязки переменных, тип которых не подходит свойству, пропущены" +"variable collections without modes were skipped" = "коллекции переменных без режимов пропущены" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "обращения к режимам переменных в выражениях прототипа, не состоящие из переменной и одного из её режимов, пропущены" +"variable modes of collections that aren't in the file were skipped" = "режимы переменных коллекций, которых нет в файле, пропущены" +"variable modes of collections that aren't in the variables JSON were skipped" = "режимы переменных коллекций, которых нет в JSON переменных, пропущены" +"variable values that don't match their type (or composed colours) were skipped" = "значения переменных, не соответствующие своему типу (или составные цвета), пропущены" +"variables of unknown types were skipped" = "переменные неизвестных типов пропущены" +"variables whose collection isn't in the variables JSON were skipped" = "переменные, коллекции которых нет в JSON переменных, пропущены" +"variables without readable values were skipped" = "переменные без читаемых значений пропущены" +"vector points with unknown stroke joins use the layer join" = "векторные точки с неизвестными соединениями обводки используют соединение слоя" +"vectors without geometry are empty" = "векторы без геометрии пусты" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "векторы без геометрии пусты (экспортируйте JSON файла с geometry=paths)" +"very long text was cut to 1,048,576 characters" = "очень длинный текст обрезан до 1 048 576 символов" +"very long vector paths were skipped" = "очень длинные векторные пути пропущены" +"video actions are not supported and were skipped" = "действия с видео не поддерживаются и пропущены" +"video triggers are not supported and were skipped" = "триггеры видео не поддерживаются и пропущены" "yesterday" = "вчера" "{instances} in {files}" = "{instances} в {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: черновой перевод, ожидает проверки носителем языка." @@ -1550,6 +1623,7 @@ name = "Русский" "{n} matches" = { one = "{n} совпадение", few = "{n} совпадения", many = "{n} совпадений", other = "{n} совпадения" } "{n} minutes ago" = { one = "{n} минуту назад", few = "{n} минуты назад", many = "{n} минут назад", other = "{n} минуты назад" } "{n} months ago" = { one = "{n} месяц назад", few = "{n} месяца назад", many = "{n} месяцев назад", other = "{n} месяца назад" } +"{n} more notes omitted from this report" = { one = "Ещё {n} замечание не включено в этот отчёт", few = "Ещё {n} замечания не включены в этот отчёт", many = "Ещё {n} замечаний не включено в этот отчёт", other = "Ещё {n} замечания не включено в этот отчёт" } "{n} of {total}" = "{n} из {total}" "{n} people joined" = { one = "Подключился {n} участник", few = "Подключились {n} участника", many = "Подключились {n} участников", other = "Подключилось {n} участника" } "{n} preferred components" = { one = "{n} предпочтительный компонент", few = "{n} предпочтительных компонента", many = "{n} предпочтительных компонентов", other = "{n} предпочтительного компонента" } diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..070b68d7 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -268,6 +268,7 @@ name = "Svenska" "Commits" = "Commits" "Company" = "Företag" "Compare changes" = "Jämför ändringar" +"Compatibility notes" = "Kompatibilitetsanteckningar" "Completed" = "Slutförd" "Component" = "Komponent" "Component name" = "Komponentnamn" @@ -474,6 +475,7 @@ name = "Svenska" "False" = "Falskt" "Feb" = "feb" "Fetching from the server…" = "Hämtar från servern…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad." "File" = "Fil" "File name" = "Filnamn" "File not found" = "Filen hittades inte" @@ -597,11 +599,14 @@ name = "Svenska" "If {condition}" = "Om {condition}" "Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" "Image" = "Bild" +"Images" = "Bilder" "Import" = "Importera" "Import path" = "Importsökväg" "Import plugin from manifest" = "Importera plugin från manifest" "Import plugin from manifest…" = "Importera plugin från manifest…" +"Import report" = "Importrapport" "Import variables" = "Importera variabler" +"Imported document" = "Importerat dokument" "Imported {name}" = "Importerade {name}" "Imported {n} new variables, updated {updated}" = { one = "Importerade {n} ny variabel, uppdaterade {updated}", other = "Importerade {n} nya variabler, uppdaterade {updated}" } "Import…" = "Importera…" @@ -803,6 +808,7 @@ name = "Svenska" "No colour variables fit here." = "Inga färgvariabler passar här." "No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler." "No comments yet" = "Inga kommentarer än" +"No compatibility warnings reported" = "Inga kompatibilitetsvarningar rapporterades" "No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}." "No conflicts." = "Inga konflikter." "No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian." @@ -894,6 +900,7 @@ name = "Svenska" "Open overlay" = "Öppna överlägg" "Open prototype preview" = "Öppna prototypförhandsgranskning" "Open variables ({count})" = "Öppna variabler ({count})" +"Opened as a new document" = "Öppnat som ett nytt dokument" "Opened “{name}” in a new tab" = "Öppnade ”{name}” i en ny flik" "Open|comments" = "Öppna" "Options" = "Alternativ" @@ -918,6 +925,8 @@ name = "Svenska" "Padding" = "Utfyllnad" "Page" = "Sida" "Pages" = "Sidor" +"Pages added to the document" = "Sidor tillagda i dokumentet" +"Placed on the current page" = "Placerat på den aktuella sidan" "Pages: {pages}" = "Sidor: {pages}" "Paint bucket" = "Färghink" "Pan the canvas" = "Panorera arbetsytan" @@ -1487,26 +1496,90 @@ name = "Svenska" "call any command its access allows" = "anropa alla kommandon som dess åtkomst tillåter" "call only: {commands}" = "anropa endast: {commands}" "can edit the document" = "kan redigera dokumentet" +"conditional prototype actions nested too deeply were skipped" = "villkorliga prototypåtgärder som var nästlade för djupt hoppades över" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "villkorliga prototypåtgärder med villkor som inte kan utvärderas hoppades över" +"connector labels were skipped" = "etiketter på kopplingslinjer hoppades över" +"connectors whose ends can't be placed are empty" = "kopplingslinjer vars ändar inte kan placeras är tomma" "crop marks" = "skärmärken" +"damaged vector geometry was skipped" = "skadad vektorgeometri hoppades över" +"damaged vector networks import as outlines" = "skadade vektornätverk importeras som former" "development: {path}" = "utveckling: {path}" +"document children other than pages were skipped" = "underordnade objekt i dokumentet som inte är sidor hoppades över" "e.g. count + 1" = "t.ex. count + 1" "e.g. count >= 3 and not done" = "t.ex. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer" +"entries without a node type were skipped" = "poster utan nodtyp hoppades över" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen." +"filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning" "from {server}" = "från {server}" +"gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över" +"grid tracks of fixed or content size import as equal tracks" = "rutnätsspår med fast storlek eller innehållsstorlek importeras som lika stora spår" "hidden" = "dold" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över" +"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som ramar" +"instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager" +"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som ramar" +"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som ramar" +"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma ramar" +"invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former" +"invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över" "just now" = "nyss" +"key triggers without a supported key were skipped" = "tangentutlösare utan en tangent som stöds hoppades över" +"key up triggers import as key presses" = "utlösare för släppt tangent importeras som tangenttryckningar" +"layers without a position were placed at their parent's origin" = "lager utan position placerades i det överordnade objektets origo" +"layers without a type were skipped" = "lager utan typ hoppades över" "locked" = "låst" "no bleed" = "inget utfall" "nobody else here yet" = "ingen annan här än" "now" = "nu" "or" = "eller" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "punkternas hörntyper behölls; streckade konturer och konturer med variabel bredd ritas med lagrets hörntyp" +"prototype actions of unknown types were skipped" = "prototypåtgärder av okända typer hoppades över" +"prototype actions without a destination in the file were skipped" = "prototypåtgärder utan mål i filen hoppades över" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototyputtryck med variabler som inte finns i variabel-JSON:en hoppades över" +"prototype expressions with unsupported functions were skipped" = "prototyputtryck med funktioner som inte stöds hoppades över" +"prototype interactions without supported actions were skipped" = "prototypinteraktioner utan åtgärder som stöds hoppades över" +"prototype navigations of unknown types were skipped" = "prototypnavigeringar av okända typer hoppades över" +"prototype transitions of unknown types play instantly" = "prototypövergångar av okända typer spelas upp direkt" +"prototype triggers of unknown types were skipped" = "prototyputlösare av okända typer hoppades över" "put a widget on the canvas" = "placera en widget på arbetsytan" "read and edit the document" = "läsa och redigera dokumentet" "read the document" = "läsa dokumentet" "reads the document" = "läser dokumentet" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "åtgärder för att ange variabel på variabler som inte finns i variabel-JSON:en hoppades över" +"set-variable actions with values that can't be evaluated were skipped" = "åtgärder för att ange variabel med värden som inte kan utvärderas hoppades över" +"set-variable actions with values that don't fit the variable were skipped" = "åtgärder för att ange variabel med värden som inte passar variabeln hoppades över" +"set-variable actions without a value were skipped" = "åtgärder för att ange variabel utan värde hoppades över" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "vissa vektorpunkter hänvisar till punktinställningar som saknas i filen; de använder lagrets hörntyper och vanliga hörn" +"stars and polygons without point counts or geometry use the default point count" = "stjärnor och polygoner utan antal punkter eller geometri använder standardantalet punkter" +"text style overrides refer to missing table entries" = "åsidosättningar av textstilar hänvisar till tabellposter som saknas" +"the file has no pages" = "filen har inga sidor" "the plugin" = "pluginet" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "variabel-JSON:en har inga `variableCollections` (svaret med lokala variabler väntades) och ignorerades" "this session only" = "endast den här sessionen" +"unknown component property types were skipped" = "okända typer av komponentegenskaper hoppades över" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "alias till variabler som inte finns i variabel-JSON:en (eller är av en annan typ) hoppades över" +"variable bindings to properties the layer doesn't have were skipped" = "variabelbindningar till egenskaper som lagret inte har hoppades över" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "bindningar till variabler som inte finns i variabel-JSON:en (biblioteksvariabler) hoppades över" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "variabelbindningar hoppades över: fil-JSON:en har inga variabeldefinitioner (skicka den lokala variabel-JSON:en som `variables`, eller bädda in dess `meta` bredvid fil-JSON:en)" +"variable bindings whose variable type doesn't fit the property were skipped" = "variabelbindningar vars variabeltyp inte passar egenskapen hoppades över" +"variable collections without modes were skipped" = "variabelsamlingar utan lägen hoppades över" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "uppslag av variabellägen i prototyputtryck som inte är en variabel och ett av dess lägen hoppades över" +"variable modes of collections that aren't in the file were skipped" = "variabellägen för samlingar som inte finns i filen hoppades över" +"variable modes of collections that aren't in the variables JSON were skipped" = "variabellägen för samlingar som inte finns i variabel-JSON:en hoppades över" +"variable values that don't match their type (or composed colours) were skipped" = "variabelvärden som inte matchar sin typ (eller sammansatta färger) hoppades över" +"variables of unknown types were skipped" = "variabler av okända typer hoppades över" +"variables whose collection isn't in the variables JSON were skipped" = "variabler vars samling inte finns i variabel-JSON:en hoppades över" +"variables without readable values were skipped" = "variabler utan läsbara värden hoppades över" +"vector points with unknown stroke joins use the layer join" = "vektorpunkter med okända hörntyper använder lagrets hörntyp" +"vectors without geometry are empty" = "vektorer utan geometri är tomma" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorer utan geometri är tomma (exportera fil-JSON:en med geometry=paths)" +"very long text was cut to 1,048,576 characters" = "mycket lång text kortades till 1 048 576 tecken" +"very long vector paths were skipped" = "mycket långa vektorbanor hoppades över" +"video actions are not supported and were skipped" = "videoåtgärder stöds inte och hoppades över" +"video triggers are not supported and were skipped" = "videoutlösare stöds inte och hoppades över" "yesterday" = "i går" "{instances} in {files}" = "{instances} i {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: utkast till översättning, väntar på granskning av en modersmålstalare." @@ -1551,6 +1624,7 @@ name = "Svenska" "{n} matches" = { one = "{n} träff", other = "{n} träffar" } "{n} minutes ago" = { one = "för {n} minut sedan", other = "för {n} minuter sedan" } "{n} months ago" = { one = "för {n} månad sedan", other = "för {n} månader sedan" } +"{n} more notes omitted from this report" = { one = "{n} anteckning till utelämnades i den här rapporten", other = "{n} anteckningar till utelämnades i den här rapporten" } "{n} of {total}" = "{n} av {total}" "{n} people joined" = { one = "{n} person anslöt", other = "{n} personer anslöt" } "{n} preferred components" = { one = "{n} föredragen komponent", other = "{n} föredragna komponenter" } diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..9a40771e 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -270,6 +270,7 @@ name = "Türkçe" "Commits" = "Commit'ler" "Company" = "Şirket" "Compare changes" = "Değişiklikleri karşılaştır" +"Compatibility notes" = "Uyumluluk notları" "Completed" = "Tamamlandı" "Component" = "Bileşen" "Component name" = "Bileşen adı" @@ -476,6 +477,7 @@ name = "Türkçe" "False" = "Yanlış" "Feb" = "Şub" "Fetching from the server…" = "Sunucudan getiriliyor…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft bu dosyaların tüm özelliklerini henüz desteklemiyor. Notlar, içe aktarılan kopyada nelerin dışarıda bırakıldığını veya değiştirildiğini listeler; özgün dosya değişmez." "File" = "Dosya" "File name" = "Dosya adı" "File not found" = "Dosya bulunamadı" @@ -599,11 +601,14 @@ name = "Türkçe" "If {condition}" = "Eğer {condition}" "Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)" "Image" = "Görüntü" +"Images" = "Görüntüler" "Import" = "İçe aktar" "Import path" = "İçe aktarma yolu" "Import plugin from manifest" = "Eklentiyi manifestten içe aktar" "Import plugin from manifest…" = "Eklentiyi manifestten içe aktar…" +"Import report" = "İçe aktarma raporu" "Import variables" = "Değişkenleri içe aktar" +"Imported document" = "İçe aktarılan belge" "Imported {name}" = "İçe aktarıldı: {name}" "Imported {n} new variables, updated {updated}" = { other = "{n} yeni değişken içe aktarıldı, {updated} güncellendi" } "Import…" = "İçe aktar…" @@ -805,6 +810,7 @@ name = "Türkçe" "No colour variables fit here." = "Buraya uyan renk değişkeni yok." "No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun." "No comments yet" = "Henüz yorum yok" +"No compatibility warnings reported" = "Uyumluluk uyarısı bildirilmedi" "No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın." "No conflicts." = "Çakışma yok." "No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş." @@ -896,6 +902,7 @@ name = "Türkçe" "Open overlay" = "Bindirmeyi aç" "Open prototype preview" = "Prototip önizlemesini aç" "Open variables ({count})" = "Açık değişkenler ({count})" +"Opened as a new document" = "Yeni belge olarak açıldı" "Opened “{name}” in a new tab" = "“{name}” yeni sekmede açıldı" "Open|comments" = "Açık" "Options" = "Seçenekler" @@ -920,6 +927,8 @@ name = "Türkçe" "Padding" = "İç boşluk" "Page" = "Sayfa" "Pages" = "Sayfalar" +"Pages added to the document" = "Sayfalar belgeye eklendi" +"Placed on the current page" = "Geçerli sayfaya yerleştirildi" "Pages: {pages}" = "Sayfalar: {pages}" "Paint bucket" = "Boya kovası" "Pan the canvas" = "Tuvali kaydır" @@ -1489,26 +1498,90 @@ name = "Türkçe" "call any command its access allows" = "erişiminin izin verdiği her komutu çağırmak" "call only: {commands}" = "yalnızca şunları çağırmak: {commands}" "can edit the document" = "belgeyi düzenleyebilir" +"conditional prototype actions nested too deeply were skipped" = "çok derin iç içe geçmiş koşullu prototip eylemleri atlandı" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "değerlendirilemeyen koşullara sahip koşullu prototip eylemleri atlandı" +"connector labels were skipped" = "bağlayıcı etiketleri atlandı" +"connectors whose ends can't be placed are empty" = "uçları yerleştirilemeyen bağlayıcılar boş" "crop marks" = "kesim işaretleri" +"damaged vector geometry was skipped" = "hasarlı vektör geometrisi atlandı" +"damaged vector networks import as outlines" = "hasarlı vektör ağları ana hat olarak içe aktarılır" "development: {path}" = "geliştirme: {path}" +"document children other than pages were skipped" = "belgenin sayfa olmayan alt öğeleri atlandı" "e.g. count + 1" = "ör. count + 1" "e.g. count >= 3 and not done" = "ör. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır" +"entries without a node type were skipped" = "düğüm türü olmayan girdiler atlandı" "figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır." +"filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır" "from {server}" = "kaynak: {server}" +"gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı" +"grid tracks of fixed or content size import as equal tracks" = "sabit veya içerik boyutlu ızgara izleri eşit izler olarak içe aktarılır" "hidden" = "gizli" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "varsayılan bileşeni dosyada olmayan örnek değiştirme özellikleri atlandı" +"instances inside their own main component were imported as frames" = "kendi ana bileşeninin içindeki örnekler çerçeve olarak içe aktarıldı" +"instances of components that aren't in the file show no layers" = "dosyada olmayan bileşenlerin örnekleri katman göstermez" +"instances of components that aren't in the file were imported as frames" = "dosyada olmayan bileşenlerin örnekleri çerçeve olarak içe aktarıldı" +"instances that would nest a component inside itself were imported as frames" = "bir bileşeni kendi içine yerleştirecek örnekler çerçeve olarak içe aktarıldı" +"instances without a main component were imported as empty frames" = "ana bileşeni olmayan örnekler boş çerçeve olarak içe aktarıldı" +"invalid or oversized vector networks import as outlines" = "geçersiz veya çok büyük vektör ağları ana hat olarak içe aktarılır" +"invalid or oversized vector path data was skipped" = "geçersiz veya çok büyük vektör yolu verileri atlandı" "just now" = "az önce" +"key triggers without a supported key were skipped" = "desteklenen bir tuşu olmayan tuş tetikleyicileri atlandı" +"key up triggers import as key presses" = "tuş bırakma tetikleyicileri tuşa basma olarak içe aktarılır" +"layers without a position were placed at their parent's origin" = "konumu olmayan katmanlar üst öğelerinin başlangıç noktasına yerleştirildi" +"layers without a type were skipped" = "türü olmayan katmanlar atlandı" "locked" = "kilitli" "no bleed" = "taşma payı yok" "nobody else here yet" = "burada henüz başka kimse yok" "now" = "şimdi" "or" = "veya" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "nokta başına kontur birleşimleri korundu; kesikli veya değişken genişlikli konturlar katmanın birleşimiyle çizilir" +"prototype actions of unknown types were skipped" = "bilinmeyen türdeki prototip eylemleri atlandı" +"prototype actions without a destination in the file were skipped" = "dosyada hedefi olmayan prototip eylemleri atlandı" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki prototip ifadeleri atlandı" +"prototype expressions with unsupported functions were skipped" = "desteklenmeyen işlevler içeren prototip ifadeleri atlandı" +"prototype interactions without supported actions were skipped" = "desteklenen eylemi olmayan prototip etkileşimleri atlandı" +"prototype navigations of unknown types were skipped" = "bilinmeyen türdeki prototip gezinmeleri atlandı" +"prototype transitions of unknown types play instantly" = "bilinmeyen türdeki prototip geçişleri anında oynatılır" +"prototype triggers of unknown types were skipped" = "bilinmeyen türdeki prototip tetikleyicileri atlandı" "put a widget on the canvas" = "tuvale bir widget koymak" "read and edit the document" = "belgeyi okumak ve düzenlemek" "read the document" = "belgeyi okumak" "reads the document" = "belgeyi okur" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki değişken ayarlama eylemleri atlandı" +"set-variable actions with values that can't be evaluated were skipped" = "değerlendirilemeyen değerlere sahip değişken ayarlama eylemleri atlandı" +"set-variable actions with values that don't fit the variable were skipped" = "değişkene uymayan değerlere sahip değişken ayarlama eylemleri atlandı" +"set-variable actions without a value were skipped" = "değeri olmayan değişken ayarlama eylemleri atlandı" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonlar veya modlar üzerindeki değişken modu ayarlama eylemleri atlandı" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "bazı vektör noktaları dosyada eksik olan nokta ayarlarına başvuruyor; bu noktalar katmanın birleşimlerini ve düz köşeleri kullanır" +"stars and polygons without point counts or geometry use the default point count" = "nokta sayısı veya geometrisi olmayan yıldızlar ve çokgenler varsayılan nokta sayısını kullanır" +"text style overrides refer to missing table entries" = "metin stili geçersiz kılmaları eksik tablo girdilerine başvuruyor" +"the file has no pages" = "dosyada sayfa yok" "the plugin" = "eklenti" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "değişken JSON'unda `variableCollections` yok (yerel değişkenler yanıtı bekleniyordu) ve yok sayıldı" "this session only" = "yalnızca bu oturum" +"unknown component property types were skipped" = "bilinmeyen bileşen özelliği türleri atlandı" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "değişken JSON'unda olmayan (veya başka türdeki) değişkenlere yönelik takma adlar atlandı" +"variable bindings to properties the layer doesn't have were skipped" = "katmanda olmayan özelliklere yönelik değişken bağlamaları atlandı" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "değişken JSON'unda olmayan değişkenlere (kitaplık değişkenleri) yönelik bağlamalar atlandı" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "değişken bağlamaları atlandı: dosya JSON'unda değişken tanımı yok (yerel değişken JSON'unu `variables` olarak verin veya `meta` alanını dosya JSON'unun yanına gömün)" +"variable bindings whose variable type doesn't fit the property were skipped" = "değişken türü özelliğe uymayan değişken bağlamaları atlandı" +"variable collections without modes were skipped" = "modu olmayan değişken koleksiyonları atlandı" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "prototip ifadelerinde bir değişken ve onun modlarından biri olmayan değişken modu aramaları atlandı" +"variable modes of collections that aren't in the file were skipped" = "dosyada olmayan koleksiyonların değişken modları atlandı" +"variable modes of collections that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonların değişken modları atlandı" +"variable values that don't match their type (or composed colours) were skipped" = "türüyle eşleşmeyen değişken değerleri (veya birleşik renkler) atlandı" +"variables of unknown types were skipped" = "bilinmeyen türdeki değişkenler atlandı" +"variables whose collection isn't in the variables JSON were skipped" = "koleksiyonu değişken JSON'unda olmayan değişkenler atlandı" +"variables without readable values were skipped" = "okunabilir değeri olmayan değişkenler atlandı" +"vector points with unknown stroke joins use the layer join" = "bilinmeyen kontur birleşimleri olan vektör noktaları katmanın birleşimini kullanır" +"vectors without geometry are empty" = "geometrisi olmayan vektörler boş" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "geometrisi olmayan vektörler boş (dosya JSON'unu geometry=paths ile dışa aktarın)" +"very long text was cut to 1,048,576 characters" = "çok uzun metin 1.048.576 karaktere kısaltıldı" +"very long vector paths were skipped" = "çok uzun vektör yolları atlandı" +"video actions are not supported and were skipped" = "video eylemleri desteklenmiyor ve atlandı" +"video triggers are not supported and were skipped" = "video tetikleyicileri desteklenmiyor ve atlandı" "yesterday" = "dün" "{instances} in {files}" = "{instances}, {files} içinde" "{language}: draft translation, waiting for a native speaker's review." = "{language}: taslak çeviri, ana dili konuşan birinin incelemesini bekliyor." @@ -1553,6 +1626,7 @@ name = "Türkçe" "{n} matches" = { other = "{n} eşleşme" } "{n} minutes ago" = { other = "{n} dakika önce" } "{n} months ago" = { other = "{n} ay önce" } +"{n} more notes omitted from this report" = { other = "{n} not daha bu rapora alınmadı" } "{n} of {total}" = "{n} / {total}" "{n} people joined" = { other = "{n} kişi katıldı" } "{n} preferred components" = { other = "{n} tercih edilen bileşen" } diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..4e740329 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -262,6 +262,7 @@ status = "draft — needs native review" "Commits" = "提交" "Company" = "公司" "Compare changes" = "比较更改" +"Compatibility notes" = "兼容性说明" "Completed" = "已完成" "Component" = "组件" "Component name" = "组件名称" @@ -461,6 +462,7 @@ status = "draft — needs native review" "False" = "假" "Fetching from the server…" = "正在从服务器获取…" "FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft 尚不支持这些文件的全部功能。说明中列出了导入副本中省略或更改的内容;原始文件保持不变。" "File" = "文件" "File name" = "文件名" "File published" = "文件已发布" @@ -577,11 +579,14 @@ status = "draft — needs native review" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" "Image" = "图片" +"Images" = "图片" "Import" = "导入" "Import path" = "导入路径" "Import plugin from manifest" = "从清单导入插件" "Import plugin from manifest…" = "从清单导入插件…" +"Import report" = "导入报告" "Import variables" = "导入变量" +"Imported document" = "导入的文档" "Imported {name}" = "已导入 {name}" "Imported {n} new variables, updated {updated}" = { other = "已导入 {n} 个新变量,更新 {updated} 个" } "Import…" = "导入…" @@ -771,6 +776,7 @@ status = "draft — needs native review" "No colour variables fit here." = "没有适用于此处的颜色变量。" "No colour variables yet. Create them in Local variables." = "还没有颜色变量。请在“本地变量”中创建。" "No comments yet" = "还没有评论" +"No compatibility warnings reported" = "未报告兼容性警告" "No components yet. Select layers and press {keys}." = "还没有组件。选择图层并按 {keys}。" "No conflicts." = "没有冲突。" "No contributor data was built into this copy." = "此副本未内置贡献者数据。" @@ -857,6 +863,7 @@ status = "draft — needs native review" "Open overlay" = "打开浮层" "Open prototype preview" = "打开原型预览" "Open variables ({count})" = "打开变量({count})" +"Opened as a new document" = "已作为新文档打开" "Opened “{name}” in a new tab" = "已在新标签页中打开“{name}”" "Open|comments" = "未解决" "Options" = "选项" @@ -880,6 +887,8 @@ status = "draft — needs native review" "Padding" = "内边距" "Page" = "页面" "Pages" = "页面" +"Pages added to the document" = "页面已添加到文档" +"Placed on the current page" = "已放置在当前页面" "Pages: {pages}" = "页面:{pages}" "Paint bucket" = "油漆桶" "Pan the canvas" = "平移画布" @@ -1427,25 +1436,89 @@ status = "draft — needs native review" "call any command its access allows" = "调用其访问权限允许的任何命令" "call only: {commands}" = "仅调用:{commands}" "can edit the document" = "可编辑文档" +"conditional prototype actions nested too deeply were skipped" = "已跳过嵌套过深的条件原型操作" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "已跳过条件无法求值的条件原型操作" +"connector labels were skipped" = "已跳过连接线标签" +"connectors whose ends can't be placed are empty" = "无法放置端点的连接线为空" "crop marks" = "裁切标记" +"damaged vector geometry was skipped" = "已跳过损坏的矢量几何" +"damaged vector networks import as outlines" = "损坏的矢量网络导入为轮廓" "development: {path}" = "开发:{path}" +"document children other than pages were skipped" = "已跳过非页面的文档子项" "e.g. count + 1" = "例如 count + 1" "e.g. count >= 3 and not done" = "例如 count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线" +"entries without a node type were skipped" = "已跳过没有节点类型的条目" "figcraft:// links from a browser, chat or email open this app and join the session." = "来自浏览器、聊天或邮件的 figcraft:// 链接会打开此应用并加入会话。" +"filled areas of a vector with their own fill use the layer's fill" = "具有自身填充的矢量填充区域使用图层的填充" "from {server}" = "来自 {server}" +"gamepad triggers without exactly one known controller button were skipped" = "已跳过没有恰好一个已知手柄按钮的游戏手柄触发器" +"grid tracks of fixed or content size import as equal tracks" = "固定尺寸或按内容尺寸的网格轨道导入为等分轨道" "hidden" = "已隐藏" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "已跳过默认组件不在文件中的实例替换属性" +"instances inside their own main component were imported as frames" = "位于自身主组件内的实例已导入为画框" +"instances of components that aren't in the file show no layers" = "文件中没有的组件的实例不显示图层" +"instances of components that aren't in the file were imported as frames" = "文件中没有的组件的实例已导入为画框" +"instances that would nest a component inside itself were imported as frames" = "会将组件嵌套到自身内部的实例已导入为画框" +"instances without a main component were imported as empty frames" = "没有主组件的实例已导入为空画框" +"invalid or oversized vector networks import as outlines" = "无效或过大的矢量网络导入为轮廓" +"invalid or oversized vector path data was skipped" = "已跳过无效或过大的矢量路径数据" +"key triggers without a supported key were skipped" = "已跳过没有受支持按键的按键触发器" +"key up triggers import as key presses" = "松开按键触发器导入为按下按键" +"layers without a position were placed at their parent's origin" = "没有位置的图层已放在其父级的原点" +"layers without a type were skipped" = "已跳过没有类型的图层" "locked" = "已锁定" "no bleed" = "无出血" "nobody else here yet" = "这里还没有其他人" "now" = "刚刚" "or" = "或" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "已保留各点的描边转角;虚线描边或可变宽度描边以图层的转角绘制" +"prototype actions of unknown types were skipped" = "已跳过未知类型的原型操作" +"prototype actions without a destination in the file were skipped" = "已跳过在文件中没有目标的原型操作" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "已跳过使用变量 JSON 中没有的变量的原型表达式" +"prototype expressions with unsupported functions were skipped" = "已跳过使用不受支持函数的原型表达式" +"prototype interactions without supported actions were skipped" = "已跳过没有受支持操作的原型交互" +"prototype navigations of unknown types were skipped" = "已跳过未知类型的原型导航" +"prototype transitions of unknown types play instantly" = "未知类型的原型过渡即时播放" +"prototype triggers of unknown types were skipped" = "已跳过未知类型的原型触发器" "put a widget on the canvas" = "在画布上放置小组件" "read and edit the document" = "读取和编辑文档" "read the document" = "读取文档" "reads the document" = "读取文档" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "已跳过针对变量 JSON 中没有的变量的设置变量操作" +"set-variable actions with values that can't be evaluated were skipped" = "已跳过值无法求值的设置变量操作" +"set-variable actions with values that don't fit the variable were skipped" = "已跳过值与变量不符的设置变量操作" +"set-variable actions without a value were skipped" = "已跳过没有值的设置变量操作" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "已跳过针对变量 JSON 中没有的集合或模式的设置变量模式操作" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "部分矢量点引用了文件中缺失的点设置;这些点使用图层的转角和普通角" +"stars and polygons without point counts or geometry use the default point count" = "没有点数或几何的星形和多边形使用默认点数" +"text style overrides refer to missing table entries" = "文本样式覆盖引用了缺失的表条目" +"the file has no pages" = "文件没有页面" "the plugin" = "插件" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "变量 JSON 中没有 `variableCollections`(应为本地变量响应),已忽略" "this session only" = "仅限本次会话" +"unknown component property types were skipped" = "已跳过未知的组件属性类型" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "已跳过指向变量 JSON 中没有的(或其他类型的)变量的别名" +"variable bindings to properties the layer doesn't have were skipped" = "已跳过绑定到图层没有的属性的变量绑定" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "已跳过绑定到变量 JSON 中没有的变量(库变量)的绑定" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "已跳过变量绑定:文件 JSON 中没有变量定义(将本地变量 JSON 作为 `variables` 传入,或将其 `meta` 嵌入文件 JSON 旁)" +"variable bindings whose variable type doesn't fit the property were skipped" = "已跳过变量类型与属性不符的变量绑定" +"variable collections without modes were skipped" = "已跳过没有模式的变量集合" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "已跳过原型表达式中不是变量及其某个模式的变量模式查找" +"variable modes of collections that aren't in the file were skipped" = "已跳过文件中没有的集合的变量模式" +"variable modes of collections that aren't in the variables JSON were skipped" = "已跳过变量 JSON 中没有的集合的变量模式" +"variable values that don't match their type (or composed colours) were skipped" = "已跳过与其类型不符的变量值(或组合颜色)" +"variables of unknown types were skipped" = "已跳过未知类型的变量" +"variables whose collection isn't in the variables JSON were skipped" = "已跳过其集合不在变量 JSON 中的变量" +"variables without readable values were skipped" = "已跳过没有可读值的变量" +"vector points with unknown stroke joins use the layer join" = "转角未知的矢量点使用图层的转角" +"vectors without geometry are empty" = "没有几何的矢量为空" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "没有几何的矢量为空(使用 geometry=paths 导出文件 JSON)" +"very long text was cut to 1,048,576 characters" = "过长的文本已截断为 1,048,576 个字符" +"very long vector paths were skipped" = "已跳过过长的矢量路径" +"video actions are not supported and were skipped" = "不支持视频操作,已跳过" +"video triggers are not supported and were skipped" = "不支持视频触发器,已跳过" "{instances} in {files}" = "{files} 中的 {instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:翻译草稿,等待母语者审校。" "{length} bleed" = "出血 {length}" @@ -1488,6 +1561,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n} 个匹配项" } "{n} minutes ago" = { other = "{n} 分钟前" } "{n} months ago" = { other = "{n} 个月前" } +"{n} more notes omitted from this report" = { other = "另有 {n} 条说明未列入此报告" } "{n} of {total}" = "第 {n} 个,共 {total} 个" "{n} people joined" = { other = "{n} 人已加入" } "{n} preferred components" = { other = "{n} 个首选组件" } From 4a572572dbb79bf66cfae89cdc96463cd503cafb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:25:56 +0700 Subject: [PATCH 117/245] #635: no negative zero from the flipped tint --- crates/format/src/rest/props.rs | 4 ++-- crates/format/src/rest/tests.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 0ee2d687..5fe20c71 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -208,8 +208,8 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { saturation: filter("saturation"), temperature: filter("temperature"), // The files' tint runs from green (negative) to magenta (positive), in REST file JSON and - // .fig alike; a positive native tint adds green. - tint: -filter("tint"), + // .fig alike; a positive native tint adds green. (`0 - x`: no negative zero.) + tint: 0.0 - filter("tint"), highlights: filter("highlights"), shadows: filter("shadows"), }, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 97e5d828..281d0896 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -194,7 +194,7 @@ fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] }) }; - let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5)])); + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5), rect("Neutral", 0.0)])); let image = |name: &str| { let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; image @@ -202,6 +202,7 @@ fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { // The same sign as a `.fig` import (`fig::tests::image_filters`). assert!(close(f64::from(image("Magenta").filters.tint), -0.75)); assert!(close(f64::from(image("Green").filters.tint), 0.5)); + assert!(image("Neutral").filters.tint.is_sign_positive(), "no negative zero"); assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); } From 02c4d7d8259c44ba536a347502e023cc7cae7a8c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:27:08 +0700 Subject: [PATCH 118/245] #620: test the summary and report of a file opened from data --- crates/ui-egui/src/import_report_tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index bc5fc8bb..8742ccd3 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -185,3 +185,19 @@ fn a_dropped_file_shows_its_report_once_it_opens() { let report = u.app.ui.import_report.as_ref().expect("the dropped file's report"); assert_eq!(report.uid, u.app.session.ds().unwrap().uid); } + +#[test] +fn a_file_opened_from_base64_data_has_a_summary_too() { + use base64::Engine as _; + let file = json!({"document": {"id": "0:0", "name": "From data", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [{"id": "1:1", "name": "No type"}]}]}}); + let data = base64::engine::general_purpose::STANDARD.encode(file.to_string()); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + let opened = app.run_user("file.open", json!({"data": data, "name": "Dropped.json"})).unwrap(); + assert_eq!(opened["summary"]["pages"], 1); + let report = app.ui.import_report.as_ref().expect("a report for the REST import"); + assert_eq!(report.pages, Some(1)); + assert!(report.notes.iter().any(|n| n == "layers without a type were skipped"), "{:?}", report.notes); + // Shown as a sentence, worked out once when the report opens. + assert!(report.shown.iter().any(|n| n == "Layers without a type were skipped"), "{:?}", report.shown); +} From 946006f1007c8a17118e50dd496017920a30ad4f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:56 +0700 Subject: [PATCH 119/245] #647: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63f281f6..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (paste from Figma (desktop); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -36,7 +36,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text, **layers copied in Figma** (desktop; best effort) (newest clipboard content wins) | layers copied in Figma arrive as a `.fig` canvas in the clipboard's HTML; `edit.pasteFromFigma` reads it with the `.fig` import and pastes it as Paste does (into the selected frame, in place or in the view's centre), with `warnings`. Image fills come without their pixels (the clipboard doesn't carry them). Not in the browser (its paste event carries plain text only). Tested on copies written by our own `.fig` export, not yet on real Figma copies | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` | ## Export @@ -162,7 +162,6 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | Paste from Figma (desktop): layers copied in Figma paste as layers. Paste reads the clipboard's HTML; its `.fig` canvas goes through the `.fig` import and lands as Paste places our own layers (`edit.pasteFromFigma`, with `warnings`). Tested on copies written by our own `.fig` export only. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 3353d2d5d3c399ec4c586f35ea5ccb0edef8a10a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:57 +0700 Subject: [PATCH 120/245] #647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64 --- crates/engine/src/cmd/edit.rs | 51 ++++++++++-------- ...a_paste_tests.rs => design_paste_tests.rs} | 52 ++++++++++++------- crates/engine/src/lib.rs | 4 +- ...a_paste_tests.rs => design_paste_tests.rs} | 0 4 files changed, 65 insertions(+), 42 deletions(-) rename crates/engine/src/{figma_paste_tests.rs => design_paste_tests.rs} (53%) rename crates/ui-egui/src/{figma_paste_tests.rs => design_paste_tests.rs} (100%) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27d9652e..21f09569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -26,11 +26,11 @@ pub fn specs() -> Vec { spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), spec!( - "edit.pasteFromFigma", - "Paste from Figma", - "{html (the clipboard's HTML) | data (base64 of the copied buffer), parent?, x?, y?} layers copied in Figma, read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + "edit.pasteDesignLayers", + "Paste design layers", + "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", true, - paste_from_figma + paste_design_layers ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), @@ -544,35 +544,42 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied { (nodes, images, bounds) } -const MAX_FIGMA_CLIP: usize = 512 * 1024 * 1024; +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; +pub const CLIP_CLOSE: &str = "(/figma)"; +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; -/// Figma puts the copied layers in the HTML as `(figma)(/figma)`. This gives those bytes. -pub fn figma_clipboard_buffer(html: &str) -> Option> { +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { use base64::Engine as _; - const OPEN: &str = "(figma)"; - let start = html.find(OPEN)? + OPEN.len(); - let len = html.get(start..)?.find("(/figma)")?; - let text = html.get(start..start + len)?; - if text.len() > MAX_FIGMA_CLIP { + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { return None; } let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - let bytes = - base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()?; + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; figcraft_format::fig::is_fig(&bytes).then_some(bytes) } -fn paste_from_figma(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "edit.pasteFromFigma"; +fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { - (Some(html), _) => figma_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no layers copied in Figma"))?, - (None, Some(d)) => { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad(CMD, e.to_string()))? - } + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, (None, None) => return Err(bad(CMD, "pass `html` or `data`")), }; - let other = super::file::load_fig(&bytes, "Pasted from Figma").ok_or_else(|| bad(CMD, "the data is not layers copied in Figma"))??; + let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; let doc = std::sync::Arc::new(other.doc); let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); if tops.is_empty() { diff --git a/crates/engine/src/figma_paste_tests.rs b/crates/engine/src/design_paste_tests.rs similarity index 53% rename from crates/engine/src/figma_paste_tests.rs rename to crates/engine/src/design_paste_tests.rs index 7468f3ac..89de80ef 100644 --- a/crates/engine/src/figma_paste_tests.rs +++ b/crates/engine/src/design_paste_tests.rs @@ -1,8 +1,11 @@ +//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in +//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers. + use base64::Engine as _; use serde_json::json; use super::*; -use crate::cmd::edit::figma_clipboard_buffer; +use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; @@ -20,12 +23,9 @@ fn copied_file() -> Vec { } fn clipboard_html(buffer: &[u8]) -> String { - let meta = B64.encode(br#"{"dataType":"scene"}"#); let mut wrapped = B64.encode(buffer); wrapped.insert(8, '\n'); - format!( - r#""# - ) + format!(r#""#) } fn names(s: &Session, ids: &[NodeId]) -> Vec { @@ -36,18 +36,31 @@ fn names(s: &Session, ids: &[NodeId]) -> Vec { #[test] fn buffer_is_found_in_the_html() { let file = copied_file(); - assert_eq!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file)); - assert_eq!(figma_clipboard_buffer("

plain

"), None); - assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None); - assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None); - assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None); + assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(design_clipboard_buffer("

plain

"), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None); + // Web text that only mentions the markers holds no design data. + assert_eq!(design_clipboard_buffer(&format!("

see {CLIP_OPEN} and {CLIP_CLOSE} in the docs

")), None); +} + +#[test] +fn oversized_design_data_is_refused_before_decoding() { + // Just past the cap (256 MB of data, as base64), made of valid base64 characters. + let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None); + let mut s = Session::new(); + let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err(); + assert!(e.to_string().contains("256 MB"), "{e}"); + assert!(s.clipboard.nodes.is_empty()); } #[test] fn layers_paste_into_the_page_and_undo() { let mut s = Session::new(); let page = s.page().unwrap(); - let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); assert_eq!(names(&s, &ids), ["Card", "Dot"]); assert_eq!(s.selection(), ids); @@ -61,9 +74,12 @@ fn layers_paste_into_the_page_and_undo() { } #[test] -fn a_second_paste_repeats_the_figma_layers() { +fn a_second_paste_repeats_the_copied_layers() { let mut s = Session::new(); - s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).unwrap(); + // `data` may be wrapped and unpadded, like the HTML's base64. + let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string(); + data.insert(8, '\n'); + s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap(); let r = s.execute("edit.paste", &json!({})).unwrap(); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); assert_eq!(names(&s, &ids), ["Card", "Dot"]); @@ -74,7 +90,7 @@ fn pastes_into_the_selected_frame() { let mut s = Session::new(); let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); s.execute("select.set", &json!({"ids": [host]})).unwrap(); - let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); let first = params::id_of(&r["ids"][0]).unwrap(); assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); } @@ -83,10 +99,10 @@ fn pastes_into_the_selected_frame() { fn other_content_is_refused_without_changes() { let mut s = Session::new(); let before = s.doc().unwrap().nodes().count(); - assert!(s.execute("edit.pasteFromFigma", &json!({})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"html": "hi"})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); assert_eq!(s.doc().unwrap().nodes().count(), before); assert!(s.clipboard.nodes.is_empty()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 908c952a..b124c1e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1500,6 +1500,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1528,8 +1530,6 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] -mod figma_paste_tests; -#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; diff --git a/crates/ui-egui/src/figma_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs similarity index 100% rename from crates/ui-egui/src/figma_paste_tests.rs rename to crates/ui-egui/src/design_paste_tests.rs From d07b55104d54a8c487aa6e17c0557dbeaecb7469 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:58 +0700 Subject: [PATCH 121/245] #647: paste takes the design path only when the HTML holds readable design data; neutral wording in every catalog --- crates/ui-egui/locales/cs.toml | 2 ++ crates/ui-egui/locales/de.toml | 4 +-- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 4 +-- crates/ui-egui/locales/fr.toml | 4 +-- crates/ui-egui/locales/id.toml | 2 ++ crates/ui-egui/locales/ja.toml | 4 +-- crates/ui-egui/locales/ko.toml | 4 +-- crates/ui-egui/locales/pt-BR.toml | 4 +-- crates/ui-egui/locales/ru.toml | 2 ++ crates/ui-egui/locales/sv.toml | 2 ++ crates/ui-egui/locales/tr.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 4 +-- crates/ui-egui/src/design_paste_tests.rs | 34 +++++++++++++++++------- crates/ui-egui/src/lib.rs | 4 +-- crates/ui-egui/src/menus.rs | 12 +++++---- 16 files changed, 59 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..2504193a 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -930,6 +930,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Vložte odkaz na týmovou knihovnu (odkaz pro prohlížení)" "Paste here" = "Vložit sem" "Paste to replace" = "Vložit a nahradit" +"Pasted design layers; {n} kinds of content were left out" = { one = "Vloženy vrstvy návrhu; vynechán {n} druh obsahu", few = "Vloženy vrstvy návrhu; vynechány {n} druhy obsahu", other = "Vloženy vrstvy návrhu; vynecháno {n} druhů obsahu" } "Pattern" = "Vzor" "Pattern alignment" = "Zarovnání vzoru" "Pattern scale" = "Měřítko vzoru" @@ -1708,6 +1709,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplikovat" "edit.findReplace" = "Najít a nahradit" "edit.paste" = "Vložit" +"edit.pasteDesignLayers" = "Vložit vrstvy návrhu" "edit.pasteOver" = "Vložit přes výběr" "edit.pasteProperties" = "Vložit vlastnosti" "edit.pasteReplace" = "Vložit a nahradit" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index e4749235..3240a642 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -899,7 +899,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Link einer Teambibliothek einfügen (ein Ansichtslink)" "Paste here" = "Hier einfügen" "Paste to replace" = "Zum Ersetzen einfügen" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Aus Figma eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Aus Figma eingefügt; {n} Arten von Inhalt wurden ausgelassen" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Designebenen eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Designebenen eingefügt; {n} Arten von Inhalt wurden ausgelassen" } "Pattern" = "Muster" "Pattern alignment" = "Musterausrichtung" "Pattern scale" = "Musterskalierung" @@ -1655,7 +1655,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplizieren" "edit.findReplace" = "Suchen und ersetzen" "edit.paste" = "Einfügen" -"edit.pasteFromFigma" = "Aus Figma einfügen" +"edit.pasteDesignLayers" = "Designebenen einfügen" "edit.pasteOver" = "Über Auswahl einfügen" "edit.pasteProperties" = "Eigenschaften einfügen" "edit.pasteReplace" = "Zum Ersetzen einfügen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index def0b827..627d0155 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -945,7 +945,7 @@ name = "English" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" "Paste here" = "Paste here" "Paste to replace" = "Paste to replace" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Pasted from Figma; {n} kind of content was left out", other = "Pasted from Figma; {n} kinds of content were left out" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" } "Pattern" = "Pattern" "Pattern alignment" = "Pattern alignment" "Pattern scale" = "Pattern scale" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 38c868b6..a6bbb17a 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -946,7 +946,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" "Paste here" = "Pegar aquí" "Paste to replace" = "Pegar para reemplazar" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Pegado desde Figma; se omitió {n} tipo de contenido", other = "Pegado desde Figma; se omitieron {n} tipos de contenido" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Capas de diseño pegadas; se omitió {n} tipo de contenido", other = "Capas de diseño pegadas; se omitieron {n} tipos de contenido" } "Pattern" = "Patrón" "Pattern alignment" = "Alineación del patrón" "Pattern scale" = "Escala del patrón" @@ -1734,7 +1734,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Buscar y reemplazar" "edit.paste" = "Pegar" -"edit.pasteFromFigma" = "Pegar desde Figma" +"edit.pasteDesignLayers" = "Pegar capas de diseño" "edit.pasteOver" = "Pegar sobre la selección" "edit.pasteProperties" = "Pegar propiedades" "edit.pasteReplace" = "Pegar para reemplazar" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 2f5ae5b9..8074a006 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -893,7 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Collez le lien d’une bibliothèque d’équipe (un lien de consultation)" "Paste here" = "Coller ici" "Paste to replace" = "Coller pour remplacer" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Collé depuis Figma ; {n} type de contenu a été omis", other = "Collé depuis Figma ; {n} types de contenu ont été omis" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Calques de design collés ; {n} type de contenu a été omis", other = "Calques de design collés ; {n} types de contenu ont été omis" } "Pattern" = "Motif" "Pattern alignment" = "Alignement du motif" "Pattern scale" = "Échelle du motif" @@ -1646,7 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "Dupliquer" "edit.findReplace" = "Rechercher et remplacer" "edit.paste" = "Coller" -"edit.pasteFromFigma" = "Coller depuis Figma" +"edit.pasteDesignLayers" = "Coller des calques de design" "edit.pasteOver" = "Coller par-dessus la sélection" "edit.pasteProperties" = "Coller les propriétés" "edit.pasteReplace" = "Coller pour remplacer" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..c39cd73f 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -934,6 +934,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Tempel link pustaka tim (link akses lihat)" "Paste here" = "Tempel di sini" "Paste to replace" = "Tempel dan ganti" +"Pasted design layers; {n} kinds of content were left out" = { other = "Layer desain ditempel; {n} jenis konten tidak disertakan" } "Pattern" = "Pola" "Pattern alignment" = "Perataan pola" "Pattern scale" = "Skala pola" @@ -1712,6 +1713,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplikat" "edit.findReplace" = "Cari dan ganti" "edit.paste" = "Tempel" +"edit.pasteDesignLayers" = "Tempel layer desain" "edit.pasteOver" = "Tempel di atas pilihan" "edit.pasteProperties" = "Tempel properti" "edit.pasteReplace" = "Tempel dan ganti" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 493bf999..bb6938de 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -939,7 +939,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け" "Paste here" = "ここに貼り付け" "Paste to replace" = "貼り付けて置換" -"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma から貼り付け済み。{n} 種類のコンテンツは含まれていない" } +"Pasted design layers; {n} kinds of content were left out" = { other = "デザインレイヤーを貼り付け済み。{n} 種類のコンテンツは含まれていない" } "Pattern" = "パターン" "Pattern alignment" = "パターンの配置" "Pattern scale" = "パターンの拡大縮小" @@ -1724,7 +1724,7 @@ status = "draft — needs native review" "edit.duplicate" = "複製" "edit.findReplace" = "検索と置換" "edit.paste" = "貼り付け" -"edit.pasteFromFigma" = "Figma から貼り付け" +"edit.pasteDesignLayers" = "デザインレイヤーを貼り付け" "edit.pasteOver" = "選択範囲の上に貼り付け" "edit.pasteProperties" = "プロパティを貼り付け" "edit.pasteReplace" = "貼り付けて置換" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 253cc54e..65e9296c 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -893,7 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기" "Paste here" = "여기에 붙여 넣기" "Paste to replace" = "붙여 넣어 바꾸기" -"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma에서 붙여 넣음. 콘텐츠 {n}종은 제외됨" } +"Pasted design layers; {n} kinds of content were left out" = { other = "디자인 레이어를 붙여 넣음. 콘텐츠 {n}종은 제외됨" } "Pattern" = "패턴" "Pattern alignment" = "패턴 정렬" "Pattern scale" = "패턴 배율" @@ -1646,7 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "복제" "edit.findReplace" = "찾기 및 바꾸기" "edit.paste" = "붙여 넣기" -"edit.pasteFromFigma" = "Figma에서 붙여 넣기" +"edit.pasteDesignLayers" = "디자인 레이어 붙여 넣기" "edit.pasteOver" = "선택 항목 위에 붙여 넣기" "edit.pasteProperties" = "속성 붙여 넣기" "edit.pasteReplace" = "붙여 넣어 바꾸기" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 12e7c0b2..b6d21e02 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -892,7 +892,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Cole o link de uma biblioteca de equipe (um link de visualização)" "Paste here" = "Colar aqui" "Paste to replace" = "Colar para substituir" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Colado do Figma; {n} tipo de conteúdo ficou de fora", other = "Colado do Figma; {n} tipos de conteúdo ficaram de fora" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Camadas de design coladas; {n} tipo de conteúdo ficou de fora", other = "Camadas de design coladas; {n} tipos de conteúdo ficaram de fora" } "Pattern" = "Padrão" "Pattern alignment" = "Alinhamento do padrão" "Pattern scale" = "Escala do padrão" @@ -1645,7 +1645,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Localizar e substituir" "edit.paste" = "Colar" -"edit.pasteFromFigma" = "Colar do Figma" +"edit.pasteDesignLayers" = "Colar camadas de design" "edit.pasteOver" = "Colar sobre a seleção" "edit.pasteProperties" = "Colar propriedades" "edit.pasteReplace" = "Colar para substituir" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..7a64557e 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -931,6 +931,7 @@ name = "Русский" "Paste a team library link (a view link)" = "Вставьте ссылку на командную библиотеку (для просмотра)" "Paste here" = "Вставить здесь" "Paste to replace" = "Вставить с заменой" +"Pasted design layers; {n} kinds of content were left out" = { one = "Слои дизайна вставлены; пропущен {n} тип содержимого", few = "Слои дизайна вставлены; пропущены {n} типа содержимого", many = "Слои дизайна вставлены; пропущено {n} типов содержимого", other = "Слои дизайна вставлены; пропущено {n} типа содержимого" } "Pattern" = "Паттерн" "Pattern alignment" = "Выравнивание паттерна" "Pattern scale" = "Масштаб паттерна" @@ -1709,6 +1710,7 @@ name = "Русский" "edit.duplicate" = "Дублировать" "edit.findReplace" = "Найти и заменить" "edit.paste" = "Вставить" +"edit.pasteDesignLayers" = "Вставить слои дизайна" "edit.pasteOver" = "Вставить поверх выделения" "edit.pasteProperties" = "Вставить свойства" "edit.pasteReplace" = "Вставить с заменой" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..c3b7f746 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -932,6 +932,7 @@ name = "Svenska" "Paste a team library link (a view link)" = "Klistra in en länk till ett teambibliotek (en visningslänk)" "Paste here" = "Klistra in här" "Paste to replace" = "Klistra in och ersätt" +"Pasted design layers; {n} kinds of content were left out" = { one = "Designlager inklistrade; {n} typ av innehåll utelämnades", other = "Designlager inklistrade; {n} typer av innehåll utelämnades" } "Pattern" = "Mönster" "Pattern alignment" = "Mönsterjustering" "Pattern scale" = "Mönsterskala" @@ -1591,6 +1592,7 @@ name = "Svenska" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" +"edit.pasteDesignLayers" = "Klistra in designlager" "edit.pasteOver" = "Klistra in över" "edit.pasteProperties" = "Klistra in egenskaper" "edit.pasteReplace" = "Klistra in och ersätt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..531b6526 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -934,6 +934,7 @@ name = "Türkçe" "Paste a team library link (a view link)" = "Bir ekip kitaplığı bağlantısı yapıştırın (görüntüleme bağlantısı)" "Paste here" = "Buraya yapıştır" "Paste to replace" = "Değiştirmek için yapıştır" +"Pasted design layers; {n} kinds of content were left out" = { other = "Tasarım katmanları yapıştırıldı; {n} tür içerik dışarıda bırakıldı" } "Pattern" = "Desen" "Pattern alignment" = "Desen hizalaması" "Pattern scale" = "Desen ölçeği" @@ -1712,6 +1713,7 @@ name = "Türkçe" "edit.duplicate" = "Çoğalt" "edit.findReplace" = "Bul ve değiştir" "edit.paste" = "Yapıştır" +"edit.pasteDesignLayers" = "Tasarım katmanlarını yapıştır" "edit.pasteOver" = "Seçimin üzerine yapıştır" "edit.pasteProperties" = "Özellikleri yapıştır" "edit.pasteReplace" = "Değiştirmek için yapıştır" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 91c251ec..153865bd 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -894,7 +894,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" "Paste here" = "粘贴到此处" "Paste to replace" = "粘贴以替换" -"Pasted from Figma; {n} kinds of content were left out" = { other = "已从 Figma 粘贴;有 {n} 类内容未包含" } +"Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" } "Pattern" = "图案" "Pattern alignment" = "图案对齐" "Pattern scale" = "图案缩放" @@ -1647,7 +1647,7 @@ status = "draft — needs native review" "edit.duplicate" = "创建副本" "edit.findReplace" = "查找和替换" "edit.paste" = "粘贴" -"edit.pasteFromFigma" = "从 Figma 粘贴" +"edit.pasteDesignLayers" = "粘贴设计图层" "edit.pasteOver" = "粘贴到所选项上方" "edit.pasteProperties" = "粘贴属性" "edit.pasteReplace" = "粘贴以替换" diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index 6147ba5e..ad33e005 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -1,4 +1,5 @@ use figcraft_engine::Session; +use figcraft_engine::cmd::edit::{CLIP_CLOSE, CLIP_OPEN}; use serde_json::json; use crate::{FigApp, Services}; @@ -17,11 +18,12 @@ impl Services for Clip { } } -fn figma_html() -> String { +/// The HTML another design app puts on the clipboard for copied layers (a `.fig` canvas inside). +fn copied_html() -> String { let mut s = Session::new(); - s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "From Figma"})).unwrap(); + s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "Copied layer"})).unwrap(); let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); - format!(r#""#, r["data"].as_str().unwrap()) + format!(r#""#, r["data"].as_str().unwrap()) } fn selected_names(app: &FigApp) -> Vec { @@ -29,12 +31,18 @@ fn selected_names(app: &FigApp) -> Vec { app.session.selection().iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() } +fn pasted_text(app: &FigApp) -> String { + let doc = app.session.doc().unwrap(); + let id = app.session.selection()[0]; + doc.node(id).unwrap().kind.text().unwrap().characters.clone() +} + #[test] -fn paste_takes_layers_copied_in_figma() { - let html = figma_html(); +fn paste_takes_layers_copied_in_another_design_app() { + let html = copied_html(); let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) })); crate::menus::paste(&mut app, None); - assert_eq!(selected_names(&app), ["From Figma"]); + assert_eq!(selected_names(&app), ["Copied layer"]); } #[test] @@ -42,7 +50,15 @@ fn html_from_other_apps_pastes_as_before() { let clip = Clip { html: "Hello".into(), text: Some("Hello".into()) }; let mut app = FigApp::new(Session::new(), Box::new(clip)); crate::menus::paste(&mut app, None); - let doc = app.session.doc().unwrap(); - let id = app.session.selection()[0]; - assert_eq!(doc.node(id).unwrap().kind.text().unwrap().characters, "Hello"); + assert_eq!(pasted_text(&app), "Hello"); +} + +#[test] +fn html_that_only_mentions_the_marker_pastes_its_text() { + let text = format!("Markers {CLIP_OPEN} and {CLIP_CLOSE} explained"); + let clip = Clip { html: format!("

{text}

"), text: Some(text.clone()) }; + let mut app = FigApp::new(Session::new(), Box::new(clip)); + crate::menus::paste(&mut app, None); + assert_eq!(pasted_text(&app), text); + assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04fe3861..e325f4df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,8 @@ mod comments_tests; #[cfg(test)] mod cursors_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_mode_tests; #[cfg(test)] mod duplicate_paint_tests; @@ -94,8 +96,6 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] -mod figma_paste_tests; -#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 36dfd237..8bd98cc5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -31,14 +31,16 @@ pub const UI_COMMANDS: &[&str] = &[ /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. /// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied -/// in Figma, an image, SVG or text from another app); otherwise our copied layers. `event_text` is -/// the text the platform handed over with the paste event (the browser has no other way to read it). +/// from another design app, an image, SVG or text from another app); otherwise our copied layers. +/// `event_text` is the text the platform handed over with the paste event (the browser has no +/// other way to read it). pub fn paste(app: &mut FigApp, event_text: Option<&str>) { - if let Some(html) = app.services.clipboard_get_html().filter(|h| h.contains("(figma)")) { - if let Some(r) = app.run("edit.pasteFromFigma", json!({"html": html})) { + // Only HTML that holds readable design data takes this path; other HTML pastes as before. + if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { + if let Some(r) = app.run("edit.pasteDesignLayers", json!({"html": html})) { let left_out = r["warnings"].as_array().map_or(0, Vec::len); if left_out > 0 { - app.toast(trn("Pasted from Figma; {n} kinds of content were left out", left_out, &[])); + app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); } } return; From 25e9be3936f15dd67ae034e4debbbd3832817416 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:49:48 +0700 Subject: [PATCH 122/245] #693: clipboard reads on Linux and the BSDs give up after 2 s A Wayland clipboard read waits on the app that copied with no timeout of its own. The read now runs on a helper thread with its own clipboard handle, so a stuck app can't hang the window. --- apps/figcraft/src/services.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..56b14d96 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -223,6 +223,28 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// How long a clipboard read may wait on the app that copied (Linux and the BSDs). +#[cfg(all(unix, not(target_os = "macos")))] +const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle +/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no +/// timeout of its own, so an app that hangs must not hang the window too. +#[cfg(all(unix, not(target_os = "macos")))] +fn read_clipboard(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c))); + }); + rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten() +} + +/// Read the clipboard (the system's own reads don't wait on the app that copied). +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn read_clipboard(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + read(c) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) @@ -239,7 +261,7 @@ impl Services for Desktop { } } fn clipboard_get_text(&mut self) -> Option { - self.clipboard.as_mut()?.get_text().ok() + read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok()) } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { @@ -247,7 +269,7 @@ impl Services for Desktop { } } fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { - let img = self.clipboard.as_mut()?.get_image().ok()?; + let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?; let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); // Refuse absurd sizes (and mismatched buffers) rather than allocate. if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 { From ef684c5df4bbde407400194260aa537c2636cbc5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:58:06 +0700 Subject: [PATCH 123/245] #691: leave the parity docs to the maintainers' docs PR --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a8dfab69..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -48,7 +48,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped; Shift-click takes a selected layer out on release, so a Shift-drag on it moves the selection along one axis instead (`selection_behaviour_tests`) | +| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | | Select layer menu (every layer under the click) | done | | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | From 22a821c7420b8a7ba3bf937adea829b778150756 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:03:02 +0700 Subject: [PATCH 124/245] #657: leave the roadmap to the maintainers' docs PR --- docs/roadmap.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 680c5b68..9e9eaec9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (points dropped on a point merge (#401); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,7 +73,6 @@ blockers. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From 58de7d4daba16ab95393bb0732b6ba4afffbed94 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:06:37 +0700 Subject: [PATCH 125/245] #657: a drop that would leave no segment only moves the point Dropping one end of a 2-point line on the other left a 1-point vector. --- crates/engine/src/vectoredit/pointer.rs | 8 +++++++- crates/engine/src/vectoredit/tests.rs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index a42b0603..6667a209 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -1082,10 +1082,16 @@ impl Session { else { return Ok(()); }; + let mut merged = net.clone(); + ops::merge_vertices(&mut merged, keep, v); + // A merge that would leave no segment (a 2-point line's ends dropped together) only moves. + if merged.segments.is_empty() { + return Ok(()); + } // The preview starts again from the document before the drag: bring the moved layer. self.preview(|doc, _| { *doc.get_mut(id)? = moved; - edit(doc, id, |net| ops::merge_vertices(net, keep, v)) + edit(doc, id, |net| *net = merged) })?; set_state(self, |e| { e.vertices = vec![if keep > v { keep - 1 } else { keep }]; diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index 63469d67..0eba28e3 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -300,6 +300,14 @@ fn a_point_dropped_on_another_merges_into_it() { ex(&mut s, "vector.edit.select", json!({"vertices": []})); drag(&mut s, (0.0, 100.0), (0.0, 50.0)); assert_eq!(net(&s, id).vertices.len(), 4); + // A 2-point line's end dropped on its other end only moves: no segment would be left. + let mut s = Session::new(); + let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + drag(&mut s, (100.0, 0.0), (2.0, 0.0)); + let n = net(&s, id); + assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}"); } #[test] From bbfc408ab8ceb4553fe1cfd9b0ee8c9f5b935b4d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:06:47 +0700 Subject: [PATCH 126/245] #654: leave the roadmap to the maintainers' docs PR --- docs/roadmap.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8acce139..9e9eaec9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flatten keeps open paths (#377); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,7 +73,6 @@ blockers. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From 426217327b1a569837cb771a1fa86db91d7d5a6b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:11:23 +0700 Subject: [PATCH 127/245] #647: text copied in another app after a design paste pastes as text again --- crates/ui-egui/src/design_paste_tests.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 ++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index ad33e005..3174275a 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -62,3 +62,26 @@ fn html_that_only_mentions_the_marker_pastes_its_text() { assert_eq!(pasted_text(&app), text); assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); } + +/// A clipboard whose contents the test replaces between pastes. +struct SharedClip(std::rc::Rc>); + +impl Services for SharedClip { + fn clipboard_get_html(&mut self) -> Option { + Some(self.0.borrow().html.clone()) + } + fn clipboard_get_text(&mut self) -> Option { + self.0.borrow().text.clone() + } +} + +#[test] +fn text_copied_after_a_design_paste_pastes_as_text() { + let clip = std::rc::Rc::new(std::cell::RefCell::new(Clip { html: copied_html(), text: None })); + let mut app = FigApp::new(Session::new(), Box::new(SharedClip(clip.clone()))); + crate::menus::paste(&mut app, None); + assert_eq!(selected_names(&app), ["Copied layer"]); + *clip.borrow_mut() = Clip { html: String::new(), text: Some("Fresh text".into()) }; + crate::menus::paste(&mut app, None); + assert_eq!(pasted_text(&app), "Fresh text"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8bd98cc5..8e86953c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -38,6 +38,8 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) { // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { if let Some(r) = app.run("edit.pasteDesignLayers", json!({"html": html})) { + // The pasted layers are now our copy: text another app puts on the clipboard later is newer. + app.ui.last_copy = Some(app.services.clipboard_get_text().unwrap_or_default()); let left_out = r["warnings"].as_array().map_or(0, Vec::len); if left_out > 0 { app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); From 45b154e40b2217af232fe7ce1c5c573a52077784 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:20:08 +0700 Subject: [PATCH 128/245] #707: drop the unrelated .gitignore line and the parity doc edit --- .gitignore | 2 -- docs/ui-parity.md | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3709c74a..928d6b49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,3 @@ plans/ /log/ .DS_Store *.profraw - -plugins/target/ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4a7bf45b..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,7 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | -| Actions palette (Cmd+K, Cmd+/) | done | tabs (All, Assets, Plugins & widgets), Recents kept in the preferences, commands grouped by menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles; rows carry icons and shortcut hints (`palette_tests`) | +| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | | Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | From 3a8e6e2860c082ec32df56718caa9af73ffc75ba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:20:08 +0700 Subject: [PATCH 129/245] #707: search every command, ranked; a highlight for Enter; recents from the palette only - A search covers every command again (the engine's and the UI's own), grouped by menu where one lists it and under Other otherwise, best matches first (prefix, then shortest), at most 60. Without a query the menus' items show as before. - Up and Down move a visible highlight (the pointer moves it too); Enter runs the highlighted row, not an invisible first one. - Only runs from the palette are kept as Recents; menus, shortcuts and context menus no longer write the preferences. Recents match the translated label. - The Plugins menu's items (run last, manage, import) show with the plugins. - The search keeps its hint, so every catalog keeps its translation. - The panel sits above the toolbar pill's own rect (toolbar::pill_rect); one set_palette/toggle_palette opens it from every entry point. - Clippy: needless borrows in the toolbar. --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/lib.rs | 13 +- crates/ui-egui/src/menus.rs | 376 ++++++++++++++-------------- crates/ui-egui/src/palette_tests.rs | 86 +++++-- crates/ui-egui/src/plugins_ui.rs | 2 - crates/ui-egui/src/toolbar.rs | 21 +- 19 files changed, 291 insertions(+), 230 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e86da423..268ff527 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1133,6 +1133,7 @@ status = "draft — needs native review" "Scroll with parent" = "Posouvat s nadřazeným prvkem" "Search plugins" = "Hledat pluginy" "Search" = "Hledat" +"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" "Search assets" = "Hledat zdroje" "Search comments" = "Hledat komentáře" "Search fonts" = "Hledat písma" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 862527e4..cd1e3482 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1096,6 +1096,7 @@ status = "draft — needs native review" "Scroll to layer" = "Zur Ebene scrollen" "Scroll with parent" = "Mit übergeordnetem Element scrollen" "Search" = "Suchen" +"Search actions, layers and tools" = "Aktionen, Ebenen und Werkzeuge suchen" "Search assets" = "Assets suchen" "Search comments" = "Kommentare suchen" "Search fonts" = "Schriften suchen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 8894ece1..514b6975 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -913,6 +913,7 @@ name = "English" "Orange" = "Orange" "Order" = "Order" "Other apps may not open every .fig file exported here." = "Other apps may not open every .fig file exported here." +"Other" = "Other" "Others lose access and keep their copy; the file stays on the server for you" = "Others lose access and keep their copy; the file stays on the server for you" "Others type this under Share ▸ Join with a code" = "Others type this under Share ▸ Join with a code" "Outline stroke" = "Outline stroke" @@ -1152,7 +1153,7 @@ name = "English" "Scroll with parent" = "Scroll with parent" "Search plugins" = "Search plugins" "Search" = "Search" -"Search actions, assets and plugins" = "Search actions, assets and plugins" +"Search actions, layers and tools" = "Search actions, layers and tools" "Search assets" = "Search assets" "Search comments" = "Search comments" "Search fonts" = "Search fonts" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d5e353cf..55e228dd 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -914,6 +914,7 @@ status = "draft — needs native review" "Orange" = "Naranja" "Order" = "Orden" "Other apps may not open every .fig file exported here." = "Puede que otras aplicaciones no abran todos los archivos .fig exportados aquí." +"Other" = "Otros" "Others lose access and keep their copy; the file stays on the server for you" = "Los demás pierden el acceso y conservan su copia; el archivo se queda en el servidor para ti" "Others type this under Share ▸ Join with a code" = "Los demás lo escriben en Compartir ▸ Unirse con un código" "Outline stroke" = "Contornear trazo" @@ -971,7 +972,7 @@ status = "draft — needs native review" "Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza." "Plugins" = "Plugins" -"Plugins & widgets" = "Plugins & widgets" +"Plugins & widgets" = "Plugins y widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" "Point" = "Punto" @@ -1153,8 +1154,8 @@ status = "draft — needs native review" "Scroll with parent" = "Desplazar con el padre" "Search plugins" = "Buscar plugins" "Search" = "Buscar" +"Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" -"Search actions, assets and plugins" = "Buscar acciones, recursos y plugins" "Search comments" = "Buscar comentarios" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 2c1fbc45..7fafd3c9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1090,6 +1090,7 @@ status = "draft — needs native review" "Scroll to layer" = "Défiler jusqu’au calque" "Scroll with parent" = "Défiler avec le parent" "Search" = "Rechercher" +"Search actions, layers and tools" = "Rechercher des actions, des calques et des outils" "Search assets" = "Rechercher des ressources" "Search comments" = "Rechercher des commentaires" "Search fonts" = "Rechercher des polices" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 53825747..5de7634d 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1137,6 +1137,7 @@ status = "draft — needs native review" "Scroll with parent" = "Gulir bersama induk" "Search plugins" = "Cari plugin" "Search" = "Cari" +"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" "Search assets" = "Cari aset" "Search comments" = "Cari komentar" "Search fonts" = "Cari font" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 670f84fc..4d4d6d34 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1140,6 +1140,7 @@ status = "draft — needs native review" "Scroll to layer" = "レイヤーまでスクロール" "Scroll with parent" = "親と一緒にスクロール" "Search" = "検索" +"Search actions, layers and tools" = "アクション、レイヤー、ツールを検索" "Search assets" = "アセットを検索" "Search comments" = "コメントを検索" "Search fonts" = "フォントを検索" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 1f7bed0c..ec732905 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1090,6 +1090,7 @@ status = "draft — needs native review" "Scroll to layer" = "레이어로 스크롤" "Scroll with parent" = "부모와 함께 스크롤" "Search" = "검색" +"Search actions, layers and tools" = "액션, 레이어, 도구 검색" "Search assets" = "에셋 검색" "Search comments" = "댓글 검색" "Search fonts" = "글꼴 검색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 2fb61d12..c2189de8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1089,6 +1089,7 @@ status = "draft — needs native review" "Scroll to layer" = "Rolar até a camada" "Scroll with parent" = "Rolar com o pai" "Search" = "Pesquisar" +"Search actions, layers and tools" = "Pesquisar ações, camadas e ferramentas" "Search assets" = "Pesquisar recursos" "Search comments" = "Pesquisar comentários" "Search fonts" = "Pesquisar fontes" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 78a8afd6..3ab40757 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1134,6 +1134,7 @@ name = "Русский" "Scroll with parent" = "Прокручивать с родителем" "Search plugins" = "Поиск плагинов" "Search" = "Поиск" +"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" "Search fonts" = "Поиск шрифтов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index a54c02e7..f7b2999d 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1135,6 +1135,7 @@ name = "Svenska" "Scroll with parent" = "Rulla med överordnat element" "Search plugins" = "Sök plugin-program" "Search" = "Sök" +"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg" "Search assets" = "Sök tillgångar" "Search comments" = "Sök i kommentarer" "Search fonts" = "Sök typsnitt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 43603287..a2be6b99 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1137,6 +1137,7 @@ name = "Türkçe" "Scroll with parent" = "Üst öğeyle birlikte kaydır" "Search plugins" = "Eklentilerde ara" "Search" = "Ara" +"Search actions, layers and tools" = "Eylem, katman ve araç ara" "Search assets" = "Varlıklarda ara" "Search comments" = "Yorumlarda ara" "Search fonts" = "Yazı tiplerinde ara" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a42360e4..af4cf653 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1091,6 +1091,7 @@ status = "draft — needs native review" "Scroll to layer" = "滚动到图层" "Scroll with parent" = "随父级滚动" "Search" = "搜索" +"Search actions, layers and tools" = "搜索操作、图层和工具" "Search assets" = "搜索资源" "Search comments" = "搜索评论" "Search fonts" = "搜索字体" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6e55c8a4..9a97081f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -414,9 +414,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs); } if let Some(h) = p.get("palette").and_then(Value::as_bool) { - app.ui.palette = if h { Some(String::new()) } else { None }; - app.ui.palette_tab = crate::menus::PaletteTab::All; - app.ui.palette_armed = true; + crate::menus::set_palette(&mut app.ui, h); } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34f6ac0d..4a047c89 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,6 +408,8 @@ pub struct UiState { pub palette: Option, /// The Actions palette's tab. pub palette_tab: menus::PaletteTab, + /// The palette row Up and Down are on (Enter runs it). + pub palette_hi: usize, /// The palette ignores one outside click after opening (the click that opened it). pub palette_armed: bool, /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. @@ -542,6 +544,7 @@ impl Default for UiState { main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, + palette_hi: 0, palette_armed: false, actions_rect: None, variables: None, @@ -799,15 +802,7 @@ impl FigApp { } "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, - "palette.actions" => { - if self.ui.palette.is_some() { - self.ui.palette = None; - } else { - self.ui.palette = Some(String::new()); - self.ui.palette_tab = menus::PaletteTab::All; - self.ui.palette_armed = true; - } - } + "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 73bee5bc..e54f0090 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,8 +7,8 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::icons::{self, Icon}; use crate::i18n::{self, tr, tr_noop, trf}; +use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular}; use crate::toolbar::pretty_shortcut; @@ -82,7 +82,7 @@ pub enum PaletteTab { Plugins, } -/// Remember a ran action for the palette's Recents (newest first, capped). +/// Remember an action run from the palette for its Recents (newest first, capped). pub fn record_recent(app: &mut FigApp, key: &str) { let r = &mut app.session.prefs.recent_actions; if r.first().is_some_and(|f| f == key) { @@ -94,7 +94,6 @@ pub fn record_recent(app: &mut FigApp, key: &str) { } pub fn invoke(app: &mut FigApp, id: &str) { - record_recent(app, &format!("command:{id}")); match id { "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), @@ -434,7 +433,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { t.menu_dim, ); if resp.clicked() { - app.ui.palette = Some(String::new()); + set_palette(&mut app.ui, true); close = true; } ui.add_space(6.0); @@ -657,19 +656,31 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) /// The Actions palette's content height (the frame adds its own 8 px margins). const PALETTE_H: f32 = 450.0; -/// The Actions palette: search over commands, assets and plugins, in Figma's shape (tabs, -/// recents, grouped sections). Rows run on click; Enter runs the first visible row. +/// Open the Actions palette afresh (empty search, All tab, first row highlighted), or close it. +pub fn set_palette(ui: &mut crate::UiState, open: bool) { + ui.palette = open.then(String::new); + ui.palette_tab = PaletteTab::All; + ui.palette_hi = 0; + ui.palette_armed = open; +} + +/// Open the Actions palette afresh, or close it when it's open (⌘K, the toolbar's Actions button). +pub fn toggle_palette(ui: &mut crate::UiState) { + let open = ui.palette.is_none(); + set_palette(ui, open); +} + +/// The Actions palette: search over commands, assets and plugins, with tabs, recents and grouped +/// sections, floating above the toolbar. Rows run on click; Up and Down move the highlight and +/// Enter runs the highlighted row. fn palette(app: &mut FigApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let t = Tokens::get(ctx); // Same centre and width as the toolbar pill (it centres on the canvas, not the window), // floating 8 px above it. Fixed size: switching tabs never resizes the panel. let bar_w = crate::toolbar::main_width(); - let canvas = app.canvas.rect; - let area = Rect::from_min_size( - pos2(canvas.center().x - bar_w / 2.0, canvas.max.y - 68.0 - (PALETTE_H + 16.0)), - vec2(bar_w, PALETTE_H + 16.0), - ); + let bar = crate::toolbar::pill_rect(app.canvas.rect, bar_w); + let area = Rect::from_min_max(pos2(bar.min.x, bar.min.y - 8.0 - (PALETTE_H + 16.0)), pos2(bar.max.x, bar.min.y - 8.0)); let mut run: Option = None; let mut close = false; // A press anywhere outside closes the panel. The opening press is spared once, and the @@ -684,135 +695,93 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { } else if !pressed { app.ui.palette_armed = false; } - egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( - ctx, - |ui| { - egui::Frame::NONE - .fill(t.panel) - .corner_radius(CornerRadius::same(13)) - .stroke(Stroke::new(1.0, t.border)) - .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) - .inner_margin(egui::Margin::same(8)) - .show(ui, |ui| { - // Same outer width as the toolbar pill, and a fixed height: switching tabs - // never resizes the panel. - let inner = crate::toolbar::main_width() - 16.0; - ui.set_width(inner); - ui.set_height(PALETTE_H); - let r = ui.add( - egui::TextEdit::singleline(&mut q) - .hint_text(tr("Search actions, assets and plugins")) - .desired_width(inner - 16.0) - .font(regular(14.0)) - .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), - ); - r.request_focus(); - crate::widgets::select_all_on_focus(ui, &r, &q); - ui.add_space(4.0); - // Tabs: All, Assets, Plugins & widgets. - let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 0.0); - for (tab, label) in tabs { - let active = app.ui.palette_tab == tab; - // The frame stays on for every tab (transparent when idle) so the - // active pill never moves its neighbours. - let resp = ui.add( - egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) - .frame(true) - .fill(if active { t.hover } else { Color32::TRANSPARENT }) - .corner_radius(CornerRadius::same(6)), - ); - if resp.clicked() { - app.ui.palette_tab = tab; - } - } - }); - ui.add_space(4.0); - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - let mut first: Option = None; - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - let tab = app.ui.palette_tab; - if tab == PaletteTab::All { - let recents: Vec = app - .session - .prefs - .recent_actions - .clone() - .iter() - .filter_map(|key| recent_row(app, key, &q)) - .collect(); - if !recents.is_empty() { - section(ui, &t, tr("Recents"), |ui| { - for row in &recents { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, row) { - run = Some(row.key.clone()); - } - } - }); - } - } - if tab != PaletteTab::Plugins { - for (group, rows) in command_sections(&q) { - if tab == PaletteTab::Assets { - continue; - } - section(ui, &t, &group, |ui| { - for row in rows { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - if tab != PaletteTab::Assets { - let plugins = plugin_rows(app, &q); - let widgets = widget_rows(app, &q); - if !plugins.is_empty() { - section(ui, &t, tr("Plugins"), |ui| { - for row in plugins { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - if !widgets.is_empty() { - section(ui, &t, tr("Widgets"), |ui| { - for row in widgets { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - if tab == PaletteTab::Assets { - for (group, rows) in asset_sections(app, &q) { - section(ui, &t, &group, |ui| { - for row in rows { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - }); - if enter { - if let Some(key) = first { - run = Some(key); + // Taken before the search field sees them (it would move its cursor). + let step = ctx.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); + egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .corner_radius(CornerRadius::same(13)) + .stroke(Stroke::new(1.0, t.border)) + .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { + // Same outer width as the toolbar pill, and a fixed height: switching tabs + // never resizes the panel. + let inner = bar_w - 16.0; + ui.set_width(inner); + ui.set_height(PALETTE_H); + let r = ui.add( + egui::TextEdit::singleline(&mut q) + .hint_text(tr("Search actions, layers and tools")) + .desired_width(inner - 16.0) + .font(regular(14.0)) + .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), + ); + r.request_focus(); + crate::widgets::select_all_on_focus(ui, &r, &q); + // A new search starts again at the first row. + if r.changed() { + app.ui.palette_hi = 0; + } + ui.add_space(4.0); + // Tabs: All, Assets, Plugins & widgets. + let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + for (tab, label) in tabs { + let active = app.ui.palette_tab == tab; + // The frame stays on for every tab (transparent when idle) so the + // active pill never moves its neighbours. + let resp = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) + .frame(true) + .fill(if active { t.hover } else { Color32::TRANSPARENT }) + .corner_radius(CornerRadius::same(6)), + ); + if resp.clicked() { + app.ui.palette_tab = tab; + app.ui.palette_hi = 0; } } }); - }, - ); + ui.add_space(4.0); + let sections = palette_sections(app, &q, app.ui.palette_tab); + let count = sections.iter().map(|(_, rows)| rows.len()).sum::(); + // At either end it stays where it was. + let hi = (app.ui.palette_hi as i64 + step).clamp(0, count.saturating_sub(1) as i64) as usize; + app.ui.palette_hi = hi; + // The pointer moving over a row moves the highlight there. + let pointer_moved = ui.input(|i| i.pointer.delta() != egui::Vec2::ZERO); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + let mut i = 0; + for (name, rows) in §ions { + section(ui, &t, name, |ui| { + for row in rows { + let resp = draw_row(ui, &t, row, i == hi); + if resp.clicked() { + run = Some(row.key.clone()); + } + if pointer_moved && resp.hovered() && i != hi { + app.ui.palette_hi = i; + // Drawn there on the next frame, even if the pointer stops now. + ui.ctx().request_repaint(); + } + if step != 0 && i == hi { + resp.scroll_to_me(None); + } + i += 1; + } + }); + } + }); + if run.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + run = sections.iter().flat_map(|(_, rows)| rows).nth(hi).map(|row| row.key.clone()); + } + }); + }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } @@ -825,10 +794,32 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { let _ = StrokeKind::Inside; } +/// The rows a palette tab shows for the query, by section: All has Recents, the commands by menu, +/// then plugins and widgets; Assets this file's components and styles; Plugins & widgets those. +pub(crate) fn palette_sections(app: &FigApp, q: &str, tab: PaletteTab) -> Vec<(String, Vec)> { + let mut out = match tab { + PaletteTab::All => { + let recents = app.session.prefs.recent_actions.iter().filter_map(|key| recent_row(app, key, q)).collect(); + let mut out = vec![(tr("Recents").to_string(), recents)]; + out.extend(command_sections(q)); + out + } + PaletteTab::Assets => asset_sections(app, q), + PaletteTab::Plugins => Vec::new(), + }; + if tab != PaletteTab::Assets { + out.push((tr("Plugins").to_string(), plugin_rows(app, q))); + out.push((tr("Widgets").to_string(), widget_rows(app, q))); + } + out.retain(|(_, rows)| !rows.is_empty()); + out +} + /// Run a palette row: `command:`, `plugin:/`, `widget:`, /// `component:` or `style:`. -fn run_action(app: &mut FigApp, key: &str) { +pub(crate) fn run_action(app: &mut FigApp, key: &str) { if let Some(id) = key.strip_prefix("command:") { + record_recent(app, key); invoke(app, id); } else if let Some(rest) = key.strip_prefix("plugin:") { if let Some((plugin, command)) = rest.split_once('/') { @@ -869,14 +860,14 @@ fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { rows(ui); } -/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint. -/// True when clicked. -fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { +/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint, +/// `highlighted` when Enter runs it. +fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow, highlighted: bool) -> egui::Response { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); - // The hover pill stays inside the row: it never touches the panel's edges. + // The highlight pill stays inside the row: it never touches the panel's edges. let pill = r.shrink2(vec2(4.0, 1.0)); - if resp.hovered() { + if highlighted { ui.painter().rect_filled(pill, CornerRadius::same(6), t.hover); } let mut label_x = r.min.x + 10.0; @@ -888,7 +879,7 @@ fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { } ui.painter().text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, &row.label, regular(13.0), t.text); ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, &row.hint, regular(11.0), t.text_secondary); - resp.clicked() + resp } /// Whether `label` (or `extra`) matches the query, in any case. @@ -896,28 +887,55 @@ fn matches(q: &str, label: &str, extra: &str) -> bool { q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q) } -/// Commands grouped by top-level menu, in catalog order, filtered by the query. +/// A command's row: its label, and its shortcut as the hint. +fn command_row(id: &str, label: String) -> ActionRow { + ActionRow { key: format!("command:{id}"), label, hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), tile: None } +} + +/// Most rows a search shows (the best matches). +const MAX_HITS: usize = 60; + +/// Commands grouped by top-level menu, filtered by the query. Without one, the menus' items in +/// catalog order; a search covers every command (the engine's and the UI's own, "Other" for those +/// no menu lists), best matches first (labels starting with the query, then the shortest), each +/// group placed by its best row. Labels match in the UI language and in English. The Plugins +/// menu's items come with the plugins ([`plugin_rows`]). pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec)> { - let ql = q.to_lowercase(); - let mut groups: Vec<(String, Vec)> = Vec::new(); - let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for m in figcraft_engine::catalog::MENU.iter() { - if m.label == figcraft_engine::catalog::SEP || m.menu.starts_with("Plugins") || !available(m.id) || !seen.insert(m.id) { + let ql = q.trim().to_lowercase(); + let mut menu_of = std::collections::HashMap::new(); + for m in MENU.iter().filter(|m| m.label != SEP) { + menu_of.entry(m.id).or_insert(m); + } + let menu_items = MENU.iter().filter(|m| m.label != SEP).map(|m| (m.id, m.label)); + let engine = figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)); + let candidates: Box> = if ql.is_empty() { Box::new(menu_items) } else { Box::new(menu_items.chain(engine)) }; + let mut seen = std::collections::HashSet::new(); + // By top-level menu; `None`: no menu lists it. + let mut hits: Vec<(Option<&str>, ActionRow)> = Vec::new(); + for (id, english) in candidates { + if !available(id) || !seen.insert(id) { continue; } - let english = m.label; - let label = i18n::menu_item(m.id, english).into_owned(); - if !matches(&ql, &label, &format!("{english} {}", m.id)) { + let menu = menu_of.get(id); + let group = menu.map(|m| m.menu.split('/').next().unwrap_or(m.menu)); + if group == Some("Plugins") { continue; } - let group = m.menu.split('/').next().unwrap_or(&m.menu); - let group = tr(group).to_string(); - let row = ActionRow { - key: format!("command:{}", m.id), - label, - hint: crate::shortcuts::shortcut(m.id).map(pretty_shortcut).unwrap_or_default(), - tile: None, + let label = match menu { + Some(m) => i18n::menu_item(id, m.label), + None => i18n::command(id, english), }; + if matches(&ql, &label, &format!("{english} {id}")) { + hits.push((group, command_row(id, label.into_owned()))); + } + } + if !ql.is_empty() { + hits.sort_by_cached_key(|(_, row)| (!row.label.to_lowercase().starts_with(&ql), row.label.chars().count())); + hits.truncate(MAX_HITS); + } + let mut groups: Vec<(String, Vec)> = Vec::new(); + for (group, row) in hits { + let group = group.map_or_else(|| tr("Other"), tr).to_string(); match groups.iter_mut().find(|(g, _)| *g == group) { Some((_, rows)) => rows.push(row), None => groups.push((group, vec![row])), @@ -945,6 +963,13 @@ fn plugin_rows(app: &FigApp, q: &str) -> Vec { }); } } + // The Plugins menu's own items (run last, manage, import). + for m in MENU.iter().filter(|m| m.label != SEP && m.menu.split('/').next() == Some("Plugins") && available(m.id)) { + let label = i18n::menu_item(m.id, m.label); + if matches(&ql, &label, &format!("{} {}", m.label, m.id)) { + rows.push(command_row(m.id, label.into_owned())); + } + } rows } @@ -974,21 +999,11 @@ fn asset_sections(app: &FigApp, q: &str) -> Vec<(String, Vec)> { .iter() .filter_map(|id| doc.node(*id)) .filter(|n| matches(&ql, &n.name, "")) - .map(|n| ActionRow { - key: format!("component:{}", n.id.0), - label: n.name.clone(), - hint: tr("Component").to_string(), - tile: None, - }) + .map(|n| ActionRow { key: format!("component:{}", n.id.0), label: n.name.clone(), hint: tr("Component").to_string(), tile: None }) .collect(); let styles: Vec = figcraft_engine::library::local_styles(doc) .filter(|s| matches(&ql, &s.name, "")) - .map(|s| ActionRow { - key: format!("style:{}", s.id), - label: s.name.clone(), - hint: tr("Style").to_string(), - tile: None, - }) + .map(|s| ActionRow { key: format!("style:{}", s.id), label: s.name.clone(), hint: tr("Style").to_string(), tile: None }) .collect(); let mut out = Vec::new(); if !components.is_empty() { @@ -1007,15 +1022,11 @@ pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option let spec = figcraft_engine::find_command(id); let menu_label = figcraft_engine::catalog::MENU.iter().find(|m| m.id == id); let english = menu_label.map(|m| m.label).or_else(|| spec.map(|s| s.label)).unwrap_or(id); - if !available(id) || !matches(&ql, english, id) { + let label = i18n::command(id, english); + if !available(id) || !matches(&ql, &label, &format!("{english} {id}")) { return None; } - return Some(ActionRow { - key: key.to_string(), - label: i18n::command(id, english).into_owned(), - hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), - tile: None, - }); + return Some(command_row(id, label.into_owned())); } if let Some(rest) = key.strip_prefix("plugin:") { let (pid, cid) = rest.split_once('/')?; @@ -1025,12 +1036,7 @@ pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option if !matches(&ql, &label, pid) { return None; } - return Some(ActionRow { - key: key.to_string(), - label, - hint: tr("Plugin").to_string(), - tile: p.manifest.name.chars().next(), - }); + return Some(ActionRow { key: key.to_string(), label, hint: tr("Plugin").to_string(), tile: p.manifest.name.chars().next() }); } if let Some(pid) = key.strip_prefix("widget:") { let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid && p.manifest.widget)?; diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index 06e9fdf5..cfe9c1cd 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -1,26 +1,22 @@ -//! The Actions palette, driven headless: invoking records recents (newest first, capped), -//! sections group commands by menu, the query filters, gone entries drop out of Recents, a used -//! plugin resolves back to a row, and a press outside the panel closes it. +//! The Actions palette, driven headless: runs from it record recents (newest first, capped), +//! sections group commands by menu, a search covers every command with the best matches first, +//! Up/Down move the highlight and Enter runs it, gone entries drop out of Recents, a used plugin +//! resolves back to a row, and a press outside the panel closes it. -use egui::Pos2; +use egui::{Event, Key, Modifiers, Pos2}; use figcraft_engine::Session; use serde_json::json; use crate::layers_drag_tests::Ui; #[test] -fn invoke_records_recents_newest_first_and_capped() { +fn palette_runs_record_recents_newest_first_and_capped() { let mut u = Ui::new(Session::new()); - for id in [ - "view.zoomIn", - "view.zoomOut", - "edit.copyAsText", - "style.list", - "edit.findReplace", - "edit.copyAsCss", - "view.zoomIn", - ] { - crate::menus::invoke(&mut u.app, id); + // Menus, shortcuts and context menus don't touch the palette's Recents. + crate::menus::invoke(&mut u.app, "view.zoomIn"); + assert!(u.app.session.prefs.recent_actions.is_empty(), "only the palette records recents"); + for id in ["view.zoomIn", "view.zoomOut", "edit.copyAsText", "style.list", "edit.findReplace", "edit.copyAsCss", "view.zoomIn"] { + crate::menus::run_action(&mut u.app, &format!("command:{id}")); } let r = &u.app.session.prefs.recent_actions; assert_eq!(r.len(), figcraft_engine::MAX_RECENT_ACTIONS, "recents are capped: {r:?}"); @@ -81,8 +77,7 @@ fn used_plugin_resolves_to_a_recent_row() { .unwrap(); u.app.session.prefs.plugins.push(plugin); crate::menus::record_recent(&mut u.app, "plugin:dev.local.solar-icons/panel"); - let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "") - .expect("a used plugin stays in Recents"); + let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "").expect("a used plugin stays in Recents"); assert_eq!(row.label, "Solar icons — Panel"); assert!(crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "zzz").is_none(), "the query filters recents"); } @@ -109,3 +104,60 @@ fn outside_press_closes_the_panel() { u.button(Pos2::new(720.0, 20.0), false); assert!(u.app.ui.palette.is_none(), "a single outside press closes the panel"); } + +fn rows(groups: &[(String, Vec)]) -> Vec<&crate::menus::ActionRow> { + groups.iter().flat_map(|(_, rows)| rows).collect() +} + +/// A search reaches commands no menu lists (in an "Other" group), not just menu items. +#[test] +fn search_covers_every_command() { + let menu = figcraft_engine::catalog::MENU; + let spec = figcraft_engine::all_commands().iter().find(|c| !menu.iter().any(|m| m.id == c.id)).expect("a command no menu lists"); + let groups = crate::menus::command_sections(spec.id); + let key = format!("command:{}", spec.id); + let group = groups.iter().find(|(_, rows)| rows.iter().any(|r| r.key == key)).map(|(g, _)| g.as_str()); + assert_eq!(group, Some("Other"), "{} is found: {:?}", spec.id, groups.iter().map(|(g, _)| g).collect::>()); + // Without a query the palette lists the menus' items only. + assert!(rows(&crate::menus::command_sections("")).iter().all(|r| r.key != key)); +} + +/// Labels starting with the query come first, the shortest first; the rest are capped. +#[test] +fn search_ranks_prefix_matches_first() { + let groups = crate::menus::command_sections("zoom"); + let hits = rows(&groups); + let first = hits.first().expect("something matches 'zoom'"); + assert!(first.label.to_lowercase().starts_with("zoom"), "a prefix match leads: {}", first.label); + let shortest = hits.iter().filter(|r| r.label.to_lowercase().starts_with("zoom")).map(|r| r.label.chars().count()).min(); + assert_eq!(Some(first.label.chars().count()), shortest, "the shortest prefix match leads"); + assert!(rows(&crate::menus::command_sections("e")).len() <= 60, "a broad search is capped"); +} + +fn key(u: &mut Ui, k: Key) { + u.frame(vec![ + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// Up and Down move the highlight (it stops at the ends); Enter runs the highlighted row. +#[test] +fn arrows_move_the_highlight_and_enter_runs_it() { + let mut u = Ui::new(Session::new()); + crate::menus::set_palette(&mut u.app.ui, true); + u.frame(vec![]); + u.frame(vec![Event::Text("zoom".into())]); + assert_eq!(u.app.ui.palette.as_deref(), Some("zoom")); + assert_eq!(u.app.ui.palette_hi, 0, "a search starts at the first row"); + key(&mut u, Key::ArrowUp); + assert_eq!(u.app.ui.palette_hi, 0, "it stops at the top"); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::ArrowUp); + assert_eq!(u.app.ui.palette_hi, 1); + let want = rows(&crate::menus::palette_sections(&u.app, "zoom", crate::menus::PaletteTab::All)).get(1).map(|r| r.key.clone()).expect("two rows"); + key(&mut u, Key::Enter); + assert!(u.app.ui.palette.is_none(), "Enter runs a row and closes the palette"); + assert_eq!(u.app.session.prefs.recent_actions.first(), Some(&want), "the highlighted row ran"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 99df5d6f..481b446c 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -171,12 +171,10 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { .flatten(); crate::menus::separator(ui); if let Some(plugin) = widget { - crate::menus::record_recent(app, &format!("widget:{plugin}")); app.run("widget.insert", json!({"plugin": plugin})); return true; } let Some((id, command)) = chosen else { return false }; - crate::menus::record_recent(app, &format!("plugin:{id}/{command}")); start(app, &id, &command); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 962b7497..8839b3a9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -85,7 +85,7 @@ pub(crate) fn main_width() -> f32 { fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let (cell, gap) = (32.0, 4.0); let width = main_width(); - let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); + let rect = pill(ui, &mut app.ui.nav, canvas, width, t); let current = app.session.tool.tool; let mut x = rect.min.x + 8.0; let cy = rect.center().y; @@ -94,7 +94,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); - let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); + let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); if in_group { a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id); } @@ -152,13 +152,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { - if app.ui.palette.is_some() { - app.ui.palette = None; - } else { - app.ui.palette = Some(String::new()); - app.ui.palette_tab = crate::menus::PaletteTab::All; - app.ui.palette_armed = true; - } + crate::menus::toggle_palette(&mut app.ui); } else { app.ui.left_tab = crate::LeftTab::Assets; app.ui.left_collapsed = false; @@ -166,7 +160,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } x += cell + 4.0; } - divider(ui, &t, x, cy); + divider(ui, t, x, cy); x += 8.0; // Mode switcher: Draw · Design · Prototype · Inspect (code). let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))]; @@ -348,10 +342,15 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { } } +/// Where a toolbar pill `width` wide sits: centred 12 px above the canvas's bottom edge. +pub(crate) fn pill_rect(canvas: Rect, width: f32) -> Rect { + Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)) +} + /// The floating pill (shadow, panel, border) `width` wide at the bottom centre of the canvas: the /// toolbar region keyboard navigation moves through. fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { - let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)); + let rect = pill_rect(canvas, width); // The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives // a drag the canvas under them would sense to the canvas, so the pen placed an anchor there. // Not focusable: the keyboard walks the buttons, not the pill. From 330f80fcf5422d08e3fda41a30bec971d811c623 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 130/245] fix(format): open large .fig files A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are expanded) didn't open: "message: too many values". Lifting that cap alone made it import only its first 200 000 layers, and lifting that one took 41 GB of memory. The global caps stay as they are. The .fig import gets budgets from the size of its compressed canvas instead (counted up to the file's own size), not from the message it unpacks to, since chunks inflate up to 512 MB: - the kiwi value budget is one value per 2 bytes of the canvas, at least the old 20 million (kiwi is only read for .fig files); - the layer cap is one layer per 32 bytes of the canvas, at least the REST import's 200 000 and at most 2 000 000. A small or crafted file can't expand its instances into millions of layers, however far it unpacks; the REST JSON import keeps 200 000. Memory: - The mapper no longer passes the document through `json!`, which serialises its arguments: the whole tree was deep-copied twice. - Vector networks are built once per distinct network and handed to the REST importer beside the JSON (`vectorNetworkRef`), not written into it as JSON objects for every instance that shows them. - Decoded kiwi objects are trimmed, and each import stage's input is dropped as soon as the next stage is built. On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 24 ++- crates/format/src/fig/map.rs | 165 ++++++++++++------ .../map_connector_snapshot_budget_tests.rs | 8 +- crates/format/src/fig/mod.rs | 30 +++- crates/format/src/fig/tail_tests.rs | 4 +- crates/format/src/fig/tests.rs | 61 ++++++- crates/format/src/rest/mod.rs | 66 +++++-- crates/format/src/rest/tests.rs | 15 ++ 8 files changed, 287 insertions(+), 86 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 528dbbf0..46a9daac 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -10,8 +10,12 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; -/// Most values decoded from one message (every scalar, array element and field counts). -const MAX_VALUES: usize = 20_000_000; +/// Most values decoded from one message (every scalar, array element and field counts): at least +/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, +/// so a large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. +const MIN_VALUES: usize = 20_000_000; +const PACKED_BYTES_PER_VALUE: usize = 2; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -284,13 +288,23 @@ impl Schema { self.defs.iter().position(|d| d.name == name) } - /// Decode a message of definition `root`. + /// Decode a message of definition `root`, within [`MIN_VALUES`] values. pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES }; + self.decode_message_within(root, data, MIN_VALUES) + } + + /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). + pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; d.def(root, 0) } } +/// Most values decoded from a file whose compressed canvas is `packed` bytes. +pub(crate) fn value_budget(packed: usize) -> usize { + MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) +} + struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, @@ -327,6 +341,8 @@ impl<'s> Decoder<'s, '_> { loop { let id = self.r.var_uint()?; if id == 0 { + // Grown by doubling: trimmed, a large file's tree takes a good part less. + out.shrink_to_fit(); return Ok(Kv::Object(out)); } let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7a3dab5c..a8af2ab9 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,9 +6,10 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::rc::Rc; +use figcraft_doc::VectorNetwork; use figcraft_doc::geom::{Affine, Point}; use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; use serde_json::{Map, Value, json}; @@ -18,8 +19,7 @@ use super::network; type Obj = Map; -/// Most layers produced (instance expansion included) and deepest nesting followed. -const MAX_NODES: usize = crate::rest::MAX_NODES; +/// Deepest nesting followed. const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; @@ -30,11 +30,41 @@ const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. const MAX_ASSET_PART: usize = 256; -/// The REST API file JSON for message `msg`, with the warnings about what was skipped. +/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_fig_parts`]). +/// [`to_rest_parts`] within the REST import's layer cap, with each vector's network written back +/// into its JSON as `vectorNetwork`: what the tests read. +#[cfg(test)] pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { - let mut m = Mapper::new(msg); + fn inline(v: &mut Value, networks: &[Option]) { + match v { + Value::Object(o) => { + let net = o.remove("vectorNetworkRef").and_then(|r| networks.get(usize::try_from(r.as_u64()?).ok()?).cloned().flatten()); + if let Some(net) = net.and_then(|n| serde_json::to_value(n).ok()) { + o.insert("vectorNetwork".into(), net); + } + o.values_mut().for_each(|c| inline(c, networks)); + } + Value::Array(a) => a.iter_mut().for_each(|c| inline(c, networks)), + _ => {} + } + } + let (mut json, warnings, networks) = to_rest_parts(msg, name, MAX_NODES); + inline(&mut json, &networks); + (json, warnings) +} + +/// The REST import's layer cap, for the tests. +#[cfg(test)] +pub(super) const MAX_NODES: usize = crate::rest::MAX_NODES; + +pub(super) fn to_rest_parts(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); let doc = m.document(); - let mut out = json!({"name": name, "document": doc}); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; if !m.library_contexts.is_empty() { out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); } @@ -54,7 +84,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { if !styles.is_empty() { out["styleDefinitions"] = Value::Array(styles); } - (out, m.warnings) + (out, m.warnings, m.networks) } #[cfg(test)] @@ -374,20 +404,27 @@ struct Mapper<'a> { sets: Obj, /// Layers and keys of each main, built when an instance of it first needs them. override_scopes: BTreeMap>, - /// Entries held by `override_scopes` (bounded by [`MAX_NODES`] together). + /// Entries held by `override_scopes` (bounded by `max_nodes` together). override_scope_entries: usize, /// Component-bearing branches of the file's internal canvases (cached library definitions). library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. library_scan_count: usize, - /// Most layers produced or scanned; the one place the cap comes from. + /// Most layers produced (instance expansion included) or scanned; the one place the cap comes + /// from (see [`super::layer_budget`]). max_nodes: usize, warnings: Vec, count: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), usize>, } impl<'a> Mapper<'a> { - fn new(msg: &'a Kv<'a>) -> Self { + fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); @@ -447,9 +484,11 @@ impl<'a> Mapper<'a> { override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, - max_nodes: MAX_NODES, + max_nodes, warnings: Vec::new(), count: 0, + networks: Vec::new(), + network_ids: HashMap::new(), } } @@ -498,7 +537,7 @@ impl<'a> Mapper<'a> { let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect(); let mut complete = true; while let Some((id, depth)) = pending.pop() { - if depth > MAX_DEPTH || visited.len() >= MAX_NODES { + if depth > MAX_DEPTH || visited.len() >= self.max_nodes { complete = false; break; } @@ -523,7 +562,7 @@ impl<'a> Mapper<'a> { } let scope = Rc::new(OverrideScope { current, aliases }); let cost = scope.current.len() + scope.aliases.len(); - if cost <= MAX_NODES.saturating_sub(self.override_scope_entries) { + if cost <= self.max_nodes.saturating_sub(self.override_scope_entries) { self.override_scope_entries += cost; self.override_scopes.insert(main.to_string(), Rc::clone(&scope)); } @@ -563,7 +602,9 @@ impl<'a> Mapper<'a> { if out.is_empty() { self.warn("the file has no pages"); } - json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) + let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"}); + doc["children"] = Value::Array(out); + doc } /// The ids of the components the visible pages use, directly or through the components they @@ -777,8 +818,9 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; } let base = *self.nodes.get(id)?; @@ -813,7 +855,7 @@ impl<'a> Mapper<'a> { let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. let connector_terminal = - connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= MAX_NODES); + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { @@ -947,7 +989,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { return None; } let mut s = Obj::new(); @@ -979,7 +1021,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= MAX_NODES { + if self.count >= self.max_nodes { return None; } self.count += 1; @@ -1013,7 +1055,7 @@ impl<'a> Mapper<'a> { if connector_has_saved_label(v) { self.warn("connector labels were skipped"); } - if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { + if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { return; } let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); @@ -1114,7 +1156,7 @@ impl<'a> Mapper<'a> { out.insert("opacity".into(), json!(root_opacity * line_opacity)); } if connector_has_saved_label(v) { - let reason = if self.count >= MAX_NODES { "node budget" } else { "depth limit" }; + let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" }; self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); } } @@ -1144,8 +1186,9 @@ impl<'a> Mapper<'a> { /// Snapshot the straight, manual connector contract carried by its generated children. fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector snapshot children were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; connector snapshot children were skipped")); return Vec::new(); } let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else { @@ -1207,8 +1250,9 @@ impl<'a> Mapper<'a> { }; let background_view = View::of(background).with([background_style]); if !background_view.list("fillPaints").is_empty() { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector label background was skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; connector label background was skipped")); } else { let mut rect = Obj::new(); rect.insert("id".into(), json!(format!("{rest_id};label-background"))); @@ -1235,8 +1279,9 @@ impl<'a> Mapper<'a> { transform: Affine, view: &View<'a>, ) { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector label was skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; connector label was skipped")); return; } let mut text = Obj::new(); @@ -1648,7 +1693,8 @@ impl<'a> Mapper<'a> { /// False when there's none or it's damaged (the outlines stand in). fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; - let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; + let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; + let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; @@ -1686,28 +1732,6 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); - let vertices: Vec = net - .vertices - .iter() - .map(|p| { - let mut out = json!({"x": p.x * sx, "y": p.y * sy}); - let st = style(p.style); - if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { - out["radius"] = json!(r); - } - match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { - Some("ANGLE") => out["mirror"] = json!("angle"), - Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), - Some("NONE") => out["mirror"] = json!("none"), - _ => {} - } - out - }) - .collect(); - let segments: Vec = - net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { self.warn("filled areas of a vector with their own fill use the layer's fill"); } @@ -1733,7 +1757,46 @@ impl<'a> Mapper<'a> { o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node)))); } - o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions})); + // Every instance of a main shows the same network: it is written once and shared. + let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize); + let id = match self.network_ids.get(&key) { + Some(id) => *id, + None => { + let vertices: Vec = net + .vertices + .iter() + .map(|p| { + let mut out = json!({"x": p.x * sx, "y": p.y * sy}); + let st = style(p.style); + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { + out["radius"] = json!(r); + } + match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { + Some("ANGLE") => out["mirror"] = json!("angle"), + Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), + Some("NONE") => out["mirror"] = json!("none"), + _ => {} + } + out + }) + .collect(); + let segments: Vec = net + .segments + .iter() + .map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})) + .collect(); + let regions: Vec = + net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + let mut entry = json!({}); + entry["vertices"] = Value::Array(vertices); + entry["segments"] = Value::Array(segments); + entry["regions"] = Value::Array(regions); + self.networks.push(crate::rest::network(&entry)); + self.network_ids.insert(key, self.networks.len() - 1); + self.networks.len() - 1 + } + }; + o.insert("vectorNetworkRef".into(), json!(id)); true } diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs index a14e671c..4d8ae9be 100644 --- a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -106,7 +106,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { let message = message(); let node = message.get("nodeChanges").unwrap().list().first().unwrap(); let view = View::of(node); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; let children = mapper.connector_snapshot_children(&view, "1:123"); assert_eq!(children.len(), 1, "the final available slot preserves the saved vector"); @@ -116,7 +116,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { assert_eq!(mapper.count, MAX_NODES); assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES; let children = mapper.connector_snapshot_children(&view, "1:123"); assert!(children.is_empty()); @@ -127,14 +127,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { #[test] fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { let message = message(); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); assert_eq!(depth_limited["type"], "VECTOR"); assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(depth_limited.get("children").is_none()); assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector"); assert_eq!(budget_limited["type"], "VECTOR"); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 0d341307..08a3409e 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -54,6 +54,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024; const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; +/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its +/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances +/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the +/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large +/// count, however it's built; a large one gets room. +const PACKED_BYTES_PER_LAYER: usize = 32; +const MAX_FIG_NODES: usize = 2_000_000; + +/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. +pub(crate) fn layer_budget(packed: usize) -> usize { + (packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES) +} /// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. pub fn is_fig(bytes: &[u8]) -> bool { @@ -72,14 +84,22 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - let archive = Archive::open(bytes)?; - let (schema, message) = decode_canvas(&archive.canvas)?; + let mut archive = Archive::open(bytes)?; + // The budgets follow the compressed canvas, which the file itself bounds (its zip member may + // inflate too). + let packed = archive.canvas.len().min(bytes.len()); + // Each stage's input is dropped as soon as the next one is built: in a large file every one + // of them takes gigabytes, and holding them all at once runs out of memory. + let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?; let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?; - let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; + let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?; + let max_nodes = layer_budget(packed); + drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (json, mut warnings) = map::to_rest(&msg, file_name); + let (json, mut warnings, networks) = map::to_rest_parts(&msg, file_name, max_nodes); + drop(msg); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import_fig(&json, &images)?; + let (doc, more) = crate::rest::import_fig_parts(&json, &images, networks, max_nodes)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index a15334a5..43e997a5 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -446,8 +446,8 @@ fn mangled_tail_files_never_panic() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest_parts(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_fig_parts(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 24baef30..5f20f5ac 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -757,7 +757,7 @@ fn mangled_kiwi_never_panics() { } for cut in (0..msg.len()).step_by(3) { if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { - let _ = map::to_rest(&kv, "x"); + let _ = map::to_rest_parts(&kv, "x", crate::rest::MAX_NODES); } } for _ in 0..2000 { @@ -767,8 +767,8 @@ fn mangled_kiwi_never_panics() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest_parts(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_fig_parts(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } let mut sbytes = schema_bytes.clone(); let i = (r.next() as usize) % sbytes.len(); @@ -790,6 +790,59 @@ fn mangled_kiwi_never_panics() { } } +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let (_, warnings, _) = map::to_rest_parts(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest_parts(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. @@ -1088,7 +1141,7 @@ fn dump_samples() { std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, _) = map::to_rest(&msg, &stem); + let (rest, ..) = map::to_rest_parts(&msg, &stem, super::layer_budget(message.len())); std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); } } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index e460c8b8..0fa53a7c 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -53,7 +53,7 @@ use crate::FormatError; use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text}; pub use variables::export as export_variables; -/// Most layers imported from one file. +/// Most layers imported from one file. The `.fig` import sets its own, from the file's size. pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; @@ -146,22 +146,43 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { - import_impl(v, variables, images, false) + import_impl(v, variables, images, false, Vec::new(), MAX_NODES) } -/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. +/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. Its +/// vectors name their networks by `vectorNetworkRef`, an index in `networks` (built once each, and +/// far smaller than as JSON); `max_nodes` is the layer cap the `.fig` import sets in place of +/// [`MAX_NODES`]. +pub(crate) fn import_fig_parts( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_impl(v, None, images, true, networks, max_nodes) +} + +/// [`import_fig_parts`] of JSON that carries its vectors' networks, within [`MAX_NODES`]. +#[cfg(test)] pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec), FormatError> { - import_impl(v, None, images, true) + import_fig_parts(v, images, Vec::new(), MAX_NODES) } -fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_sources: bool) -> Result<(Document, Vec), FormatError> { +fn import_impl( + v: &Value, + variables: Option<&Value>, + images: ImageResolver, + fig_sources: bool, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); } let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str); let mut cx = Ctx::new(Document::new(name), images); - cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(MAX_NODES)); + cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(max_nodes)); let blank = cx.doc.first_page(); let mut loose: Option = None; for e in &entries { @@ -175,6 +196,8 @@ fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_ cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"); } cx.style_definitions(v); + cx.networks = networks; + cx.max_nodes = max_nodes; for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -356,6 +379,10 @@ pub(crate) struct Ctx<'a> { /// Imported variables by id, and the mode ids of imported collections (default first). var_kinds: BTreeMap, var_modes: BTreeMap>, + /// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`. + networks: Vec>, + /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. + max_nodes: usize, } impl<'a> Ctx<'a> { @@ -380,6 +407,8 @@ impl<'a> Ctx<'a> { overlay_meta: BTreeMap::new(), var_kinds: BTreeMap::new(), var_modes: BTreeMap::new(), + networks: Vec::new(), + max_nodes: MAX_NODES, } } @@ -435,8 +464,9 @@ impl<'a> Ctx<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return; } let Some(ty) = text(j, "type") else { @@ -642,14 +672,18 @@ impl<'a> Ctx<'a> { } } - /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our - /// extension, written by the `.fig` import), else from its geometry. A stroke-only vector - /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape): - /// returns whether it does, and the caller moves the paints with [`outline_stroke`] once the - /// styles and variables of the stroke are read. + /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or + /// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its + /// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints + /// (exact look, editable as a shape): returns whether it does, and the caller moves the paints + /// with [`outline_stroke`] once the styles and variables of the stroke are read. fn vector(&mut self, n: &mut Node, j: &Value) -> bool { - if let Some(net) = j.get("vectorNetwork") { - match network(net) { + let net = match j.get("vectorNetworkRef") { + Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())), + None => j.get("vectorNetwork").map(network), + }; + if let Some(net) = net { + match net { Some(network) => { n.kind = NodeKind::Vector { network }; return false; @@ -1173,7 +1207,7 @@ fn local(parent: Affine, world: Affine) -> Affine { /// Finite path data within the coordinate cap. /// A vector network in our own JSON shape, when it's within bounds and refers only to what it has. -fn network(v: &Value) -> Option { +pub(crate) fn network(v: &Value) -> Option { use serde::Deserialize; let mut net = VectorNetwork::deserialize(v).ok()?; let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 7db22db1..cdf27905 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -729,3 +729,18 @@ fn layer_count_is_capped() { assert_eq!(doc.node_count(), MAX_NODES + 1); assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); } + +#[test] +fn the_fig_import_sets_its_own_layer_cap() { + // The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in. + let kids: Vec = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let json = file(json!([page("Page", Value::Array(kids))])); + let (doc, warnings) = import_fig_parts(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap(); + assert_eq!(doc.node_count(), MAX_NODES + 6); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); + // Below it, the import stops at the cap it was given. + let few: Vec = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let (doc, warnings) = import_fig_parts(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap(); + assert_eq!(doc.node_count(), 5); + assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}"); +} From 62ade9b9ad86f0014627f569b95995162907f66f Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 131/245] fix(format): reopen large documents once saved `document.json` was read under the 512 MB cap for any archive member, so a large imported document saved but didn't open again: 900 000 layers save as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16 times its packed size (counted up to the archive's own size), within 512 MB and 4 GB; images and other members keep 512 MB. A small archive still can't unpack into gigabytes. That saved file reopens in 13 s with a 5.9 GB peak. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/large_tests.rs | 15 +++++++++++++++ crates/format/src/lib.rs | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 3ff352ac..2dc9eb4c 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -202,3 +202,18 @@ fn the_header_is_read_on_its_own() { let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap(); assert_eq!(index, Some(["ab".to_string()].into_iter().collect())); } + +#[test] +fn document_json_may_unpack_in_proportion_to_its_packed_size() { + const MB: u64 = 1024 * 1024; + // A small archive keeps the member cap: it can't unpack into gigabytes. + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and + // didn't open again under the 512 MB cap. + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + // The packed size an archive claims counts only up to the archive's own size. + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + // Never more than the ceiling. + assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f88416e2..dd96b932 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -39,6 +39,11 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; /// Largest archive member we read (guards against zip bombs). const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within +/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs +/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +const DOCUMENT_RATIO: u64 = 16; +const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). @@ -455,6 +460,11 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`. +pub(crate) fn document_limit(packed: u64, len: u64) -> u64 { + packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} + /// How many images, and how many bytes of them together, one file may give. #[derive(Clone, Copy, Debug)] pub(crate) struct ImageBudget { @@ -476,7 +486,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result Date: Sat, 17 Oct 2026 09:27:42 +0300 Subject: [PATCH 132/245] fix(format): one kiwi value per compressed canvas byte One value per two bytes of the compressed canvas was too tight for a public design system: a 39.5 MB canvas holding 29.7 million values got a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75 values per compressed byte; the budget is now one per byte, at least the old 20 million and at most 128 million. A small file still gets main's 20 million, however far it unpacks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 10 +++++----- crates/format/src/fig/tests.rs | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 46a9daac..6496062b 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -11,11 +11,11 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; /// Most values decoded from one message (every scalar, array element and field counts): at least -/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, -/// so a large real file decodes while a small one can't expand into a huge tree, however far its -/// chunks inflate. +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a large +/// real file decodes while a small one can't expand into a huge tree, however far its chunks +/// inflate. Real files hold 0.37 to 0.75 values per compressed byte. const MIN_VALUES: usize = 20_000_000; -const PACKED_BYTES_PER_VALUE: usize = 2; +const MAX_VALUES: usize = 128 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -302,7 +302,7 @@ impl Schema { /// Most values decoded from a file whose compressed canvas is `packed` bytes. pub(crate) fn value_budget(packed: usize) -> usize { - MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) + packed.clamp(MIN_VALUES, MAX_VALUES) } struct Decoder<'s, 'b> { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 5f20f5ac..852700bf 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -798,8 +798,11 @@ fn large_files_get_a_larger_value_budget() { // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds // 30.5 million values was refused as "too many values". assert!(kiwi::value_budget(83_491_232) > 30_514_899); - // Still bounded by the largest canvas read. - assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); + // A public design system packs more densely: a 39.5 MB canvas holding 29.7 million values, + // which one value per two compressed bytes refused. + assert!(kiwi::value_budget(39_500_000) > 29_703_730); + // Never more than the ceiling, however large the canvas. + assert_eq!(kiwi::value_budget(MAX_CANVAS as usize), 128 << 20); } #[test] From 32266dcd93aae516fa04381913a0e69e79bf8f7a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:49:01 +0700 Subject: [PATCH 133/245] #624: open the picker only for a shared paint list (mixed-selection guard) --- crates/ui-egui/src/panel_remove_tests.rs | 54 +++++++++++++----------- crates/ui-egui/src/right.rs | 12 +++--- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index ee289bb8..3805dca3 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -1,5 +1,6 @@ -//! Removing fill, effect and layout grid rows in the Design panel, driven through egui headless: an -//! open colour picker and open settings stay with their items, and go when their item goes. +//! Adding and removing fill, effect and layout grid rows in the Design panel, driven through egui headless: +//! adding a paint opens its colour picker; an open colour picker and open settings stay with their items, +//! and go when their item goes. use egui::{Event, Id, Pos2}; use figcraft_doc::NodeId; @@ -124,6 +125,14 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { assert!(flag(&u, "grid_open", 0) && !flag(&u, "grid_open", 1)); } +/// The centre of the button labelled `label` (found in the AccessKit tree). +fn button(u: &mut Ui, label: &str) -> Pos2 { + let out = u.frame_output(vec![]); + let b = out.platform_output.accesskit_update.unwrap().nodes.into_iter().find(|(_, n)| n.label() == Some(label)).and_then(|(_, n)| n.bounds()); + let b = b.unwrap_or_else(|| panic!("no {label:?} button")); + Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + #[test] fn adding_a_fill_or_stroke_opens_the_color_picker() { let mut s = Session::new(); @@ -133,31 +142,28 @@ fn adding_a_fill_or_stroke_opens_the_color_picker() { u.ctx.enable_accesskit(); assert_eq!(u.app.ui.picker, None); - let out = u.frame_output(vec![]); - let add_fill = out - .platform_output - .accesskit_update - .unwrap() - .nodes - .into_iter() - .find(|(_, n)| n.label() == Some("Add fill")) - .and_then(|(_, n)| n.bounds()) - .map(|b| Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) - .expect("Add fill button"); + let add_fill = button(&mut u, "Add fill"); click(&mut u, add_fill); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); - let out = u.frame_output(vec![]); - let add_stroke = out - .platform_output - .accesskit_update - .unwrap() - .nodes - .into_iter() - .find(|(_, n)| n.label() == Some("Add stroke")) - .and_then(|(_, n)| n.bounds()) - .map(|b| Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) - .expect("Add stroke button"); + let add_stroke = button(&mut u, "Add stroke"); click(&mut u, add_stroke); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![r] })); } + +#[test] +fn adding_a_fill_to_mixed_fills_opens_no_picker() { + let mut s = Session::new(); + let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + let b = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("fill.add", &json!({"ids": [b]})).unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + + let add_fill = button(&mut u, "Add fill"); + click(&mut u, add_fill); + let doc = u.app.session.doc().unwrap(); + assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3)); + assert_eq!(u.app.ui.picker, None); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 5899a949..fcb13724 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1101,13 +1101,11 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: empty, ) { if i == 1 { - if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() { - if let Ok(doc) = app.session.doc() { - let count = ids.first().and_then(|id| doc.node(*id)).map_or(0, |n| if fills { n.fills.len() } else { n.strokes.len() }); - if count > 0 { - app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: count - 1, ids: ids.clone() }); - } - } + // The new paint goes on top; open its picker. A mixed selection has no one index to edit. + if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() + && let Some(l) = &list + { + app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }); } } else { style_picker::open(app, ui, &ids, slot); From b04403885716478797827b1c8a465de263b63e71 Mon Sep 17 00:00:00 2001 From: Jorrevdb <80039681+Jorrevdb@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:49:01 +0200 Subject: [PATCH 134/245] UI: submenu hover fixes (stale state, safe triangle, hover delay) --- crates/ui-egui/src/a11y.rs | 3 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menu_aim_tests.rs | 281 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 392 ++++++++++++++++++++++----- crates/ui-egui/src/plugins_ui.rs | 4 +- 5 files changed, 617 insertions(+), 65 deletions(-) create mode 100644 crates/ui-egui/src/menu_aim_tests.rs diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 824defd4..5fc098a4 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -942,10 +942,11 @@ pub fn menu_item(resp: &Response, label: &str, trail: &str, checked: Option Self { + let app = FigApp::new(Session::new(), Box::new(NoServices)); + let mut u = Self { ctx: egui::Context::default(), app, time: 0.0 }; + for _ in 0..3 { + u.frame(vec![]); + } + u + } + + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SIZE)), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + fn hover(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p)]); + } + + /// Hover `p` and stay there until a submenu could open. + fn settle(&mut self, p: Pos2) { + self.hover(p); + self.wait(HOVER_DELAY + 0.05); + } + + /// Frames without input for `secs`. + fn wait(&mut self, secs: f64) { + let until = self.time + secs; + while self.time < until { + self.frame(vec![]); + } + } + + fn open_main_menu(&mut self) { + self.app.ui.main_menu = true; + // Somewhere off the menu: nothing hovered. + self.hover(pos2(900.0, 700.0)); + self.frame(vec![]); + } + + fn shown(&self, area: Id) -> bool { + self.ctx.memory(|m| m.areas().visible_layer_ids().contains(&LayerId::new(Order::Foreground, area))) + } + + fn rect(&self, area: Id) -> Rect { + self.ctx.memory(|m| m.area_rect(area)).expect("area drawn") + } + + /// The main menu's row for top-level menu `i` (File 0, Edit 1, View 2, …). + fn top_row(&self, i: usize) -> Rect { + let m = self.rect(main_menu_id()); + // Frame margin, the Actions row and the space under it. + let y = m.min.y + 8.0 + 30.0 + 6.0 + ROW * i as f32; + Rect::from_min_max(pos2(m.min.x + 8.0, y), pos2(m.max.x - 8.0, y + ROW)) + } + + /// Which top-level menus' panels show. + fn tops_shown(&self) -> Vec<&'static str> { + TOPS.iter().copied().filter(|t| self.shown(top(t))).collect() + } +} + +const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; + +fn top(name: &str) -> Id { + Id::new(("menu", name)) +} + +/// The submenus of top-level menu `menu` (their keys), from the menu catalog. +fn submenu_keys(menu: &str) -> Vec { + let mut keys: Vec = figcraft_engine::catalog::MENU + .iter() + .filter_map(|m| m.menu.strip_prefix(menu).and_then(|r| r.strip_prefix('/')).map(|r| format!("{menu}/{}", r.split('/').next().unwrap_or(r)))) + .collect(); + keys.extend(figcraft_engine::catalog::MENU.iter().filter(|m| m.menu == menu && m.id == "file.openRecent").map(|m| format!("{menu}/{}", m.label))); + keys.dedup(); + keys +} + +#[test] +fn the_triangle_takes_the_pointer_from_the_row_toward_the_panel() { + let panel = Rect::from_min_max(pos2(300.0, 50.0), pos2(500.0, 650.0)); + let from = pos2(250.0, 100.0); + assert!(in_triangle(pos2(1.0, 1.0), pos2(0.0, 0.0), pos2(4.0, 0.0), pos2(0.0, 4.0))); + assert!(!in_triangle(pos2(3.0, 3.0), pos2(0.0, 0.0), pos2(4.0, 0.0), pos2(0.0, 4.0))); + // Diagonally down toward the panel: heading for it. + assert!(heading_for(from, pos2(260.0, 130.0), panel)); + // Straight down, straight up, back left, or far below the panel's reach: not. + assert!(!heading_for(from, pos2(250.0, 130.0), panel)); + assert!(!heading_for(from, pos2(250.0, 80.0), panel)); + assert!(!heading_for(from, pos2(240.0, 105.0), panel)); + assert!(!heading_for(from, pos2(260.0, 400.0), Rect::from_min_max(pos2(300.0, 50.0), pos2(500.0, 120.0)))); + // A panel on the left works the same way, mirrored. + let left = Rect::from_min_max(pos2(0.0, 50.0), pos2(200.0, 650.0)); + assert!(heading_for(from, pos2(240.0, 130.0), left)); + assert!(!heading_for(from, pos2(260.0, 130.0), left)); + // Junk positions never count. + assert!(!heading_for(pos2(f32::NAN, 0.0), from, panel)); +} + +#[test] +fn a_reopened_main_menu_shows_no_stale_submenu() { + let mut u = Ui::new(); + u.open_main_menu(); + assert!(u.tops_shown().is_empty(), "nothing open before hovering"); + u.settle(u.top_row(0).center()); + assert_eq!(u.tops_shown(), ["File"]); + // Escape closes it; it reopens without File open. + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + u.frame(vec![]); + assert!(!u.app.ui.main_menu); + u.open_main_menu(); + assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); + // Closed from outside (the menu button toggling it) it forgets too. + u.settle(u.top_row(1).center()); + assert_eq!(u.tops_shown(), ["Edit"]); + u.app.ui.main_menu = false; + u.frame(vec![]); + u.open_main_menu(); + assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); +} + +#[test] +fn a_submenu_one_level_down_reopens_closed() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(0).center()); + let keys = submenu_keys("File"); + assert!(!keys.is_empty(), "File has submenus"); + let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::new(("submenu", k.as_str())))).cloned(); + // Down the middle of File's panel until one of its submenus opens. + let panel = u.rect(top("File")); + let mut y = panel.min.y + 10.0; + while sub_shown(&u).is_none() && y < panel.max.y { + u.settle(pos2(panel.center().x, y)); + y += 7.0; + } + let Some(open) = sub_shown(&u) else { panic!("no File submenu opened (looked for {keys:?})") }; + // Over to Edit and back to File: File shows, without its submenu. + u.hover(u.top_row(1).center()); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["Edit"]); + u.hover(u.top_row(0).center()); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["File"]); + assert_eq!(sub_shown(&u), None, "{open} came back open"); +} + +#[test] +fn crossing_rows_toward_the_open_submenu_keeps_it_open() { + let mut u = Ui::new(); + u.open_main_menu(); + let file = u.top_row(0); + let start = pos2(file.max.x - 30.0, file.center().y); + u.settle(start); + assert_eq!(u.tops_shown(), ["File"]); + // Diagonally across Edit and View into File's panel, a few rows down. + let panel = u.rect(top("File")); + let end = pos2(panel.min.x + 20.0, u.top_row(3).center().y); + for k in 1..=8 { + let p = start + (end - start) * (k as f32 / 8.0); + u.hover(p); + assert_eq!(u.tops_shown(), ["File"], "at {p:?}"); + } + assert!(panel.contains(end)); + u.wait(AIM_REST + 0.2); + assert_eq!(u.tops_shown(), ["File"]); +} + +#[test] +fn resting_on_a_crossed_row_hands_over_to_it() { + let mut u = Ui::new(); + u.open_main_menu(); + let file = u.top_row(0); + let start = pos2(file.max.x - 30.0, file.center().y); + u.settle(start); + // Toward the panel, stopping on View. + let view = u.top_row(2); + let stop = pos2(view.max.x - 4.0, view.center().y); + for k in 1..=4 { + u.hover(start + (stop - start) * (k as f32 / 4.0)); + } + assert_eq!(u.tops_shown(), ["File"], "still on the way"); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["View"]); +} + +#[test] +fn moving_straight_down_switches_after_a_short_hover() { + let mut u = Ui::new(); + u.open_main_menu(); + let file = u.top_row(0); + u.settle(file.center()); + assert_eq!(u.tops_shown(), ["File"]); + // Down to Edit in small steps: no progress toward File's panel, so no triangle. + let edit = u.top_row(1).center(); + let mut p = file.center(); + while p.y < edit.y { + p.y = (p.y + 4.0).min(edit.y); + u.hover(p); + } + assert!(u.tops_shown().is_empty(), "File closes at once, Edit waits: {:?}", u.tops_shown()); + u.wait(HOVER_DELAY + 0.05); + assert_eq!(u.tops_shown(), ["Edit"]); +} + +#[test] +fn the_first_submenu_opens_after_a_short_hover() { + let mut u = Ui::new(); + u.open_main_menu(); + u.hover(u.top_row(0).center()); + u.frame(vec![]); + assert!(u.tops_shown().is_empty(), "opened at once"); + u.wait(HOVER_DELAY + 0.05); + assert_eq!(u.tops_shown(), ["File"]); +} + +#[test] +fn sweeping_across_rows_opens_only_where_the_pointer_stops() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(0).center()); + // Quickly down the left of the rows, a row a frame, to Arrange. + for i in 1..=5 { + let r = u.top_row(i); + u.hover(pos2(r.min.x + 20.0, r.center().y)); + // (A panel drawn above the row the pointer is on goes the next frame.) + u.frame(vec![]); + assert!(u.tops_shown().is_empty(), "{:?} open on row {i}", u.tops_shown()); + } + u.wait(HOVER_DELAY + 0.05); + assert_eq!(u.tops_shown(), ["Arrange"]); +} + +#[test] +fn hovering_a_plain_row_closes_the_open_submenu_at_once() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(0).center()); + assert_eq!(u.tops_shown(), ["File"]); + // Up onto the Actions row. + let m = u.rect(main_menu_id()); + u.hover(pos2(u.top_row(0).center().x, m.min.y + 8.0 + 15.0)); + assert!(u.tops_shown().is_empty(), "{:?} still open", u.tops_shown()); +} + +#[test] +fn leaving_the_menu_keeps_the_last_submenu() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(1).center()); + // Off the menus onto the canvas. + u.hover(pos2(900.0, 700.0)); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["Edit"]); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..aa59d4b9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -215,7 +215,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { subs.push(sub.clone()); last_sep = false; let full = format!("{prefix}/{sub}"); - if submenu(ui, prefix, &full, tr(&sub), &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() { + if submenu(ui, &full, tr(&sub), &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() { return true; } } @@ -231,7 +231,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { /// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. `key` is /// the menu's English label, `label` the one shown. Returns whether an item ran. fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens) -> bool { - let picked = submenu(ui, prefix, &format!("{prefix}/{key}"), label, t, |ui| { + let picked = submenu(ui, &format!("{prefix}/{key}"), label, t, |ui| { let recents = &app.session.prefs.recent_files; let mut picked = None; for (i, p) in recents.iter().enumerate() { @@ -257,20 +257,285 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s true } -/// A submenu row `label` in the menu at `prefix`; while it's open (hovered last), `add` draws its -/// items beside it. `key` is unique among the menu's submenus. Returns what `add` returned. -pub(crate) fn submenu(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { - let resp = submenu_item(ui, label, t); - let slot = Id::new(("submenu_open", prefix)); - if resp.hovered() || resp.has_focus() { - ui.data_mut(|d| d.insert_temp(slot, key.to_string())); +/// A submenu row `label` in the menu being drawn; while it's open, `add` draws its items beside +/// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add` +/// returned. +pub(crate) fn submenu(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { + submenu_at(ui, Id::new(("submenu", key)), key, label, 4.0, false, t, add) +} + +// --------------------------------------------------------------------------------------------- +// Which submenu is open: hover intent with a safe triangle. +// +// Every menu tree (the main menu, the context menu) keeps, per level, which of its submenus is +// open. Hovering another row closes the open submenu at once; resting on a submenu row for +// `HOVER_DELAY` opens it (sweeping across rows doesn't flash every submenu on the way). Except while the +// pointer is on its way from the open submenu's row to the submenu: while each move lands inside +// the triangle between where it was and the submenu's near edge (it keeps getting nearer), rows +// it crosses don't take over and the submenu's row stays highlighted. Resting (`AIM_REST`) or +// turning away ends that, and the row under the pointer takes over. Leaving the menus keeps the +// last submenu open. +// Closing a menu tree forgets all of it, so it reopens with no submenu open. + +/// How long the pointer stays on a submenu row before its submenu opens. +pub(crate) const HOVER_DELAY: f64 = 0.15; +/// How long the pointer may rest inside the safe triangle before the row under it takes over. +pub(crate) const AIM_REST: f64 = 0.3; +/// A pointer that moved less than this is resting. +const AIM_STILL: f32 = 1.0; +/// The triangle reaches this far above and below the submenu. +const AIM_EDGE_SLOP: f32 = 8.0; + +/// The hover state of one menu level. +#[derive(Clone, Default, Debug)] +struct Level { + /// The open submenu's key. + open: Option, + /// The open submenu's row and where its panel was drawn (last frame). + row: Option, + panel: Option, + /// The pointer's last position counted as heading for the panel, and when it got there. + apex: Option, + moved_at: f64, + /// This pass's verdict: the pointer is aiming at the panel. + pass: Option, + aiming: bool, + /// The row the pointer is on, waiting to take over (a submenu's key, or `None`: a plain row), + /// and since when; whether a row asked this pass (unasked, it lapses). + pending: Option<(Option, f64)>, + asked: bool, + /// When the pointer came to rest in the triangle, if that's what ended aiming this pass. + rested_at: Option, +} + +/// One menu tree's levels, by menu path ("" for the top). +#[derive(Clone, Default, Debug)] +struct Tree { + levels: std::collections::HashMap, +} + +/// The menu level a menu frame's ui draws: its tree's root and its path. +#[derive(Clone, Debug)] +struct At { + root: Id, + path: String, +} + +fn at_key(ui: &Ui) -> Id { + ui.id().with("menu_level") +} + +fn level_at(ui: &Ui) -> At { + ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::new("menus"), path: String::new() }) +} + +fn with_level(ctx: &egui::Context, at: &At, f: impl FnOnce(&mut Level) -> R) -> R { + ctx.data_mut(|d| f(d.get_temp_mut_or_default::(at.root).levels.entry(at.path.clone()).or_default())) +} + +/// Forget everything a menu tree remembers (it closed or opened anew). +pub(crate) fn forget_tree(ctx: &egui::Context, root: Id) { + ctx.data_mut(|d| d.remove::(root)); +} + +/// Open (`Some(key)`) or close (`None`) a submenu of `root`'s level `path`; the levels below the +/// one it replaces are forgotten (they reopen closed). +fn set_open(ctx: &egui::Context, root: Id, path: &str, key: Option<&str>) { + ctx.data_mut(|d| { + let tree = d.get_temp_mut_or_default::(root); + let level = tree.levels.entry(path.to_string()).or_default(); + if level.open.as_deref() == key { + return; + } + let old = std::mem::replace(&mut level.open, key.map(str::to_string)); + level.row = None; + level.panel = None; + level.apex = None; + level.pending = None; + if let Some(old) = old { + let below = format!("{old}/"); + tree.levels.retain(|p, _| *p != old && !p.starts_with(&below)); + } + }); +} + +/// Whether `p` is in the triangle `a`, `b`, `c` (either winding, edges included). +pub(crate) fn in_triangle(p: egui::Pos2, a: egui::Pos2, b: egui::Pos2, c: egui::Pos2) -> bool { + let side = |u: egui::Pos2, v: egui::Pos2| (v.x - u.x) * (p.y - u.y) - (v.y - u.y) * (p.x - u.x); + let (d1, d2, d3) = (side(a, b), side(b, c), side(c, a)); + let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(neg && pos) +} + +/// Whether a pointer that moved from `from` to `p` is heading for `panel`: `p` is inside the +/// triangle from `from` to the panel's near edge, so it got nearer the panel without leaving the +/// rows' way to it. Straight up or down (or away) isn't. +pub(crate) fn heading_for(from: egui::Pos2, p: egui::Pos2, panel: Rect) -> bool { + if ![from.x, from.y, p.x, p.y].iter().all(|v| v.is_finite()) { + return false; } - let open: Option = ui.data(|d| d.get_temp(slot)); - if open.as_deref() != Some(key) { + let edge = if panel.min.x >= from.x { + panel.min.x + } else if panel.max.x <= from.x { + panel.max.x + } else { + return false; + }; + in_triangle(p, from, pos2(edge, panel.min.y - AIM_EDGE_SLOP), pos2(edge, panel.max.y + AIM_EDGE_SLOP)) +} + +/// Whether the pointer is on its way to this level's open submenu (rows it crosses don't take +/// over). Worked out once per pass per level. +fn aiming(ui: &Ui) -> bool { + let at = level_at(ui); + let ctx = ui.ctx(); + let pass = ctx.cumulative_pass_nr(); + let (p, now) = ctx.input(|i| (i.pointer.hover_pos(), i.time)); + let (aiming, wait) = with_level(ctx, &at, |l| { + if l.pass == Some(pass) { + return (l.aiming, None); + } + l.pass = Some(pass); + l.aiming = false; + l.rested_at = None; + if !l.asked { + l.pending = None; + } + l.asked = false; + let (Some(p), Some(_), Some(panel)) = (p, l.open.as_ref(), l.panel) else { + l.apex = None; + return (false, None); + }; + if panel.contains(p) { + l.apex = None; + return (false, None); + } + // On the row (its bottom edge is the next row's). + if l.row.is_some_and(|r| (r.min.x..r.max.x).contains(&p.x) && (r.min.y..r.max.y).contains(&p.y)) { + l.apex = Some(p); + l.moved_at = now; + return (false, None); + } + let Some(from) = l.apex else { return (false, None) }; + if (p - from).length() <= AIM_STILL { + // Resting: the submenu holds a moment longer. + if !(0.0..AIM_REST).contains(&(now - l.moved_at)) { + l.apex = None; + // It has rested long enough: the row under it needn't wait again. + l.rested_at = Some(l.moved_at); + return (false, None); + } + } else if heading_for(from, p, panel) { + l.apex = Some(p); + l.moved_at = now; + } else { + l.apex = None; + return (false, None); + } + l.aiming = true; + (true, Some(AIM_REST - (now - l.moved_at))) + }); + // Wake up when the rest runs out, so the row under a still pointer takes over. + if let Some(w) = wait.filter(|w| w.is_finite() && *w > 0.0) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(w)); + } + aiming +} + +/// The pointer is on a row of level `at` (`target`: the row's submenu, or `None` for a plain +/// row): another open submenu closes at once, and `target` opens once the pointer has stayed +/// there `HOVER_DELAY`. +fn hover_request(ctx: &egui::Context, at: &At, target: Option<&str>) { + let now = ctx.input(|i| i.time); + if with_level(ctx, at, |l| l.open.is_some() && l.open.as_deref() != target) { + set_open(ctx, at.root, &at.path, None); + } + let left = with_level(ctx, at, |l| { + l.asked = true; + if l.open.as_deref() == target { + l.pending = None; + return None; + } + let since = match &l.pending { + Some((t, since)) if t.as_deref() == target => *since, + _ => { + let since = l.rested_at.unwrap_or(now); + l.pending = Some((target.map(str::to_string), since)); + since + } + }; + Some(HOVER_DELAY - (now - since)) + }); + match left { + Some(left) if left <= 0.0 => set_open(ctx, at.root, &at.path, target), + // Wake up when it's due, so a still pointer gets there. + Some(left) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(left.min(HOVER_DELAY))), + None => {} + } +} + +/// Whether the pointer is on a row of level `at` other than the open submenu's (it shows as +/// hovered instead). +fn pending_elsewhere(ctx: &egui::Context, at: &At) -> bool { + with_level(ctx, at, |l| l.pending.as_ref().is_some_and(|(t, _)| *t != l.open)) +} + +/// A plain row of the level being drawn: hovering it (not while aiming at a submenu) closes the +/// level's open submenu. Returns whether it shows as hovered. +fn plain_row_hover(ui: &Ui, resp: &egui::Response) -> bool { + let hovered = resp.hovered() && !aiming(ui); + if hovered { + hover_request(ui.ctx(), &level_at(ui), None); + } + hovered || resp.has_focus() +} + +/// A submenu row whose panel (area `area`, `gap` right of the row) shows while it's open. +#[allow(clippy::too_many_arguments)] +fn submenu_at( + ui: &mut Ui, + area: Id, + key: &str, + label: &str, + gap: f32, + constrain: bool, + t: &Tokens, + add: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let at = level_at(ui); + let aim = aiming(ui); + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); + // The keyboard and clicks open it at once; hovering after a moment. + if resp.has_focus() || resp.clicked() { + set_open(ui.ctx(), at.root, &at.path, Some(key)); + } else if resp.hovered() && !aim { + hover_request(ui.ctx(), &at, Some(key)); + } + let open = with_level(ui.ctx(), &at, |l| { + let open = l.open.as_deref() == Some(key); + if open { + l.row = Some(r); + } + open + }); + a11y::submenu(&resp, label, open); + a11y::own_focus(&resp); + // The open submenu's row stays highlighted while the pointer is in the submenu (not while it + // rests on another row of this menu). + let keep = open && !pending_elsewhere(ui.ctx(), &at); + paint_submenu_row(ui, r, label, keep || (resp.hovered() && !aim) || resp.has_focus(), t); + if !open { return None; } - let pos = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0); - egui::Area::new(Id::new(("submenu", key))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| menu_frame(ui, t, add)).inner + let pos = pos2(r.max.x + gap, r.min.y - 8.0); + let child = At { root: at.root, path: key.to_string() }; + let shown = + egui::Area::new(area).fixed_pos(pos).order(egui::Order::Foreground).constrain(constrain).show(ui.ctx(), |ui| menu_frame(ui, t, child, add)); + let panel = shown.response.rect; + with_level(ui.ctx(), &at, |l| l.panel = Some(panel)); + shown.inner } /// A thin line between groups of menu items. @@ -281,7 +546,8 @@ pub(crate) fn separator(ui: &mut Ui) { ui.add_space(4.0); } -fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R { +/// A menu panel drawing menu level `at`. +fn menu_frame(ui: &mut Ui, t: &Tokens, at: At, add: impl FnOnce(&mut Ui) -> R) -> R { egui::Frame::NONE .fill(t.menu) .corner_radius(CornerRadius::same(13)) @@ -292,6 +558,8 @@ fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R { let w = ui.data(|d| d.get_temp::(width_key(ui))).unwrap_or(0.0).max(MENU_W); ui.set_width(w); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + let key = at_key(ui); + ui.data_mut(|d| d.insert_temp(key, at)); add(ui) }) .inner @@ -328,7 +596,7 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke } // The keyboard's item is highlighted like the hovered one. a11y::own_focus(&resp); - let hov = (resp.hovered() || resp.has_focus()) && enabled; + let hov = plain_row_hover(ui, &resp) && enabled; if hov { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } @@ -348,24 +616,24 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke resp.clicked() } -fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response { - let w = ui.available_width(); - let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); - a11y::submenu(&resp, label); - a11y::own_focus(&resp); - if resp.hovered() || resp.has_focus() { +fn paint_submenu_row(ui: &mut Ui, r: Rect, label: &str, highlight: bool, t: &Tokens) { + if highlight { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), t.menu_text); need_width(ui, 28.0 + galley.size().x + 28.0); ui.painter().galley(pos2(r.min.x + 28.0, r.center().y - galley.size().y / 2.0), galley, t.menu_text); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(12.0, 12.0)), Icon::ChevronRight, t.menu_text); - resp } /// Open the main menu at top-level menu `top` (control channel). pub fn open_main_menu(ctx: &egui::Context, top: &str) { - ctx.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string())); + set_open(ctx, main_menu_id(), "", Some(top)); +} + +/// The context menu's area (and menu tree). +fn ctx_menu_id() -> Id { + Id::new("ctx_menu") } /// The main menu's area. @@ -394,13 +662,29 @@ const TOP_MENUS: &[&str] = &[ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); + // A closed menu forgets its open submenus (and a context menu opened somewhere else starts + // afresh), so neither reopens with a stale submenu showing. + if !app.ui.main_menu { + forget_tree(ctx, main_menu_id()); + } + let ctx_at = app.ui.context_menu.as_ref().map(|(p, _)| *p); + let ctx_seen = Id::new("ctx_menu_at"); + if ctx.data(|d| d.get_temp::>(ctx_seen)) != Some(ctx_at) { + ctx.data_mut(|d| d.insert_temp(ctx_seen, ctx_at)); + forget_tree(ctx, ctx_menu_id()); + } + // Select layer opened or closed from the control channel. + if let Some(open) = ctx.data_mut(|d| d.remove_temp::(select_layer_ask_id())) { + set_open(ctx, ctx_menu_id(), "", open.then_some(SELECT_LAYER)); + } // Main menu. if app.ui.main_menu { let mut close = false; let area = egui::Area::new(main_menu_id()).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| { - menu_frame(ui, &t, |ui| { + menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| { // Quick actions search row. let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + plain_row_hover(ui, &resp); a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None); ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18)); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim); @@ -418,20 +702,8 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } ui.add_space(6.0); for top in TOP_MENUS { - let resp = submenu_item(ui, tr(top), &t); - if resp.hovered() || resp.has_focus() { - ui.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string())); - } - let open: Option = ui.data(|d| d.get_temp(Id::new("main_open"))); - if open.as_deref() == Some(*top) { - let pos = pos2(resp.rect.max.x + 12.0, resp.rect.min.y - 8.0); - egui::Area::new(Id::new(("menu", *top))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - menu_frame(ui, &t, |ui| { - if menu_level(app, ui, top) { - close = true; - } - }); - }); + if submenu_at(ui, Id::new(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())).is_some() { + close = true; } } }); @@ -439,6 +711,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape)); if close || (clicked_outside && !area.response.hovered()) { app.ui.main_menu = false; + forget_tree(ctx, main_menu_id()); } } // Context menu (from the keyboard: at the focused layer row or the canvas selection, taking the @@ -451,8 +724,8 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu")); } let mut close = false; - let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { - menu_frame(ui, &t, |ui| { + let area = egui::Area::new(ctx_menu_id()).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { + menu_frame(ui, &t, At { root: ctx_menu_id(), path: String::new() }, |ui| { if select_layer_menu(app, ui, pos, &t) { close = true; } @@ -548,20 +821,23 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); if close || clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.context_menu = None; - ctx.data_mut(|d| d.remove::(select_layer_id())); + forget_tree(ctx, ctx_menu_id()); } } crate::colorpicker::show(app, ctx); palette(app, ctx); } -fn select_layer_id() -> Id { - Id::new("ctx_select_layer_open") +/// The context menu's Select layer submenu (its key in the context menu's tree). +const SELECT_LAYER: &str = "Select layer"; + +fn select_layer_ask_id() -> Id { + Id::new("ctx_select_layer_ask") } -/// Open (or close) the context menu's Select layer submenu. +/// Open (or close) the context menu's Select layer submenu (applied when the menu next draws). pub fn open_select_layer(ctx: &egui::Context, open: bool) { - ctx.data_mut(|d| d.insert_temp(select_layer_id(), open)); + ctx.data_mut(|d| d.insert_temp(select_layer_ask_id(), open)); } /// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the @@ -585,23 +861,15 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) return false; } let sel = ds.selection.clone(); - let resp = submenu_item(ui, tr("Select layer"), t); - if resp.hovered() { - ui.data_mut(|d| d.insert_temp(select_layer_id(), true)); - } - let mut picked = None; - if ui.data(|d| d.get_temp::(select_layer_id())).unwrap_or(false) { - let at = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0); - egui::Area::new(Id::new("ctx_select_layer")).fixed_pos(at).order(egui::Order::Foreground).constrain(true).show(ui.ctx(), |ui| { - menu_frame(ui, t, |ui| { - for (id, depth, name) in &layers { - if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { - picked = Some(*id); - } - } - }); - }); - } + let picked = submenu_at(ui, Id::new("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { + let mut picked = None; + for (id, depth, name) in &layers { + if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { + picked = Some(*id); + } + } + picked + }); separator(ui); match picked { Some(id) => { diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..745cde88 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -151,7 +151,7 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { } } commands => { - let picked = crate::menus::submenu(ui, "Plugins", &format!("Plugins/plugin:{}", m.id), &m.name, t, |ui| { + let picked = crate::menus::submenu(ui, &format!("Plugins/plugin:{}", m.id), &m.name, t, |ui| { commands.iter().find(|c| crate::menus::item(ui, &c.label, "", true, false, t)).map(|c| c.id.clone()) }); if let Some(c) = picked { @@ -164,7 +164,7 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { app.session.prefs.plugins.iter().filter(|p| p.manifest.widget).map(|p| (p.manifest.id.as_str(), p.manifest.name.as_str())).collect(); let widget = (!widgets.is_empty()) .then(|| { - crate::menus::submenu(ui, "Plugins", "Plugins/widgets", tr("Widgets"), t, |ui| { + crate::menus::submenu(ui, "Plugins/widgets", tr("Widgets"), t, |ui| { widgets.iter().find(|(_, name)| crate::menus::item(ui, name, "", true, false, t)).map(|(id, _)| id.to_string()) }) }) From 01ce23ca765fabba5ec22bd83de5684614e259d3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:58:23 +0700 Subject: [PATCH 135/245] #680: page refusal names the calling command (edit.copy / edit.cut) --- crates/engine/src/cmd/edit.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ab503a02..22610934 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -496,13 +496,13 @@ fn bump_name(n: &str) -> String { } /// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; let doc = &ds.doc; // Pages are not layers: they can't be pasted anywhere. let layers: Vec = ids.iter().copied().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); if layers.is_empty() && !ids.is_empty() { - return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); } let ids = z_sorted(doc, &roots(doc, &layers)); let mut nodes = Vec::new(); @@ -540,13 +540,13 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { fn copy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.copy", &ids)?; Ok(json!({"copied": s.clipboard.nodes.len()})) } fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.cut", &ids)?; let doc = s.doc()?; let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); delete(s, &json!({"ids": layers})) From 5ea5590673bff20d96097b37cc6e2177e9c69348 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:58:24 +0700 Subject: [PATCH 136/245] #680: move the page clipboard test to its own module, check the error id --- crates/engine/src/lib.rs | 2 ++ crates/engine/src/page_clipboard_tests.rs | 36 +++++++++++++++++++++++ crates/engine/src/tests.rs | 17 ----------- 3 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/page_clipboard_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6e0e8b12..9f00467a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1592,6 +1592,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_clipboard_tests; +#[cfg(test)] mod page_copy_navigation_tests; #[cfg(test)] mod page_target_tests; diff --git a/crates/engine/src/page_clipboard_tests.rs b/crates/engine/src/page_clipboard_tests.rs new file mode 100644 index 00000000..79ff7419 --- /dev/null +++ b/crates/engine/src/page_clipboard_tests.rs @@ -0,0 +1,36 @@ +//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a +//! selection of only pages under their own command id. + +use serde_json::json; + +use crate::{EngineError, Session}; + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + match s.execute(cmd, &json!({"ids": [page.0]})) { + Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd), + r => panic!("{cmd} on a page: {r:?}"), + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} + +#[test] +fn cut_of_a_page_and_a_layer_moves_only_the_layer() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = crate::params::id_of(&r["id"]).unwrap(); + s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap(); + assert!(s.doc().unwrap().node(page).is_some()); + assert!(s.doc().unwrap().node(rect).is_none()); + s.execute("edit.paste", &json!({})).unwrap(); + assert!(s.doc().unwrap().validate().is_empty()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6010559e..dbf432d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,20 +621,3 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } - -#[test] -fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { - for cmd in ["edit.copy", "edit.cut"] { - let mut s = sess(); - let page = s.page().unwrap(); - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let r = s.execute(cmd, &json!({"ids": [page.0]})); - if let Ok(v) = &r { - assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); - } - let _ = s.execute("edit.paste", &json!({})); - let doc = s.doc().unwrap(); - assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); - assert!(doc.node(page).is_some(), "{cmd} removed the page"); - } -} From 5e2913db34ba306176864cce5309690146dcab75 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:59:52 +0700 Subject: [PATCH 137/245] #679: leave the progress docs to the maintainer --- docs/components-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/components-parity.md b/docs/components-parity.md index 8ad788bf..659676fd 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (variant properties reorder by dragging in the Properties section; variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -19,7 +19,7 @@ accounts). | Component sets / variants, add variant, switch | done | | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | -| Variant property order (names rewritten), duplicate combinations reported | done | drag a variant property by its left margin in the Properties section (or `component.editProperty {index}`); variant properties reorder among themselves; boolean, text, instance swap and slot properties keep a fixed (alphabetical) order | +| Variant property order (names rewritten), duplicate combinations reported | done | reordering through `component.editProperty {index}`; no drag in the panel yet | | Properties: boolean, text, instance swap (preferred values), variant | done | | | Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | | Exposed nested instances | done | hidden ones hide their properties | @@ -76,7 +76,6 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | Variant properties reorder by dragging their left margin in a component or set's Properties section (insertion line, Escape cancels, one undo step; instances follow) | | 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | | 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | From 6f0e14a27176a4399888d46955fe8a29445665c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:03:41 +0700 Subject: [PATCH 138/245] #673: read the failed step with get() instead of indexing --- apps/figcraft-cli/src/files.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46c8dd22..3e08474a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -350,7 +350,8 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe Ok(()) }); if let Err(e) = outcome { - let (i, (cmd, _)) = (failed_at, &steps[failed_at]); + let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); From 1503f0743cbc440892dbc60b4dc8615d5d0f2955 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:06:26 +0700 Subject: [PATCH 139/245] #675: cap the offending value echoed in id/ids errors --- .../engine/src/id_params_behaviour_tests.rs | 10 ++++++++++ crates/engine/src/params.rs | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs index ae77c8a1..c5207b44 100644 --- a/crates/engine/src/id_params_behaviour_tests.rs +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -149,3 +149,13 @@ fn an_image_and_an_instance_parent_are_checked_the_same_way() { let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); } + +#[test] +fn a_huge_bad_value_is_cut_short_in_the_error() { + let mut s = Session::new(); + card(&mut s); + let junk = "é".repeat(10_000); + let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail")); + assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}"); + assert!(msg.chars().count() < 200, "{} chars", msg.chars().count()); +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 67a5b80a..35a74e0e 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,17 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// The offending value for an error message, cut to a few dozen characters so a huge param +/// doesn't end up in the error whole. +fn shown(v: &Value) -> String { + const MAX: usize = 40; + let text = v.to_string(); + match text.char_indices().nth(MAX) { + Some((cut, _)) => format!("{}…", &text[..cut]), + None => text, + } +} + /// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a /// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — /// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't @@ -95,7 +106,7 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { match get(p, key) { None | Some(Value::Null) => Ok(None), - Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {v})"))), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))), } } @@ -125,17 +136,17 @@ pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { match get(p, "ids") { Some(Value::Array(a)) => { if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { - return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {v})"))); + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v)))); } } Some(Value::Null) | None => { if let Some(v) = get(p, "id").filter(|v| !v.is_null()) && id_of(v).is_none() { - return Err(bad(cmd, format!("`id` must be a node id (got {v})"))); + return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v)))); } } - Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {v})"))), + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))), } let t = targets(s, p); if t.is_empty() { From e095ef1fe1b35aa7e19c06b606f2dadf56afc6dd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:08:01 +0700 Subject: [PATCH 140/245] #652: note that a file stamped in the future is re-read until the clock passes it --- crates/engine/src/library.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 5f87c862..daa1f5bd 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -459,6 +459,8 @@ struct Entry { const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2); impl Entry { + /// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on + /// every load until the clock passes it. fn still_valid(&self, now: &Stamp) -> bool { let settled = match (now.modified, self.read_at) { (Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at), From 3c73045088480866d6dfd9bf9604a9513ced0677 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:11:59 +0700 Subject: [PATCH 141/245] #705: cap what a client buffers from a server at 1 GiB; warn the operator about files over 64 MiB when open and over what the app opens --- apps/figcraft-server/src/config.rs | 14 ++++++++++++++ apps/figcraft-server/src/public_tests.rs | 21 +++++++++++++++++---- crates/collab-net/src/limits.rs | 9 ++++----- docs/server.md | 2 +- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index 3e951e87..101c7815 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -135,9 +135,23 @@ impl Config { /// abuse contact, or one whose clients would all count as its proxy). pub fn warnings(&self) -> Vec { let mut out = Vec::new(); + let client_max = NetLimits::default().max_server_message >> 20; + if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) { + out.push(format!( + "max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened", + self.file_mb(), + client_max - 1 + )); + } if !self.open_publish { return out; } + if self.file_mb() > 64 { + out.push(format!( + "open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server", + self.file_mb() + )); + } if self.operator_contact.trim().is_empty() { out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into()); } diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 6403212c..978d9e8e 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -506,8 +506,21 @@ fn files_above_64_mib_fit_every_limit() { assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); } - // A client takes the largest file any server can keep, or as much as a 32-bit target can address - // (computed in u64: `4097 << 20` in a 32-bit usize wraps to 1 MiB). - let client = NetLimits::default().max_server_message as u64; - assert!(client > 4096 << 20 || client == u64::from(u32::MAX), "{client}"); + // A client takes such a file too, but caps what any server may make it buffer at 1 GiB. + assert_eq!(NetLimits::default().max_server_message, 1 << 30); +} + +#[test] +fn large_files_warn_the_operator() { + let public_url = Some("wss://figcraft.example.com/".into()); + let warns = |open_publish, mb| { + Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join( + " +", + ) + }; + assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64)); + assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65)); + assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023)); + assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024)); } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index 02167770..a0647fa6 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,10 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document): the - /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, - /// so a client can open every file a server takes; on a 32-bit target (the browser) as much as - /// `usize` holds. + /// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB, + /// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server + /// (any link can name one) can't make the app or a browser tab buffer more than that. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -64,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: usize::try_from(4097u64 << 20).unwrap_or(usize::MAX), + max_server_message: 1 << 30, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/docs/server.md b/docs/server.md index 55d784f4..5cbab6a8 100644 --- a/docs/server.md +++ b/docs/server.md @@ -282,7 +282,7 @@ are errors): | `data` | `figcraft-data` | The data directory | | `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) | | `max_files` | 1000 | Most files kept | -| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server) | +| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server). The app opens files of up to 1023 MiB; above 64 with `open_publish` the server warns | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | | `max_people` | 256 | Most people in one file at once | From 02b231db6dd9457097017ddc30d96d8a947c724d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:18:50 +0700 Subject: [PATCH 142/245] #712: read the invite link from the URL fragment (#join=), never sent to the web host; ignore overlong links --- apps/figcraft-web/src/main.rs | 10 ++++++---- apps/figcraft-web/src/query.rs | 21 +++++++++++-------- apps/figcraft-web/src/query_tests.rs | 30 ++++++++++++++++++---------- apps/figcraft-web/src/web.rs | 6 +++--- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 35aebb02..7eed2ffb 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,10 +13,12 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query: `?webgl` forces the WebGL2 backend instead of WebGPU; `?join=` joins that live session once the page has loaded, so a bookmark opens a shared file. -//! `join` takes the rest of the query: put it last. The link carries the session's secret, so the -//! URL (and the browser history) is as private as the link itself. +//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! +//! URL fragment: `#join=` joins that live session once the page has +//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. +//! The link carries the session's secret; a fragment is never sent to the web host, but the address +//! bar and the browser history keep it, so the URL is as private as the link itself. #[cfg(any(target_arch = "wasm32", test))] mod query; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs index be68f598..befeb36b 100644 --- a/apps/figcraft-web/src/query.rs +++ b/apps/figcraft-web/src/query.rs @@ -1,10 +1,15 @@ -//! The page's URL query: which flags it sets and the invite link it names. +//! The page's URL: the invite link its fragment names. -/// The invite link in `?join=` (`search` is `location.search`, with or without its `?`), still -/// escaped. `join` takes the rest of the query, so a pasted `figcraft://join?host=…&file=…&token=…` -/// keeps its own `&`s; put it last. -pub fn join_link(search: &str) -> Option<&str> { - let query = search.strip_prefix('?').unwrap_or(search); - let start = if query.starts_with("join=") { 0 } else { query.find("&join=")? + 1 }; - query.get(start + "join=".len()..).filter(|link| !link.is_empty()) +/// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still +/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out +/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted +/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite +/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is +/// opened with. +pub fn join_link(fragment: &str) -> Option<&str> { + // An invite link is at most 2 KiB; every byte of it escaped is three. + const MAX: usize = 3 * 2048; + let fragment = fragment.strip_prefix('#').unwrap_or(fragment); + let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 }; + fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) } diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs index 9582682f..261ebaeb 100644 --- a/apps/figcraft-web/src/query_tests.rs +++ b/apps/figcraft-web/src/query_tests.rs @@ -1,21 +1,29 @@ -//! `?join=` in the page's URL. +//! `#join=` in the page's URL. use crate::query::join_link; #[test] -fn join_takes_the_rest_of_the_query() { +fn join_takes_the_rest_of_the_fragment() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; - assert_eq!(join_link(&format!("?join={link}")), Some(link), "a pasted link keeps its own &s"); - assert_eq!(join_link(&format!("?webgl&join={link}")), Some(link), "after other flags"); - assert_eq!(join_link("?join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); - assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading ?"); + assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries"); + assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #"); } #[test] -fn no_join_without_the_parameter() { +fn no_join_without_the_entry() { assert_eq!(join_link(""), None); - assert_eq!(join_link("?webgl"), None); - assert_eq!(join_link("?join="), None); - assert_eq!(join_link("?rejoin=x"), None, "only the whole parameter name"); - assert_eq!(join_link("?xjoin=x&a=1"), None); + assert_eq!(join_link("#"), None); + assert_eq!(join_link("#join="), None); + assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name"); + assert_eq!(join_link("#xjoin=x&a=1"), None); + assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host"); +} + +#[test] +fn an_overlong_link_is_ignored() { + let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000)); + assert_eq!(join_link(&long), None); + assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index d3c16441..29213621 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -41,9 +41,9 @@ pub fn start() { let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); - // `?join=`: join that session, as opening an invite link does on the desktop. - let search = query(); - if let Some(link) = crate::query::join_link(&search) { + // `#join=`: join that session, as opening an invite link does on the desktop. + let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); + if let Some(link) = crate::query::join_link(&hash) { let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); figcraft_ui_egui::live::open_invite(&mut fig, &link); } From 532b0f7321cf315c3b79bff36cc8e7d194c7b3de Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:18:57 +0700 Subject: [PATCH 143/245] Batch 11: Paste here puts copied design layers at the point --- crates/ui-egui/src/design_paste_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index c320230a..35743a81 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -45,6 +45,16 @@ fn paste_takes_layers_copied_in_another_design_app() { assert_eq!(selected_names(&app), ["Copied layer"]); } +#[test] +fn paste_here_puts_copied_design_layers_at_the_point() { + let html = copied_html(); + let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) })); + crate::menus::paste(&mut app, None, Some((300.0, 200.0))); + let id = app.session.selection()[0]; + let b = app.session.doc().unwrap().world_bounds(id).unwrap(); + assert_eq!((b.x0, b.y0), (300.0, 200.0)); +} + #[test] fn html_from_other_apps_pastes_as_before() { let clip = Clip { html: "Hello".into(), text: Some("Hello".into()) }; From b6d98e28dccc8303c252e590a6c802f380c5cced Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:29:31 +0700 Subject: [PATCH 144/245] #699: keep en.toml's end of file as on main --- crates/ui-egui/locales/en.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 53cc5c45..efd23d04 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1606,4 +1606,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" +"⌥ drag" = "⌥ drag" \ No newline at end of file From 7f7921260ca81914d48e6a69d61c01431f28f611 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:29:34 +0700 Subject: [PATCH 145/245] #699: menu input dialog and language picker use the shared widgets; File > Rename edits the file name in place --- crates/ui-egui/src/dialogs.rs | 70 +++++++++++++++----------- crates/ui-egui/src/layers.rs | 3 +- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/menu_input_tests.rs | 27 +++++++--- crates/ui-egui/src/menus.rs | 7 +-- 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 31eb1adb..649bcb3f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -74,7 +74,6 @@ pub enum AboutTab { /// Values collected by a menu or Actions command before dispatching it to the engine. #[derive(Clone, Debug, PartialEq)] pub enum MenuInput { - RenameFile(String), Link(String), Offset(f64), MoveToPage, @@ -274,18 +273,27 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.horizontal(|ui| { ui.label(tr("Language")); // Each language by its own name; System follows the OS (the browser on the web). - let langs = i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)); - let choices: Vec<_> = [("system", tr("System"))].into_iter().chain(langs).collect(); - let selected = - choices.iter().find(|(code, _)| app.session.prefs.language.eq_ignore_ascii_case(code)).map_or(tr("System"), |(_, name)| *name); - egui::ComboBox::from_id_salt("pref_language").selected_text(selected).width(220.0).show_ui(ui, |ui| { - for (code, name) in choices { - if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { - app.run("pref.language", json!({"language": code})); - ui.ctx().request_repaint(); + let langs = || { + [("system", tr("System"))] + .into_iter() + .chain(i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name))) + }; + let on = |code: &str| app.session.prefs.language.eq_ignore_ascii_case(code); + let selected = langs().find(|(code, _)| on(code)).map_or(tr("System"), |(_, name)| name); + let resp = crate::widgets::dropdown(ui, selected, 220.0, None); + let mut pick = None; + crate::right::menu_popup(&resp).show(|ui| { + for (code, name) in langs() { + if crate::right::menu_check(ui, on(code), name, "") { + pick = Some(code); + ui.close(); } } }); + if let Some(code) = pick { + app.run("pref.language", json!({"language": code})); + ui.ctx().request_repaint(); + } }); if let Some(lang) = i18n::current().filter(|l| l.draft) { let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]); @@ -355,8 +363,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { /// command so the same validation and undo behaviour apply as for programmatic callers. fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { let Some(mut input) = app.ui.menu_input.take() else { return }; + let t = Tokens::get(ctx); let title = match &input { - MenuInput::RenameFile(_) => i18n::command("file.rename", "Rename file"), MenuInput::Link(_) => i18n::command("text.link", "Create link"), MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), @@ -366,26 +374,28 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { window(&title, ctx).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); match &mut input { - MenuInput::RenameFile(name) => { - let field = ui.add(egui::TextEdit::singleline(name).desired_width(300.0)); - if (ui.button(tr("Rename")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) - && !name.trim().is_empty() - { - action = Some(("file.rename", json!({"name": name.trim()}))); - } - } MenuInput::Link(url) => { - let field = ui.add(egui::TextEdit::singleline(url).hint_text("https://").desired_width(300.0)); - if (ui.button(tr("Done")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) && !url.trim().is_empty() - { - action = Some(("text.link", json!({"url": url.trim()}))); - } + ui.horizontal(|ui| { + let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://"); + let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let ok = !url.trim().is_empty(); + if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { + action = Some(("text.link", json!({"url": url.trim()}))); + } + }); } MenuInput::Offset(offset) => { - ui.add(egui::DragValue::new(offset).speed(1.0).range(-100_000.0..=100_000.0)); - if ui.button(tr("Offset")).clicked() { - action = Some(("vector.offset", json!({"offset": offset}))); - } + ui.horizontal(|ui| { + let id = egui::Id::new("menu_input_offset"); + if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = + crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) + { + *offset = v.clamp(-100_000.0, 100_000.0); + } + if crate::widgets::button(ui, tr("Offset"), true, true).clicked() { + action = Some(("vector.offset", json!({"offset": *offset}))); + } + }); } MenuInput::MoveToPage => { let mut choices = 0; @@ -395,7 +405,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { continue; } if let Some(node) = ds.doc.node(page) - && ui.button(&node.name).clicked() + && crate::widgets::button(ui, &node.name, false, true).clicked() { action = Some(("object.moveToPage", json!({"page": page}))); } @@ -403,7 +413,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { } } if choices == 0 { - ui.label(tr("No other pages")); + crate::widgets::label(ui, tr("No other pages"), t.text_secondary); } } } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8b614762..f05c9f87 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -148,7 +148,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } else { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); - if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(13.0)) { + let start = std::mem::take(&mut app.ui.rename_file); + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new("filename"), &name, nr, semibold(13.0), start) { app.run("file.rename", json!({"name": new})); } ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4671391..982c275f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -414,6 +414,8 @@ pub struct UiState { pub preferences: bool, /// A menu command that needs a value before the engine can run it. pub menu_input: Option, + /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. + pub rename_file: bool, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -542,6 +544,7 @@ impl Default for UiState { about_tab: dialogs::AboutTab::About, preferences: false, menu_input: None, + rename_file: false, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index 0de3757f..f317b65b 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -31,13 +31,9 @@ fn preference_actions_open_preferences_without_changing_values() { #[test] fn menu_entries_collect_required_arguments_before_running() { let mut ui = Ui::new(Session::new()); - let name = ui.app.session.doc().unwrap().name.clone(); - for (id, expected) in [ - ("file.rename", MenuInput::RenameFile(name)), - ("text.link", MenuInput::Link(String::new())), - ("vector.offset", MenuInput::Offset(0.0)), - ("object.moveToPage", MenuInput::MoveToPage), - ] { + for (id, expected) in + [("text.link", MenuInput::Link(String::new())), ("vector.offset", MenuInput::Offset(0.0)), ("object.moveToPage", MenuInput::MoveToPage)] + { ui.app.ui.menu_input = None; menus::invoke(&mut ui.app, id); assert_eq!(ui.app.ui.menu_input, Some(expected), "{id}"); @@ -72,3 +68,20 @@ fn batch_rename_opens_its_existing_dialog() { assert_eq!(ui.app.ui.batch_rename.as_ref().map(|d| d.ids.len()), Some(1)); assert!(ui.app.ui.last_error.is_none()); } + +#[test] +fn rename_file_edits_the_file_name_in_place() { + let mut ui = Ui::new(Session::new()); + ui.app.ui.left_collapsed = true; + menus::invoke(&mut ui.app, "file.rename"); + assert!(!ui.app.ui.left_collapsed, "the left panel shows the file name"); + assert!(ui.app.ui.menu_input.is_none(), "no dialog"); + ui.frame(vec![]); + ui.frame(vec![]); + assert!(!ui.app.ui.rename_file, "the rename started"); + assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::new("filename").with("te"))), "the file name's editor has the keyboard"); + ui.frame(vec![egui::Event::Text("Brand kit".into())]); + ui.frame(vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert_eq!(ui.app.session.doc().unwrap().name, "Brand kit"); + assert!(ui.app.ui.last_error.is_none()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 18023e67..5a9017a8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -137,10 +137,11 @@ pub fn invoke(app: &mut FigApp, id: &str) { "pref.language" | "pref.userName" | "pref.publishServer" | "pref.nudge" => { app.run("app.preferences", json!({})); } + // The file name in the left panel's header turns into its editor, the name selected. "file.rename" => { - if let Ok(doc) = app.session.doc() { - app.ui.menu_input = Some(crate::dialogs::MenuInput::RenameFile(doc.name.clone())); - } + app.ui.left_tab = crate::LeftTab::File; + app.ui.left_collapsed = false; + app.ui.rename_file = true; } "text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())), "vector.offset" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Offset(0.0)), From 234339a78681c634075336e442ffe1f3685d706b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:47:07 +0000 Subject: [PATCH 146/245] Refuse pages in edit.copy and edit.cut Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++-- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 21f09569..eef4beaa 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -505,7 +505,12 @@ fn bump_name(n: &str) -> String { /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let (nodes, images, bounds) = copied(&ds.doc, ids); + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); s.clipboard.source = Some(source); s.clipboard.source_path = source_path; @@ -605,7 +610,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..6010559e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = sess(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let r = s.execute(cmd, &json!({"ids": [page.0]})); + if let Ok(v) = &r { + assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} From fec54d93619a9356fbefedd50c4f5993ad1f4ec7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:36:54 +0700 Subject: [PATCH 147/245] #599: leave the progress docs to the maintainer --- ROADMAP.md | 2 +- docs/localization-parity.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2411799f..006b81b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`: every UI string, command label and menu wording; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d3f69a9a..45a7a925 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -100,8 +100,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: Swedish (`sv`): every UI string, command label and menu wording translated; native -review welcome. Figma ships none beyond the eight +Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and +menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries From e912a2efe8b2574b9835caf629c7a871fb436bb7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:38:35 +0700 Subject: [PATCH 148/245] #655: fill.set / stroke.set read index with params::opt_index (#675) --- crates/engine/src/cmd/edit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1d7cb88e..12ff561e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1184,7 +1184,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = if get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; + let idx = opt_index(cmd, p, "index")?; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None, From 00b0b086803d0f8357069036822dc08471b97430 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 17:02:34 +0700 Subject: [PATCH 149/245] #668: a short pages list drags from the rows it shows, not the room it was given --- crates/ui-egui/src/layers.rs | 3 +- crates/ui-egui/src/layers_behaviour_tests.rs | 30 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index a6bbe5f5..c9fe528f 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -306,7 +306,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { { app.run("page.select", json!({"id": p})); } - pages_edge(app, ui, moved.inner_rect.height()); + // `inner_rect` is the room the list was given; a short list shrinks to its rows. + pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } /// The pages list's height in a panel with `avail` below the Pages header: the height asked for diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 14d625a7..d4ef4f8a 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -390,6 +390,36 @@ fn the_line_between_pages_and_layers_drags_to_size_the_pages_list() { assert!((u.row_at(rect).y - before).abs() < 3.0); } +#[test] +fn dragging_up_the_line_under_a_short_pages_list_shrinks_it_at_once() { + // Five pages fit in the room the list is given: the drag starts from the rows shown, not + // from that room, so the line follows the pointer. + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..4 { + s.execute("page.add", &json!({})).unwrap(); + } + let first = s.doc().unwrap().pages[0]; + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let before = u.row_at(rect).y; + let edge = pages_edge_at(&mut u, before); + let at = |dy: f32| Pos2::new(150.0, edge + dy); + let button = |p, pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + u.frame(vec![Event::PointerMoved(at(0.0))]); + u.frame(vec![button(at(0.0), true)]); + for k in 1..=4 { + u.frame(vec![Event::PointerMoved(at(-10.0 * k as f32))]); + } + u.frame(vec![button(at(-40.0), false)]); + for _ in 0..30 { + u.frame(vec![]); + } + let after = u.row_at(rect).y; + assert!((before - after - 40.0).abs() < 3.0, "the layers moved from {before} to {after}"); +} + #[test] fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() { use crate::layers::{ROW, pages_list_height}; From 41abce53b75e95c470b5148a072c1114ce2daffb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 18:11:27 +0700 Subject: [PATCH 150/245] #621: a panic inside the boolean-op sweep is bad input, not a crash (text strokes clip to glyph outlines) --- crates/pathops/src/lib.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..74a120b1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -59,6 +59,13 @@ pub fn closed(path: &BezPath) -> BezPath { /// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input). pub use figcraft_geom::measure::finite_path as finite; +/// Run the sweep library on `f`. Version 0.4 can panic on rare near-degenerate input (an internal +/// `unwrap` while ordering curves, hit by some font outlines): that is bad input, not a crash. +// shortcut: no catch where panics abort (wasm32); drop this once a linesweeper release no longer panics there. +fn sweep(f: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(PathOpsError::BadInput)) +} + fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result { if a.elements().is_empty() && b.elements().is_empty() { return Ok(BezPath::new()); @@ -66,7 +73,7 @@ fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result Result, PathOpsE let bbox = closed_paths.iter().filter(|p| !p.elements().is_empty()).map(Shape::bounding_box).reduce(|a, b| a.union(b)); let m = bbox.map(|b| b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs())).unwrap_or(0.0); let eps = (m * f64::EPSILON * 64.0).max(1e-6); + let even_odd: Vec = paths.iter().map(|(_, e)| *e).collect(); + sweep(|| arrange(&closed_paths, &even_odd, eps)) +} + +/// [`faces`] of the closed `paths`, swept with tolerance `eps`. +fn arrange(closed_paths: &[BezPath], even_odd: &[bool], eps: f64) -> Result, PathOpsError> { let topo = linesweeper::topology::Topology::::from_paths(closed_paths.iter().enumerate().map(|(i, p)| (p, i)), eps) .map_err(|_| PathOpsError::BadInput)?; - let even_odd: Vec = paths.iter().map(|(_, e)| *e).collect(); let cover_of = |w: &Windings| -> Vec { w.0.iter() .enumerate() @@ -441,6 +453,13 @@ mod tests { p.area().abs() } + #[test] + fn a_panic_in_the_sweep_is_bad_input() { + let r: Result<(), PathOpsError> = sweep(|| panic!("sweep bug")); + assert!(matches!(r, Err(PathOpsError::BadInput))); + assert!(matches!(sweep(|| Ok(1)), Ok(1))); + } + #[test] fn union_intersect_subtract_exclude() { let a = sq(0.0, 0.0, 10.0); From fdd4eb5cadda8e3c08006829767051967e4bf917 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 19:16:10 +0700 Subject: [PATCH 151/245] Docs: roadmap rows for the community fixes from #524 up (#727, #728, #733, #735, #736, #729) (#744) Adds the progress rows for the community PRs landed today: batches #727, #728, #733, #735 and #736, plus #729. That's 55 PRs, and the batch-13 boolean-op crash guard. - `ROADMAP.md` and `docs/roadmap.md`: the header's "Last updated/Change" line and one revision-history row each. - Headline numbers are unchanged. The per-area rows for the `.fig` import and component overrides come with the #731/#732 PR. --- ROADMAP.md | 3 ++- docs/roadmap.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 006b81b9..2dcc2b9b 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 (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -315,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e9eaec9..cf285464 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From 6cdfe2ab8e1f4bc39717ebc24620913206059ebe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 18:15:34 +0700 Subject: [PATCH 152/245] M3: layers inside instances keep their resizing overrides (#731) A layer inside an instance can now override its resizing (hug, fill or fixed, with min and max sizes; override group `resizing`) and its size. They were refused, and imported ones were dropped at the first relayout. Kept through rebuilds, undo, save, .fig and REST import, .fig export and detach; reset per group and with Reset all changes. Position, constraints and absolute or grid placement stay the main's. --- crates/engine/src/cmd/component.rs | 8 +- crates/engine/src/cmd/props.rs | 11 +- crates/engine/src/components.rs | 77 ++++--- .../engine/src/components_behaviour_tests.rs | 15 -- crates/engine/src/components_tests.rs | 2 - crates/engine/src/cursor_tests.rs | 11 +- crates/engine/src/instance_resizing_tests.rs | 190 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/format/src/fig/export.rs | 13 +- crates/format/src/fig/map.rs | 2 + crates/format/src/rest/mod.rs | 18 +- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/component_panel.rs | 1 + crates/ui-egui/src/layout_panel.rs | 17 +- docs/components-parity.md | 5 +- docs/file-format-parity.md | 5 +- 28 files changed, 320 insertions(+), 70 deletions(-) create mode 100644 crates/engine/src/instance_resizing_tests.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index e35c3f5b..a8901f24 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -678,8 +678,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { if !doc.contains(component) { return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); } - // Copy every overridden layer's values to its source in the main (geometry, swaps, property - // values and slot content stay with the instance). + // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, + // property values and slot content stay with the instance). let mut pushed = 0; for d in doc.subtree(inst) { let Some(n) = doc.node(d).cloned() else { continue }; @@ -691,7 +691,9 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { let groups: BTreeSet = n .overrides .iter() - .filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")) + .filter(|g| { + !matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name") + }) .cloned() .collect(); for g in &groups { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 598be752..78b448c0 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -783,12 +783,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map &'static str { "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings" | "hangingPunctuation" | "hangingList" => "textStyle", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", - "width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size", + "width" | "height" | "w" | "h" => "size", + "sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing", "gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap", "padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding", "mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment", diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 30f21b8c..ff582b8a 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -11,12 +11,12 @@ //! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, //! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` //! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, -//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested -//! instance) and `slot` (a slot holding the instance's own content). An instance's own export -//! settings and interactions are its own, like its place. `size` and `position` overrides of -//! layers inside an instance are still read and kept, but new ones are refused -//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the -//! main; the instance itself resizes. +//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers +//! inside an instance, `component` (a swapped nested instance), `props` (property values of a +//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own +//! export settings, interactions and resizing are its own, like its place. `position` +//! overrides of layers inside an instance are still read and kept, but new ones are refused +//! ([`check_override_edit`]): those layers take their place and constraints from the main. //! //! Switching variants keeps an override where the layer's name path matches and the two variants //! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps @@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "exports", "interactions", "size", + "resizing", "position", "component", "props", @@ -388,11 +389,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { "size" => { to.width = from.width; to.height = from.height; - to.child_layout = from.child_layout.clone(); if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { b.auto_resize = a.auto_resize; } } + "resizing" => { + let (a, b) = (&from.child_layout, &mut to.child_layout); + (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); + } "position" => to.transform = from.transform, "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { @@ -773,6 +777,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT for s in &src { let Some(nid) = map.get(s).copied() else { continue }; let Some(o) = old.get(&nid) else { continue }; + // A frame that kept the size its own resizing gave it: its layers (copied at their main + // layers' places) follow their constraints from its main layer's size. + if o.overrides.contains("resizing") + && !o.overrides.contains("size") + && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) + && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) + { + figcraft_layout::apply_constraints(doc, nid, mw, mh); + } if !o.overrides.contains("size") && !o.overrides.contains("position") { continue; } @@ -985,6 +998,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { out.push("size"); } + if resizing(a) != resizing(b) { + out.push("resizing"); + } if a.transform != b.transform { out.push("position"); } @@ -1027,6 +1043,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { out } +/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. +fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { + let c = &n.child_layout; + (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) +} + /// Whether two texts differ in anything but their characters (the `textStyle` group). fn text_style_differs(x: &TextData, y: &TextData) -> bool { x.style != y.style @@ -1107,9 +1129,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); - // An instance's own place, export settings and interactions are its own, not overrides. + // An instance's own place, resizing, export settings and interactions are its own, not + // overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1414,7 +1437,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { let Some(p) = doc.node(parent) else { return false }; let constrains = p.layout.is_none() || absolute; - let resized = p.overrides.contains("size") + let resized = sized_own(p) || match &p.kind { NodeKind::Instance { component, .. } => { doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) @@ -1518,7 +1541,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { chain.windows(2).all(|w| { let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; let constrains = a.layout.is_none() || below.child_layout.absolute; - if a.overrides.contains("size") && constrains { + if sized_own(a) && constrains { return false; } match &a.kind { @@ -1530,6 +1553,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { }) } +/// Whether a layer inside an instance overrides its size or resizing: it may not have its main +/// layer's size, so the layers in it can't take their main layers' places as they are. +fn sized_own(n: &Node) -> bool { + n.overrides.contains("size") || n.overrides.contains("resizing") +} + /// Rebuild every instance of `comps` (and of the components that hold those instances, and so on /// outwards), each outermost instance once, inner components first. fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { @@ -1665,10 +1694,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St /// What about layer `id` an edit from `before` to `after` changed that only its main can: the /// auto layout of an instance or a layer in one but its padding, gap and alignment, or the -/// position (rotation too), size, constraints or resizing of a layer inside an instance (the -/// instance itself resizes, and its layers follow; text whose box follows its content still -/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a -/// library update). +/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer +/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also +/// when the main changed the same way in this edit (a library update). pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { let (n, b) = (after.node(id)?, before.node(id)?); if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { @@ -1684,8 +1712,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti l }) }; - let size = |x: &Node| (x.width, x.height); - let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6; + // Child layout settings other than the resizing ones. + let placing = |x: &Node| { + let mut c = x.child_layout.clone(); + (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); + c + }; if mode(n) != mode(b) && mode(ma) == mode(mb) { return Some("The auto layout direction"); } @@ -1698,14 +1730,11 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { return Some("A layer's position"); } - if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") { - return Some("A layer's size"); - } if n.constraints != b.constraints && ma.constraints == mb.constraints { return Some("A layer's constraints"); } - if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout { - return Some("A layer's resizing"); + if placing(n) != placing(b) && placing(ma) == placing(mb) { + return Some("A layer's place in auto layout"); } None } @@ -1923,6 +1952,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .filter(|g| placed_source.is_some() || g != "resizing") .collect(); let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; if named { @@ -1946,7 +1976,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } if let Some(n) = doc.node_mut(inst) { n.source = root_base; - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position")); + // A top-level instance's resizing is its own (a nested one's overrides its main layer's). + n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); n.overrides.extend(root_groups); } } diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 325ccdee..15f660e1 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -316,14 +316,6 @@ fn what_instances_cannot_override_is_refused() { ("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"), ("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"), ("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"), - ( - "resize a layer", - "node.set", - json!({"id": "$", "props": {"width": 50}}), - Layer::Bg, - "not among the overridable properties; the instance resizes", - ), - ("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""), ( "change constraints", "node.set", @@ -331,13 +323,6 @@ fn what_instances_cannot_override_is_refused() { Layer::Bg, "\"any constraints applied to the layers\"", ), - ( - "make a layer fill its auto layout", - "node.set", - json!({"id": "$", "props": {"sizingH": "fill"}}), - Layer::Chip, - "resizing comes with the layer's size", - ), ( "take a layer out of auto layout", "node.set", diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index eb21b20e..209560cd 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -67,8 +67,6 @@ fn overrides_survive_main_edits() { ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}})); ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"})); ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}})); - // A layer inside an instance keeps its main's size (the instance itself resizes). - assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err()); let ov = node(&s, ibg).overrides; for g in ["fills", "strokes", "effects", "name"] { assert!(ov.contains(g), "{g} in {ov:?}"); diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 05a95f66..7a9e0621 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() { let before = s.doc().unwrap().world_bounds(inner).unwrap(); assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0)); ex(&mut s, "select.set", json!({"ids": [inner]})); - // Resizing it by its right edge: only the main component can. - assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow"); + // Resizing it by its right edge: a size of its own (an override). + assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow"); s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap(); - assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed); + assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed); s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap(); - assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone"); - // Moving it: refused too. + let before = s.doc().unwrap().world_bounds(inner).unwrap(); + assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays"); + // Moving it: only the main component can. s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap(); diff --git a/crates/engine/src/instance_resizing_tests.rs b/crates/engine/src/instance_resizing_tests.rs new file mode 100644 index 00000000..43386af9 --- /dev/null +++ b/crates/engine/src/instance_resizing_tests.rs @@ -0,0 +1,190 @@ +//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or +//! keep a fixed size of its own, and that override is kept through relayout, edits of the main +//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import, +//! detach and reset. + +use base64::Engine; +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::{copy_of, ex, healthy, idv, node}; +use figcraft_doc::*; + +/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding +/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of +/// it. Returns (component, atom, dot, instance). +fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"}))); + ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}})); + let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"}))); + ex( + s, + "node.set", + json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}), + ); + let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}}))); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100}))); + (c, atom, dot, i) +} + +fn sizing_h(s: &Session, id: NodeId) -> Sizing { + node(s, id).child_layout.sizing_h +} + +/// The layer named `name` below instance `inst`. +fn named(s: &Session, inst: NodeId, name: &str) -> NodeId { + let doc = s.doc().unwrap(); + doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap() +} + +/// The first instance in the document. +fn instance(s: &Session) -> NodeId { + s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id +} + +fn open(s: &mut Session, bytes: &[u8], name: &str) { + ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name})); +} + +#[test] +fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() { + let mut s = Session::new(); + let (c, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide"); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance"); + assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides); + // Edits of the main relay the instance out: the override stays, other properties follow. + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}})); + assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0)); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}})); + assert_eq!(node(&s, atom).width, 120.0); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0))); + assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main"); + // The main fixing its own width is the same property: the instance keeps filling. + ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}})); + assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0)); + // A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves. + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0)); + healthy(&s); + // Back to the main's resizing: no override left. + ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width)); + assert!(!node(&s, ia).overrides.contains("resizing")); + healthy(&s); +} + +#[test] +fn size_and_resizing_overrides_undo_reset_and_detach() { + let mut s = Session::new(); + let (_, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0)); + assert!(node(&s, ia).overrides.is_empty()); + // A fixed size of its own: kept when the main resizes the layer. + ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}})); + assert!(node(&s, idot).overrides.contains("size")); + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}})); + assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0)); + // A min width of its own. + ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}})); + assert_eq!(node(&s, ia).width, 150.0); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}})); + assert_eq!(node(&s, ia).width, 150.0); + healthy(&s); + // Reset all changes: the main's values again. + ex(&mut s, "component.resetInstance", json!({"ids": [i]})); + assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0)); + assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty()); + // Detaching keeps what the instance showed. + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "component.detach", json!({"ids": [i]})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None)); + healthy(&s); +} + +#[test] +fn layers_in_instances_resize_but_keep_their_place() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}}))); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let it = copy_of(&s, i, title); + ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}})); + assert_eq!(node(&s, it).width, 150.0, "a text box resizes"); + assert!(node(&s, it).overrides.contains("size")); + // Its place, rotation and constraints are still the main's. + for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] { + assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused"); + } + // The instance's own resizing is its own, not an override. + ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}})); + assert!(!node(&s, i).overrides.contains("resizing")); + healthy(&s); +} + +#[test] +fn resizing_overrides_survive_save_and_reopen() { + let mut s = Session::new(); + let (_, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}})); + let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] { + let mut s = Session::new(); + open(&mut s, &bytes, name); + let i = instance(&s); + let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot")); + assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides); + assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides); + // An edit of the main relays the instance out. + let main = |s: &Session, id: NodeId| node(s, id).source.unwrap(); + let (atom, dot) = (main(&s, ia), main(&s, idot)); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}})); + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}"); + healthy(&s); + } +} + +#[test] +fn imported_resizing_overrides_survive_relayout() { + let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); + let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}}); + let atom = |id: &str, dot_id: &str, w: f64, more: Value| { + let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20}, + "layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]}); + a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + a + }; + let field = |id: &str, ty: &str, y: f64, kids: Value| { + json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL", + "primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids}) + }; + let mut inst = + field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))])); + inst["componentId"] = json!("1:1"); + inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]); + let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]} + ]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}}); + let mut s = Session::new(); + open(&mut s, file.to_string().as_bytes(), "field.json"); + let i = instance(&s); + let ia = named(&s, i, "Atom"); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0)); + assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides); + // The main's first edit relays every instance out from it. + let main_atom = node(&s, ia).source.unwrap(); + ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0)); + healthy(&s); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f49d3fc6..a0d87f9c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1623,6 +1623,8 @@ mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod instance_resizing_tests; +#[cfg(test)] mod interaction_rollback_tests; #[cfg(test)] mod invite_links_tests; diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index de1705ea..ca9e2682 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1858,12 +1858,13 @@ impl<'d> Exporter<'d> { let named = set.is_none_or(|s| s.name != n.name); let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect(); // Each layer below: its id path, the fields that differ from the main's layer, that layer's - // fields, and the component a nested instance was swapped for. + // fields, whether its size is its own (a nested instance, a size override) and the + // component a nested instance was swapped for. struct Row { path: Kv<'static>, changed: Fields, theirs: Fields, - nested: bool, + sized: bool, swap: Option>, } let mut rows: Vec = Vec::new(); @@ -1891,8 +1892,8 @@ impl<'d> Exporter<'d> { (NodeKind::Instance { component, .. }, _) => self.guid_of(*component), _ => None, }; - let nested = matches!(d.kind, NodeKind::Instance { .. }); - rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, nested, swap }); + let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size"); + rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap }); } // An instance scaled as a whole (the Scale tool) is `factor` times its main in every length: // sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights. @@ -1927,8 +1928,8 @@ impl<'d> Exporter<'d> { let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]); for (k, v) in r.changed { let follows = follows(k, &v, &r.theirs); - // A nested instance's size is its own to set. - if follows && r.nested && k == "size" { + // A nested instance's size is its own to set, as is a size the layer overrides. + if follows && r.sized && k == "size" { of.push((k, v.clone())); } if follows { &mut df } else { &mut of }.push((k, v)); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 5c32c583..230faa7d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -860,6 +860,8 @@ impl<'a> Mapper<'a> { "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE", "slotContent": inst.is_some_and(|i| i.slot_content), + // A size the instance gives this layer itself (kept when the instance relays out). + "sizeOverride": entries.iter().any(|e| e.entry == Entry::Override && e.row.get("size").is_some()), }), ); o.insert("type".into(), json!(ty)); diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 60f1cc9c..953522e1 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -481,9 +481,11 @@ impl<'a> Ctx<'a> { let id = self.doc.alloc_id(); let label = kind.label(); let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h); - if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true) - { - n.overrides.insert("slot".into()); + // Overrides the .fig names explicitly (the rest are found by comparing with the main). + for (flag, group) in [("slotOverride", "slot"), ("sizeOverride", "size")] { + if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get(flag)).and_then(Value::as_bool) == Some(true) { + n.overrides.insert(group.into()); + } } n.transform = local(space.world, world); self.common(&mut n, j, parent_mode); @@ -1152,6 +1154,16 @@ fn override_group(field: &str) -> Option<&'static str> { | "textDecorationSkipInk" | "textDecorationColor" => "textStyle", "width" | "height" | "size" => "size", + "layoutSizingHorizontal" + | "layoutSizingVertical" + | "layoutGrow" + | "layoutAlign" + | "primaryAxisSizingMode" + | "counterAxisSizingMode" + | "minWidth" + | "maxWidth" + | "minHeight" + | "maxHeight" => "resizing", "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "horizontalPadding" | "verticalPadding" => "padding", "itemSpacing" | "counterAxisSpacing" => "gap", "primaryAxisAlignItems" | "counterAxisAlignItems" => "alignment", diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index f87bc401..2459d104 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1087,6 +1087,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "Změnit velikost o velikost posunu" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Přizpůsobit velikost rámců obrazovce zařízení (nebo oknu bez zařízení): omezení a automatické rozvržení se přizpůsobí" "Resize to fit" = "Přizpůsobit velikost obsahu" +"Resizing" = "Změna velikosti" "Resolve" = "Vyřešit" "Resolve change" = "Vyřešit změnu" "Resolved" = "Vyřešené" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index dd991077..b679feb7 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1053,6 +1053,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "Um die Schrittweite skalieren" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Passt Rahmen an den Bildschirm des Geräts an (ohne Gerät an das Fenster): ihre Constraints und ihr Auto-Layout reagieren" "Resize to fit" = "Größe an Inhalt anpassen" +"Resizing" = "Größenanpassung" "Resolve" = "Erledigen" "Resolve change" = "Änderung erledigen" "Resolved" = "Erledigt" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 6d5f0a5a..9ce38a22 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1107,6 +1107,7 @@ name = "English" "Resize by the nudge amount" = "Resize by the nudge amount" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" "Resize to fit" = "Resize to fit" +"Resizing" = "Resizing" "Resolve" = "Resolve" "Resolve change" = "Resolve change" "Resolved" = "Resolved" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1d43c320..2849f4ec 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1108,6 +1108,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "Redimensionar según el desplazamiento" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ajusta los marcos a la pantalla del dispositivo (o a la ventana sin dispositivo): sus restricciones y su diseño automático se adaptan" "Resize to fit" = "Ajustar tamaño" +"Resizing" = "Cambio de tamaño" "Resolve" = "Resolver" "Resolve change" = "Resolver cambio" "Resolved" = "Resuelto" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c59c1cae..1c561f15 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1047,6 +1047,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "Redimensionner du pas de décalage" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensionne les cadres à l’écran de l’appareil (ou à la fenêtre sans appareil) : leurs contraintes et leur mise en page auto s’adaptent" "Resize to fit" = "Redimensionner au contenu" +"Resizing" = "Redimensionnement" "Resolve" = "Résoudre" "Resolve change" = "Résoudre la modification" "Resolved" = "Résolu" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 2bb41e03..7a0a3c3c 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1091,6 +1091,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "Ubah ukuran sebesar jarak geser" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ubah ukuran frame ke layar perangkat (atau jendela tanpa perangkat): batasan dan auto layout-nya menyesuaikan" "Resize to fit" = "Ubah ukuran agar pas" +"Resizing" = "Pengubahan ukuran" "Resolve" = "Selesaikan" "Resolve change" = "Selesaikan perubahan" "Resolved" = "Terselesaikan" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 84c3d6b3..b09db3bd 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1097,6 +1097,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "微調整の量でサイズ変更" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "フレームをデバイスの画面(デバイスなしの場合はウィンドウ)に合わせてサイズ変更:制約とオートレイアウトが反応" "Resize to fit" = "サイズを合わせる" +"Resizing" = "サイズ変更" "Resolve" = "解決" "Resolve change" = "変更を解決" "Resolved" = "解決済み" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 0d64aeb1..f2cfcfff 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1047,6 +1047,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "미세 이동 거리만큼 크기 조정" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "프레임 크기를 기기 화면(기기가 없으면 창)에 맞게 조정: 제약과 자동 레이아웃이 반응함" "Resize to fit" = "크기 맞춤" +"Resizing" = "크기 조정" "Resolve" = "해결" "Resolve change" = "변경 사항 해결" "Resolved" = "해결됨" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 98039666..e9e1300c 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1046,6 +1046,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "Redimensionar pelo valor do deslocamento" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensiona os quadros para a tela do dispositivo (ou para a janela, sem dispositivo): as restrições e o layout automático deles se adaptam" "Resize to fit" = "Redimensionar para caber" +"Resizing" = "Redimensionamento" "Resolve" = "Resolver" "Resolve change" = "Resolver alteração" "Resolved" = "Resolvido" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 91d0a702..2023b692 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1089,6 +1089,7 @@ name = "Русский" "Resize by the nudge amount" = "Изменить размер на шаг сдвига" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Менять размер Frame под экран устройства (или окно, если устройство не выбрано): ограничения и автолейаут подстраиваются" "Resize to fit" = "Подогнать размер" +"Resizing" = "Изменение размера" "Resolve" = "Решить" "Resolve change" = "Разрешить изменение" "Resolved" = "Решённые" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d4e031e4..51f16508 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1101,6 +1101,7 @@ name = "Svenska" "Resize by the nudge amount" = "Ändra storlek med knuffavståndet" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ändra storlek på ramar efter enhetens skärm (eller fönstret utan enhet): deras begränsningar och automatiska layout anpassas" "Resize to fit" = "Anpassa storlek" +"Resizing" = "Storleksändring" "Resolve" = "Lös" "Resolve change" = "Lös ändring" "Resolved" = "Löst" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 7e7996e6..cc002654 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1091,6 +1091,7 @@ name = "Türkçe" "Resize by the nudge amount" = "Kaydırma miktarı kadar yeniden boyutlandır" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Çerçeveleri cihazın ekranına (veya cihaz yoksa pencereye) göre yeniden boyutlandır: kısıtlamaları ve otomatik yerleşimleri tepki verir" "Resize to fit" = "Sığacak şekilde yeniden boyutlandır" +"Resizing" = "Yeniden boyutlandırma" "Resolve" = "Çöz" "Resolve change" = "Değişikliği çöz" "Resolved" = "Çözüldü" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index b95e3b39..702cae7b 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1048,6 +1048,7 @@ status = "draft — needs native review" "Resize by the nudge amount" = "按微移量调整大小" "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "将画框调整为设备屏幕大小(无设备时为窗口大小):其约束和自动布局随之响应" "Resize to fit" = "调整大小以适应" +"Resizing" = "调整大小" "Resolve" = "解决" "Resolve change" = "解决更改" "Resolved" = "已解决" diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 719c2588..6c9ff7d4 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -503,6 +503,7 @@ fn group_label(g: &str) -> String { "exports" => tr("Export settings"), "interactions" => tr("Interactions"), "size" => tr("Size"), + "resizing" => tr("Resizing"), "position" => tr("Position"), "component" => tr("Swapped instance"), "props" => tr("Instance properties"), diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index bb60ebeb..9d3c0dcc 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -29,8 +29,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; - // An instance's auto layout is its main's (only padding, gap and alignment are its own), and - // the layers inside it keep their main's size. + // An instance's auto layout is its main's (only padding, gap and alignment are its own); the + // layers inside it override their size and resizing, not their place in the layout. let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some()); let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); let btns: Vec<(Icon, &str)> = if in_instance { @@ -49,7 +49,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node if let Some(Some(l)) = &al { ui.add_enabled_ui(!in_instance, |ui| direction(app, ui, &ids, l)).response.on_disabled_hover_text(from_main); } - ui.add_enabled_ui(!nested, |ui| dimensions(app, ui, doc, sel, has_al)).response.on_disabled_hover_text(from_main); + dimensions(app, ui, doc, sel, has_al, nested, from_main); if let Some(Some(l)) = &al { alignment(app, ui, &ids, l); spacing(app, ui, &ids, l); @@ -146,7 +146,8 @@ fn limit(n: &Node, horizontal: bool, is_min: bool) -> Option { } } -fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has_al: bool) { +/// `nested`: layers inside an instance, whose proportion lock is their main's (`from_main` says so). +fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has_al: bool, nested: bool, from_main: &str) { let ids = sel.to_vec(); let w_of = |n: &Node| (n.width * 100.0).round() / 100.0; let h_of = |n: &Node| (n.height * 100.0).round() / 100.0; @@ -159,7 +160,13 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); app.field_of(&ids, "height", e, h_of, |v| json!(v.max(0.0))); let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); - if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, tr("Constrain proportions")).clicked() { + let link = ui + .add_enabled_ui(!nested, |ui| { + icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, tr("Constrain proportions")) + }) + .inner + .on_disabled_hover_text(from_main); + if link.clicked() { app.set_props(&ids, json!({"constrainProportions": !locked})); } }); diff --git a/docs/components-parity.md b/docs/components-parity.md index 659676fd..1ecd9686 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -14,8 +14,8 @@ accounts). |---|---|---| | Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main | | Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances | -| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent) | -| What it doesn't: position, rotation, size, constraints and resizing of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | +| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push | +| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | | Component sets / variants, add variant, switch | done | | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | | 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | | 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..e6591853 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,9 +17,9 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | -| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | +| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 64423c58bc5ab7af44f76aeac9f4e05edae5a680 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 18:45:32 +0700 Subject: [PATCH 153/245] M3: .fig and REST imports keep Hug on auto layout and text (#732) An auto layout frame a .fig records no primary-axis sizing for hugs on that axis (auto layout sized it by its content before that axis could be fixed) instead of turning fixed; an instance that records no auto layout takes its main's; REST frames whose sizing modes are left out hug (the documented default); text hugs by its resize mode (auto width both axes, auto height its height) instead of coming in fixed. --- .../src/fig/auto_layout_sizing_tests.rs | 193 ++++++++++++++++++ crates/format/src/fig/export_tests.rs | 11 + crates/format/src/fig/map.rs | 16 +- crates/format/src/fig/mod.rs | 2 + crates/format/src/rest/props.rs | 11 +- docs/file-format-parity.md | 5 +- 6 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/fig/auto_layout_sizing_tests.rs diff --git a/crates/format/src/fig/auto_layout_sizing_tests.rs b/crates/format/src/fig/auto_layout_sizing_tests.rs new file mode 100644 index 00000000..99730f70 --- /dev/null +++ b/crates/format/src/fig/auto_layout_sizing_tests.rs @@ -0,0 +1,193 @@ +//! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in +//! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and +//! text that hugs by its resize mode. + +use figcraft_doc::{Document, Sizing}; +use serde_json::{Value, json}; + +use super::kiwi::{Kv, Schema}; + +const SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE +enum StackMode NONE HORIZONTAL VERTICAL +enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE +enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextData string characters +message SymbolData GUID symbolID +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData +message Message NodeChange[] nodeChanges +"; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))]) +} + +fn at(x: f64, y: f64) -> Kv<'static> { + let f = |v: f64| Kv::Float(v as f32); + o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))]) +} + +fn node(id: u64, parent: u64, pos: &str, ty: &'static str, name: &str, mut more: Fields) -> Kv<'static> { + let mut v = vec![("guid", g(id)), ("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(pos.into()))]))]; + v.extend([("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]); + v.append(&mut more); + o(v) +} + +/// An auto layout frame's fields: its direction and the sizing it records (`None`: none). +fn stack(mode: &'static str, primary: Option<&'static str>, counter: Option<&'static str>) -> Fields { + let mut f = vec![("stackMode", Kv::Enum(mode))]; + f.extend(primary.map(|p| ("stackPrimarySizing", Kv::Enum(p)))); + f.extend(counter.map(|c| ("stackCounterSizing", Kv::Enum(c)))); + f +} + +/// A text layer " text" in `parent`, with resize mode `resize` (`None`: none recorded). +fn label(id: u64, parent: u64, name: &str, resize: Option<&'static str>) -> Kv<'static> { + let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 0.0)), ("fontSize", Kv::Float(12.0))]; + f.push(("textData", o(vec![("characters", Kv::Str(name.into()))]))); + f.extend(resize.map(|r| ("textAutoResize", Kv::Enum(r)))); + node(id, parent, "!", "TEXT", &format!("{name} text"), f) +} + +/// A component at `x` holding a text layer, with `fields` of its own. +fn component(id: u64, x: f64, name: &str, fields: Fields, resize: Option<&'static str>) -> Vec> { + let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(x, 0.0))]; + f.extend(fields); + vec![node(id, 1, &format!("{id}"), "SYMBOL", name, f), label(id + 1, id, name, resize)] +} + +fn import(nodes: Vec>) -> Document { + let mut all = vec![ + o(vec![ + ("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), + ("type", Kv::Enum("DOCUMENT")), + ("name", Kv::Str("D".into())), + ]), + o(vec![ + ("guid", g(1)), + ("parentIndex", o(vec![("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CANVAS")), + ("name", Kv::Str("Page".into())), + ]), + ]; + all.extend(nodes); + let schema = Schema::parse(SCHEMA).unwrap(); + let bytes = super::tests::canvas(&schema, &o(vec![("nodeChanges", Kv::Array(all))])); + super::import(&bytes, "sizing").unwrap().0 +} + +fn sizing(doc: &Document, name: &str) -> (Sizing, Sizing) { + let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")); + (n.child_layout.sizing_h, n.child_layout.sizing_v) +} + +#[test] +fn auto_layout_frames_hug_as_recorded_and_by_default_on_their_primary_axis() { + use Sizing::{Fixed, Hug}; + let fit = Some("RESIZE_TO_FIT"); + // (component, its auto layout fields, expected (horizontal, vertical), derivation) + let rows: Vec<(&str, Fields, (Sizing, Sizing), &str)> = vec![ + ("Button", stack("HORIZONTAL", fit, fit), (Hug, Hug), "both axes hug, as recorded"), + ("Implicit", stack("HORIZONTAL", Some("RESIZE_TO_FIT_WITH_IMPLICIT_SIZE"), fit), (Hug, Hug), "hugging with an implicit size"), + ("Fixed", stack("HORIZONTAL", Some("FIXED"), Some("FIXED")), (Fixed, Fixed), "both axes fixed, as recorded"), + ("Legacy", stack("HORIZONTAL", None, fit), (Hug, Hug), "no primary sizing: the primary axis sizes by its content"), + ("Legacy column", stack("VERTICAL", None, None), (Fixed, Hug), "no sizing: the primary (vertical) axis hugs, the counter axis is fixed"), + ]; + let mut nodes = Vec::new(); + for (k, (name, fields, _, _)) in rows.iter().enumerate() { + nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, fields.clone(), Some("WIDTH_AND_HEIGHT"))); + } + let doc = import(nodes); + for (name, _, want, why) in rows { + assert_eq!(sizing(&doc, name), want, "{name}: {why}"); + } +} + +#[test] +fn text_hugs_by_its_resize_mode() { + use Sizing::{Fixed, Hug}; + let fit = Some("RESIZE_TO_FIT"); + let mut nodes = Vec::new(); + for (k, (name, resize)) in + [("Auto width", Some("WIDTH_AND_HEIGHT")), ("Auto height", Some("HEIGHT")), ("Fixed box", Some("NONE")), ("Unset", None)] + .into_iter() + .enumerate() + { + nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, stack("HORIZONTAL", fit, fit), resize)); + } + // An instance of the auto width one: it hugs, and so does its copy of the text. + nodes.push(node( + 90, + 1, + "~", + "INSTANCE", + "Auto width instance", + [vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(10))]))], stack("HORIZONTAL", fit, fit)] + .concat(), + )); + // One that records nothing of its auto layout: it has its main's. + nodes.push(node( + 91, + 1, + "~~", + "INSTANCE", + "Bare instance", + vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 150.0)), ("symbolData", o(vec![("symbolID", g(10))]))], + )); + let doc = import(nodes); + for (name, want) in + [("Auto width text", (Hug, Hug)), ("Auto height text", (Fixed, Hug)), ("Fixed box text", (Fixed, Fixed)), ("Unset text", (Fixed, Fixed))] + { + assert_eq!(sizing(&doc, name), want, "{name}"); + } + let inst = doc.nodes().find(|n| n.name == "Auto width instance").unwrap(); + assert_eq!((inst.child_layout.sizing_h, inst.child_layout.sizing_v), (Hug, Hug)); + let copy = inst.children.first().and_then(|c| doc.node(*c)).unwrap(); + assert_eq!((copy.child_layout.sizing_h, copy.child_layout.sizing_v), (Hug, Hug)); + let bare = doc.nodes().find(|n| n.name == "Bare instance").unwrap(); + assert_eq!(bare.layout.as_ref().map(|l| l.mode), Some(figcraft_doc::LayoutMode::Horizontal)); + assert_eq!((bare.child_layout.sizing_h, bare.child_layout.sizing_v), (Hug, Hug)); +} + +#[test] +fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() { + use Sizing::{Fixed, Hug}; + let at = json!([[1, 0, 0], [0, 1, 0]]); + let text = |id: &str, name: &str, more: Value| { + let mut t = json!({"id": id, "name": name, "type": "TEXT", "relativeTransform": at, "size": {"x": 40, "y": 16}, "characters": name, + "style": {"fontFamily": "Inter", "fontSize": 12, "textAutoResize": "WIDTH_AND_HEIGHT"}}); + t.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + t + }; + let frame = |id: &str, name: &str, more: Value, kids: Value| { + let mut f = json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at, "size": {"x": 40, "y": 16}, + "layoutMode": "HORIZONTAL", "children": kids}); + f.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + f + }; + let file = json!({"name": "Sizing", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + frame("1:1", "Defaults", json!({}), json!([text("1:2", "Label", json!({}))])), + frame("2:1", "Fixed", json!({"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED"}), + json!([text("2:2", "Fixed label", json!({"layoutSizingHorizontal": "FIXED"}))])), + ]}]}}); + let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap(); + // The REST API leaves out the default sizing modes (AUTO); explicit per-axis sizing wins. + for (name, want) in [("Defaults", (Hug, Hug)), ("Label", (Hug, Hug)), ("Fixed", (Fixed, Fixed)), ("Fixed label", (Fixed, Hug))] { + assert_eq!(sizing(&doc, name), want, "{name}"); + } +} diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 53cfaf91..56e9fcbf 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -595,6 +595,17 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { n.source = None; n.overrides.clear(); n.constrain_proportions = false; + // A text's resize mode is its sizing (the editor keeps them together; generated documents + // don't): auto width hugs both axes, auto height its height. + if let NodeKind::Text(t) = &n.kind { + let cl = &mut n.child_layout; + let hug = |s: &mut Sizing| *s = if *s == Sizing::Fixed { Sizing::Hug } else { *s }; + match t.auto_resize { + TextAutoResize::WidthAndHeight => [&mut cl.sizing_h, &mut cl.sizing_v].into_iter().for_each(hug), + TextAutoResize::Height => hug(&mut cl.sizing_v), + _ => {} + } + } let fold = |ps: &mut Vec| { for p in ps.iter_mut() { if let PaintKind::Solid { color } = &mut p.kind { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 230faa7d..25540ff6 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -924,7 +924,16 @@ impl<'a> Mapper<'a> { } else if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); - let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); + let fit = Fit::frame(main.as_ref().and_then(View::size), view.size()); + // An instance that records no auto layout of its own has its main's. + let view = match main { + Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { + let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) }; + self.auto_layout(&mut o, &layered); + layered + } + _ => view.clone(), + }; for c in self.children(&sym) { // The main's branch can live outside the cached context being mapped. kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); @@ -1407,7 +1416,10 @@ impl<'a> Mapper<'a> { }; o.insert("counterAxisAlignItems".into(), json!(cross)); let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" }; - o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing"))); + // A flow that records no sizing for its primary axis sizes it by its content: auto layout + // did before that axis could be fixed. + let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") }; + o.insert("primaryAxisSizingMode".into(), json!(primary)); o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing"))); if let Some(b) = v.flag("bordersTakeSpace") { o.insert("strokesIncludedInLayout".into(), json!(b)); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 97f35f63..fc9293d9 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -13,6 +13,8 @@ //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped //! with a warning; malformed input never panics. +#[cfg(test)] +mod auto_layout_sizing_tests; #[cfg(test)] mod connector_label_tests; mod export; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 49c9d372..1003682b 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -484,8 +484,17 @@ pub(super) fn child_layout(v: &Value, own: Option, parent_mode: Opti let mut cl = ChildLayout::default(); let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical; // Legacy fields first, the explicit per-axis sizing wins. + // A text's resize mode is its sizing: auto width hugs both axes, auto height its height. + if text(v, "type") == Some("TEXT") && v.get("style").and_then(|s| text(s, "textTruncation")) != Some("ENDING") { + match v.get("style").and_then(|s| text(s, "textAutoResize")) { + Some("WIDTH_AND_HEIGHT") => (cl.sizing_h, cl.sizing_v) = (Sizing::Hug, Sizing::Hug), + Some("HEIGHT") => cl.sizing_v = Sizing::Hug, + _ => {} + } + } if let Some(m) = own { - let hug = |k: &str| text(v, k) == Some("AUTO"); + // Both default to AUTO (hug) when the file leaves them out. + let hug = |k: &str| text(v, k) != Some("FIXED"); let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode")); let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) }; if h { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e6591853..250d796a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,9 +17,9 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | -| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | +| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | From ab1ae981f339aff938f6c86059826cb95137f2c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 19:51:27 +0700 Subject: [PATCH 154/245] #359: drop the progress-doc edit --- docs/ui-parity.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cda1891b..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (native-density canvas textures aligned to device pixels; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -94,7 +94,6 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| | 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`) | -| Canvas texture sharpness | done (ours) | Native-density canvas images start on whole device pixels, avoiding extra linear-filter blending at fractional zoom, pan and display scale. Placement shifts by at most half a device pixel; texture extent, document geometry and overlays stay unchanged. Stale renders and pixel preview retain their existing projection (`canvas_image_rect_tests`). This does not establish text-rendering parity with Figma. | | 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; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | @@ -167,7 +166,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-15 | minor | Canvas image origins align to device pixels only at native render density. Texture extent, stale-render reprojection and pixel preview remain unchanged; deterministic geometry tests cover fractional zoom and display scale. | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From ac4b98dbd72cea838447bd34621a47f1d40f0801 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 19:56:20 +0700 Subject: [PATCH 155/245] M8: confine MCP file access with --automation-read-root/--automation-write-root (#715) (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, `--flag=DIR` works too). When either is given, every file an agent's tool call reads must lie inside a read root or a write root, and every file it writes inside a write root. Anything else is an ordinary tool error (`automation path rejected: … is outside the folders that may be written: …`); nothing is written and the server keeps serving. Without the flags nothing changes. - The desktop app takes the same flags (`figcraft --control PORT --automation-read-root DIR …`) and confines every control request and every frame of every window to them, so `figcraft-cli mcp --attach` drives a confined app. `--attach` together with the flags is a usage error, because the app runs the commands and only the app can confine them. - New engine module `crates/engine/src/file_access.rs`: `Roots` (resolution and comparison), a thread-scoped `confined(roots, f)`, and the file primitives (`read`, `read_capped`, `open`, `metadata`, `read_dir`, `write` (atomic), `create_dir_all`, `exists`, `is_dir`). Every engine file access now goes through it. `crates/engine/clippy.toml` denies `std::fs::*`, `File::open/create`, `OpenOptions::open`, `Path::exists/is_file/is_dir/metadata/read_dir/canonicalize` and `figcraft_format::{write_file_atomic, save_file, load_file}` everywhere else in the engine, so a command added later is confined without a check of its own. - `ui.screenshot` and `ui.render` return the PNG as base64 `data` when no `path` is given (before, `ui.screenshot` wrote `figcraft-screenshot.png` in the working directory and `ui.render` required a path). The attached MCP backend uses that, so it no longer writes and deletes temporary PNGs, and a confined app needs no exemption for them. - `initialize`'s `instructions` name the folders the agent may read and write. - `figcraft-cli` options may be given more than once (`Args::values`); single-valued options keep the last value, as before. - Docs: `docs/mcp.md` › Confining file access (options, semantics, coverage, resolution, limits), `docs/control-protocol.md` › Confining file access and the optional `path` of `ui.screenshot` / `ui.render`, `docs/cli.md`, `figcraft-cli --help`, and the Automation line plus a revision row in `docs/target-app-parity.md`. Fixes #715, fixes #481. --- apps/figcraft-cli/src/args.rs | 26 +- apps/figcraft-cli/src/main.rs | 29 +- apps/figcraft-cli/tests/cli.rs | 46 +++ apps/figcraft/src/args.rs | 18 +- apps/figcraft/src/args_tests.rs | 12 +- apps/figcraft/src/main.rs | 17 + crates/engine/clippy.toml | 39 ++ crates/engine/src/cmd/create.rs | 6 +- crates/engine/src/cmd/file.rs | 48 +-- crates/engine/src/file_access.rs | 293 ++++++++++++++ crates/engine/src/file_access_tests.rs | 371 ++++++++++++++++++ crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 4 +- crates/engine/src/playground.rs | 2 +- crates/engine/src/plugin_directory.rs | 15 +- crates/engine/src/plugins.rs | 29 +- .../engine/tests/library_style_acceptance.rs | 2 +- crates/mcp/src/backend.rs | 28 +- crates/mcp/src/headless.rs | 4 +- crates/mcp/src/server.rs | 31 +- crates/mcp/tests/automation_roots.rs | 126 ++++++ crates/ui-egui/src/colorpicker.rs | 2 +- crates/ui-egui/src/control.rs | 41 +- crates/ui-egui/src/control_roots_tests.rs | 74 ++++ crates/ui-egui/src/home.rs | 3 +- crates/ui-egui/src/home_recents.rs | 10 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/windows.rs | 4 +- docs/cli.md | 7 +- docs/control-protocol.md | 33 +- docs/mcp.md | 56 +++ docs/target-app-parity.md | 5 +- 33 files changed, 1280 insertions(+), 112 deletions(-) create mode 100644 crates/engine/clippy.toml create mode 100644 crates/engine/src/file_access.rs create mode 100644 crates/engine/src/file_access_tests.rs create mode 100644 crates/mcp/tests/automation_roots.rs create mode 100644 crates/ui-egui/src/control_roots_tests.rs diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 372082c2..4c162dbb 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -1,5 +1,6 @@ //! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value` -//! options, and boolean `--flag`s. Unknown options are usage errors. +//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s. +//! Unknown options are usage errors. use std::collections::BTreeMap; @@ -8,7 +9,7 @@ use crate::{CliError, CliResult}; #[derive(Debug, Default)] pub struct Args { pub positional: Vec, - values: BTreeMap, + values: BTreeMap>, flags: Vec, } @@ -34,7 +35,7 @@ impl Args { Some(v) => v, None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?, }; - a.values.insert(name.to_string(), v); + a.values.entry(name.to_string()).or_default().push(v); } else if flags.contains(&name) && inline.is_none() { a.flags.push(name.to_string()); } else { @@ -45,7 +46,12 @@ impl Args { } pub fn value(&self, k: &str) -> Option { - self.values.get(k).cloned() + self.values.get(k).and_then(|v| v.last()).cloned() + } + + /// Every value of an option that may be given more than once, in order. + pub fn values(&self, k: &str) -> &[String] { + self.values.get(k).map_or(&[], Vec::as_slice) } pub fn flag(&self, k: &str) -> bool { @@ -62,9 +68,9 @@ impl Args { /// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`, /// `--bleed-marks`, `--cmyk`. pub fn print(&self) -> CliResult { - let bleed = match self.values.get("bleed") { + let bleed = match self.value("bleed") { None => 0.0, - Some(s) => figcraft_doc::PrintSettings::parse_length(s) + Some(s) => figcraft_doc::PrintSettings::parse_length(&s) .filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED) .ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?, }; @@ -72,7 +78,7 @@ impl Args { } pub fn scale(&self) -> CliResult { - match self.values.get("scale") { + match self.value("scale") { None => Ok(1.0), Some(s) => s .trim_end_matches('x') @@ -85,7 +91,7 @@ impl Args { /// A comma-separated list of whole numbers (`--layers 1000,10_000`). pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult> { - match self.values.get(k) { + match self.value(k) { None => Ok(default.to_vec()), Some(s) => s .split(',') @@ -110,6 +116,10 @@ mod tests { assert_eq!(a.value("o").as_deref(), Some("out")); assert_eq!(a.scale().unwrap(), 2.0); assert!(a.flag("json")); + let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap(); + assert_eq!(a.values("root"), ["a", "b"]); + assert_eq!(a.value("root").as_deref(), Some("b")); + assert!(a.values("none").is_empty()); } #[test] diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 590b50f1..281d79dd 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -23,7 +23,7 @@ USAGE: figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] figcraft-cli commands [--json] [--filter TEXT] - figcraft-cli mcp [--attach PORT] + figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] figcraft-cli help | --help | --version @@ -42,6 +42,10 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) to a figcraft-server's plugin directory as a new version; the server's key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history). +`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders +must exist) confine the files the agent's commands touch: reads only inside a read or write root, +writes only inside a write root, everything else refused. A running app confines itself: start it +with `figcraft --control PORT` and the same flags (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; fn main() -> ExitCode { @@ -133,11 +137,23 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "mcp" => { - let a = Args::parse(rest, &["attach"], &[])?; + let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } - mcp(a.value("attach").as_deref()) + let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); + let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) { + (true, _) => None, + // The app runs the commands: only its own roots can confine them. + (false, Some(_)) => { + return Err(CliError::Usage( + "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" + .into(), + )); + } + (false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?), + }; + mcp(a.value("attach").as_deref(), roots) } "plugin" => { let a = Args::parse(rest, &["server"], &[])?; @@ -205,7 +221,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult { Ok(ExitCode::SUCCESS) } -fn mcp(attach: Option<&str>) -> CliResult { +fn mcp(attach: Option<&str>, roots: Option) -> CliResult { let backend: Box = match attach { Some(p) => { let addr = if p.contains(':') { @@ -220,7 +236,10 @@ fn mcp(attach: Option<&str>) -> CliResult { None => Box::new(figcraft_mcp::Headless::new()), }; eprintln!("figcraft MCP server ({}) on stdio", backend.describe()); - let mut server = figcraft_mcp::Server::new(backend); + if let Some(r) = &roots { + eprintln!("file access confined to {}", r.to_json()); + } + let mut server = figcraft_mcp::Server::new(backend).with_roots(roots); let stdin = std::io::stdin(); let stdout = std::io::stdout(); server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?; diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 9dd52203..7eaf051f 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() { &["render", "a", "-o"], &["perf", "--shapes", "lots"], &["mcp", "--attach", "notaport"], + // An attached app confines itself: roots go to `figcraft --control`. + &["mcp", "--attach", "7979", "--automation-read-root", "."], + &["mcp", "--automation-write-root"], &["convert", "a"], ] { let out = bin().args(args).output().unwrap(); @@ -120,6 +123,49 @@ fn mcp_over_stdio() { assert_eq!(lines[2]["result"]["isError"], false); } +#[test] +fn mcp_over_stdio_confined_to_automation_roots() { + let d = tmp("roots"); + for sub in ["in", "out", "outside"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + // A root that isn't a folder stops the server before it serves. + let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root")); + let mut child = bin() + .args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + let call = |id: u32, tool: &str, args: serde_json::Value| { + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string() + }; + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap(); + writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap(); + writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap(); + writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap(); + writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap(); + } + drop(child.stdin.take()); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 6); + assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read")); + let error = |i: usize| lines[i]["result"]["isError"] == true; + assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}"); + assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected")); + assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists()); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn commands_json_lists_everything() { let out = bin().args(["commands", "--json"]).output().unwrap(); diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs index 2e3099cb..3b4136d9 100644 --- a/apps/figcraft/src/args.rs +++ b/apps/figcraft/src/args.rs @@ -1,4 +1,5 @@ -//! The command line: `figcraft [--control ] [files…] [figcraft://join?…]`. An invite link +//! The command line: `figcraft [--control ] [--automation-read-root
]… +//! [--automation-write-root ]… [files…] [figcraft://join?…]`. An invite link //! (what the system passes when a `figcraft://` link is opened) joins that session once the window //! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the //! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). @@ -11,6 +12,11 @@ pub struct Args { pub files: Vec, /// The last invite link given. pub join: Option, + /// `--automation-read-root ` / `--automation-write-root ` (each repeatable; `=` works + /// too): the folders the app's file access is confined to. A flag without a value gives `""`, + /// which is refused at startup. + pub read_roots: Vec, + pub write_roots: Vec, } /// An invite link rather than a file (`figcraft:` links, in any case). @@ -49,7 +55,15 @@ pub fn parse(args: &[String]) -> Args { let mut out = Args::default(); let mut it = args.iter(); while let Some(a) = it.next() { - if a == "--control" { + let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string()))); + let roots = match flag { + "--automation-read-root" => Some(&mut out.read_roots), + "--automation-write-root" => Some(&mut out.write_roots), + _ => None, + }; + if let Some(roots) = roots { + roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default()); + } else if a == "--control" { out.control = it.next().and_then(|p| p.parse().ok()); } else if is_invite(a) { out.join = Some(a.clone()); diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index 0ed404c8..2ae29b3c 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -10,12 +10,22 @@ fn args(a: &[&str]) -> Vec { fn files_and_the_control_port() { assert_eq!(parse(&args(&[])), Args::default()); let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"])); - assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None }); + assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() }); // A missing or bad port is ignored, not taken as a file. assert_eq!(parse(&args(&["--control"])), Args::default()); assert_eq!(parse(&args(&["--control", "nope"])).control, None); } +#[test] +fn automation_roots_repeat_and_are_never_files() { + let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"])); + assert_eq!(a.read_roots, args(&["in", "more"])); + assert_eq!(a.write_roots, args(&["out"])); + assert_eq!(a.files, args(&["a.figcraft"])); + // A flag without its folder is kept as an empty one, which startup refuses. + assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""])); +} + #[test] fn invite_links_join_instead_of_opening() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index fdcc66f9..651cc521 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -6,6 +6,11 @@ //! `--control ` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}` //! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`. +//! +//! `--automation-read-root ` / `--automation-write-root ` (each repeatable) confine the +//! files the window and the control channel read and write (`figcraft_engine::file_access`); the +//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on +//! the command line are not. A root that isn't an existing folder stops the app at startup. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] @@ -119,6 +124,17 @@ fn main() -> eframe::Result { return Ok(()); } let args = args::parse(&args); + let roots = match ( + args.read_roots.is_empty() && args.write_roots.is_empty(), + figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots), + ) { + (true, _) => None, + (false, Ok(r)) => Some(Arc::new(r)), + (false, Err(e)) => { + eprintln!("FigCraft: {e}"); + std::process::exit(2); + } + }; let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok())); // System fonts: index them in the background, then wake the UI (an idle window draws no frames) // to lay text out again with them. @@ -174,6 +190,7 @@ fn main() -> eframe::Result { // open (no frame then). figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); + fig.automation_roots = roots; fig.ui.home = false; fig.note_recovered(recovered.copies); if let Some(link) = &args.join { diff --git a/crates/engine/clippy.toml b/crates/engine/clippy.toml new file mode 100644 index 00000000..3ceac899 --- /dev/null +++ b/crates/engine/clippy.toml @@ -0,0 +1,39 @@ +# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's +# own rule: shipped engine code reaches the file system only through `file_access`, which enforces +# the automation roots (#715), so every command is confined without a check of its own. +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +too-many-arguments-threshold = 10 +disallowed-methods = [ + { path = "std::fs::read", reason = "use file_access::read (automation roots)" }, + { path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" }, + { path = "std::fs::write", reason = "use file_access::write (automation roots)" }, + { path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" }, + { path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" }, + { path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" }, + { path = "std::fs::canonicalize", reason = "use file_access (automation roots)" }, + { path = "std::fs::copy", reason = "use file_access (automation roots)" }, + { path = "std::fs::rename", reason = "use file_access (automation roots)" }, + { path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" }, + { path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" }, + { path = "std::fs::remove_file", reason = "use file_access (automation roots)" }, + { path = "std::fs::remove_dir", reason = "use file_access (automation roots)" }, + { path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" }, + { path = "std::fs::hard_link", reason = "use file_access (automation roots)" }, + { path = "std::fs::File::open", reason = "use file_access::open (automation roots)" }, + { path = "std::fs::File::create", reason = "use file_access::write (automation roots)" }, + { path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" }, + { path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" }, + { path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" }, + { path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" }, + { path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" }, + { path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" }, + { path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" }, + { path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" }, + { path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" }, + { path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" }, + { path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" }, + { path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" }, + { path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" }, +] diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 9b471290..9b3f7067 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; +use crate::{Result, Session, bad, spec}; /// Frame presets shown by the Frame tool (category, name, width, height). pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[ @@ -313,12 +313,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { #[cfg(not(target_arch = "wasm32"))] fn read_path(path: &str) -> Result> { - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::file_access::read(path) } #[cfg(target_arch = "wasm32")] fn read_path(path: &str) -> Result> { - Err(EngineError::Other(format!("{path}: files can't be read in the browser"))) + Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) } /// Place an image as a rectangle with an image fill (Figma's image layer). diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6ba1ba6c..511e0fc6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -435,7 +435,7 @@ pub(crate) fn recovered(path: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] fn read(path: &str) -> Result> { - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::file_access::read(path) } #[cfg(target_arch = "wasm32")] @@ -445,11 +445,7 @@ fn read(path: &str) -> Result> { /// The bytes of file `path`, refused when it's larger than `max`. pub(crate) fn read_capped(path: &str, max: u64) -> Result> { - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if !meta.is_file() || meta.len() > max { - return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes"))); - } - read(path) + crate::file_access::read_capped(path, max) } /// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one @@ -457,7 +453,7 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result> { /// truncates `path`, and no file or symlink at a staging name is ever written through. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::file_access::write(path, bytes) } #[cfg(target_arch = "wasm32")] @@ -577,11 +573,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option> { return None; } let dir = dir.as_ref()?; - ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| { - let p = dir.join(format!("{r}{e}")); - let meta = std::fs::metadata(&p).ok()?; - if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None } - }) + ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] + .iter() + .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) } } @@ -604,11 +598,7 @@ fn sibling_variables(path: &str) -> Option { let file = std::path::Path::new(path); let dir = file.parent()?; [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { - let meta = std::fs::metadata(&c).ok()?; - if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE { - return None; - } - let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?; + let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; figcraft_format::rest::is_variables_json(&v).then_some(v) }) } @@ -646,20 +636,29 @@ fn remember(s: &mut Session, path: &str) { } fn open_recent(s: &mut Session, p: &Value) -> Result { + use crate::file_access::{Access, allow, allowed}; const CMD: &str = "file.openRecent"; + // Confined to automation roots, the recent files outside them are never listed, opened, removed + // or named: `index` counts the files listed. + let listed = |r: &String| allowed(r, Access::Read); if p.get("clear").and_then(Value::as_bool) == Some(true) { - s.prefs.recent_files.clear(); + s.prefs.recent_files.retain(|r| !listed(r)); } if let Some(gone) = string(p, "remove") { - s.prefs.recent_files.retain(|r| *r != gone); + s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); } let path = match (string(p, "path"), num(p, "index")) { - (Some(path), _) => Some(path), - (None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?), + (Some(path), _) => { + allow(&path, Access::Read)?; + Some(path) + } + (None, Some(i)) => { + Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) + } _ => None, }; if let Some(path) = path { - if !std::path::Path::new(&path).exists() { + if !crate::file_access::exists(&path) { s.prefs.recent_files.retain(|r| *r != path); return Err(bad(CMD, format!("{path} no longer exists"))); } @@ -669,9 +668,10 @@ fn open_recent(s: &mut Session, p: &Value) -> Result { .prefs .recent_files .iter() + .filter(|r| listed(r)) .map(|r| { let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); - json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()}) + json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) }) .collect(); Ok(json!({"files": files})) @@ -1183,7 +1183,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { let path = std::path::Path::new(d).join(&job.file); // A layer name with slashes exports into folders. if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + let _ = crate::file_access::create_dir_all(parent); } let path = path.to_string_lossy().to_string(); write(&path, &bytes)?; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 00000000..8abeaee5 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,293 @@ +//! Automation roots (#715): the folders the files an agent's commands read and write must lie in. +//! +//! `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` and the desktop app +//! started with the same flags run their work [`confined`] to [`Roots`]: while a scope is active on +//! the thread, every file the engine reads must lie inside a read or a write root and every file it +//! writes inside a write root; anything else is an ordinary error. The engine reaches the file +//! system only through this module (`crates/engine/clippy.toml` denies `std::fs` and friends +//! anywhere else), so every command, present or future, is covered. Without a scope nothing changes. +//! +//! A path is made absolute against the working directory and resolved before it is compared: its +//! links are followed (`std::fs::canonicalize`), and for a file that doesn't exist yet its nearest +//! existing folder is resolved and the rest must be plain names. The comparison is by whole +//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was +//! checked is what is opened. +#![allow(clippy::disallowed_methods)] + +use std::cell::RefCell; +use std::ffi::{OsStr, OsString}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use crate::{EngineError, Result}; + +/// What a command does with a file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read (read and write roots) and write (write roots), resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Roots { + read: Vec, + write: Vec, +} + +impl Roots { + /// Roots from the folders given (relative ones against the working directory). Each must be an + /// existing folder. + pub fn new(read: &[String], write: &[String]) -> std::result::Result { + let resolve = |flag: &str, dirs: &[String]| { + dirs.iter() + .map(|d| match std::fs::canonicalize(d) { + Ok(p) if p.is_dir() => Ok(p), + Ok(_) => Err(format!("{flag} {d}: not a folder")), + Err(e) => Err(format!("{flag} {d}: {e}")), + }) + .collect::, String>>() + }; + Ok(Self { read: resolve("--automation-read-root", read)?, write: resolve("--automation-write-root", write)? }) + } + + /// The resolved path to use for `access` to `path`, or why it is refused. + pub fn check(&self, path: &Path, access: Access) -> std::result::Result { + let asked = path.display(); + let (roots, what) = match access { + Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"), + Access::Write => (self.write.clone(), "written"), + }; + match resolve(path) { + Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved), + Ok(_) => {} + // Why a path can't be resolved says what exists there: only told for paths inside a root. + Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), + Err(_) => {} + } + if roots.is_empty() { + return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}")); + } + let shown: Vec = roots.iter().map(|r| shown(r)).collect(); + Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", "))) + } + + /// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`. + pub fn to_json(&self) -> serde_json::Value { + let list = |v: &[PathBuf]| v.iter().map(|p| shown(p)).collect::>(); + serde_json::json!({"read": list(&[self.read.as_slice(), self.write.as_slice()].concat()), "write": list(&self.write)}) + } +} + +/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one +/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix). +fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { + let Ok(abs) = std::path::absolute(path) else { return false }; + let mut lexical = PathBuf::new(); + for c in abs.components() { + match c { + Component::ParentDir => { + lexical.pop(); + } + Component::CurDir => {} + c => lexical.push(c), + } + } + roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r))) +} + +/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed); +/// false when either can't be resolved. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool { + matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f)) +} + +/// `p` without Windows' `\\?\` prefix, for messages. +fn shown(p: &Path) -> String { + let s = p.to_string_lossy(); + match s.strip_prefix(r"\\?\UNC\") { + Some(unc) => format!(r"\\{unc}"), + None => s.strip_prefix(r"\\?\").unwrap_or(&s).to_string(), + } +} + +/// `path` made absolute and resolved: links followed, and for a path that doesn't exist yet its +/// nearest existing folder resolved with the rest appended (plain names only). +fn resolve(path: &Path) -> std::result::Result { + if path.as_os_str().is_empty() { + return Err("not a file path".into()); + } + let abs = std::path::absolute(path).map_err(|e| e.to_string())?; + let mut rest: Vec = Vec::new(); + let mut at = abs.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut base) => { + base.extend(rest.iter().rev()); + return Ok(base); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Writing through a link that leads nowhere would create its target, wherever it is. + if std::fs::symlink_metadata(at).is_ok() { + return Err("a link that leads nowhere".into()); + } + match at.components().next_back() { + Some(Component::Normal(name)) => { + plain_name(name)?; + rest.push(name.to_os_string()); + } + Some(Component::CurDir) => {} + _ => return Err("`..` (or a drive) below a folder that doesn't exist".into()), + } + at = at.parent().ok_or("no existing folder")?; + } + Err(e) => return Err(e.to_string()), + } + } +} + +/// A name for a file or folder that doesn't exist yet: on Windows no device (`CON`, `NUL`, `COM1`…) +/// and no alternate data stream (`a:b`). +fn plain_name(name: &OsStr) -> std::result::Result<(), String> { + if cfg!(windows) { + let n = name.to_string_lossy(); + let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase(); + let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") + || ((stem.starts_with("COM") || stem.starts_with("LPT")) + && stem.chars().count() == 4 + && stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³'))); + if device || n.contains(':') { + return Err(format!("`{n}` is a device or stream name, not a file")); + } + } + Ok(()) +} + +thread_local! { + static ROOTS: RefCell>> = const { RefCell::new(None) }; +} + +/// Run `f` with this thread's file access confined to `roots` (unchanged when `None`). +pub fn confined(roots: Option<&Arc>, f: impl FnOnce() -> R) -> R { + match roots { + Some(roots) => scoped(Some(roots.clone()), f), + None => f(), + } +} + +/// Run `f` unconfined: only for the app's own files, never for a path a command was given. +// Only plugins read from disk use it, and the browser has no disk. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn app_owned(f: impl FnOnce() -> R) -> R { + scoped(None, f) +} + +fn scoped(roots: Option>, f: impl FnOnce() -> R) -> R { + /// Puts the outer scope's roots back, also when `f` panics. + struct Restore(Option>); + impl Drop for Restore { + fn drop(&mut self) { + let outer = self.0.take(); + ROOTS.with(|r| *r.borrow_mut() = outer); + } + } + let _restore = Restore(ROOTS.with(|r| std::mem::replace(&mut *r.borrow_mut(), roots))); + f() +} + +/// The roots this thread's file access is confined to now, if any. +pub fn current() -> Option> { + ROOTS.with(|r| r.borrow().clone()) +} + +/// The path to use for `access` to `path`: resolved when confined, else as given. +pub fn allow(path: impl AsRef, access: Access) -> Result { + let path = path.as_ref(); + match current() { + Some(roots) => roots.check(path, access).map_err(EngineError::Other), + None => Ok(path.to_path_buf()), + } +} + +/// Whether `path` may be accessed so now. +pub fn allowed(path: impl AsRef, access: Access) -> bool { + allow(path, access).is_ok() +} + +fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ { + move |e| EngineError::Other(format!("{}: {e}", path.display())) +} + +/// The bytes of file `path`. +pub fn read(path: impl AsRef) -> Result> { + let path = path.as_ref(); + std::fs::read(allow(path, Access::Read)?).map_err(io(path)) +} + +/// The bytes of file `path`, refused when it isn't a file or is larger than `max`. +pub fn read_capped(path: impl AsRef, max: u64) -> Result> { + let path = path.as_ref(); + let p = allow(path, Access::Read)?; + let meta = std::fs::metadata(&p).map_err(io(path))?; + if !meta.is_file() || meta.len() > max { + return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display()))); + } + std::fs::read(p).map_err(io(path)) +} + +/// File `path` opened for reading. +pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::File::open(allow(path, Access::Read)?).map_err(io(path)) +} + +/// What the file system says about `path` (links followed). +pub fn metadata(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::metadata(allow(path, Access::Read)?).map_err(io(path)) +} + +/// Whether `path` is a folder (false when it may not be read). +pub fn is_dir(path: impl AsRef) -> bool { + metadata(path).is_ok_and(|m| m.is_dir()) +} + +/// Whether `path` exists (false when it may not be read). +pub fn exists(path: impl AsRef) -> bool { + metadata(path).is_ok() +} + +/// The paths of the entries of folder `path`. +pub fn read_dir(path: impl AsRef) -> Result> { + let path = path.as_ref(); + let dir = allow(path, Access::Read)?; + Ok(std::fs::read_dir(&dir).map_err(io(path))?.flatten().map(|e| e.path()).collect()) +} + +/// Write `bytes` to `path` through a uniquely named staging file and one atomic rename (see +/// [`figcraft_format::write_file_atomic`]). +pub fn write(path: impl AsRef, bytes: &[u8]) -> Result<()> { + let path = path.as_ref(); + write_atomic(&allow(path, Access::Write)?, bytes).map_err(io(path)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + figcraft_format::write_file_atomic(path, bytes) +} + +#[cfg(target_arch = "wasm32")] +fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser")) +} + +/// Create folder `path` and the folders above it that are missing. +pub fn create_dir_all(path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + std::fs::create_dir_all(allow(path, Access::Write)?).map_err(io(path)) +} + +#[cfg(test)] +#[path = "file_access_tests.rs"] +mod tests; diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs new file mode 100644 index 00000000..a05ad88c --- /dev/null +++ b/crates/engine/src/file_access_tests.rs @@ -0,0 +1,371 @@ +//! Automation roots: how paths are resolved and compared, and that commands run confined can't +//! reach past them. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::json; + +use super::{Access, Roots, allow, confined, current}; +use crate::Session; + +/// A fresh folder for one test: `/figcraft-roots--`, holding `root/`, `root2/` +/// (a sibling whose name starts like the root's) and `outside/`. +struct Fixture(PathBuf); + +impl Fixture { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("figcraft-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + for sub in ["root/sub", "root2", "outside"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + std::fs::write(d.join("root/in.txt"), b"in").unwrap(); + std::fs::write(d.join("outside/secret.txt"), b"secret").unwrap(); + Self(d) + } + + fn p(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().into_owned() + } + + /// The root as both read and write root. + fn rw(&self) -> Roots { + Roots::new(&[], &[self.p("root")]).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(unix)] +fn symlink_dir(target: &Path, link: &Path) -> bool { + std::os::unix::fs::symlink(target, link).is_ok() +} + +#[cfg(windows)] +fn symlink_dir(target: &Path, link: &Path) -> bool { + std::os::windows::fs::symlink_dir(target, link).is_ok() +} + +#[cfg(unix)] +fn symlink_file(target: &Path, link: &Path) -> bool { + std::os::unix::fs::symlink(target, link).is_ok() +} + +#[cfg(windows)] +fn symlink_file(target: &Path, link: &Path) -> bool { + std::os::windows::fs::symlink_file(target, link).is_ok() +} + +#[test] +fn inside_is_allowed_and_resolved_outside_is_refused() { + let f = Fixture::new("inside"); + let r = f.rw(); + let got = r.check(Path::new(&f.p("root/in.txt")), Access::Read).unwrap(); + assert_eq!(got, std::fs::canonicalize(f.p("root/in.txt")).unwrap(), "the I/O uses the resolved path"); + assert!(r.check(Path::new(&f.p("root/new.png")), Access::Write).is_ok()); + let e = r.check(Path::new(&f.p("outside/secret.txt")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected:") && e.contains("outside the folders that may be read"), "{e}"); + assert!(r.check(Path::new(&f.p("outside/new.png")), Access::Write).is_err()); + // The root itself is inside. + assert!(r.check(Path::new(&f.p("root")), Access::Read).is_ok()); +} + +#[test] +fn a_sibling_whose_name_starts_like_the_root_is_outside() { + let f = Fixture::new("prefix"); + let r = f.rw(); + for p in ["root2/x.png", "root2", "root.png"] { + assert!(r.check(Path::new(&f.p(p)), Access::Write).is_err(), "{p}"); + assert!(r.check(Path::new(&f.p(p)), Access::Read).is_err(), "{p}"); + } +} + +#[test] +fn traversal_is_resolved_before_comparing() { + let f = Fixture::new("dots"); + let r = f.rw(); + assert!(r.check(Path::new(&f.p("root/sub/../../outside/secret.txt")), Access::Read).is_err()); + assert!(r.check(Path::new(&f.p("root/sub/../../outside/new.txt")), Access::Write).is_err()); + assert!(r.check(Path::new(&f.p("root/sub/../in.txt")), Access::Read).is_ok()); + assert!(r.check(Path::new(&f.p("root/./sub/../new.txt")), Access::Write).is_ok()); + // `..` below a folder that doesn't exist can't be resolved (on Windows it is taken lexically, + // as the system does, and stays inside). + let deep = f.p("root/missing/../../outside/x.txt"); + assert!(r.check(Path::new(&deep), Access::Write).is_err(), "{deep}"); +} + +#[test] +fn files_that_dont_exist_yet_resolve_through_their_nearest_folder() { + let f = Fixture::new("missing"); + let r = f.rw(); + let got = r.check(Path::new(&f.p("root/a/b/c.png")), Access::Write).unwrap(); + assert_eq!(got, std::fs::canonicalize(f.p("root")).unwrap().join("a").join("b").join("c.png")); + assert!(r.check(Path::new(&f.p("outside/a/b/c.png")), Access::Write).is_err()); +} + +#[test] +fn relative_paths_are_resolved_against_the_working_directory() { + // `cargo test` runs in the crate's folder, which holds Cargo.toml. + let cwd = std::env::current_dir().unwrap().to_string_lossy().into_owned(); + let here = Roots::new(std::slice::from_ref(&cwd), &[]).unwrap(); + assert_eq!(here.check(Path::new("Cargo.toml"), Access::Read).unwrap(), std::fs::canonicalize("Cargo.toml").unwrap()); + assert!(here.check(Path::new("src/../Cargo.toml"), Access::Read).is_ok()); + let f = Fixture::new("relative"); + assert!(f.rw().check(Path::new("Cargo.toml"), Access::Read).is_err()); + // Relative roots too. + assert_eq!(Roots::new(&[".".into()], &[]).unwrap(), here); +} + +#[test] +fn read_and_write_authority_are_separate() { + let f = Fixture::new("modes"); + let read_only = Roots::new(&[f.p("root")], &[]).unwrap(); + assert!(read_only.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok()); + let e = read_only.check(Path::new(&f.p("root/out.png")), Access::Write).unwrap_err(); + assert!(e.contains("no folder may be written"), "{e}"); + // A write root may be read too; a read root elsewhere doesn't make it writable. + let both = Roots::new(&[f.p("outside")], &[f.p("root")]).unwrap(); + assert!(both.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok()); + assert!(both.check(Path::new(&f.p("outside/secret.txt")), Access::Read).is_ok()); + assert!(both.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_err()); + // Several roots of each kind. + let many = Roots::new(&[f.p("root"), f.p("root2")], &[f.p("root2"), f.p("outside")]).unwrap(); + assert!(many.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_ok()); + assert!(many.check(Path::new(&f.p("root/new.txt")), Access::Write).is_err()); +} + +#[test] +fn roots_must_be_existing_folders() { + let f = Fixture::new("bad-roots"); + assert!(Roots::new(&[f.p("nope")], &[]).unwrap_err().contains("--automation-read-root")); + assert!(Roots::new(&[], &[f.p("root/in.txt")]).unwrap_err().contains("not a folder")); + assert!(Roots::new(&[], &[String::new()]).is_err()); + assert!(f.rw().check(Path::new(""), Access::Read).is_err()); +} + +#[test] +fn links_that_lead_out_of_a_root_are_outside() { + let f = Fixture::new("links"); + let r = f.rw(); + if !symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) || !symlink_file(&f.0.join("outside/secret.txt"), &f.0.join("root/secret.txt")) { + eprintln!("skipped: this system doesn't let tests make symbolic links"); + return; + } + assert!(r.check(Path::new(&f.p("root/escape/secret.txt")), Access::Read).is_err()); + assert!(r.check(Path::new(&f.p("root/escape/new.txt")), Access::Write).is_err()); + assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Read).is_err()); + assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Write).is_err()); + // A link that leads nowhere: writing through it would create its target. + assert!(symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt"))); + let e = r.check(Path::new(&f.p("root/dangling.txt")), Access::Write).unwrap_err(); + assert!(e.contains("leads nowhere"), "{e}"); + // A link inside the root to the root is fine. + assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias"))); + assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok()); +} + +#[cfg(windows)] +#[test] +fn windows_case_devices_and_streams() { + let f = Fixture::new("windows"); + let r = f.rw(); + // Case is taken from the disk: another spelling of the root is the root. + let upper = f.0.join("ROOT").join("IN.TXT"); + assert!(r.check(&upper, Access::Read).is_ok()); + for name in ["CON", "nul.txt", "Com1.png", "LPT9", "a.txt:stream", "conout$"] { + assert!(r.check(&f.0.join("root").join(name), Access::Write).is_err(), "{name}"); + } + assert!(r.check(&f.0.join("root").join("console.png"), Access::Write).is_ok()); + // The `\\?\` form of a path inside is inside; of a path outside, outside. + let verbatim = std::fs::canonicalize(f.p("root")).unwrap().join("v.png"); + assert!(verbatim.to_string_lossy().starts_with(r"\\?\")); + assert!(r.check(&verbatim, Access::Write).is_ok()); + assert!(r.check(&std::fs::canonicalize(f.p("outside")).unwrap().join("v.png"), Access::Write).is_err()); + // Messages don't show the prefix. + let e = r.check(Path::new(&f.p("outside/x.png")), Access::Write).unwrap_err(); + assert!(!e.contains(r"\\?\"), "{e}"); +} + +#[test] +fn a_scope_confines_the_thread_and_ends_with_it() { + let f = Fixture::new("scope"); + let roots = Arc::new(f.rw()); + assert!(current().is_none()); + confined(Some(&roots), || { + assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err()); + assert!(super::read(f.p("outside/secret.txt")).is_err()); + assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in"); + // Not confined: the app's own files. + assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret"); + assert!(current().is_some(), "back in the scope after app_owned"); + // Another thread isn't confined by this one's scope. + let p = f.p("outside/secret.txt"); + assert!(std::thread::spawn(move || current().is_none() && allow(p, Access::Read).is_ok()).join().unwrap()); + }); + assert!(current().is_none()); + assert!(allow(f.p("outside/secret.txt"), Access::Read).is_ok()); + // A panic inside the scope still ends it. + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(Some(&roots), || panic!("boom")))); + assert!(r.is_err()); + assert!(current().is_none()); + // `None` changes nothing. + assert!(confined(None, || current().is_none())); +} + +#[test] +fn commands_run_confined_read_and_write_only_inside() { + let f = Fixture::new("commands"); + std::fs::create_dir_all(f.0.join("outside/readable")).unwrap(); + let roots = Arc::new(Roots::new(&[f.p("outside/readable")], &[f.p("root")]).unwrap()); + // Unconfined, the outside is writable: the bait files a confined command must not reach. + let bait = f.p("outside/bait.figcraft"); + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path": bait})).unwrap(); + std::fs::copy(&bait, f.0.join("outside/readable/ok.figcraft")).unwrap(); + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap(); + s.prefs.recent_files = vec![bait.clone(), f.p("root/in.txt")]; + confined(Some(&roots), || { + let e = s.execute("file.export", &json!({"format": "png", "path": f.p("outside/x.png")})).unwrap_err().to_string(); + assert!(e.contains("automation path rejected"), "{e}"); + assert!(s.execute("file.export", &json!({"format": "png", "path": f.p("root/x.png")})).is_ok()); + assert!(s.execute("file.exportLayers", &json!({"dir": f.p("outside/layers")})).is_err()); + assert!(s.execute("file.open", &json!({"path": bait})).is_err()); + assert!(s.execute("file.import", &json!({"path": bait})).is_err()); + assert!(s.execute("file.placeImage", &json!({"path": f.p("outside/secret.txt")})).is_err()); + assert!(s.execute("file.open", &json!({"path": f.p("outside/readable/ok.figcraft")})).is_ok(), "read roots may be read"); + assert!(s.execute("file.save", &json!({})).is_err(), "but not written"); + assert!(s.execute("file.saveAs", &json!({"path": f.p("root/ok.figcraft")})).is_ok()); + // Recent files outside the roots aren't listed. + let recent = s.execute("file.openRecent", &json!({})).unwrap().to_string(); + assert!(!recent.contains("bait") && recent.contains("in.txt"), "{recent}"); + assert!(s.execute("file.openRecent", &json!({"path": bait})).is_err()); + }); + assert!(f.0.join("root/x.png").is_file() && !f.0.join("outside/x.png").exists() && !f.0.join("outside/layers").exists()); +} + +/// Every command, given paths outside the roots in its usual path parameters, writes nothing +/// there and opens nothing from there: they all reach files through `file_access`. +#[test] +fn no_command_reaches_outside_the_roots() { + let f = Fixture::new("sweep"); + let roots = Arc::new(f.rw()); + let outside = f.0.join("outside"); + // Bait the sweep could pick up: a document, an SVG, a PNG and variables JSON, each holding a + // name that must never show up in a document or a result. + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "BAIT-LAYER"})).unwrap(); + s.execute("file.saveAs", &json!({"path": f.p("outside/bait.figcraft")})).unwrap(); + s.execute("file.export", &json!({"format": "png", "path": f.p("outside/bait.png")})).unwrap(); + std::fs::write( + outside.join("bait.svg"), + r#""#, + ) + .unwrap(); + let before: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect(); + // The bait is what an unconfined command finds. + for (cmd, bait) in [("file.open", "bait.figcraft"), ("file.import", "bait.svg")] { + let mut s = Session::new(); + s.execute(cmd, &json!({"path": f.p(&format!("outside/{bait}"))})).unwrap(); + assert!(crate::inspect::document(&s, 64, true).to_string().contains("BAIT-LAYER"), "{cmd} {bait}"); + } + let mut s = Session::new(); + s.execute("file.placeImage", &json!({"path": f.p("outside/bait.png")})).unwrap(); + assert!(!s.doc().unwrap().images.is_empty()); + for bait in ["bait.figcraft", "bait.svg", "bait.png", "new.figcraft", "new.png"] { + let path = f.p(&format!("outside/{bait}")); + let params = json!({"path": path, "dir": f.p("outside"), "file": path, "variables": path, "folder": f.p("outside"), "confirm": true}); + for c in crate::all_commands() { + let mut s = Session::new(); + let r = confined(Some(&roots), || s.execute(c.id, ¶ms)); + let shown = format!("{r:?}"); + assert!(!shown.contains("BAIT-LAYER"), "{} read {bait}: {shown}", c.id); + let docs = crate::inspect::document(&s, 64, true).to_string(); + assert!(!docs.contains("BAIT-LAYER"), "{} opened {bait}", c.id); + assert!(s.docs.iter().all(|ds| ds.doc.images.is_empty()), "{} placed {bait}", c.id); + let now: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(now, before, "{} wrote outside the roots with {bait}", c.id); + } + } +} + +#[test] +fn recent_files_outside_the_roots_are_never_opened_removed_or_named() { + let f = Fixture::new("recents"); + let roots = Arc::new(f.rw()); + let (bait, gone, ok) = (f.p("outside/bait.figcraft"), f.p("outside/gone.figcraft"), f.p("root/ok.figcraft")); + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path": bait})).unwrap(); + s.execute("file.saveAs", &json!({"path": ok})).unwrap(); + let mut s = Session::new(); + let recents = vec![bait.clone(), gone.clone(), ok.clone()]; + s.prefs.recent_files = recents.clone(); + let quiet = |r: &crate::Result| { + let shown = format!("{r:?}"); + assert!(!shown.contains("bait") && !shown.contains("gone"), "an outside recent file was named: {shown}"); + }; + confined(Some(&roots), || { + let listing = s.execute("file.openRecent", &json!({})); + quiet(&listing); + assert_eq!(listing.unwrap()["files"].as_array().unwrap().len(), 1); + // `index` counts the files listed: 0 is the first one inside the roots. + let opened = s.execute("file.openRecent", &json!({"index": 0})); + quiet(&opened); + assert!(opened.is_ok(), "{opened:?}"); + assert_eq!(s.ds().unwrap().path.as_deref(), Some(ok.as_str())); + for i in [1, 2] { + let r = s.execute("file.openRecent", &json!({"index": i})); + quiet(&r); + assert!(r.is_err()); + } + // A path outside is refused like any outside path, and never dropped from the list. + let e = s.execute("file.openRecent", &json!({"path": gone})).unwrap_err().to_string(); + assert!(e.contains("is outside the folders that may be read") && !e.contains("no longer exists"), "{e}"); + quiet(&s.execute("file.openRecent", &json!({"remove": bait}))); + assert!(s.prefs.recent_files.contains(&bait) && s.prefs.recent_files.contains(&gone)); + // Clear clears what is listed. + quiet(&s.execute("file.openRecent", &json!({"clear": true}))); + }); + assert_eq!(s.prefs.recent_files, vec![bait, gone]); + // Unconfined, a recent file that is gone is still dropped. + s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); + assert_eq!(s.prefs.recent_files.len(), 1); +} + +#[test] +fn outside_paths_are_refused_alike_whatever_exists_there() { + let f = Fixture::new("oracle"); + let r = f.rw(); + let refusal = |p: &str| r.check(Path::new(p), Access::Write).unwrap_err(); + let standard = |p: &str| format!("automation path rejected: {} is outside the folders that may be written:", Path::new(p).display()); + let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")]; + if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) { + outside.push(f.p("outside/dangling.txt")); + } + for p in &outside { + let e = refusal(p); + assert!(e.starts_with(&standard(p)), "{p}: {e}"); + } + // Inside a root the reason is given. + if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) { + assert!(refusal(&f.p("root/dangling.txt")).contains("leads nowhere")); + } +} + +#[test] +fn only_resolved_paths_count_as_the_apps_own_folder() { + let f = Fixture::new("own"); + let install = f.0.join("root"); + assert!(super::within_app_folder(&install.join("in.txt"), &install)); + assert!(!super::within_app_folder(&install.join("..").join("outside").join("secret.txt"), &install), "`..` leaves it"); + assert!(!super::within_app_folder(&f.0.join("root2"), &install), "a folder named like it is another"); + assert!(!super::within_app_folder(&install.join("missing.wasm"), &install)); + assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder"))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a0d87f9c..8796dc9c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -6,6 +6,8 @@ //! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document //! edits and are journaled the same way. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml). +#![cfg_attr(test, allow(clippy::disallowed_methods))] pub mod al_keys; pub mod annotations; @@ -25,6 +27,7 @@ pub mod devcompare; pub mod devinspect; pub mod distance; pub mod drop; +pub mod file_access; pub mod guard; pub mod guides; pub mod inspect; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index daa1f5bd..26863561 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -541,7 +541,7 @@ fn now() -> Option { #[cfg(not(target_arch = "wasm32"))] fn stat(path: &str) -> std::result::Result { - let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; + let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?; if !m.is_file() { return Err(format!("{path} is not a file")); } @@ -555,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result { #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> std::result::Result { - let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let f = crate::file_access::open(path).map_err(|e| e.to_string())?; match figcraft_format::read_library(std::io::BufReader::new(f)) { Ok(Some(lib)) => Ok(lib), Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")), diff --git a/crates/engine/src/playground.rs b/crates/engine/src/playground.rs index 0023a6c1..64286cef 100644 --- a/crates/engine/src/playground.rs +++ b/crates/engine/src/playground.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { if let Some(path) = string(p, "path") { let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0)); let png = crate::cmd::file::export_bytes(©, &[inst], ExportFormat::Png, scale)?; - std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::file_access::write(&path, &png)?; out["path"] = json!(path); } Ok(out) diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index c3bf7dc4..2f721fea 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result { /// Keep an installed plugin's files in its own folder under the install folder (named by the hash /// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without /// an install folder (the browser, the CLI and tests) the plugin lasts for the session. +// The install folder is the app's own (set by the desktop app, never by a command), so automation +// roots don't apply to it. +#[allow(clippy::disallowed_methods)] fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result, String> { let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) }; #[cfg(not(target_arch = "wasm32"))] @@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result` and `--automation-write-root ` (each repeatable, `--flag=` +works too; the same flags as `figcraft-cli mcp`) confine the files the app reads and writes for +whoever asks: every control request (`engine.execute`, `ui.render`, `ui.screenshot`, `window.new`, +`ui.key`, `ui.menu.invoke`…) and every frame of every window, so input injected with `ui.input` +and what a person does in the window are held to the same folders. Files are read only inside a +read or write root and written only inside a write root; anything else is an ordinary error reply +(`automation path rejected: …`), and recent files outside the roots aren't listed. Paths are +resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder +stops the app before its window opens. Without the flags nothing changes. + +Not confined: opening the files given on the command line when the app starts and the documents +the system hands over (Finder, Open With; saving them later is confined like any write), and the +app's own folders (preferences, recovery copies, plugins installed from a plugin directory). Run `figcraft-cli mcp --attach PORT` without the flags against +such an app (it refuses them: the app does the confining). `ui.screenshot` and `ui.render` without a +`path` return the PNG as data, which is how the attached MCP server takes them, so it writes no +temporary files. + ## Methods ### `engine.execute {command, params?}` @@ -265,15 +288,17 @@ the annotations the canvas shows). `resetFrameStats` (true: start `framesDrawn` `mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the focus view shows, or `null`) and `annotationFilter`). -### `ui.screenshot {path}` +### `ui.screenshot {path?}` PNG of the whole window (`window` picks one), UI included (needs a visible window; an idle one draws the frame to -capture). Returns `{path, width, height}`, or an error after 60 s when the window doesn't deliver. +capture). Returns `{path, width, height}`, without `path` `{format: "png", width, height, data}` +(base64), or an error after 60 s when the window doesn't deliver. -### `ui.render {path, scale?, ids?}` +### `ui.render {path?, scale?, ids?}` PNG of the document without UI chrome (works without a window). Without `ids` it exports the -selection, else the page's top-level layers. Returns the export result. +selection, else the page's top-level layers. Returns the export result: written to `path`, or +`{format: "png", data}` (base64) without one. ### `window.new {path?, index?}` diff --git a/docs/mcp.md b/docs/mcp.md index e9b621f1..4e6f7bc3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -26,6 +26,62 @@ If that connection cannot be established, the error says the request was not sen A connection the app closed while it sat idle is noticed before the next request is written and replaced then, so a pause between calls is not an error. +### Confining file access + +```sh +figcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"figcraft": {"command": "/path/to/figcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (the other Craft apps take the same ones) keep an agent to the folders you give it. +Each may be given more than once, as `--flag DIR` or `--flag=DIR`: + +- Every file a tool call reads must lie inside a **read root or a write root**; every file it writes + inside a **write root**. Anything else is refused with an ordinary tool error the agent can read + and correct, for example `automation path rejected: /etc/x.png is outside the folders that may be + written: /work/project/out`. Nothing is written and the server keeps serving. +- Read and write authority are separate. With only `--automation-read-root` nothing can be written + to disk (`export` and the export commands without a `path` still return the bytes). Without either + flag nothing changes. +- The roots must be existing folders: a missing one, or a file, stops the server at startup. + `initialize`'s `instructions` name the folders, so the agent knows where it may work. +- **What is covered:** everything a call does, because the engine reaches the file system through + one module (`crates/engine/src/file_access.rs`), and `crates/engine/clippy.toml` refuses `std::fs` + anywhere else in the engine, so a command added later is covered too. That is `open_file` (and the + `variables` file it may name), `save_file`, `export {path}`, and through `run_command` / `batch` + every command that names a file: `file.open`, `file.openRecent` (recent files outside the roots + are never listed, opened, removed or named: `index` counts the files listed), `file.import`, + `file.placeImage`, `image.place`, `file.save`, `file.saveAs`, `file.revert`, `file.recover`, + `file.export`, `file.exportSvg`, `file.exportLayers` (and the folders it makes), `file.exportFramesPdf`, the images and variables JSON read next to an imported REST API + file, `library.add` and the library files open documents link to, `variable.import` / + `variable.export`, `codeConnect.import` / `codeConnect.export`, `component.playground {path}`, + `plugin.import {path}`, a development plugin's module read again from disk, + `plugin.directory.publish {path}` and its assets, `collab.recoverFile {path}`, and the headless + `ui.render` / `ui.screenshot` with a `path`. +- **How paths are resolved:** a relative path is made absolute against the server's working + directory, then its links are followed: the real path is what is compared and what is opened. For + a file that doesn't exist yet, its nearest existing folder is resolved and the rest must be plain + names, so `..` below a folder that doesn't exist is refused, and so is a link that leads nowhere + (writing through it would create its target, wherever that is). A path outside the roots gets + the same refusal whatever exists there, so a refusal doesn't tell what is outside. Folders are + compared by whole names: `/work2/x` is not inside `/work`. On Windows the case comes from the disk (`C:\WORK\x` is + inside `C:\work`), `\\?\` and UNC (`\\server\share\…`) paths are resolved like drive-letter ones, + and new names that are devices (`CON`, `NUL`, `COM1`…) or streams (`a.png:x`) are refused. +- Not confined: the app's own folders, which no tool names (preferences, Data Recovery copies, + plugins installed from a plugin directory, the system fonts). +- `--attach` with either flag is a usage error: the app runs the commands, so only the app can + confine them. Start it with the same flags, `figcraft --control 7979 --automation-read-root DIR + --automation-write-root DIR` (see [Control protocol](control-protocol.md#confining-file-access)), + then attach as usual. The attached `screenshot` gets its PNGs from the app as data, so no + temporary file is written anywhere. +- A path is checked, then opened: another program that swaps a folder for a link in between can + still win that race. The roots keep an agent to the files it was given, not other software on the + computer; if that matters, run the server as a user that can't change the roots' folders. + ### Fonts The headless server lays text out with the same fonts as the app: the fonts built into FigCraft diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c3842d4f..07c6a4dd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -621,13 +621,16 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs. - **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, - synthetic pointer/keys/typing, menu invocation, screenshots, renders). + synthetic pointer/keys/typing, menu invocation, screenshots, renders). What agents read and write + on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root` on + `figcraft-cli mcp` and the desktop app, #715). - **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From 96091494a52fcb94e892226e9b1fa1ab3faf68bb Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:03:28 +0200 Subject: [PATCH 156/245] M8: Keyboard shortcuts panel closes on the home screen (#548) --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/shortcuts_tests.rs | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 360955a3..6d867abf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1414,6 +1414,8 @@ impl FigApp { return; } if !self.has_files() || self.ui.home { + // The shortcuts panel belongs to the canvas; it doesn't hang over the home screen. + self.ui.shortcuts_panel = false; tabs::tab_bar(self, ui); home::show(self, ui); dialogs::keys(self, &ctx); diff --git a/crates/ui-egui/src/shortcuts_tests.rs b/crates/ui-egui/src/shortcuts_tests.rs index cd0f0ea5..5c1e06fa 100644 --- a/crates/ui-egui/src/shortcuts_tests.rs +++ b/crates/ui-egui/src/shortcuts_tests.rs @@ -188,3 +188,12 @@ fn shortcuts_panel_opens_switches_tabs_and_searches() { u.app.run("help.shortcuts", json!({})); assert!(!u.app.ui.shortcuts_panel, "the command toggles it"); } + +#[test] +fn shortcuts_panel_does_not_show_on_the_home_screen() { + let mut u = Ui::new(Session::empty()); + u.app.ui.home = true; + u.app.ui.shortcuts_panel = true; + u.frame(vec![]); + assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen"); +} From 17758458ef02560fbc206eb2dbb83bae0f970fff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:03:45 +0700 Subject: [PATCH 157/245] #575: drop the progress-doc edit --- docs/ui-parity.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b534d317..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Selection colors for mixed selections (#403); fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -109,25 +109,6 @@ interaction feel not yet compared. | Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side | | Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions | -### Selection colors - -The Design panel shows Selection colors below Effects when the selection contains multiple -paint groups. It includes solid fills and gradients, strokes, descendants and effective text -range fills; images, patterns, hidden paints and masks are omitted. A boolean group's own paints -are included, without its operands. Equal paints from different styles or variables stay separate. -Each row edits the matching slots through the ordinary color picker or hex/opacity fields and -can select their owning layers. A picker drag is one undo step and retains its original targets -even when two colors become equal. Paint discovery is cached per document snapshot and selection; -the rows are virtualized. During an interaction, the panel keeps its cached rows disabled and -defers discovery until the gesture ends; selection commands still process all matching paints. - -Color styles already in the document (including imported styles) can be applied from a row. -As with the existing Fill/Stroke style controls, applying a style replaces the matching layer's -whole fill or stroke list and links it. A text range takes the style's resolved paints while -keeping its typography; the document model has no paint-style link on character ranges. -Browsing enabled libraries and binding/detaching variables from this section are not implemented; -this is **partial** UI parity. - ## Keyboard shortcuts One keymap (`figcraft_engine::keymap`) drives the menus, the canvas, the shortcuts panel, the @@ -185,7 +166,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-15 | minor | Selection colors (#403): mixed-selection paint editing, color styles and select-by-color; engine and egui regression tests. Bounds and remaining parity are documented above; headline estimates unchanged. | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From af27ff7d2a7c4e0217757cf082907853032ffb65 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:03:54 +0700 Subject: [PATCH 158/245] #575: Selection colors strings and command labels in every catalog --- crates/ui-egui/locales/cs.toml | 8 ++++++++ crates/ui-egui/locales/de.toml | 8 ++++++++ crates/ui-egui/locales/fr.toml | 8 ++++++++ crates/ui-egui/locales/id.toml | 8 ++++++++ crates/ui-egui/locales/ja.toml | 8 ++++++++ crates/ui-egui/locales/ko.toml | 8 ++++++++ crates/ui-egui/locales/pt-BR.toml | 8 ++++++++ crates/ui-egui/locales/ru.toml | 8 ++++++++ crates/ui-egui/locales/sv.toml | 8 ++++++++ crates/ui-egui/locales/tr.toml | 8 ++++++++ crates/ui-egui/locales/zh-Hans.toml | 8 ++++++++ 11 files changed, 88 insertions(+) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 2459d104..cc0e66fd 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -638,6 +638,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps." "Intersect" = "Průnik" "Intersects" = "Průniky" +"Invalid color" = "Neplatná barva" "Invite link" = "Odkaz s pozvánkou" "Invite link copied" = "Odkaz s pozvánkou zkopírován" "It asks permission to:" = "Žádá o oprávnění:" @@ -804,6 +805,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Žádné větve. Větev je kopie souboru pro vyzkoušení změn, které pak zkontrolujete a sloučíte." "No category" = "Bez kategorie" "No changes" = "Žádné změny" +"No color styles" = "Žádné barevné styly" "No colour variables fit here." = "Žádné vhodné barevné proměnné." "No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných." "No comments yet" = "Zatím žádné komentáře" @@ -1161,12 +1163,14 @@ status = "draft — needs native review" "Select all with same" = "Vybrat vše se stejným" "Select layer" = "Vybrat vrstvu" "Select layers to apply the style to" = "Vyberte vrstvy, na které chcete použít styl" +"Select layers with this color" = "Vybrat vrstvy s touto barvou" "Select overlapping shapes" = "Vyberte překrývající se tvary" "Select points to edit their position, radius and mirroring" = "Vyberte body a upravte jejich polohu, poloměr a zrcadlení" "Select slot" = "Vybrat slot" "Selection" = "Výběr" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru, {w} × {h} na pozici {x}, {y}: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší" +"Selection colors" = "Barvy výběru" "Send backward" = "Přesunout o úroveň níž" "Send report" = "Odeslat hlášení" "Send to back" = "Přesunout do pozadí" @@ -1992,6 +1996,10 @@ status = "draft — needs native review" "select.sameStroke" = "Vybrat stejný tah" "select.sameText" = "Vybrat stejné vlastnosti textu" "select.set" = "Vybrat" +"selection.applyColorStyle" = "Použít barevný styl na výběr" +"selection.colors" = "Barvy výběru" +"selection.selectColor" = "Vybrat vrstvy s touto barvou" +"selection.setPaint" = "Změnit barvu výběru" "shape.arrow" = "Šipka" "shape.ellipse" = "Elipsa" "shape.line" = "Čára" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b679feb7..ba9c90bd 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -623,6 +623,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-Design und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps." "Intersect" = "Schnittmenge" "Intersects" = "Schnittmengen" +"Invalid color" = "Ungültige Farbe" "Invite link" = "Einladungslink" "Invite link copied" = "Einladungslink kopiert" "It asks permission to:" = "Es möchte:" @@ -777,6 +778,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Keine Branches. Ein Branch ist eine Kopie der Datei, in der Sie Änderungen ausprobieren, prüfen und dann zusammenführen." "No category" = "Keine Kategorie" "No changes" = "Keine Änderungen" +"No color styles" = "Keine Farbstile" "No colour variables fit here." = "Hier passen keine Farbvariablen." "No colour variables yet. Create them in Local variables." = "Noch keine Farbvariablen. Erstellen Sie sie unter Lokale Variablen." "No comments yet" = "Noch keine Kommentare" @@ -1125,12 +1127,14 @@ status = "draft — needs native review" "Select all with same" = "Alle auswählen mit gleicher" "Select layer" = "Ebene auswählen" "Select layers to apply the style to" = "Wählen Sie die Ebenen aus, auf die der Stil angewendet werden soll" +"Select layers with this color" = "Ebenen mit dieser Farbe auswählen" "Select overlapping shapes" = "Wählen Sie überlappende Formen aus" "Select points to edit their position, radius and mirroring" = "Wählen Sie Punkte aus, um Position, Radius und Spiegelung zu bearbeiten" "Select slot" = "Slot auswählen" "Selection" = "Auswahl" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen, {w} × {h} bei {x}, {y}: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab" +"Selection colors" = "Auswahlfarben" "Send backward" = "Eine Ebene nach hinten" "Send report" = "Meldung senden" "Send to back" = "In den Hintergrund" @@ -1935,6 +1939,10 @@ status = "draft — needs native review" "select.sameStroke" = "Gleiche Kontur auswählen" "select.sameText" = "Gleiche Texteigenschaften auswählen" "select.set" = "Auswählen" +"selection.applyColorStyle" = "Farbstil auf Auswahl anwenden" +"selection.colors" = "Auswahlfarben" +"selection.selectColor" = "Ebenen mit dieser Farbe auswählen" +"selection.setPaint" = "Farbe der Auswahl ändern" "shape.arrow" = "Pfeil" "shape.ellipse" = "Ellipse" "shape.line" = "Linie" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1c561f15..6edfbd03 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -619,6 +619,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft." "Intersect" = "Intersection" "Intersects" = "Intersections" +"Invalid color" = "Couleur non valide" "Invite link" = "Lien d’invitation" "Invite link copied" = "Lien d’invitation copié" "It asks permission to:" = "Il demande l’autorisation de :" @@ -773,6 +774,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Aucune branche. Une branche est une copie du fichier pour essayer des modifications, puis les relire et les fusionner." "No category" = "Sans catégorie" "No changes" = "Aucune modification" +"No color styles" = "Aucun style de couleur" "No colour variables fit here." = "Aucune variable de couleur ne convient ici." "No colour variables yet. Create them in Local variables." = "Aucune variable de couleur pour l’instant. Créez-en dans Variables locales." "No comments yet" = "Aucun commentaire pour l’instant" @@ -1119,12 +1121,14 @@ status = "draft — needs native review" "Select all with same" = "Sélectionner tout ce qui a le même" "Select layer" = "Sélectionner le calque" "Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style" +"Select layers with this color" = "Sélectionner les calques de cette couleur" "Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent" "Select points to edit their position, radius and mirroring" = "Sélectionnez des points pour modifier leur position, leur rayon et leur symétrie" "Select slot" = "Sélectionner l’emplacement" "Selection" = "Sélection" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection, {w} sur {h} à {x}, {y} : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule" +"Selection colors" = "Couleurs de la sélection" "Send backward" = "Reculer" "Send report" = "Envoyer le signalement" "Send to back" = "Mettre à l’arrière-plan" @@ -1926,6 +1930,10 @@ status = "draft — needs native review" "select.sameStroke" = "Sélectionner le même contour" "select.sameText" = "Sélectionner les mêmes propriétés de texte" "select.set" = "Sélectionner" +"selection.applyColorStyle" = "Appliquer un style de couleur à la sélection" +"selection.colors" = "Couleurs de la sélection" +"selection.selectColor" = "Sélectionner les calques de cette couleur" +"selection.setPaint" = "Changer la couleur de la sélection" "shape.arrow" = "Flèche" "shape.ellipse" = "Ellipse" "shape.line" = "Ligne" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 7a0a3c3c..99d6cff4 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -642,6 +642,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps." "Intersect" = "Irisan" "Intersects" = "Beririsan" +"Invalid color" = "Warna tidak valid" "Invite link" = "Link undangan" "Invite link copied" = "Link undangan disalin" "It asks permission to:" = "Plugin ini meminta izin untuk:" @@ -808,6 +809,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Tidak ada branch. Branch adalah salinan file untuk mencoba perubahan, lalu ditinjau dan di-merge." "No category" = "Tanpa kategori" "No changes" = "Tidak ada perubahan" +"No color styles" = "Tidak ada gaya warna" "No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini." "No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal." "No comments yet" = "Belum ada komentar" @@ -1165,12 +1167,14 @@ status = "draft — needs native review" "Select all with same" = "Pilih semua yang sama" "Select layer" = "Pilih layer" "Select layers to apply the style to" = "Pilih layer untuk menerapkan gaya" +"Select layers with this color" = "Pilih layer dengan warna ini" "Select overlapping shapes" = "Pilih bentuk yang tumpang tindih" "Select points to edit their position, radius and mirroring" = "Pilih titik untuk mengedit posisi, radius, dan pencerminannya" "Select slot" = "Pilih slot" "Selection" = "Pilihan" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan, {w} x {h} di {x}, {y}: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan" +"Selection colors" = "Warna pilihan" "Send backward" = "Bawa mundur" "Send report" = "Kirim laporan" "Send to back" = "Bawa ke paling belakang" @@ -1996,6 +2000,10 @@ status = "draft — needs native review" "select.sameStroke" = "Pilih stroke yang sama" "select.sameText" = "Pilih properti teks yang sama" "select.set" = "Pilih" +"selection.applyColorStyle" = "Terapkan gaya warna ke pilihan" +"selection.colors" = "Warna pilihan" +"selection.selectColor" = "Pilih layer dengan warna ini" +"selection.setPaint" = "Ubah warna pilihan" "shape.arrow" = "Panah" "shape.ellipse" = "Elips" "shape.line" = "Garis" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b09db3bd..788ed68b 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -647,6 +647,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "インターフェースデザインとプロトタイピング。Rust でゼロから書かれた、無料でオープンソースのクリーンルーム設計のデザインツール。ArtCraft Crafting Apps の一つ。" "Intersect" = "交差" "Intersects" = "交差" +"Invalid color" = "無効なカラー" "Invite link" = "招待リンク" "Invite link copied" = "招待リンクをコピー済み" "It asks permission to:" = "次の権限を求めている:" @@ -813,6 +814,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "ブランチはない。ブランチはファイルのコピーで、変更を試してからレビューしてマージできる。" "No category" = "カテゴリなし" "No changes" = "変更なし" +"No color styles" = "カラースタイルがありません" "No colour variables fit here." = "ここに使えるカラー変数はない。" "No colour variables yet. Create them in Local variables." = "カラー変数はまだない。ローカル変数で作成。" "No comments yet" = "コメントはまだない" @@ -1172,12 +1174,14 @@ status = "draft — needs native review" "Select layer" = "レイヤーを選択" "Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整" "Select layers to apply the style to" = "スタイルを適用するレイヤーを選択" +"Select layers with this color" = "このカラーのレイヤーを選択" "Select overlapping shapes" = "重なったシェイプを選択" "Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる" "Select slot" = "スロットを選択" "Selection" = "選択範囲" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス、{w} × {h}、位置 {x}, {y}:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル" +"Selection colors" = "選択範囲のカラー" "Send backward" = "背面へ" "Send report" = "報告を送信" "Send to back" = "最背面へ" @@ -2008,6 +2012,10 @@ status = "draft — needs native review" "select.sameStroke" = "同じ線を選択" "select.sameText" = "同じテキストプロパティを選択" "select.set" = "選択" +"selection.applyColorStyle" = "選択範囲にカラースタイルを適用" +"selection.colors" = "選択範囲のカラー" +"selection.selectColor" = "このカラーのレイヤーを選択" +"selection.setPaint" = "選択範囲のカラーを変更" "shape.arrow" = "矢印" "shape.ellipse" = "楕円" "shape.line" = "線" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index f2cfcfff..a6bfbf6e 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -619,6 +619,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "인터페이스 디자인과 프로토타이핑. Rust로 처음부터 작성한 무료 오픈 소스 클린룸 디자인 도구이며 ArtCraft Crafting Apps의 하나." "Intersect" = "교차" "Intersects" = "교차" +"Invalid color" = "잘못된 색상" "Invite link" = "초대 링크" "Invite link copied" = "초대 링크 복사됨" "It asks permission to:" = "다음 권한을 요청함:" @@ -773,6 +774,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "브랜치 없음. 브랜치는 변경을 시험해 본 뒤 검토하고 병합하는 파일 사본임." "No category" = "카테고리 없음" "No changes" = "변경 사항 없음" +"No color styles" = "색상 스타일 없음" "No colour variables fit here." = "여기에 맞는 색상 변수가 없음." "No colour variables yet. Create them in Local variables." = "아직 색상 변수가 없음. 로컬 변수에서 만드세요." "No comments yet" = "아직 댓글 없음" @@ -1119,12 +1121,14 @@ status = "draft — needs native review" "Select all with same" = "같은 항목 모두 선택" "Select layer" = "레이어 선택" "Select layers to apply the style to" = "스타일을 적용할 레이어를 선택하세요" +"Select layers with this color" = "이 색상의 레이어 선택" "Select overlapping shapes" = "겹친 도형 선택" "Select points to edit their position, radius and mirroring" = "점을 선택해 위치, 반경, 대칭 편집" "Select slot" = "슬롯 선택" "Selection" = "선택 항목" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자, {w} × {h}, 위치 {x}, {y}: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소" +"Selection colors" = "선택 항목 색상" "Send backward" = "뒤로 보내기" "Send report" = "신고 보내기" "Send to back" = "맨 뒤로 보내기" @@ -1926,6 +1930,10 @@ status = "draft — needs native review" "select.sameStroke" = "같은 선 선택" "select.sameText" = "같은 텍스트 속성 선택" "select.set" = "선택" +"selection.applyColorStyle" = "선택 항목에 색상 스타일 적용" +"selection.colors" = "선택 항목 색상" +"selection.selectColor" = "이 색상의 레이어 선택" +"selection.setPaint" = "선택 항목 색상 변경" "shape.arrow" = "화살표" "shape.ellipse" = "타원" "shape.line" = "선" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index e9e1300c..75bbb301 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -618,6 +618,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft." "Intersect" = "Interseção" "Intersects" = "Interseções" +"Invalid color" = "Cor inválida" "Invite link" = "Link de convite" "Invite link copied" = "Link de convite copiado" "It asks permission to:" = "Ele pede permissão para:" @@ -772,6 +773,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Nenhuma branch. Uma branch é uma cópia do arquivo para testar alterações e depois revisá-las e mesclá-las." "No category" = "Sem categoria" "No changes" = "Nenhuma alteração" +"No color styles" = "Nenhum estilo de cor" "No colour variables fit here." = "Nenhuma variável de cor se encaixa aqui." "No colour variables yet. Create them in Local variables." = "Ainda não há variáveis de cor. Crie-as em Variáveis locais." "No comments yet" = "Ainda não há comentários" @@ -1118,12 +1120,14 @@ status = "draft — needs native review" "Select all with same" = "Selecionar tudo com o mesmo" "Select layer" = "Selecionar camada" "Select layers to apply the style to" = "Selecione as camadas às quais aplicar o estilo" +"Select layers with this color" = "Selecionar camadas com esta cor" "Select overlapping shapes" = "Selecione formas sobrepostas" "Select points to edit their position, radius and mirroring" = "Selecione pontos para editar posição, raio e espelhamento" "Select slot" = "Selecionar slot" "Selection" = "Seleção" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção, {w} por {h} em {x}, {y}: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela" +"Selection colors" = "Cores da seleção" "Send backward" = "Enviar para trás" "Send report" = "Enviar denúncia" "Send to back" = "Enviar para o plano de fundo" @@ -1925,6 +1929,10 @@ status = "draft — needs native review" "select.sameStroke" = "Selecionar mesmo traço" "select.sameText" = "Selecionar mesmas propriedades de texto" "select.set" = "Selecionar" +"selection.applyColorStyle" = "Aplicar estilo de cor à seleção" +"selection.colors" = "Cores da seleção" +"selection.selectColor" = "Selecionar camadas com esta cor" +"selection.setPaint" = "Alterar cor da seleção" "shape.arrow" = "Seta" "shape.ellipse" = "Elipse" "shape.line" = "Linha" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 2023b692..782aa225 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -639,6 +639,7 @@ name = "Русский" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps." "Intersect" = "Пересечение" "Intersects" = "Пересечения" +"Invalid color" = "Недопустимый цвет" "Invite link" = "Ссылка приглашения" "Invite link copied" = "Ссылка приглашения скопирована" "It asks permission to:" = "Запрашивает разрешение на:" @@ -805,6 +806,7 @@ name = "Русский" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Веток нет. Ветка — копия файла, в которой можно попробовать изменения, затем проверить и объединить их." "No category" = "Без категории" "No changes" = "Нет изменений" +"No color styles" = "Нет цветовых стилей" "No colour variables fit here." = "Нет подходящих цветовых переменных." "No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»." "No comments yet" = "Комментариев пока нет" @@ -1163,12 +1165,14 @@ name = "Русский" "Select all with same" = "Выделить всё с одинаковым" "Select layer" = "Выделить слой" "Select layers to apply the style to" = "Выделите слои для применения стиля" +"Select layers with this color" = "Выделить слои с этим цветом" "Select overlapping shapes" = "Выделите пересекающиеся фигуры" "Select points to edit their position, radius and mirroring" = "Выделите точки для изменения положения, радиуса и зеркальности" "Select slot" = "Выделить слот" "Selection" = "Выделение" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения {w} на {h} в {x}, {y}: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить" +"Selection colors" = "Цвета выделения" "Send backward" = "На уровень назад" "Send report" = "Отправить жалобу" "Send to back" = "На задний план" @@ -1994,6 +1998,10 @@ name = "Русский" "select.sameStroke" = "Выделить с одинаковой обводкой" "select.sameText" = "Выделить с одинаковыми свойствами текста" "select.set" = "Выделить" +"selection.applyColorStyle" = "Применить цветовой стиль к выделению" +"selection.colors" = "Цвета выделения" +"selection.selectColor" = "Выделить слои с этим цветом" +"selection.setPaint" = "Изменить цвет выделения" "shape.arrow" = "Стрелка" "shape.ellipse" = "Эллипс" "shape.line" = "Линия" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 51f16508..f751ecb5 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -649,6 +649,7 @@ name = "Svenska" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps." "Intersect" = "Skärning" "Intersects" = "Skär" +"Invalid color" = "Ogiltig färg" "Invite link" = "Inbjudningslänk" "Invite link copied" = "Inbjudningslänken har kopierats" "It asks permission to:" = "Den ber om behörighet att:" @@ -815,6 +816,7 @@ name = "Svenska" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Inga grenar. En gren är en kopia av filen där du kan prova ändringar och sedan granska och sammanfoga dem." "No category" = "Ingen kategori" "No changes" = "Inga ändringar" +"No color styles" = "Inga färgformatmallar" "No colour variables fit here." = "Inga färgvariabler passar här." "No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler." "No comments yet" = "Inga kommentarer än" @@ -1176,12 +1178,14 @@ name = "Svenska" "Select all with same" = "Markera alla med samma" "Select layer" = "Markera lager" "Select layers to apply the style to" = "Markera de lager som formatmallen ska användas på" +"Select layers with this color" = "Markera lager med den här färgen" "Select overlapping shapes" = "Markera överlappande former" "Select points to edit their position, radius and mirroring" = "Markera punkter för att redigera deras position, radie och spegling" "Select slot" = "Markera plats" "Selection" = "Markering" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta, {w} gånger {h} vid {x}, {y}: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter" +"Selection colors" = "Markeringsfärger" "Send backward" = "Flytta bakåt" "Send report" = "Skicka rapport" "Send to back" = "Flytta längst bak" @@ -2016,6 +2020,10 @@ name = "Svenska" "select.sameStroke" = "Markera samma kontur" "select.sameText" = "Markera samma textegenskaper" "select.set" = "Markera" +"selection.applyColorStyle" = "Använd färgformatmall på markeringen" +"selection.colors" = "Markeringsfärger" +"selection.selectColor" = "Markera lager med den här färgen" +"selection.setPaint" = "Ändra markeringens färg" "shape.arrow" = "Pil" "shape.ellipse" = "Ellips" "shape.line" = "Linje" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index cc002654..788d7142 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -642,6 +642,7 @@ name = "Türkçe" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası." "Intersect" = "Kesiştir" "Intersects" = "Kesişir" +"Invalid color" = "Geçersiz renk" "Invite link" = "Davet bağlantısı" "Invite link copied" = "Davet bağlantısı kopyalandı" "It asks permission to:" = "Şunlar için izin istiyor:" @@ -808,6 +809,7 @@ name = "Türkçe" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "Dal yok. Dal, değişiklikleri denemek, sonra gözden geçirip birleştirmek için dosyanın bir kopyasıdır." "No category" = "Kategori yok" "No changes" = "Değişiklik yok" +"No color styles" = "Renk stili yok" "No colour variables fit here." = "Buraya uyan renk değişkeni yok." "No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun." "No comments yet" = "Henüz yorum yok" @@ -1165,12 +1167,14 @@ name = "Türkçe" "Select all with same" = "Aynı olanların tümünü seç" "Select layer" = "Katman seç" "Select layers to apply the style to" = "Stilin uygulanacağı katmanları seçin" +"Select layers with this color" = "Bu renkteki katmanları seç" "Select overlapping shapes" = "Çakışan şekilleri seç" "Select points to edit their position, radius and mirroring" = "Konumlarını, yarıçaplarını ve yansıtmalarını düzenlemek için noktaları seçin" "Select slot" = "Yuvayı seç" "Selection" = "Seçim" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu, {x}, {y} konumunda {w} × {h}: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder" +"Selection colors" = "Seçim renkleri" "Send backward" = "Geriye gönder" "Send report" = "Raporu gönder" "Send to back" = "En arkaya gönder" @@ -1996,6 +2000,10 @@ name = "Türkçe" "select.sameStroke" = "Aynı konturu seç" "select.sameText" = "Aynı metin özelliklerini seç" "select.set" = "Seç" +"selection.applyColorStyle" = "Seçime renk stili uygula" +"selection.colors" = "Seçim renkleri" +"selection.selectColor" = "Bu renkteki katmanları seç" +"selection.setPaint" = "Seçimin rengini değiştir" "shape.arrow" = "Ok" "shape.ellipse" = "Elips" "shape.line" = "Çizgi" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 702cae7b..3434832d 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -620,6 +620,7 @@ status = "draft — needs native review" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "界面设计与原型制作。一款以 Rust 从零编写的免费、开源、净室设计工具,属于 ArtCraft Crafting Apps 系列。" "Intersect" = "相交" "Intersects" = "相交" +"Invalid color" = "无效颜色" "Invite link" = "邀请链接" "Invite link copied" = "已复制邀请链接" "It asks permission to:" = "它请求以下权限:" @@ -774,6 +775,7 @@ status = "draft — needs native review" "No branches. A branch is a copy of the file to try changes in, then review and merge." = "没有分支。分支是文件的副本,可在其中尝试更改,再审阅并合并。" "No category" = "无类别" "No changes" = "无更改" +"No color styles" = "没有颜色样式" "No colour variables fit here." = "没有适用于此处的颜色变量。" "No colour variables yet. Create them in Local variables." = "还没有颜色变量。请在“本地变量”中创建。" "No comments yet" = "还没有评论" @@ -1120,12 +1122,14 @@ status = "draft — needs native review" "Select all with same" = "选择所有相同" "Select layer" = "选择图层" "Select layers to apply the style to" = "选择要应用样式的图层" +"Select layers with this color" = "选择使用此颜色的图层" "Select overlapping shapes" = "选择重叠的形状" "Select points to edit their position, radius and mirroring" = "选择点以编辑其位置、半径和镜像" "Select slot" = "选择插槽" "Selection" = "所选项" "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框,{w} × {h},位于 {x}, {y}:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消" "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消" +"Selection colors" = "所选项颜色" "Send backward" = "下移一层" "Send report" = "发送举报" "Send to back" = "置于底层" @@ -1927,6 +1931,10 @@ status = "draft — needs native review" "select.sameStroke" = "选择相同描边" "select.sameText" = "选择相同文本属性" "select.set" = "选择" +"selection.applyColorStyle" = "将颜色样式应用于所选项" +"selection.colors" = "所选项颜色" +"selection.selectColor" = "选择使用此颜色的图层" +"selection.setPaint" = "更改所选项颜色" "shape.arrow" = "箭头" "shape.ellipse" = "椭圆" "shape.line" = "直线" From 8aa5fd1a3b096ce89e4b191b20981f4f8c1b9189 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:10:32 +0700 Subject: [PATCH 159/245] #595: drop the progress-doc edits --- docs/target-app-parity.md | 3 +-- docs/ui-parity.md | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b599bb30..c3842d4f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Smart-selection spacing fields and on-canvas gap handles (#402); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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 @@ -630,7 +630,6 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th |---|---|---| | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | -| 2026-10-15 | minor | Smart-selection spacing (#402): Layout fields and pink on-canvas handles adjust horizontal/vertical gaps for movable sibling rows, columns and aligned grids through `arrange.spacing`; mixed gaps normalize, track contents and sizes stay intact, one Undo step per scrub/drag, Escape cancels. Auto-layout flow children and locked/instance-internal layers are excluded. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 116dcbb6..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Smart-selection spacing fields and on-canvas gap handles (#402); fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -65,7 +65,6 @@ interaction feel not yet compared. | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | | 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; the pixel grid shows from 400% (`view_behaviour_tests`) | -| Smart-selection spacing: numeric fields and canvas handles | done | Layout shows Horizontal/Vertical spacing for movable sibling rows, columns and aligned grids (up to 2,000 layers). Gaps are between track bounds: moving a column/row preserves its internal offsets and sizes. Mixed gaps can be normalized by typing or dragging a handle. Panel, pointer input and drawing share a cached layout; stationary or sub-step drags do not dispatch another edit. Pink gap handles show on hover, report live distances, respect zoom and commit one Undo step; Shift rounds to 10, Alt to 0.1, Escape cancels, release outside the canvas finishes. Parent/child or different-parent selections, auto-layout flow children, locked/hidden layers and instance internals have no spacing controls. This adds gap editing only, not the smart-selection reorder rings (`selection_spacing` engine/UI tests). | | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | 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 | @@ -167,7 +166,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-15 | minor | Smart-selection spacing (#402): Layout fields and pink on-canvas handles adjust horizontal/vertical gaps for movable sibling rows, columns and aligned grids through `arrange.spacing`; mixed gaps normalize, track contents and sizes stay intact, one Undo step per scrub/drag, Escape cancels. Auto-layout flow children and locked/instance-internal layers are excluded. Numbers unchanged. | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From 6b52bddc5b4624fc494907ad91e4c8216277cbe8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:10:33 +0700 Subject: [PATCH 160/245] #595: arrange.spacing label in the Swedish catalog --- crates/ui-egui/locales/sv.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 51f16508..e173bd75 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1707,6 +1707,7 @@ name = "Svenska" "arrange.packH" = "Packa horisontellt" "arrange.packV" = "Packa vertikalt" "arrange.roundToPixels" = "Avrunda till pixlar" +"arrange.spacing" = "Avstånd" "arrange.tidy" = "Snygga till" "bool.exclude" = "Uteslut markering" "bool.flatten" = "Platta till boolesk form" From cc10c0de557f6306611c5c671a6fc2c0e18bb43a Mon Sep 17 00:00:00 2001 From: Ivan Shishkanov Date: Sat, 17 Oct 2026 16:13:47 +0300 Subject: [PATCH 161/245] M7: use native macOS paste modifiers in tests --- crates/ui-egui/src/paste_image_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 48bd142e..b6775f93 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -163,7 +163,8 @@ impl Win { } } -const CMD: Modifiers = Modifiers::COMMAND.plus(Modifiers::CTRL); +// egui sets `command` for Cmd on macOS and for Ctrl elsewhere; macOS Ctrl is a separate key. +const CMD: Modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } From 88925c2f820eb176a03df2245c2a82502da41e09 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:17:02 +0700 Subject: [PATCH 162/245] #537: drop the progress-doc edits --- ROADMAP.md | 5 +---- docs/gaps.md | 15 +-------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 82b6c4c0..2dcc2b9b 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 (responsive presenter device sizing, bare tablet and computer screens; presenter tab, device picker and thumbnail; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -318,9 +318,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | -| 2026-10-16 | minor | The full presenter now scrolls content beyond a responsive phone or tablet screen in both directions. A drag that starts inside the screen continues when the pointer leaves it; the device body stays fixed. Computer presets keep their ordinary scrolling rules. The unsaved-changes dialog appears over presentation when closing a window. The saved design is unchanged. Numbers unchanged. | -| 2026-10-16 | minor | Choosing a device in the presenter now enables responsive layout at that device's screen size, clears offsets from the previous screen, and lets No device restore the design's frame size. Tablet and computer presets show a bare screen at their resolution; phones keep their device body. The saved frame geometry is unchanged. Numbers unchanged. | -| 2026-10-15 | minor | Presenter has a separate tab, keeps its current screen when returning to design, and adds a frame thumbnail, device picker and scale controls. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 94c387e5..e8b111a8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (responsive presenter device sizing and bare tablet and computer screens; presenter navigation and device controls; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -138,17 +138,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, `theme_pages_tests`). -- **Landed (2026-10-15):** presentation keeps its own tab beside the design, preserves the - current screen when switching back to edit, shows a small current-screen preview and offers - screen, device and scale controls (`present_view_tests`). -- **Landed (2026-10-16):** choosing a presenter device reflows a copy of the frame to its screen - size using constraints and auto layout, clears the previous screen's scroll and pan, and keeps - the file's frame geometry unchanged; No device restores the original size. Tablet and computer - presets show the screen at their resolution without a drawn hardware body. In the full presenter, - dragging inside a phone or tablet scrolls content in either direction when it exceeds the screen, - even when the pointer moves outside the device. The device remains fixed; computer presets keep - their ordinary scrolling rules. These gestures do not alter the file. Closing a window from - presentation shows the unsaved-changes dialog over the presenter. - **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). @@ -411,8 +400,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | Gap 4: presenter device selection now uses responsive layout at the selected screen size and resets stale offsets; tablets and computers show bare screens, and the original frame is preserved. Estimates and ranking unchanged. | -| 2026-10-15 | minor | Gap 4: presenter tab, thumbnail, device picker and scale controls. Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | From e77a02993ed4d2d89d90c72e945bf368d1d386f4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:17:04 +0700 Subject: [PATCH 163/245] #537: remove the two screenshots nothing references --- ATTRIBUTION.md | 2 -- docs/images/shot-8-presenter-tab.png | Bin 75341 -> 0 bytes docs/images/shot-9-device-picker.png | Bin 106019 -> 0 bytes 3 files changed, 2 deletions(-) delete mode 100644 docs/images/shot-8-presenter-tab.png delete mode 100644 docs/images/shot-9-device-picker.png diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cf2e7e63..1f72aeb7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -14,8 +14,6 @@ MIT OR Apache-2.0. No third-party icon sets are used. | `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) | | `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only | | `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork | -| `docs/images/shot-8-presenter-tab.png` | @YarilaFox | Original screenshot of FigCraft presenting a synthetic document made in FigCraft | MIT OR Apache-2.0 | Presenter tab and top bar; no third-party artwork | -| `docs/images/shot-9-device-picker.png` | @YarilaFox | Original screenshot of FigCraft presenting a synthetic document made in FigCraft | MIT OR Apache-2.0 | Device and scale controls; no third-party artwork | | `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork | ## Fonts (build input, not in this repository) diff --git a/docs/images/shot-8-presenter-tab.png b/docs/images/shot-8-presenter-tab.png deleted file mode 100644 index d5cee8e179bebf78e69afdb7311d460c4a5a072c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75341 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 E02Al^oB#j- diff --git a/docs/images/shot-9-device-picker.png b/docs/images/shot-9-device-picker.png deleted file mode 100644 index 2f4805154b9cef0c9e122acc1c8c7ed484c1d72e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106019 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 ILUZqjTh)0r*Z=?k From 725e2c6913aeeab509507de738b9156278cbc7b8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:17:14 +0700 Subject: [PATCH 164/245] #537: presenter strings in every catalog --- crates/ui-egui/locales/cs.toml | 4 ++++ crates/ui-egui/locales/de.toml | 4 ++++ crates/ui-egui/locales/fr.toml | 4 ++++ crates/ui-egui/locales/id.toml | 4 ++++ crates/ui-egui/locales/ja.toml | 4 ++++ crates/ui-egui/locales/ko.toml | 4 ++++ crates/ui-egui/locales/pt-BR.toml | 4 ++++ crates/ui-egui/locales/ru.toml | 4 ++++ crates/ui-egui/locales/sv.toml | 4 ++++ crates/ui-egui/locales/tr.toml | 4 ++++ crates/ui-egui/locales/zh-Hans.toml | 4 ++++ 11 files changed, 44 insertions(+) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 2459d104..24a1f8d5 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -333,6 +333,7 @@ status = "draft — needs native review" "Current version" = "Aktuální verze" "Cursor chat" = "Chat u kurzoru" "Custom" = "Vlastní" +"Custom size" = "Vlastní velikost" "Cut" = "Vyjmout" "Dark" = "Tmavý" "Darken" = "Ztmavit" @@ -795,6 +796,7 @@ status = "draft — needs native review" "Nested instance" = "Vnořená instance" "New design file" = "Nový soubor návrhu" "Newest first" = "Od nejnovějších" +"Next frame" = "Další rámec" "Next or previous control in the focused region" = "Další nebo předchozí ovládací prvek v zaměřené oblasti" "Next ready for dev" = "Další připravené pro vývoj" "Next size option" = "Další možnost velikosti" @@ -811,6 +813,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}." "No conflicts." = "Žádné konflikty." "No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích." +"No device" = "Bez zařízení" "No fonts match" = "Žádná odpovídající písma" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Kód pro připojení není dostupný (server kódy nepodporuje nebo tuto adresu nelze do kódu vložit): sdílejte odkaz." "No layer changes to show." = "Žádné změny vrstev k zobrazení." @@ -994,6 +997,7 @@ status = "draft — needs native review" "Preview" = "Náhled" "Preview this flow" = "Náhled tohoto toku" "Preview this frame" = "Náhled tohoto rámce" +"Previous frame" = "Předchozí rámec" "Previous ready for dev" = "Předchozí připravené pro vývoj" "Progressive" = "Postupné" "Properties" = "Vlastnosti" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b679feb7..51a480d4 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -329,6 +329,7 @@ status = "draft — needs native review" "Current version" = "Aktuelle Version" "Cursor chat" = "Cursor-Chat" "Custom" = "Benutzerdefiniert" +"Custom size" = "Benutzerdefinierte Größe" "Cut" = "Ausschneiden" "Dark" = "Dunkel" "Darken" = "Abdunkeln" @@ -768,6 +769,7 @@ status = "draft — needs native review" "Navigate to" = "Navigieren zu" "Nested instance" = "Verschachtelte Instanz" "New design file" = "Neue Designdatei" +"Next frame" = "Nächster Rahmen" "Next or previous control in the focused region" = "Nächstes oder vorheriges Steuerelement im fokussierten Bereich" "Next ready for dev" = "Nächstes bereit für Entwicklung" "Next size option" = "Nächste Größenoption" @@ -784,6 +786,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Noch keine Komponenten. Wählen Sie Ebenen aus und drücken Sie {keys}." "No conflicts." = "Keine Konflikte." "No contributor data was built into this copy." = "Diese Kopie enthält keine Daten zu Mitwirkenden." +"No device" = "Kein Gerät" "No fonts match" = "Keine passenden Schriften" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier gibt es keinen Beitrittscode (der Server ist älter als Beitrittscodes, oder diese Adresse passt in keinen): Teilen Sie stattdessen den Link." "No layer changes to show." = "Keine Ebenenänderungen vorhanden." @@ -960,6 +963,7 @@ status = "draft — needs native review" "Preview" = "Vorschau" "Preview this flow" = "Vorschau dieses Ablaufs" "Preview this frame" = "Vorschau dieses Rahmens" +"Previous frame" = "Vorheriger Rahmen" "Previous ready for dev" = "Vorheriges bereit für Entwicklung" "Progressive" = "Progressiv" "Properties" = "Eigenschaften" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1c561f15..60985e5e 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -328,6 +328,7 @@ status = "draft — needs native review" "Current version" = "Version actuelle" "Cursor chat" = "Chat au curseur" "Custom" = "Personnalisé" +"Custom size" = "Taille personnalisée" "Cut" = "Couper" "Dark" = "Sombre" "Darken" = "Assombrir" @@ -764,6 +765,7 @@ status = "draft — needs native review" "Navigate to" = "Naviguer vers" "Nested instance" = "Instance imbriquée" "New design file" = "Nouveau fichier de design" +"Next frame" = "Cadre suivant" "Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active" "Next ready for dev" = "Prêt pour le dev suivant" "Next size option" = "Option de taille suivante" @@ -780,6 +782,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Aucun composant pour l’instant. Sélectionnez des calques et appuyez sur {keys}." "No conflicts." = "Aucun conflit." "No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs." +"No device" = "Aucun appareil" "No fonts match" = "Aucune police ne correspond" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien." "No layer changes to show." = "Aucune modification de calque à afficher." @@ -954,6 +957,7 @@ status = "draft — needs native review" "Preview" = "Aperçu" "Preview this flow" = "Prévisualiser ce flux" "Preview this frame" = "Prévisualiser ce cadre" +"Previous frame" = "Cadre précédent" "Previous ready for dev" = "Prêt pour le dev précédent" "Progressive" = "Progressif" "Properties" = "Propriétés" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 7a0a3c3c..52a2bda6 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -337,6 +337,7 @@ status = "draft — needs native review" "Current version" = "Versi saat ini" "Cursor chat" = "Chat kursor" "Custom" = "Kustom" +"Custom size" = "Ukuran kustom" "Cut" = "Potong" "Dark" = "Gelap" "Darken" = "Gelapkan" @@ -799,6 +800,7 @@ status = "draft — needs native review" "Nested instance" = "Instance bersarang" "New design file" = "File desain baru" "Newest first" = "Terbaru dulu" +"Next frame" = "Frame berikutnya" "Next or previous control in the focused region" = "Kontrol berikutnya atau sebelumnya di wilayah yang difokuskan" "Next ready for dev" = "Siap dev berikutnya" "Next size option" = "Opsi ukuran berikutnya" @@ -815,6 +817,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}." "No conflicts." = "Tidak ada konflik." "No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini." +"No device" = "Tanpa perangkat" "No fonts match" = "Tidak ada font yang cocok" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Tidak ada kode gabung di sini (versi server lebih lama dari fitur kode gabung, atau alamat ini tidak dapat dimasukkan ke kode): bagikan link-nya saja." "No layer changes to show." = "Tidak ada perubahan layer untuk ditampilkan." @@ -998,6 +1001,7 @@ status = "draft — needs native review" "Preview" = "Pratinjau" "Preview this flow" = "Pratinjau alur ini" "Preview this frame" = "Pratinjau frame ini" +"Previous frame" = "Frame sebelumnya" "Previous ready for dev" = "Versi siap dev sebelumnya" "Progressive" = "Progresif" "Properties" = "Properti" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b09db3bd..4e6a532e 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -340,6 +340,7 @@ status = "draft — needs native review" "Current version" = "現在のバージョン" "Cursor chat" = "カーソルチャット" "Custom" = "カスタム" +"Custom size" = "カスタムサイズ" "Cut" = "カット" "Dark" = "ダーク" "Darken" = "比較(暗)" @@ -804,6 +805,7 @@ status = "draft — needs native review" "Nested instance" = "ネストされたインスタンス" "New design file" = "新規デザインファイル" "Newest first" = "新しい順" +"Next frame" = "次のフレーム" "Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール" "Next ready for dev" = "次の開発準備完了" "Next size option" = "次のサイズオプション" @@ -820,6 +822,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "コンポーネントはまだない。レイヤーを選択して {keys} を押す。" "No conflicts." = "競合なし。" "No contributor data was built into this copy." = "このコピーには貢献者データが含まれていない。" +"No device" = "デバイスなし" "No fonts match" = "一致するフォントなし" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "参加コードなし(サーバーが参加コードより古いか、このアドレスはコードにできない):代わりにリンクを共有すること。" "No layer changes to show." = "表示するレイヤーの変更なし。" @@ -1003,6 +1006,7 @@ status = "draft — needs native review" "Preview" = "プレビュー" "Preview this flow" = "このフローをプレビュー" "Preview this frame" = "このフレームをプレビュー" +"Previous frame" = "前のフレーム" "Previous ready for dev" = "前の開発準備完了" "Progressive" = "プログレッシブ" "Properties" = "プロパティ" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index f2cfcfff..f999f9ad 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -328,6 +328,7 @@ status = "draft — needs native review" "Current version" = "현재 버전" "Cursor chat" = "커서 채팅" "Custom" = "사용자 지정" +"Custom size" = "사용자 지정 크기" "Cut" = "잘라내기" "Dark" = "다크" "Darken" = "어둡게 하기" @@ -764,6 +765,7 @@ status = "draft — needs native review" "Navigate to" = "이동 대상" "Nested instance" = "중첩된 인스턴스" "New design file" = "새 디자인 파일" +"Next frame" = "다음 프레임" "Next or previous control in the focused region" = "포커스된 영역의 다음 또는 이전 컨트롤" "Next ready for dev" = "다음 개발 준비 완료" "Next size option" = "다음 크기 옵션" @@ -780,6 +782,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "아직 컴포넌트가 없음. 레이어를 선택하고 {keys}를 누르세요." "No conflicts." = "충돌 없음." "No contributor data was built into this copy." = "이 사본에는 기여자 데이터가 포함되지 않음." +"No device" = "기기 없음" "No fonts match" = "일치하는 글꼴 없음" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "참여 코드 없음(서버가 참여 코드 이전 버전이거나 이 주소를 코드에 담을 수 없음): 대신 링크를 공유하세요." "No layer changes to show." = "표시할 레이어 변경 사항 없음." @@ -954,6 +957,7 @@ status = "draft — needs native review" "Preview" = "미리 보기" "Preview this flow" = "이 플로우 미리 보기" "Preview this frame" = "이 프레임 미리 보기" +"Previous frame" = "이전 프레임" "Previous ready for dev" = "이전 개발 준비 완료" "Progressive" = "점진적" "Properties" = "속성" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index e9e1300c..ea443327 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -327,6 +327,7 @@ status = "draft — needs native review" "Current version" = "Versão atual" "Cursor chat" = "Chat do cursor" "Custom" = "Personalizado" +"Custom size" = "Tamanho personalizado" "Cut" = "Recortar" "Dark" = "Escuro" "Darken" = "Escurecer" @@ -763,6 +764,7 @@ status = "draft — needs native review" "Navigate to" = "Navegar para" "Nested instance" = "Instância aninhada" "New design file" = "Novo arquivo de design" +"Next frame" = "Próximo quadro" "Next or previous control in the focused region" = "Controle seguinte ou anterior na região em foco" "Next ready for dev" = "Próximo pronto para dev" "Next size option" = "Próxima opção de tamanho" @@ -779,6 +781,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Ainda não há componentes. Selecione camadas e pressione {keys}." "No conflicts." = "Nenhum conflito." "No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores." +"No device" = "Nenhum dispositivo" "No fonts match" = "Nenhuma fonte corresponde" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Não há código de acesso aqui (o servidor é anterior aos códigos de acesso, ou este endereço não cabe em um): compartilhe o link." "No layer changes to show." = "Nenhuma alteração de camada para mostrar." @@ -953,6 +956,7 @@ status = "draft — needs native review" "Preview" = "Visualização" "Preview this flow" = "Visualizar este fluxo" "Preview this frame" = "Visualizar este quadro" +"Previous frame" = "Quadro anterior" "Previous ready for dev" = "Anterior pronto para dev" "Progressive" = "Progressivo" "Properties" = "Propriedades" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 2023b692..e02e12a1 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -334,6 +334,7 @@ name = "Русский" "Current version" = "Текущая версия" "Cursor chat" = "Чат у курсора" "Custom" = "Пользовательский" +"Custom size" = "Пользовательский размер" "Cut" = "Вырезать" "Dark" = "Тёмная" "Darken" = "Затемнение" @@ -796,6 +797,7 @@ name = "Русский" "Nested instance" = "Вложенный экземпляр" "New design file" = "Новый файл дизайна" "Newest first" = "Сначала новые" +"Next frame" = "Следующий Frame" "Next or previous control in the focused region" = "Следующий или предыдущий элемент в активной области" "Next ready for dev" = "Следующий готовый к разработке" "Next size option" = "Следующий вариант размера" @@ -812,6 +814,7 @@ name = "Русский" "No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}." "No conflicts." = "Конфликтов нет." "No contributor data was built into this copy." = "В этой сборке нет данных об участниках." +"No device" = "Без устройства" "No fonts match" = "Подходящих шрифтов нет" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой." "No layer changes to show." = "Нет изменений слоёв для показа." @@ -996,6 +999,7 @@ name = "Русский" "Preview" = "Предпросмотр" "Preview this flow" = "Предпросмотр этого сценария" "Preview this frame" = "Предпросмотр этого Frame" +"Previous frame" = "Предыдущий Frame" "Previous ready for dev" = "Предыдущий готовый к разработке" "Progressive" = "Прогрессивный" "Properties" = "Свойства" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 51f16508..472b83b1 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -339,6 +339,7 @@ name = "Svenska" "Current version" = "Aktuell version" "Cursor chat" = "Markörchatt" "Custom" = "Anpassad" +"Custom size" = "Anpassad storlek" "Cut" = "Klipp ut" "Dark" = "Mörk" "Darken" = "Mörkare" @@ -806,6 +807,7 @@ name = "Svenska" "Nested instance" = "Kapslad instans" "New design file" = "Ny designfil" "Newest first" = "Nyaste först" +"Next frame" = "Nästa ram" "Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen" "Next ready for dev" = "Nästa redo för utveckling" "Next size option" = "Nästa storleksalternativ" @@ -822,6 +824,7 @@ name = "Svenska" "No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}." "No conflicts." = "Inga konflikter." "No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian." +"No device" = "Ingen enhet" "No fonts match" = "Inga typsnitt matchar" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Ingen anslutningskod här (servern är äldre än anslutningskoder, eller så kan den här adressen inte göras om till en): dela länken i stället." "No layer changes to show." = "Inga lagerändringar att visa." @@ -1007,6 +1010,7 @@ name = "Svenska" "Preview" = "Förhandsgranskning" "Preview this flow" = "Förhandsgranska det här flödet" "Preview this frame" = "Förhandsgranska den här ramen" +"Previous frame" = "Föregående ram" "Previous ready for dev" = "Föregående redo för utveckling" "Progressive" = "Progressiv" "Properties" = "Egenskaper" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index cc002654..4de75c8a 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -337,6 +337,7 @@ name = "Türkçe" "Current version" = "Geçerli sürüm" "Cursor chat" = "İmleç sohbeti" "Custom" = "Özel" +"Custom size" = "Özel boyut" "Cut" = "Kes" "Dark" = "Koyu" "Darken" = "Koyulaştır" @@ -799,6 +800,7 @@ name = "Türkçe" "Nested instance" = "İç içe örnek" "New design file" = "Yeni tasarım dosyası" "Newest first" = "Önce en yeni" +"Next frame" = "Sonraki çerçeve" "Next or previous control in the focused region" = "Odaklanılan bölgedeki sonraki veya önceki denetim" "Next ready for dev" = "Sonraki geliştirmeye hazır" "Next size option" = "Sonraki boyut seçeneği" @@ -815,6 +817,7 @@ name = "Türkçe" "No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın." "No conflicts." = "Çakışma yok." "No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş." +"No device" = "Cihaz yok" "No fonts match" = "Eşleşen yazı tipi yok" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Burada katılma kodu yok (sunucu katılma kodlarından eski veya bu adres bir koda sığmıyor): bunun yerine bağlantıyı paylaşın." "No layer changes to show." = "Gösterilecek katman değişikliği yok." @@ -998,6 +1001,7 @@ name = "Türkçe" "Preview" = "Önizleme" "Preview this flow" = "Bu akışı önizle" "Preview this frame" = "Bu çerçeveyi önizle" +"Previous frame" = "Önceki çerçeve" "Previous ready for dev" = "Önceki geliştirmeye hazır" "Progressive" = "Aşamalı" "Properties" = "Özellikler" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 702cae7b..8ea0d6a9 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -329,6 +329,7 @@ status = "draft — needs native review" "Current version" = "当前版本" "Cursor chat" = "光标聊天" "Custom" = "自定义" +"Custom size" = "自定义大小" "Cut" = "剪切" "Dark" = "深色" "Darken" = "变暗" @@ -765,6 +766,7 @@ status = "draft — needs native review" "Navigate to" = "导航到" "Nested instance" = "嵌套实例" "New design file" = "新建设计文件" +"Next frame" = "下一个画框" "Next or previous control in the focused region" = "聚焦区域中的下一个或上一个控件" "Next ready for dev" = "下一个可供开发" "Next size option" = "下一个尺寸选项" @@ -781,6 +783,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "还没有组件。选择图层并按 {keys}。" "No conflicts." = "没有冲突。" "No contributor data was built into this copy." = "此副本未内置贡献者数据。" +"No device" = "无设备" "No fonts match" = "没有匹配的字体" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "此处没有加入代码(服务器版本早于加入代码,或此地址无法写入代码):请改为分享链接。" "No layer changes to show." = "没有可显示的图层更改。" @@ -955,6 +958,7 @@ status = "draft — needs native review" "Preview" = "预览" "Preview this flow" = "预览此流程" "Preview this frame" = "预览此画框" +"Previous frame" = "上一个画框" "Previous ready for dev" = "上一个可供开发" "Progressive" = "渐进" "Properties" = "属性" From acfc0352c3fde75f2c717a39863a60a2993986fc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:22:44 +0700 Subject: [PATCH 165/245] #537: frames scroll on phones only as their overflow allows; tablets and laptops keep their device body --- crates/ui-egui/src/present.rs | 109 +++++++++++++++------------------- 1 file changed, 48 insertions(+), 61 deletions(-) diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 5a9d4bf3..863b540f 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -541,13 +541,7 @@ impl Presenter { /// device's screen, other frames within themselves. fn range(&self, doc: &Document, frame: NodeId) -> (f64, f64) { let vp = if frame == self.current { viewport(doc, frame) } else { None }; - // In a responsive preview, make content outside the device reachable even if the source - // frame has no overflow mode. Its settings and geometry stay untouched in the file. - let mobile = frame == self.current && page_settings(doc, frame).device().is_some_and(|d| d.rotates); - let overflow = doc - .node(frame) - .map_or(Overflow::None, |n| if mobile && self.fit.is_some() && n.overflow == Overflow::None { Overflow::Both } else { n.overflow }); - scroll_range_with_overflow(doc, frame, vp, overflow) + scroll_range_in(doc, frame, vp) } /// What "Scroll to" `dest` moves: every scrolling frame around it, innermost first, brings the @@ -1177,13 +1171,8 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { /// frame taller than the screen scrolls through its whole height. Without a viewport the frame /// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { - let overflow = doc.node(frame).map_or(Overflow::None, |n| n.overflow); - scroll_range_with_overflow(doc, frame, viewport, overflow) -} - -fn scroll_range_with_overflow(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>, overflow: Overflow) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; - if overflow == Overflow::None { + if n.overflow == Overflow::None { return (0.0, 0.0); } let (mut x1, mut y1) = (0.0f64, 0.0f64); @@ -1208,8 +1197,8 @@ fn scroll_range_with_overflow(doc: &Document, frame: NodeId, viewport: Option<(f } None => (n.width, n.height), }; - let rx = if matches!(overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; - let ry = if matches!(overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; + let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; + let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } @@ -1546,11 +1535,6 @@ fn body_outset(device: &str, size: Vec2, landscape: bool) -> (f32, f32, f32) { } } -/// Tablet and computer presets size the screen without drawing a hardware shell around it. -fn has_device_body(device: Device) -> bool { - !device.name.starts_with("iPad") && !matches!(device.name, "MacBook Air" | "Desktop") -} - /// Place the presented frame in `area`: on its device's screen (the whole device sized by the /// view's scale option), else the frame itself. Fit and 100% keep the chrome's padding from the /// edges; Fit never goes above 100%. What doesn't fit the window pans (`Presenter::pan`). The @@ -1566,7 +1550,7 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { let fb = disp.world_bounds(p.current)?; let view = if chrome.pane { PresentView::default() } else { p.view }; // Custom sizes are just a screen: no body. - let body = device.filter(|d| view.device_frame && has_device_body(*d) && !d.colors.is_empty()); + let body = device.filter(|d| view.device_frame && !d.colors.is_empty()); let pad = if !fills && matches!(view.scale, PresentScale::Fit | PresentScale::Actual) { chrome.pad } else { 0.0 }; let avail = area.shrink(pad); let (sw, sh) = device.map_or((fb.width(), fb.height()), |d| (d.width, d.height)); @@ -2357,7 +2341,7 @@ fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option, responsi } ui.separator(); } - if device.is_some_and(|d| has_device_body(d) && !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, tr("Show device frame"), "") { + if device.is_some_and(|d| !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, tr("Show device frame"), "") { p.view.device_frame = !p.view.device_frame; ui.close(); } @@ -2378,7 +2362,7 @@ fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, p let mut out: Vec = disp .subtree(root) .into_iter() - .filter(|f| disp.node(*f).is_some_and(|n| n.visible)) + .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) .filter(|f| p.range(base, *f) != (0.0, 0.0)) .collect(); @@ -2904,19 +2888,16 @@ mod tests { } #[test] - fn tablet_and_computer_presets_show_only_their_screen_at_the_real_resolution() { + fn device_presets_show_their_screen_at_the_real_resolution_in_their_body() { let mut session = figcraft_engine::Session::new(); let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); let mut ui = Ui::new(session); ui.run("proto.present", json!({"start": frame})); let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 860.0)); - for (name, width, height, body) in [ - ("iPad mini", 744.0, 1133.0, false), - ("MacBook Air", 1280.0, 832.0, false), - ("Desktop", 1440.0, 1024.0, false), - ("iPhone 17", 402.0, 874.0, true), - ] { + for (name, width, height) in + [("iPad mini", 744.0, 1133.0), ("MacBook Air", 1280.0, 832.0), ("Desktop", 1440.0, 1024.0), ("iPhone 17", 402.0, 874.0)] + { let mut presenter = ui.app.ui.present.take().unwrap(); choose_device(&mut ui.app, &mut presenter, name); ui.app.ui.present = Some(presenter); @@ -2924,38 +2905,52 @@ mod tests { let presenter = ui.app.ui.present.as_mut().unwrap(); let laid = layout(presenter, area, FULL).unwrap(); - assert_eq!(laid.color.is_some(), body, "{name}: body visibility"); + assert!(laid.color.is_some(), "{name}: drawn in its body"); assert!((f64::from(laid.screen.width()) / laid.scale - width).abs() < 0.1, "{name}: width"); assert!((f64::from(laid.screen.height()) / laid.scale - height).abs() < 0.1, "{name}: height"); assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); } } + /// A phone shows a frame's content past its screen only as far as the frame's overflow lets it + /// scroll: with "No scrolling" it's clipped, like on every other device. #[test] - fn responsive_device_scrolls_content_in_both_directions_without_changing_saved_overflow() { - let mut session = figcraft_engine::Session::new(); - let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); - session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap(); - let mut ui = Ui::new(session); - ui.run("proto.present", json!({"start": frame})); - let mut presenter = ui.app.ui.present.take().unwrap(); - choose_device(&mut ui.app, &mut presenter, "iPhone 17"); - ui.app.ui.present = Some(presenter); - ui.frame(vec![]); - - let range = &ui.app.inspect()["presenter"]["scrollRange"]; - assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}"); + fn a_phone_scrolls_only_what_the_frames_overflow_allows() { + let on_phone = |overflow: &str| { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap(); + session.execute("node.set", &json!({"id": frame, "props": {"overflow": overflow}})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + (ui, frame) + }; let start = pos2(720.0, 440.0); - ui.button(start, true); - ui.frame(vec![egui::Event::PointerMoved(start - vec2(100.0, 120.0))]); - ui.button(start - vec2(100.0, 120.0), false); - let scroll = &ui.app.inspect()["presenter"]["scroll"]; - assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}"); - assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().overflow, Overflow::None); + let drag = |ui: &mut Ui, to: Pos2| { + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(to)]); + ui.button(to, false); + ui.app.inspect()["presenter"].clone() + }; + + let (mut ui, frame) = on_phone("none"); + assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); + assert_eq!(drag(&mut ui, start - vec2(100.0, 120.0))["scroll"], json!([0.0, 0.0]), "no scrolling stays clipped"); assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); - // Like the phone preview, the content keeps following a drag after the pointer leaves - // the screen, while the device itself stays fixed on the stage. + let (mut ui, frame) = on_phone("both"); + let range = &ui.app.inspect()["presenter"]["scrollRange"]; + assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}"); + let scroll = drag(&mut ui, start - vec2(100.0, 120.0))["scroll"].clone(); + assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + + // The content keeps following a drag after the pointer leaves the screen, while the + // device itself stays fixed on the stage. ui.frames(60, Vec::new); let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; @@ -2963,16 +2958,8 @@ mod tests { ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); let during = ui.app.inspect()["presenter"].clone(); assert!(during["scroll"][0].as_f64().unwrap() > 0.0, "{during}"); - let screen_after = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; - assert_eq!(screen_after, screen_before); + assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); ui.button(pos2(300.0, 300.0), false); - - let mut presenter = ui.app.ui.present.take().unwrap(); - choose_device(&mut ui.app, &mut presenter, "Desktop"); - ui.app.ui.present = Some(presenter); - ui.frame(vec![]); - let desktop = ui.app.inspect()["presenter"].clone(); - assert_eq!(desktop["scrollRange"], json!([0.0, 0.0])); } fn doc_with_two_frames() -> (Document, NodeId, NodeId) { From 420ef78842d27e6843beefdd77790a569ed4c0ac Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:23:06 +0700 Subject: [PATCH 166/245] #592: drop the README section (the module docs in linux_window.rs cover it) --- README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/README.md b/README.md index 0aa73a0c..a072a06f 100644 --- a/README.md +++ b/README.md @@ -358,16 +358,3 @@ Forks and modified versions must remove them. ## Star history [![Star History Chart](https://api.star-history.com/svg?repos=storytold/figcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Ffigcraft&type=date&legend=top-left) - -## Linux window controls - -On Wayland without the `zxdg_decoration_manager_v1` protocol (including GNOME), -FigCraft enables winit's Adwaita client-side frame with vector minimize, -maximize/restore and close buttons. The tab strip keeps the document names; -the frame's title-font renderer is disabled, so this does not embed Cantarell. -X11 and Wayland compositors advertising server decorations, including KDE and -wlroots, retain FigCraft's existing frameless tab strip and its own controls. - -The actual window backend is checked before requesting the frame. A failed or -slow capability query, or a launch using only an inherited Wayland socket, -keeps the existing custom controls. Desktop names are not used to guess capabilities. From b8f97a5f4f4c354ff218c17782979d0d342f66b3 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 20:29:32 +0700 Subject: [PATCH 167/245] M8: the control channel answers while the window is minimized (#720) (#751) - A minimised or covered window now answers every control request that acts on the app's state (`engine.execute`, `document.inspect`, `node.get`, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `ui.menu.invoke`, `ui.render`, `window.list`, `window.focus`, `app.quit`), in that window's file, from the app's `logic` (no frame drawn). Before, only six methods were answered there. - What only a drawn frame can answer (`ui.screenshot`, `ui.input`, `window.new`) is an error at once for a hidden window: `window 0 is minimised or hidden: `ui.screenshot` needs it on screen (`ui.render` draws the document without it)`. Before, these waited up to 60 s. - `crates/ui-egui/src/control.rs`: the `NO_FRAME` allow-list becomes a short `NEEDS_FRAME` list; `drain_hidden` answers the rest and fails the frame-only ones at once. - Docs: `docs/control-protocol.md` (hidden windows, `ui.screenshot`) and `docs/mcp.md` (attached mode while the window is minimised). ROADMAP progress log and revision history. Part of #720. --- ROADMAP.md | 2 ++ crates/ui-egui/src/control.rs | 25 +++++++++++++++++-------- crates/ui-egui/src/idle_tests.rs | 19 ++++++++++++------- crates/ui-egui/src/windows_tests.rs | 9 +++++---- docs/control-protocol.md | 17 +++++++++++------ docs/mcp.md | 6 ++++++ 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2dcc2b9b..a8ae28ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **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. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. @@ -315,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 45311df2..a66e1ed9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -139,8 +139,10 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { } } -/// Methods that need no frame drawn (#269): a hidden window answers them too. -const NO_FRAME: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "node.get", "window.list", "ui.render"]; +/// Methods only a drawn frame can answer: a capture of the window, raw window input and a new +/// window (the main window's frame opens it). Everything else acts on the app's state, which a +/// hidden window answers without a frame (#269, #720). +const NEEDS_FRAME: &[&str] = &["ui.screenshot", "ui.input", "window.new"]; /// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also /// on another Space). @@ -150,10 +152,11 @@ fn hidden(ctx: &Context, id: u64) -> bool { /// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's /// `logic` still runs, for the main window even while every window is hidden: it answers the -/// requests for any hidden window that need no frame, in that window's file, so an attached agent -/// can edit, inspect and save. The rest (screenshots, input, the interface's own state) wait for the -/// window to show; another window on screen is asked to draw, which the main window's frame would -/// do but doesn't while it is hidden. A request for a window that doesn't exist is an error at once. +/// requests for any hidden window, in that window's file, so an attached agent can edit, inspect, +/// type and save while the app is out of sight. What needs a frame ([`NEEDS_FRAME`]) is an error at +/// once instead of waiting for the window to show. A request for a window on screen waits for its +/// frame; another window on screen is asked to draw, which the main window's frame would do but +/// doesn't while it is hidden. A request for a window that doesn't exist is an error at once. pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { @@ -164,15 +167,21 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); continue; } - if !hidden(ctx, target) || !NO_FRAME.contains(&r.method.as_str()) { + if !hidden(ctx, target) { // Another window on screen draws only when asked: while the main window is hidden no // frame of it does that (`drain`), so ask here. - if target != app.windows.current && !hidden(ctx, target) { + if target != app.windows.current { ctx.request_repaint_of(crate::windows::viewport(target)); } app.control_queue.push(r); continue; } + if NEEDS_FRAME.contains(&r.method.as_str()) { + let error = + format!("window {target} is minimised or hidden: `{}` needs it on screen (`ui.render` draws the document without it)", r.method); + let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) }); + continue; + } // Outside a frame, so outside the frame's guard: within the window's swap, a panic still // ends in recovery copies and leaves every window its own state. let res = crate::windows::as_window(app, target, |app| { diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index b78c7474..6ae19546 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -332,9 +332,10 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { } #[test] -fn a_hidden_window_answers_requests_that_need_no_frame() { - // #269: minimised or covered, the window draws no frames, but the app's logic still runs: it - // answers edits, inspection and saves; what needs a frame waits for the window to show. +fn a_hidden_window_answers_at_once() { + // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: + // it answers edits, inspection, saves and the interface's state; what needs a frame (a capture + // of the window) is an error at once instead of waiting for the window to show. let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("hidden.figcraft"); @@ -350,6 +351,7 @@ fn a_hidden_window_answers_requests_that_need_no_frame() { }; h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}}))); h.app.control_queue.push(request(2, "ui.inspect", json!({}))); + h.app.control_queue.push(request(3, "ui.screenshot", json!({"path": dir.join("shot.png").to_string_lossy()}))); let mut hidden = RawInput::default(); hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); let app = &mut h.app; @@ -357,10 +359,13 @@ fn a_hidden_window_answers_requests_that_need_no_frame() { let saved = rx.try_recv().unwrap(); assert_eq!(saved.id, json!(1)); assert!(saved.result.is_ok() && path.exists(), "saved while hidden: {:?}", saved.result); - assert!(rx.try_recv().is_err(), "the interface's state waits for a frame"); - // Shown again: the next frame answers it. - h.frame(vec![]); - assert_eq!(rx.try_recv().unwrap().id, json!(2)); + let inspected = rx.try_recv().unwrap(); + assert_eq!(inspected.id, json!(2)); + assert!(inspected.result.is_ok(), "the interface's state: {:?}", inspected.result); + let shot = rx.try_recv().unwrap(); + assert_eq!(shot.id, json!(3)); + assert!(shot.result.unwrap_err().contains("minimised or hidden"), "a capture needs the window on screen"); + assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 1a6e91bb..74c596bd 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -116,13 +116,14 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { assert_eq!(inspect.try_recv().expect("answered").result.unwrap()["name"], json!("A")); assert_eq!(app.session.ds().unwrap().doc.name, "B"); - // What needs a frame keeps waiting for the other window; an unknown window is an error at once. - let ui = request(&mut app, "ui.inspect", json!({"window": w})); + // What needs a frame, and an unknown window, are errors at once: nothing waits for a window + // that may stay hidden. + let shot = request(&mut app, "ui.screenshot", json!({"window": w, "path": "unused.png"})); let unknown = request(&mut app, "window.list", json!({"window": 99})); logic(&ctx, &mut app, &[ViewportId::ROOT, other]); - assert!(ui.try_recv().is_err(), "waits for the window to show"); + assert!(shot.try_recv().expect("answered").result.unwrap_err().contains(&format!("window {w} is minimised or hidden"))); assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); - assert_eq!(app.control_queue.len(), 1); + assert!(app.control_queue.is_empty()); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 801bba6b..3a06874e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,11 +37,15 @@ requests still queued after the UI timeout are discarded, including requests def window. A command already running is not interrupted. A minimised or covered window (the main one or another, see Windows below) draws no frames, but -still answers `engine.execute`, `engine.commands`, `document.inspect`, `node.get`, `window.list` -and `ui.render`, acting on the file that window shows, so an attached agent can edit, inspect and -save while the app is out of sight. Screenshots, input (`ui.input`, -`ui.pointer`, `ui.key`, `ui.type`) and the interface's own state wait for the window to show, and -time out after 60 s if it doesn't. +still answers every method that acts on the app's state (`engine.execute`, `document.inspect`, +`node.get`, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `ui.render`, `window.list`, +…), in the file that window shows, so an attached agent can edit, inspect and save while the app is +out of sight. The app is not drawn meanwhile and only wakes for requests, at most ten times a +second while minimised, so a request there takes up to about 0.1 s. What only a drawn frame can +answer (`ui.screenshot`, `ui.input`, `window.new`) is an error at once (`window 0 is minimised or +hidden: …`) instead of waiting for the window to show; `ui.render` draws the document without it. +Under Wayland the app can't tell that its window is minimised: where the compositor then stops +drawing it, a request waits for the window and gets the 60 s timeout error. Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent frame (groups have no coordinate space of their own). @@ -292,7 +296,8 @@ focus view shows, or `null`) and `annotationFilter`). PNG of the whole window (`window` picks one), UI included (needs a visible window; an idle one draws the frame to capture). Returns `{path, width, height}`, without `path` `{format: "png", width, height, data}` -(base64), or an error after 60 s when the window doesn't deliver. +(base64); an error at once when the window is minimised or hidden, or after 60 s when the window +doesn't deliver. ### `ui.render {path?, scale?, ids?}` diff --git a/docs/mcp.md b/docs/mcp.md index 4e6f7bc3..12d1a871 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -26,6 +26,12 @@ If that connection cannot be established, the error says the request was not sen A connection the app closed while it sat idle is noticed before the next request is written and replaced then, so a pause between calls is not an error. +The app keeps answering while its window is minimised or covered by other windows (see +`docs/control-protocol.md`): edits, inspection (`inspect_ui` too), pointer, key and text input and +renders work as usual (minimised, each request to the app takes up to about 0.1 s). Only a capture +of the window itself (`screenshot` with `window`) needs it on screen: it is then an error at once; +`screenshot` without `window` renders the document instead. + ### Confining file access ```sh From c532053396ce7864fa8e5d9856c49244f96d7479 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 20:43:37 +0700 Subject: [PATCH 168/245] #749: clear the shortcuts panel after the home screen draws, so it never shows for a frame --- crates/ui-egui/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6d867abf..9ad7bae6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1414,10 +1414,11 @@ impl FigApp { return; } if !self.has_files() || self.ui.home { - // The shortcuts panel belongs to the canvas; it doesn't hang over the home screen. - self.ui.shortcuts_panel = false; tabs::tab_bar(self, ui); home::show(self, ui); + // The shortcuts panel belongs to the canvas; it doesn't hang over the home screen + // (cleared after the home widgets, which may have just asked for it). + self.ui.shortcuts_panel = false; dialogs::keys(self, &ctx); dialogs::show(self, &ctx); self.toast_ui(&ctx); From 825cb1e7afe46b6791ceb6992d21f13a6dba0096 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 21:49:32 +0700 Subject: [PATCH 169/245] M7: list the fonts a file uses that aren't installed (#752) Text in a family that isn't installed was drawn in a fallback font without a word. Now: - `text.missingFonts` lists the families a document names that no installed or built-in font provides: text layers and their styled ranges, text styles, and string variables bound to a font family (every mode, through aliases), with the number of text layers. - The desktop app shows a Missing fonts window after the user opens a file that has any: once installed fonts are indexed (before that every one of them would count as missing) and after any import report is closed. Files named on the command line count. Programmatic opens and the browser build never open it by themselves. Text > Missing fonts... shows it for the file in front; Escape closes it. - `figcraft-cli info` prints `missing font` lines (`missingFonts` with --json). - file-format-parity: the row claimed REST import warnings that no code produced. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/figcraft-cli/src/files.rs | 6 ++ apps/figcraft-cli/tests/fonts.rs | 22 ++++ apps/figcraft/src/main.rs | 3 + crates/engine/src/catalog.rs | 4 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/fonts.rs | 100 ++++++++++++++++++ crates/engine/src/fonts_tests.rs | 123 ++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/text/src/fontdb.rs | 5 + crates/text/src/lib.rs | 4 +- crates/ui-egui/locales/en.toml | 5 + crates/ui-egui/locales/es.toml | 6 ++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/import_report_tests.rs | 2 +- crates/ui-egui/src/lib.rs | 11 ++ crates/ui-egui/src/missing_fonts.rs | 102 ++++++++++++++++++ crates/ui-egui/src/missing_fonts_tests.rs | 112 ++++++++++++++++++++ docs/cli.md | 4 +- docs/file-format-parity.md | 3 +- docs/mcp.md | 2 + 20 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/fonts.rs create mode 100644 crates/engine/src/fonts_tests.rs create mode 100644 crates/ui-egui/src/missing_fonts.rs create mode 100644 crates/ui-egui/src/missing_fonts_tests.rs diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3e08474a..65b19ee4 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames})) }) .collect(); + let missing = figcraft_engine::fonts::missing(&doc); let v = json!({ "file": path, "name": doc.name, @@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { "variables": doc.variables.variables.len(), "images": doc.images.len(), "notes": notes, + "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), }); if as_json { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult { println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); } } + for m in &missing { + let layers = if m.layers == 1 { "text layer" } else { "text layers" }; + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + } Ok(ExitCode::SUCCESS) } diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index c5901ac5..ef5c638f 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() { assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family"); assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back"); } + +#[test] +fn info_lists_the_fonts_a_file_uses_that_are_missing() { + let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let (script, file) = (d.join("make.json"), d.join("doc.figcraft")); + let text = + |family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}}); + std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap(); + let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli")); + let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let out = cli().arg("info").arg(&file).arg("--json").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}])); + + let out = cli().arg("info").arg(&file).output().unwrap(); + let shown = String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 651cc521..a646c7d4 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -159,6 +159,7 @@ fn main() -> eframe::Result { log::error!("{f}: {e}"); } } + let opened: Vec = session.docs.iter().map(|d| d.uid).collect(); // Documents a crashed or killed run left unsaved come back; this run keeps its own copies. let recovery_root = services::config_dir().map(|d| d.join("Recovery")); // The preferences first: they hold the online files a recovered document may be one of. @@ -192,6 +193,8 @@ fn main() -> eframe::Result { let mut fig = FigApp::new(session, Box::new(svc)); fig.automation_roots = roots; fig.ui.home = false; + // Files named on the command line are the user's too: their missing fonts are listed. + fig.ui.font_checks = opened; fig.note_recovered(recovered.copies); if let Some(link) = &args.join { figcraft_ui_egui::live::open_invite(&mut fig, link); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c8f34fc1..4b6cd3fc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + m!("Text", SEP, ""), + m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align horizontal centers", "align.hcenter"), @@ -287,7 +289,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"]; +const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6579494c..28fcd81a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -79,6 +79,7 @@ fn registry() -> &'static Registry { list.extend(page::specs()); list.extend(view::specs()); list.extend(text::specs()); + list.extend(crate::fonts::specs()); list.extend(styles::specs()); list.extend(image::specs()); list.extend(variables::specs()); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 00000000..e20d4d40 --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,100 @@ +//! Fonts a document names that this machine doesn't have (#752): text layers, their styled +//! ranges, text styles and the string variables bound to a font family. Text in such a family +//! is drawn in a fallback font, so the user is told which ones are missing. + +use std::collections::BTreeMap; + +use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::{Result, Session, spec}; + +/// A family the document names that no installed or built-in font provides. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + /// As the document spells it. + pub family: String, + /// Text layers that use it (0: only a text style or a variable names it). + pub layers: usize, +} + +/// The families `doc` names that the font database doesn't have, by name. On the desktop the +/// installed fonts are indexed in the background: ask once that's done +/// (`figcraft_text::system_fonts_scanned`), or every installed family counts as missing. +pub fn missing(doc: &Document) -> Vec { + // Lower-case family → (spelling, layers). + let mut used: BTreeMap = BTreeMap::new(); + let mut add = |family: &str, layer: bool| { + let family = family.trim(); + if family.is_empty() { + return; + } + let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0)); + e.1 += usize::from(layer); + }; + for n in doc.nodes() { + if let NodeKind::Text(t) = &n.kind { + let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect(); + families.push(&t.style.font_family); + families.sort_by_key(|f| f.trim().to_lowercase()); + families.dedup_by_key(|f| f.trim().to_lowercase()); + for f in families { + add(f, true); + } + } + if let Some(var) = n.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + for st in &doc.styles { + if let StyleKind::Text { style } = &st.kind { + add(&style.font_family, false); + } + if let Some(var) = st.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() +} + +/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +fn string_values(vars: &Variables, id: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![(id.to_string(), 0)]; + while let Some((id, depth)) = todo.pop() { + let Some(var) = vars.get(&id) else { continue }; + for v in var.values.values() { + match v { + VariableValue::String(s) => out.push(s.clone()), + VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + _ => {} + } + } + } + out +} + +fn missing_fonts(s: &mut Session, _: &Value) -> Result { + let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); + s.request_ui("dialog.missingFonts", json!({})); + Ok(json!({"families": families})) +} + +pub fn specs() -> Vec { + vec![spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + )] +} + +#[cfg(test)] +#[path = "fonts_tests.rs"] +mod tests; diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs new file mode 100644 index 00000000..6ba925b3 --- /dev/null +++ b/crates/engine/src/fonts_tests.rs @@ -0,0 +1,123 @@ +use serde_json::json; + +use super::*; +use crate::params::id_of; +use figcraft_doc::{Style, StyleRun, TextStyle}; + +fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId { + let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind { + t.style.font_family = family.into(); + } + id +} + +fn families(s: &mut Session) -> Vec<(String, u64)> { + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect() +} + +/// An installed family (built in, or found by the system scan), if this machine has any. +fn installed() -> Option { + figcraft_text::scan_system_fonts(); + figcraft_text::family_names().into_iter().next() +} + +#[test] +fn lists_families_of_layers_ranges_styles_and_variables_once_each() { + let mut s = Session::new(); + text(&mut s, "FigCraft Missing A"); + let b = text(&mut s, "figcraft missing a"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind { + let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() }; + t.runs.push(StyleRun { start: 0, end: 3, style }); + } + let doc = s.doc_mut().unwrap(); + doc.styles.push(Style { + id: "S:1".into(), + name: "Heading".into(), + description: String::new(), + kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + bound: Default::default(), + }); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap(); + s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap(); + let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone(); + s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var); + + assert_eq!( + families(&mut s), + vec![ + ("FigCraft Missing A".to_string(), 2), + ("FigCraft Missing B".to_string(), 1), + ("FigCraft Missing C".to_string(), 0), + ("FigCraft Missing D".to_string(), 0), + ("FigCraft Missing H".to_string(), 0), + ] + ); + assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts")); +} + +#[test] +fn installed_families_are_not_listed() { + let Some(family) = installed() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + text(&mut s, &family.to_uppercase()); + text(&mut s, "FigCraft Missing E"); + assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]); +} + +#[test] +fn a_family_found_only_by_the_system_scan_is_not_missing() { + figcraft_text::scan_system_fonts(); + let embedded: Vec = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect(); + let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else { + eprintln!("skipped: no system fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + assert!(families(&mut s).is_empty(), "{family} is installed"); +} + +#[test] +fn blank_families_and_aliases_are_handled() { + let mut s = Session::new(); + text(&mut s, " "); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap(); + let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone(); + let t = text(&mut s, "FigCraft Missing G"); + let doc = s.doc_mut().unwrap(); + // A variable that only aliases another, and one aliasing itself. + let mut alias = doc.variables.get(&base).unwrap().clone(); + alias.id = "V:alias".into(); + alias.name = "Alias".into(); + for v in alias.values.values_mut() { + *v = VariableValue::Alias(base.clone()); + } + let mut cycle = alias.clone(); + cycle.id = "V:cycle".into(); + cycle.name = "Cycle".into(); + for v in cycle.values.values_mut() { + *v = VariableValue::Alias("V:cycle".into()); + } + doc.variables.variables.push(alias); + doc.variables.variables.push(cycle); + doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); + let other = text(&mut s, "FigCraft Missing G"); + s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); + // The bound layer takes the value its alias resolves to; the cycle resolves to nothing. + assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); +} + +#[test] +fn needs_an_open_document() { + let mut s = Session::empty(); + assert!(s.execute("text.missingFonts", &json!({})).is_err()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8796dc9c..8d6f8f0b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,6 +28,7 @@ pub mod devinspect; pub mod distance; pub mod drop; pub mod file_access; +pub mod fonts; pub mod guard; pub mod guides; pub mod inspect; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d5ff4190..54adc282 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -835,6 +835,11 @@ pub fn family_names() -> Vec { db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families() } +/// The global db has `family` (embedded, loaded or indexed; any case). +pub fn has_family(family: &str) -> bool { + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).has_family(family) +} + /// Style names of a family in the global db. pub fn family_styles(family: &str) -> Vec { db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 27456d82..721471ad 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,8 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, - scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, + no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 9ce38a22..b387db0d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -91,6 +91,7 @@ name = "English" "All components" = "All components" "All fonts" = "All fonts" "All supported properties" = "All supported properties" +"All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" @@ -769,6 +770,7 @@ name = "English" "Mirror angle and length" = "Mirror angle and length" "Mirroring" = "Mirroring" "Missing" = "Missing" +"Missing fonts" = "Missing fonts" "Missing layer" = "Missing layer" "Missing style" = "Missing style" "Missing variable" = "Missing variable" @@ -1366,6 +1368,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." @@ -1562,6 +1565,7 @@ name = "English" "no bleed" = "no bleed" "nobody else here yet" = "nobody else here yet" "now" = "now" +"only in a text style or variable" = "only in a text style or variable" "or" = "or" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" "prototype actions of unknown types were skipped" = "prototype actions of unknown types were skipped" @@ -1662,6 +1666,7 @@ name = "English" "{n} replies" = { one = "{n} reply", other = "{n} replies" } "{n} rows" = { one = "{n} row", other = "{n} rows" } "{n} styles" = { one = "{n} style", other = "{n} styles" } +"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } "{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 2849f4ec..3e70bc83 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -93,6 +93,7 @@ status = "draft — needs native review" "All comments" = "Todos los comentarios" "All components" = "Todos los componentes" "All fonts" = "Todas las fuentes" +"All the fonts this file uses are installed" = "Todas las fuentes que usa este archivo están instaladas" "All supported properties" = "Todas las propiedades compatibles" "All variables" = "Todas las variables" "Alpha" = "Alfa" @@ -772,6 +773,7 @@ status = "draft — needs native review" "Missing" = "No encontrado" "Missing layer" = "Capa no encontrada" "Missing style" = "Estilo no encontrado" +"Missing fonts" = "Fuentes que faltan" "Missing variable" = "Variable no encontrada" "Miter" = "Inglete" "Miter angle" = "Ángulo de inglete" @@ -1367,6 +1369,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." "This frame isn't on the branch." = "Este marco no está en la rama." @@ -1563,6 +1566,7 @@ status = "draft — needs native review" "no bleed" = "sin sangrado" "nobody else here yet" = "aún no hay nadie más" "now" = "ahora" +"only in a text style or variable" = "solo en un estilo de texto o una variable" "or" = "o" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" "prototype actions of unknown types were skipped" = "se omitieron las acciones de prototipo de tipos desconocidos" @@ -1663,6 +1667,7 @@ status = "draft — needs native review" "{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" } "{n} rows" = { one = "{n} fila", other = "{n} filas" } "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } +"{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } "{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } @@ -2083,6 +2088,7 @@ status = "draft — needs native review" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" "text.maxLines" = "Máximo de líneas" +"text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" "text.onPath" = "Texto en trazado" "text.paragraphIndent" = "Sangría de párrafo" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 39001b56..448501ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -151,6 +151,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.is_some() || u.batch_rename.is_some() || u.import_report.is_some() + || u.missing_fonts.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel @@ -164,6 +165,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() + || u.missing_fonts.take().is_some() { return; } @@ -178,6 +180,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); crate::import_report::show(app, ctx); + crate::missing_fonts::show(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 8742ccd3..a2411582 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -108,7 +108,7 @@ fn escape_closes_the_import_report_like_other_simple_dialogs() { assert!(u.app.ui.import_report.is_none(), "Escape closes the report"); } -fn shown_texts(shapes: Vec) -> Vec { +pub(crate) fn shown_texts(shapes: Vec) -> Vec { fn walk(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 360955a3..1316faa3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod live; pub mod live_text; pub mod measure_overlay; pub mod menus; +pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; @@ -143,6 +144,8 @@ mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; #[cfg(test)] +mod missing_fonts_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -494,6 +497,9 @@ pub struct UiState { pub pending_open_frames: u8, /// The latest successful file import's summary and compatibility notes. pub import_report: Option, + /// Files the user opened whose fonts are yet to be checked (by document uid). + pub font_checks: Vec, + pub missing_fonts: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -619,6 +625,8 @@ impl Default for UiState { pending_opens: Vec::new(), pending_open_frames: 0, import_report: None, + font_checks: Vec::new(), + missing_fonts: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -747,6 +755,7 @@ impl FigApp { if let Some(result) = &result { import_report::capture(self, id, ¶ms, result); } + missing_fonts::check_later(self, id, result.as_ref()); result } @@ -861,6 +870,7 @@ impl FigApp { self.ui.about_tab = dialogs::AboutTab::About; } "dialog.preferences" => self.ui.preferences = true, + "dialog.missingFonts" => missing_fonts::request(self), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, @@ -1636,6 +1646,7 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state } } diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs new file mode 100644 index 00000000..8236ca90 --- /dev/null +++ b/crates/ui-egui/src/missing_fonts.rs @@ -0,0 +1,102 @@ +//! The Missing fonts window (#752): the fonts a file uses that aren't installed, whose text is +//! drawn in a fallback font. It opens by itself after the user opens a file that has any (once +//! the installed fonts are indexed and any import report is closed), and from Text ▸ Missing fonts. + +use egui::RichText; +use figcraft_engine::fonts::{Missing, missing}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, trn}; +use crate::theme::{Tokens, regular}; + +#[derive(Clone, Debug)] +pub struct Shown { + /// The document it lists: the window goes when that document closes. + pub uid: u64, + pub families: Vec, +} + +impl Shown { + pub fn inspect(&self) -> Value { + json!({"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>()}) + } +} + +/// After a file the user opened (or imported into) is in front: look for missing fonts in it +/// once that can be told ([`show`]). +pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { + if result.is_some() + && matches!(command, "file.open" | "file.openRecent" | "file.import") + && let Ok(ds) = app.session.ds() + { + app.ui.font_checks.push(ds.uid); + } +} + +/// The window for document `uid`, listing what it misses (even nothing, when asked for). +fn list(app: &mut FigApp, uid: u64) -> Option> { + let ds = app.session.docs.iter().find(|d| d.uid == uid)?; + let families = missing(&ds.doc); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone() }); + Some(families) +} + +/// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. +pub fn request(app: &mut FigApp) { + if let Ok(uid) = app.session.ds().map(|d| d.uid) { + list(app, uid); + } +} + +/// Installed fonts are indexed: before that, every one of them would count as missing. The +/// browser has only its built-in fonts, so files there don't open the window by themselves. +fn fonts_known() -> bool { + !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let uid = app.ui.font_checks.remove(0); + if list(app, uid).is_some_and(|f| f.is_empty()) { + app.ui.missing_fonts = None; + } + } + if app.ui.missing_fonts.as_ref().is_some_and(|m| !app.session.docs.iter().any(|d| d.uid == m.uid)) { + app.ui.missing_fonts = None; + } + let Some(shown) = &app.ui.missing_fonts else { return }; + let t = Tokens::get(ctx); + let mut open = true; + let mut close_clicked = false; + crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + if shown.families.is_empty() { + ui.label(tr("All the fonts this file uses are installed")); + } else { + ui.label( + RichText::new(tr( + "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them.", + )) + .color(t.text_secondary) + .font(regular(11.0)), + ); + ui.add_space(6.0); + egui::ScrollArea::vertical().id_salt("missing_fonts").max_height(260.0).show(ui, |ui| { + for m in &shown.families { + ui.horizontal(|ui| { + ui.label(RichText::new(&m.family).strong()); + let used = + if m.layers == 0 { tr("only in a text style or variable").to_string() } else { trn("{n} text layers", m.layers, &[]) }; + ui.label(RichText::new(used).color(t.text_secondary)); + }); + } + }); + } + ui.add_space(12.0); + close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + }); + if !open || close_clicked { + app.ui.missing_fonts = None; + } +} diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs new file mode 100644 index 00000000..ba86cc7e --- /dev/null +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -0,0 +1,112 @@ +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::import_report::Report; +use crate::layers_drag_tests::Ui; + +/// A saved file with text in `families` (the first two layers share the first family). +fn saved(name: &str, families: &[&str]) -> String { + let mut s = Session::new(); + for (i, f) in families.iter().chain(families.first()).enumerate() { + s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 40 * i, "props": {"fontFamily": f}})).unwrap(); + } + let path = std::env::temp_dir().join(format!("figcraft-missing-fonts-{name}-{}.figcraft", std::process::id())); + let path = path.to_string_lossy().into_owned(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + path +} + +/// The installed fonts are indexed, as the desktop app does at startup; false when this machine +/// opted out (nothing would ever count as known). +fn fonts_indexed() -> bool { + figcraft_text::scan_system_fonts(); + figcraft_text::system_fonts_scanned() +} + +fn listed(u: &Ui) -> Value { + u.app.inspect()["missingFonts"].clone() +} + +#[test] +fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let installed = figcraft_text::family_names().into_iter().next(); + let mut families = vec!["FigCraft Missing UI"]; + families.extend(installed.as_deref()); + let path = saved("open", &families); + let mut u = Ui::new(Session::new()); + // Programmatic opens (control channel, MCP, scripts) never open the window. + u.app.run("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}]})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); + + // It goes with its file. + u.app.run("file.close", json!({})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); +} + +#[test] +fn a_file_without_missing_fonts_opens_no_window() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let Some(installed) = figcraft_text::family_names().into_iter().next() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let path = saved("clean", &[&installed]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + assert!(u.app.ui.font_checks.is_empty(), "the check ran"); +} + +#[test] +fn it_waits_for_the_import_report_to_close() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let path = saved("report", &["FigCraft Missing Report"]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + let mut report = + Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic"}})).unwrap(); + report.uid = u.app.session.ds().unwrap().uid; + u.app.ui.import_report = Some(report); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null, "the import report comes first"); + u.app.ui.import_report = None; + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Report"); +} + +#[test] +fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + let mut u = Ui::new(Session::new()); + assert!(u.app.run("text.missingFonts", json!({})).is_some()); + assert_eq!(listed(&u), json!({"families": []})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); + let escape = + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + u.frame(escape); + assert_eq!(listed(&u), Value::Null, "Escape closes it"); +} diff --git a/docs/cli.md b/docs/cli.md index 7b35c2d5..7af73fd6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,7 +29,9 @@ be open to the public: [server.md](server.md#running-a-public-server)). See [ser ### `info [--json]` Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs -of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). +of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). Fonts the +file names that aren't installed are listed as `missing font` lines (`missingFonts: [{family, +layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 250d796a..68f00f94 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -156,12 +156,13 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | warnings only (REST import) | +| Missing fonts on open | listed, with replacement | listed (Missing fonts window after an open, Text ▸ Missing fonts…, `text.missingFonts`, `figcraft-cli info`); no replacement; text draws in a fallback font | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Missing fonts (#752): the fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables in any mode) are listed when the user opens it (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. The row claimed REST import warnings no code produced. | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 12d1a871..aa045e61 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -233,6 +233,8 @@ Or in a project's `.mcp.json`: user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies); `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). + `text.missingFonts` lists the families the document names that aren't installed + (`{families: [{family, layers}]}`); their text draws in a fallback font. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline From c6d643347329060f8f0d737124025f5158c090dd Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 21:56:26 +0700 Subject: [PATCH 170/245] Docs: parity rows for the 2026-10-17 landings (#756) Brings the progress docs up to date with what landed on 2026-10-17: community batches #727, #728, #733, #735, #736 and #753, plus #729, #745, #746 and #751. Docs only. --- ROADMAP.md | 25 +++++----- docs/auto-layout-parity.md | 5 +- docs/collaboration-parity.md | 7 +-- docs/components-parity.md | 9 ++-- docs/file-format-parity.md | 16 ++++--- docs/gaps.md | 93 ++++++++++++++++++++++++++---------- docs/hardware-parity.md | 7 +-- docs/localization-parity.md | 73 ++++++++++++++-------------- docs/parity-checklist.md | 6 +-- docs/prototyping-parity.md | 5 +- docs/roadmap.md | 3 +- docs/target-app-parity.md | 23 ++++++--- docs/ui-parity.md | 29 ++++++----- 13 files changed, 187 insertions(+), 114 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a8ae28ab..3908e591 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 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-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) 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 @@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | | Value | Kind | |---|---|---| -| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | +| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | @@ -99,21 +99,21 @@ native speakers' reviews of the six language drafts need people. | Language | Code | Strings | Status | |---|---|---|---| | English | en | 100% | full | -| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | -| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | +| Simplified Chinese | zh-Hans | 95% | draft (needs native review) | +| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | -| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | -| Indonesian | id | 0% | none | -| Japanese | ja | 100% | draft (needs native review) | -| German | de | 100% | draft (needs native review) | -| Korean | ko | 100% | draft (needs native review) | +| French | fr | 95% | draft (needs native review) | +| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | +| Indonesian | id | 99% | draft (needs native review) | +| Japanese | ja | 99% | draft (needs native review) | +| German | de | 96% | draft (needs native review) | +| Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ae230608..ea2cc54f 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -69,7 +69,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | 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 | | | Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | | -| Suggest auto layout / "Add auto layout" on a selection | done | | +| Suggest auto layout / "Add auto layout" on a selection | done | adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | | Real Figma files laid out identically | **not checked** | needs `.fig` or REST import of real files ([gaps](gaps.md) #1, #3) | | Performance: re-flow only where sizes or places changed | done | measured in `cargo xtask bench` (components at scale) | @@ -77,6 +77,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-11 | minor | Behaviour suite from the public documentation (64 layout cases, 8 command tests). Fixed: padding floor, inside-only included strokes (and included by default), auto gaps stop at 0, grid `fr` like CSS `fr`, canvas stacking actually drawn and hit. Added: auto gaps evenly/around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Breadth 95% → 98%, ready 68% → 74%, remaining 6–12 h → 3–6 h | diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 95084d74..ae503633 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,10 +12,10 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | | 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged | diff --git a/docs/components-parity.md b/docs/components-parity.md index 1ecd9686..3b9793b0 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -19,14 +19,14 @@ accounts). | Component sets / variants, add variant, switch | done | | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | -| Variant property order (names rewritten), duplicate combinations reported | done | reordering through `component.editProperty {index}`; no drag in the panel yet | +| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | | Properties: boolean, text, instance swap (preferred values), variant | done | | | Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | | Exposed nested instances | done | hidden ones hide their properties | | Descriptions | done | | | Instance swap: panel, Alt-drop from Assets | done | the panel keeps overrides on same-named layers, an Alt-drop from Assets only text layers' | | Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` | -| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); no Hide when publishing menu item yet | +| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) | | Instances pasted into other files follow their mains | done | missing main restorable | | Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) | | Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 | @@ -48,7 +48,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. |---|---|---| | Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files | | Enable a library in a file; read-only linked copies | done | | -| Library updates available; overrides kept | done | | +| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) | | Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) | | Library analytics | partial | instances per component and the files using them, as apps report them; nothing over time, no detach counts | | Swap library (instances, styles and variables to another library's of the same names, overrides kept) | done | what has no match stays and is listed; one undo step | @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | | 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 250d796a..96293b34 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (`.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,9 +17,9 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | -| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | +| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -36,7 +36,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins), **layers copied from another design app, best effort** | Cmd/Ctrl+V reads an image copied in another app (under Wayland through the data-control protocol, not run on Linux here; Linux and BSD clipboard reads give up after 2 s). Copied design layers: the `.fig` canvas in the clipboard's HTML is read like a `.fig` file (with its warnings) and pasted as Paste pastes, in place or into the selected frame (`edit.pasteDesignLayers {html \| data}`, refused over 256 MB before decoding); text copied later pastes as text again. Tested with clipboard HTML made from our own `.fig` export (`design_paste_tests`), never with a real clipboard from other software | with `.fig` | ## Export @@ -56,13 +56,16 @@ export limit, not a native document nesting limit. | WebP | — | yes (ours) | | — | | Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — | | Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — | -| `.fig` ("Save local copy") | yes | **experimental** | the whole file; see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) | +| `.fig` ("Save local copy") | yes | **experimental** | the whole file, also as a copy from File ▸ Save as… (the document keeps its `.figcraft` path and unsaved state); see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) | ### Export to `.fig` (experimental) `file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental), `figcraft-cli export -o .fig` (and `convert`) and MCP's `export {format: "fig"}` write -the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format +the whole document; so do File ▸ Save as… and `file.save` / `file.saveAs` with a `.fig` path, as a +copy: the document keeps its `.figcraft` path, unsaved changes and version preview, and a file an +earlier build saved under a `.fig` name is never overwritten by Save (`save_format_tests`, +`save_as_tests`). The writer (`crates/format/src/fig/export.rs`) works from the same public format notes as the import and inverts its mapping: the zip holds `canvas.fig` (the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message), `meta.json`, `thumbnail.png` and `images/`; `meta.json` names the file and gives the page colour and the thumbnail size. The @@ -162,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -14,11 +14,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | # | Gap | Kind | Hours | Doc | |---|---|---|---|---| -| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | +| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, then (2026-10-17) more fidelity on synthetic files, budgets for large files, an import report and paste from another design app; an experimental export, also as a copy from Save as (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -60,20 +60,43 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe derived data for their layers, a scale factor), variables with modes and bindings, shared styles and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there), like effects such as glass and noise, images and prototype interactions. -- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight), - table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke - joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks and video - fills aren't read; text lays out again with - local fonts. Nothing has been checked against real-world files yet (gap 3). The export is checked - only against our own importer: whether other apps open its files is unknown. +- **Landed (2026-10-17, community batches #727 and #728, then #745):** the import keeps an + instance's own identity and the cached copies of library components a file carries, applies + instance overrides by key and order, lets local text values and instance overrides win over a + shared text style, and reads grid track sizes (fixed, content-sized and fill), slot content, + labels of connectors saved with their drawn path (text and background, as a static snapshot: + the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, + vertical trim, per-point stroke joins, editable text on a path, image adjustments (the tint + sign shared with the REST import) and images carried in the file's message; layers inside + instances keep their own size and resizing as overrides (#731), and auto layout and text that + record no sizing hug (#732). Large files open within budgets set from the file's own size (one + value per compressed byte, 20M–128M values, lower ceilings on 32-bit targets; a 15.4 MB + synthetic file with 300,000 layers opens whole). After a `.fig`, REST JSON or SVG import an + import report lists what was left out or changed (translated where the catalog has the note; + `file.open {data}` returns a `summary`), and imported file bytes stay out of the command + journal. File ▸ Save as… to a `.fig` name writes an export copy and the document keeps its + `.figcraft` path. Layers copied from another design app paste (`edit.pasteDesignLayers`, and + Cmd/Ctrl+V when the clipboard's HTML holds a `.fig` canvas): read like a `.fig` file, with its + warnings, size-checked (256 MB) before decoding. +- **Missing:** elbowed or curved connectors without a saved path (drawn straight), labels of + connectors without a saved snapshot, table cells beyond their own layers, filled areas of a + vector with their own fills (#634, held for an owner decision on the document model), variables + bound to fields the editor can't bind and video fills aren't read; text lays out again with + local fonts. Nothing has been checked against real-world files yet (gap 3): the 2026-10-17 + changes were tested on files built in code, and paste from another design app on clipboard HTML + made from our own export, never on a real clipboard. The export was checked in other software + on our example documents and small test files only. - **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`. + 2026-10-17: `crates/format/src/fig/{connector_label,slot,source_identity,text_style,text_list,text_paragraph,text_path,text_skip_ink,image_filter,auto_layout_sizing}_tests.rs`, + `map_connector_snapshot_budget_tests.rs`, `crates/engine/src/{save_format,design_paste,instance_resizing}_tests.rs`, + `crates/ui-egui/src/{import_report,save_as}_tests.rs`. Export: 60 generated documents round trip through the importer layer by layer, written files decode with the generic decoder and list parents first, the encoder agrees with the decoder, junk documents never panic (`crates/format/src/fig/export_tests.rs`, `crates/engine/src/fig_export_tests.rs`). - **Impact:** existing work can be brought across, but only real files will show how much of it. -- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); checking the export against real files a further 5–15 h (with gap 3). +- **Estimate:** 5–10 h for the read tail (real-file fixes, table cells, connectors without a saved path); checking the export against real files a further 5–15 h (with gap 3). Unchanged on 2026-10-17: most of the read tail is real-file fixes, which nothing has measured yet. ### 2. Multiplayer and sharing are a preview, without accounts - **Missing:** accounts, teams and projects, per-person permissions, audio, @@ -93,8 +116,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe `figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing confirmed), takes files published from the app and hands out share links that let people edit or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse - proxy, and releases ship it with a deployment guide (`docs/server.md`). But there are no - accounts (a link is the access) (`docs/collab-protocol.md`). + proxy, and releases ship it with a deployment guide (`docs/server.md`); server message limits + follow `max_file_mb`, so files above 64 MiB publish and open (#705), a damaged generation field + no longer drops a log's flushed edits (#687), and the browser build joins from a `#join=` link + (#712: unit-tested, not run in a browser). But there are no accounts (a link is the access) + (`docs/collab-protocol.md`). - **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work without it. - **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions @@ -138,29 +164,43 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, `theme_pages_tests`). +- **Landed (2026-10-17, community batches #733, #735, #736 and #729):** submenus open after a + short hover, keep their place while the pointer heads for them (a safe triangle) and reopen + closed (`menu_aim_tests`; timings tuned by feel, not measured); the Actions palette has tabs, + ranked search over every command, a keyboard highlight and recents (`palette_tests`); menu items + that need a value ask for it; live font preview on hover; the colour picker opens on a new fill + or stroke; Shift-drag on a selected layer moves the selection; a double-click near a thin stroke + enters vector edit; points dropped on a point merge; a draggable divider between Pages and + Layers; Paste over and Paste properties from the keyboard; then (#753) Selection colors for mixed + selections and smart-selection spacing fields and canvas handles (no reorder rings). Each was checked in the app (on + Windows; the submenus by their author), none side by side with Figma, none on macOS. - **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). ### 5. Localization: English, reviewed Russian, plus unreviewed drafts - **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, - Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings - in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number - formatting; CJK UI text on the web without the craft-fonts CJK build. + Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish + overlay, the strings added since each catalog was drafted (1–5% of each), Hindi, Arabic and + Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the + craft-fonts CJK build. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in - `crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings), - Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, - `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts - (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay - listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the - others show English for strings added later). Russian adds all 2,165 current entries, - reviewed and approved by VanTan, with one/few/many integer plurals. + `crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31 + menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, + Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese, + Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they + were drafted show English), with `es-419.toml` a Latin American overlay listing only what + differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals) + covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker + reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)). CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. - **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English errors. - **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers; - 29–51 h for the key languages left and the drafts' follow-ups. Detail in + 29–51 h for the key languages left and the drafts' follow-ups (kept on 2026-10-17: the + Indonesian draft takes 2–3 h off, about what bringing the drafts that slipped to 95% back to + every string adds). Detail in [`localization-parity.md`](localization-parity.md); how to add one in [`translating.md`](translating.md). @@ -215,7 +255,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe PR (Windows 11, the desktop app driven over the control channel, the same workflow through MCP): eight bugs fixed, among them unsaved changes lost when the app was killed or crashed, Ctrl+arrows and Ctrl+Backspace acting on the whole line in text, scrubbed fields snapping back - and skewed linear gradients. + and skewed linear gradients. Linux and the BSDs (2026-10-17, #693): clipboard reads run on a + helper thread that gives up after 2 s, and images copied in other apps paste under Wayland + through the data-control protocol; compiled in CI, not run on a Linux desktop here. Wayland + compositors without server-side decorations (GNOME) give the window winit's Adwaita controls + (#592: checked by its author on GNOME; KDE not run). - **Impact:** unknown bugs on the platforms where FigCraft is most attractive (Linux has no Figma desktop app). - **Estimate:** 10–20 h, partly human (hardware). @@ -400,6 +444,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index a990d75b..bf945c12 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -14,7 +14,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu |---|---|---|---| | Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | | Effects (blurs, glass, noise) | GPU | CPU | in the above | -| HiDPI / Retina | yes | yes (all) | — | +| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — | | Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | | Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — | | Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 | @@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). |---|---|---|---| | macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens | | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR | -| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here | +| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | | Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 45a7a925..15d49be9 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,11 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) -FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin -American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese -drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key +FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed +by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian +Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted, +waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -17,19 +18,21 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-12, from source) +## What FigCraft has (measured 2026-10-17, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 536 command labels - and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control +- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels + and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete + Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. -- **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español - (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a +- **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: + English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, + 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional @@ -37,13 +40,6 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS language, never Simplified) or the browser's language on the web. - (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), Svenska, 简体中文), applied at once without a - restart; System takes the first of the OS's preferred languages that has a catalog (Windows' - language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag - falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other - Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while - Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS - language, never Simplified) or the browser's language on the web. `pref.language {language}` sets it from the CLI, MCP and the control channel. - **Regional overlays:** a regional catalog whose language has a catalog lists only what differs (`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the @@ -51,9 +47,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v - **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml` lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay entries that read as in their base language and, for complete languages (Spanish), anything - untranslated. The other drafts are not held to it: a string added later shows English there - until someone translates it. Every draft translates every string as of 2026-10-12 (checked by - adding each to `COMPLETE` locally and running the check). + untranslated. The other catalogs are not held to it: a string added later shows English there + until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17 + the strings added since left them at 95–99% (table below). - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian @@ -87,37 +83,42 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,076 / 2,076 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,076 / 2,076 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 | -| Japanese | ja | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,076 / 2,076 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and -menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight -above. +Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved +by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu +wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header +says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft +with one/few/other plurals. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries (a machine draft, then a pass over the panels for overflow), plus each language's plural rule where it isn't one/other; every language also needs a native speaker's review (human). Left before full parity besides the languages: localizing engine messages and number formatting (4–6 h); -right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h**. +right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** (kept on +2026-10-17: Indonesian's draft takes 2–3 h off, about what bringing the drafts back from 95% to +every string adds). -Priority: native reviews of the seven drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, which Figma doesn't ship), then Hindi, Indonesian, -Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). +Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages +and variants has one, plus Simplified Chinese, Indonesian and Czech, which Figma doesn't ship), then +Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | | 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index bdb30e88..775a4fcb 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-13 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (547 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine). -**211 / 211 menu items (100.0%)** +**212 / 212 menu items (100.0%)** Every catalog item has a command. diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index b9220489..e4cf8c7f 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -86,7 +86,7 @@ case pins the current behaviour). | Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas | | Interactive components: instances play their variant's interactions, overridden per trigger | done | | | Drag-driven animations that follow the pointer, in their direction | done | | -| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code | +| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put, as the frame's overflow setting allows ("No scrolling" frames clip); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | | Hotspot hints, inspect panel in the presenter, comments in the presenter | done | | | Preview pane following edits live | done | | | Edit interactions on several layers at once | done | | @@ -100,6 +100,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | | 2026-10-11 | minor | Behaviour suite from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag follows its animation's direction and the pointer, key combinations, overlay timers from when they open, interactive components play their variant's interactions and change the right instance, physical springs that play until they settle, smart animate overshoots with the easing, fixed layers above scrolling content; added scroll to offsets. Breadth 88% → 90%, ready 70% → 74%, 5–9 h → 3–6 h | diff --git a/docs/roadmap.md b/docs/roadmap.md index cf285464..8d63a469 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community 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). 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. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 07c6a4dd..16a5af69 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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 @@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Number | Value | Kind | |---|---|---| -| Menu/command breadth | **100%** (211 / 211 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | +| Menu/command breadth | **100%** (212 / 212 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-17) | | Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below | | Ready for real work | **~53%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | @@ -107,7 +107,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | -| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | +| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | @@ -124,12 +124,12 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects |---|---|---|---|---|---| | Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | 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 (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | -| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); 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) | +| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); 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% | 52–104 | 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, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | -| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | +| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech (none reviewed by a native speaker; measured 2026-10-17, the drafts cover 95–99% of the 2,272 current strings), Swedish and Turkish catalogs, CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | | 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 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue 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) | @@ -423,7 +423,11 @@ Hours in this narrative are the builders' estimates; the tables above supersede JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard - content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and + content wins over our own copy; pasted SVG lands centred in the view), and layers copied from + another design app (`edit.pasteDesignLayers`: the `.fig` canvas in the clipboard's HTML read + like a `.fig` file, best effort; tested with clipboard HTML made from our own export only). + File ▸ Save as… to a `.fig` name writes an experimental export copy and the document keeps its + `.figcraft` path. SVG import is editable and checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure, export and re-import): groups named from their ids with transforms baked into the layers; rectangles (corner radius), ellipses and lines kept as those shapes; gradients with exact @@ -623,13 +627,18 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th - **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, synthetic pointer/keys/typing, menu invocation, screenshots, renders). What agents read and write on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root` on - `figcraft-cli mcp` and the desktop app, #715). + `figcraft-cli mcp` and the desktop app, #715). A minimised or covered window answers every + request that acts on the app's state without drawing (about 100 ms each on Windows), and the + ones that need a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) fail at once instead of + waiting 60 s (#720; not tested on macOS, and under Wayland a minimised window can't be told + apart). - **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..0cccf008 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -15,19 +15,20 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons | +| UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | -| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | -| Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | +| Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | +| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | -| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | -| Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | +| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | +| Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click (`widgets::select_all_on_focus`, `select_all_tests`) | +| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | ## Layers and Assets panels @@ -40,15 +41,15 @@ interaction feel not yet compared. | Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) | | Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned | | Find layers (filter by name) | done | not covered by the documentation | -| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | | -| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | | +| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences (`layers_behaviour_tests`) | +| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert) and the toolbar's Resources open it (they didn't, #710) | | Hide when publishing from the Assets panel's context menu | done | a right-click on one of this file's components (also Libraries ▸ Hide when publishing for the selection, `component.hideWhenPublishing {ids?, on?}`): the model's sign of it is the name, so hiding puts a `.` before the name and showing takes the leading `.` and `_` away (one undo step, saved and synced like a rename; a variant or a layer inside hides its whole component or set); a hidden component shows a crossed eye on its tile and a dimmer name, the menu item its check mark; the next publish leaves it out and brings it back when shown again (`hide_publishing_tests`). Library components (read-only here) have no such menu | ## Selection and transform | Item | Status | Notes | |---|---|---| -| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | +| Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to one axis (#498); a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | | Select layer menu (every layer under the click) | done | | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | @@ -68,6 +69,8 @@ interaction feel not yet compared. | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | 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 | +| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -79,10 +82,10 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | -| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | @@ -137,6 +140,9 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look Ctrl+Alt+Shift+K to go to the main component, the arrows panning the view when nothing is selected (`view.pan`, new), Control suspending snapping on macOS, and Space, N, C and F while presenting. The paint bucket's tooltip shows Shift+B (B still works). +- **Fixed (2026-10-17):** Shift+Cmd/Ctrl+V (Paste over selection) and Alt+Cmd/Ctrl+V (Paste + properties) work on the desktop (they were bound but didn't act there, #716); Cmd/Ctrl+V pastes an + image copied in another app (#663). Counts above unchanged (the suite checks bindings). - **Where the documentation disagrees with itself** (noted per row): pixel grid (Shift+' or Cmd+'), letter spacing (Alt or Cmd with < >; Cmd would be the font size keys, Alt bound), Windows remove fill (Alt+/ or Alt+,; Alt+, is letter spacing), the Layers panel on Windows (Alt+1 @@ -166,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From b84ee179d474da8024dd55a707a40cb1f8e19a97 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Sat, 17 Oct 2026 17:05:49 +0200 Subject: [PATCH 171/245] M8: draw the macOS shortcut glyphs the UI font lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menus and tooltips showed boxes for ⌃ ⌥ ↵ ⌫ ⇥ ⇞ ⇟ (e.g. Present ⌥⌘↵): egui's default face has ⌘ and ⇧ but not these. The desktop app now adds a system face that has them after the UI fonts. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/theme.rs | 47 +++++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 1 + 2 files changed, 48 insertions(+) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 080bce43..933ebece 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -274,6 +274,7 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { .any(|x| x.has_char(c)) }; extra.extend(crate::bidi::script_fallbacks(&embedded)); + extra.extend(key_symbol_fallbacks(&embedded)); extra.extend(crate::cjk::faces(cjk, lang, &embedded).iter().map(|f| (format!("system-{}-{}", f.family, f.style), font_data(f)))); } extra.extend(craft(true)); @@ -288,6 +289,26 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { ctx.set_fonts(fonts); } +/// The glyphs macOS shortcuts are written with ([`figcraft_engine::keymap::display`]). +const KEY_SYMBOLS: &str = "⌃⌥⇧⌘⌫↵⇥⇞⇟←→↑↓"; + +/// System faces for the shortcut glyphs the UI's own fonts lack (egui's default face has ⌘ but +/// not ⌥ or ↵). +fn key_symbol_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, FontData)> { + const FAMILIES: [&str; 6] = ["Lucida Grande", "Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols 2", "FreeSans"]; + let mut out = Vec::new(); + let mut taken: Vec> = Vec::new(); + for c in KEY_SYMBOLS.chars() { + if have(c) || taken.iter().any(|f| f.has_char(c)) { + continue; + } + let Some(face) = figcraft_text::face_with_char(c, &FAMILIES) else { continue }; + out.push((format!("system-{}-{}", face.family, face.style), font_data(&face))); + taken.push(face); + } + out +} + /// egui font data over a face's bytes, without copying them (CJK faces are 10–20 MB). Font /// database faces are never dropped, so a system face's shared bytes are kept for good: leaking /// one more reference to them costs a pointer and gives egui the `'static` slice it needs. @@ -331,3 +352,29 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 { let conv = ctx.data(|d| d.get_temp::(screen_conversion_id())); c32(conv.map_or(c, |k| k.apply(c))) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_font_draws_every_mac_shortcut_glyph() { + figcraft_text::scan_system_fonts(); + if key_symbol_fallbacks(&|_| false).is_empty() { + eprintln!("no system face with shortcut glyphs; skipping"); + return; + } + let ctx = Context::default(); + install_fonts(&ctx, true, 0); + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 100.0))), ..Default::default() }; + ctx.run_ui(input, |_| {}).textures_delta.clear(); + ctx.fonts_mut(|f| { + for c in KEY_SYMBOLS.chars() { + for font in [regular(11.0), semibold(11.0)] { + assert!(f.has_glyphs(&font, &c.to_string()), "UI font draws {c:?}"); + } + } + }); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..a28749bf 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | patch | macOS shortcut keycaps draw ⌃ ⌥ ↵ ⌫ ⇥ ⇞ ⇟ in menus and tooltips (they were boxes): the desktop app adds a system face with them (Lucida Grande, Apple Symbols, Segoe UI Symbol, DejaVu Sans…) after the UI fonts. Checked in the app on macOS. The web build on a Mac still shows boxes (no system fonts there). Summary and hours unchanged | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From a3c5025186da95d9a519ee212365f7806f058c11 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 22:06:03 +0700 Subject: [PATCH 172/245] M7: missing fonts window only after a file opens; wait for the font scan (#752) From review: - The check ran after any file.open/openRecent/import result, so a pasted or dropped image, reopening a file that was already open, or Clear recent brought the window back. It now runs only for results with a `summary` (a file opened, or pages imported), and never in the browser. - Text > Missing fonts... before the startup scan finished listed installed fonts as missing. It now says it is checking the installed fonts and lists them once that is done (`figcraft_text::installed_fonts_known`, also true when system fonts are opted out, where checks used to wait forever). - ROADMAP progress log and file-format-parity status line. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/engine/src/fonts.rs | 2 +- crates/engine/src/fonts_tests.rs | 5 ++- crates/text/src/fontdb.rs | 10 +++++ crates/text/src/lib.rs | 2 +- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/missing_fonts.rs | 38 +++++++++++------- crates/ui-egui/src/missing_fonts_tests.rs | 47 ++++++++++++++++++++--- docs/file-format-parity.md | 2 +- 10 files changed, 87 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a8ae28ab..3dcf67b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Missing fonts (M7, #752): fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables) are listed in a Missing fonts window after the user opens the file (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Their text still draws in a fallback font. 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-17 | minor | Missing fonts (M7, #752): fonts a file names that aren't installed are listed after the user opens it, from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. 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. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index e20d4d40..3548124d 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -21,7 +21,7 @@ pub struct Missing { /// The families `doc` names that the font database doesn't have, by name. On the desktop the /// installed fonts are indexed in the background: ask once that's done -/// (`figcraft_text::system_fonts_scanned`), or every installed family counts as missing. +/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing. pub fn missing(doc: &Document) -> Vec { // Lower-case family → (spelling, layers). let mut used: BTreeMap = BTreeMap::new(); diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 6ba925b3..a6e2d213 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -112,7 +112,10 @@ fn blank_families_and_aliases_are_handled() { doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); let other = text(&mut s, "FigCraft Missing G"); s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); - // The bound layer takes the value its alias resolves to; the cycle resolves to nothing. + // The bound layer takes the value its alias resolves to (bindings are applied after the next + // command); the cycle resolves to nothing. + let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() }; + assert_eq!(bound.style.font_family, "FigCraft Missing F"); assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 54adc282..d3101bfc 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -876,6 +876,16 @@ fn system_fonts_opted_out() -> bool { std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") } +/// Every font text can use is known: the system fonts were indexed, or there are none to index +/// (opted out, or the web). Until then a family the scan would find counts as missing. +pub fn installed_fonts_known() -> bool { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + return system_fonts_scanned(); + } + true +} + /// Index the system fonts the first time the font database is used (headless apps: a run that /// lays out no text never pays for the scan). The desktop app scans in the background instead. pub fn scan_system_fonts_on_demand() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 721471ad..683350f6 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,7 +17,7 @@ pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b387db0d..49387e36 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -198,6 +198,7 @@ name = "English" "C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Can view" = "Can view" +"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3e70bc83..6b4362b7 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -201,6 +201,7 @@ status = "draft — needs native review" "C or ⇧C" = "C o ⇧C" "CMYK colour" = "Color CMYK" "Can view" = "Puede ver" +"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…" "Checking the server…" = "Comprobando el servidor…" "Clear from this list" = "Borrar de esta lista" "Code or invite link" = "Código o enlace de invitación" diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs index 8236ca90..6c07068d 100644 --- a/crates/ui-egui/src/missing_fonts.rs +++ b/crates/ui-egui/src/missing_fonts.rs @@ -15,19 +15,26 @@ pub struct Shown { /// The document it lists: the window goes when that document closes. pub uid: u64, pub families: Vec, + /// Asked for before the installed fonts were indexed: the list comes once they are. + pub indexing: bool, } impl Shown { pub fn inspect(&self) -> Value { - json!({"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>()}) + json!({ + "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), + "indexing": self.indexing, + }) } } -/// After a file the user opened (or imported into) is in front: look for missing fonts in it -/// once that can be told ([`show`]). +/// After the user opened a file, or imported pages into one (results with a `summary`; not a +/// placed image or SVG, a file that was open already, or Clear recent): look for missing fonts in +/// it once that can be told ([`show`]). Not in the browser, which has only its built-in fonts. pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { - if result.is_some() + if !cfg!(target_arch = "wasm32") && matches!(command, "file.open" | "file.openRecent" | "file.import") + && result.is_some_and(|r| r.get("summary").is_some()) && let Ok(ds) = app.session.ds() { app.ui.font_checks.push(ds.uid); @@ -38,25 +45,26 @@ pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { fn list(app: &mut FigApp, uid: u64) -> Option> { let ds = app.session.docs.iter().find(|d| d.uid == uid)?; let families = missing(&ds.doc); - app.ui.missing_fonts = Some(Shown { uid, families: families.clone() }); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone(), indexing: false }); Some(families) } /// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. pub fn request(app: &mut FigApp) { - if let Ok(uid) = app.session.ds().map(|d| d.uid) { + let Ok(uid) = app.session.ds().map(|d| d.uid) else { return }; + if figcraft_text::installed_fonts_known() { list(app, uid); + } else { + app.ui.missing_fonts = Some(Shown { uid, families: Vec::new(), indexing: true }); } } -/// Installed fonts are indexed: before that, every one of them would count as missing. The -/// browser has only its built-in fonts, so files there don't open the window by themselves. -fn fonts_known() -> bool { - !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() -} - pub fn show(app: &mut FigApp, ctx: &egui::Context) { - if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let known = figcraft_text::installed_fonts_known(); + if let Some(uid) = app.ui.missing_fonts.as_ref().filter(|m| m.indexing && known).map(|m| m.uid) { + list(app, uid); + } + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && known { let uid = app.ui.font_checks.remove(0); if list(app, uid).is_some_and(|f| f.is_empty()) { app.ui.missing_fonts = None; @@ -71,7 +79,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut close_clicked = false; crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); - if shown.families.is_empty() { + if shown.indexing { + ui.label(tr("Checking the fonts installed on this computer…")); + } else if shown.families.is_empty() { ui.label(tr("All the fonts this file uses are installed")); } else { ui.label( diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs index ba86cc7e..2b11260a 100644 --- a/crates/ui-egui/src/missing_fonts_tests.rs +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -16,11 +16,10 @@ fn saved(name: &str, families: &[&str]) -> String { path } -/// The installed fonts are indexed, as the desktop app does at startup; false when this machine -/// opted out (nothing would ever count as known). +/// The installed fonts are indexed, as the desktop app does at startup. fn fonts_indexed() -> bool { figcraft_text::scan_system_fonts(); - figcraft_text::system_fonts_scanned() + figcraft_text::installed_fonts_known() } fn listed(u: &Ui) -> Value { @@ -42,11 +41,12 @@ fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { u.app.run("file.open", json!({"path": path})); u.frame(vec![]); assert_eq!(listed(&u), Value::Null); + u.app.run("file.close", json!({})); u.app.run_user("file.open", json!({"path": path})); std::fs::remove_file(&path).unwrap(); u.frame(vec![]); - assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}]})); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}], "indexing": false})); u.frame(vec![]); let shown = u.frame_output(vec![]).shapes; assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); @@ -99,9 +99,10 @@ fn it_waits_for_the_import_report_to_close() { #[test] fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + assert!(fonts_indexed()); let mut u = Ui::new(Session::new()); assert!(u.app.run("text.missingFonts", json!({})).is_some()); - assert_eq!(listed(&u), json!({"families": []})); + assert_eq!(listed(&u), json!({"families": [], "indexing": false})); u.frame(vec![]); let shown = u.frame_output(vec![]).shapes; assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); @@ -110,3 +111,39 @@ fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() u.frame(escape); assert_eq!(listed(&u), Value::Null, "Escape closes it"); } + +#[test] +fn asked_for_before_the_fonts_are_indexed_it_lists_them_once_they_are() { + assert!(fonts_indexed()); + let path = saved("indexing", &["FigCraft Missing Indexing"]); + let mut u = Ui::new(Session::new()); + u.app.run("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + // As Text ▸ Missing fonts leaves it while the startup scan is still running. + let uid = u.app.session.ds().unwrap().uid; + u.app.ui.missing_fonts = Some(crate::missing_fonts::Shown { uid, families: Vec::new(), indexing: true }); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2}], "indexing": false})); +} + +#[test] +fn placing_images_or_clearing_recents_does_not_bring_it_back() { + assert!(fonts_indexed()); + let path = saved("place", &["FigCraft Missing Place"]); + let svg = std::env::temp_dir().join(format!("figcraft-missing-fonts-place-{}.svg", std::process::id())); + std::fs::write(&svg, r#""#).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Place"); + u.app.ui.missing_fonts = None; + // A placed SVG, the same file opened again (it only comes to the front), Clear recent. + assert!(u.app.run_user("file.import", json!({"path": svg.to_string_lossy()})).is_some()); + assert!(u.app.run_user("file.open", json!({"path": path})).is_some()); + u.app.run_user("file.openRecent", json!({"clear": true})); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_file(&svg).unwrap(); + u.frame(vec![]); + assert!(u.app.ui.font_checks.is_empty()); + assert_eq!(listed(&u), Value::Null); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 68f00f94..5205d55d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's From 6ce46b3532dfba9f1631d2aa9349387d2177130f Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Sat, 17 Oct 2026 17:09:51 +0200 Subject: [PATCH 173/245] M1: double-click a frame's name on the canvas to rename it (#757) A frame's name above its top edge (or a section's pill) only selected and dragged it; a double-click there did nothing, since the label lies outside the frame and hits no layer. With the Move or Scale tool, a double-click on a label now selects that frame and asks the UI (`canvas.renameLabel`) for a field over the label with the name selected, as the layers panel and pages list do. Enter or a click elsewhere renames it (`object.rename`, one undo step) and Escape leaves the name as it was. Rotated labels work too. Tests drive the canvas: a double-click on a label opens the field, typing and Enter rename the frame without moving it, undo restores the name, and Escape cancels on a rotated frame. --- crates/engine/src/tools.rs | 11 +++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/frame_label_tests.rs | 55 +++++++++++++++++++++++++ crates/ui-egui/src/frame_labels.rs | 31 ++++++++++++++ crates/ui-egui/src/lib.rs | 9 ++++ docs/roadmap.md | 1 + docs/ui-parity.md | 5 ++- 7 files changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dbda1894..dba3eaf7 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1875,6 +1875,17 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } + // A frame's or section's name label: rename it there (the canvas puts a field over it). + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bc184270..4c2b66ef 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -242,6 +242,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } app.session.tool.labels = overlays(app, &painter, &t); + crate::frame_labels::rename_field(app, ui); // The zoom tool's area. if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { let area = Rect::from_two_pos(start, p); diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index 38ebef0a..d7f2371a 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -79,3 +79,58 @@ fn pressing_a_rotated_label_selects_and_drags_the_frame() { assert!(after.x0 > before.x0 + 1.0 && after.y0 > before.y0 + 1.0, "{before:?} → {after:?}"); assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1); } + +fn key(k: egui::Key) -> Vec { + vec![ + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }, + ] +} + +/// Double-click `f`'s label and wait for the field to take the keyboard. +fn double_click_label(u: &mut Ui, f: NodeId) { + let at = mid(&label(u, f)); + for _ in 0..2 { + u.button(at, true); + u.button(at, false); + } + u.frame(vec![]); + u.frame(vec![]); +} + +fn name(u: &Ui, f: NodeId) -> String { + u.app.session.doc().unwrap().node(f).unwrap().name.clone() +} + +#[test] +fn double_clicking_a_label_renames_the_frame_there() { + let (mut u, a, b) = setup(); + let before = u.app.session.doc().unwrap().world_bounds(a).unwrap(); + double_click_label(&mut u, a); + assert_eq!(u.app.session.selection(), vec![a]); + assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(a), "a field over the label"); + // The name starts selected: typing replaces it, Enter keeps it. + u.frame(vec![Event::Text("Sign in".into())]); + u.frame(key(egui::Key::Enter)); + u.frame(vec![]); + assert_eq!(name(&u, a), "Sign in"); + assert!(u.app.ui.rename_label.is_none()); + // The double-click didn't move the frame or rename the other one. + assert_eq!(u.app.session.doc().unwrap().world_bounds(a).unwrap(), before); + assert_eq!(name(&u, b), "Turned"); + // One undo step puts the old name back. + u.app.session.undo().unwrap(); + assert_eq!(name(&u, a), "Upright"); +} + +#[test] +fn escape_leaves_a_label_rename_with_the_old_name() { + let (mut u, _, b) = setup(); + double_click_label(&mut u, b); + assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(b), "rotated labels rename too"); + u.frame(vec![Event::Text("Nope".into())]); + u.frame(key(egui::Key::Escape)); + u.frame(vec![]); + assert_eq!(name(&u, b), "Turned"); + assert!(u.app.ui.rename_label.is_none()); +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 2718fddd..43dd712d 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -151,6 +151,37 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl))) } +/// Rename `id`'s name label on the canvas: a field over the label from the next frame on. +pub fn start_rename(app: &mut FigApp, id: NodeId) { + // The field takes the focus once it's drawn (as the pages list does). + app.ui.rename_label = Some((id, true)); +} + +/// The field over a label being renamed (see [`start_rename`]): Enter or a click elsewhere +/// renames the layer, Escape keeps the name. +pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { + let Some((id, start)) = app.ui.rename_label else { return }; + let quad = app.session.tool.labels.iter().find(|(i, _)| *i == id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y))); + let name = app.session.doc().ok().and_then(|d| d.node(id)).map(|n| n.name.clone()); + let (Some(quad), Some(name)) = (quad, name) else { + app.ui.rename_label = None; + return; + }; + let area = Rect::from_points(&quad); + let font = regular(11.0); + let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; + let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", id)), &name, rect, font, start); + if let Some(new) = done { + app.ui.rename_label = None; + app.run("object.rename", serde_json::json!({"ids": [id], "name": new})); + } else if !open && !start { + app.ui.rename_label = None; + } else { + app.ui.rename_label = Some((id, false)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..6141b3dd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -430,6 +430,9 @@ pub struct UiState { /// The layer being renamed in the layers panel. pub rename: Option, pub rename_page: Option<(NodeId, String)>, + /// The frame or section whose name label on the canvas is being renamed (a double-click on + /// it), and whether the rename just started (its field takes the keyboard when next drawn). + pub rename_label: Option<(NodeId, bool)>, pub main_menu: bool, pub palette: Option, /// The Actions palette's tab. @@ -587,6 +590,7 @@ impl Default for UiState { expanded: BTreeSet::new(), rename: None, rename_page: None, + rename_label: None, main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, @@ -961,6 +965,11 @@ impl FigApp { "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), + "canvas.renameLabel" => { + if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) { + frame_labels::start_rename(self, id); + } + } "layers.rename" => { let ids: Vec = r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d63a469..b89f1842 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | M1: double-clicking a frame's or section's name on the canvas renames it in place (#757; Enter commits, Escape cancels, one undo step). 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). 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. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..d2b3c9e2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (renaming a frame or section from its name on the canvas (#757); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -98,7 +98,7 @@ interaction feel not yet compared. |---|---|---| | 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 | | +| Frame labels (rotated, right-to-left), size badge | done | double-click a frame's or section's name to rename it in place: Enter or a click elsewhere keeps the name, Escape restores it, one undo step (#757, `frame_label_tests`) | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | | 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 | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frame and section names on the canvas rename on a double-click (#757): a field over the label with the name selected; Enter or a click elsewhere commits (`object.rename`, one undo step), Escape cancels. Rotated labels too. Not compared side by side with Figma | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 0a01188c31da237cf1ecd548b994c375c10fe757 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 22:38:10 +0700 Subject: [PATCH 174/245] M7: translate the .fig import notes (#761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The import report translates an importer note when the catalog has it, but 30 notes added by the `.fig` import series (#626–#639) had no catalog entries, so they showed in English: connector labels and snapshots, grid track sizing, image filters, slots and their content, vector segment styles, and text on a path. - All 30 are now in `en.toml` and in every full catalog: es, de, fr, pt-BR, ru, ja, ko, zh-Hans, cs, id, sv and tr (30 each). `es-419` overrides only the 3 notes about settings (configuración rather than Spain's ajustes), as the overlay does elsewhere. The wording follows each catalog's existing terms for connector, layer, frame, stroke, grid, slot and text on path. The translations are drafts, like the catalogs, and wait for native speakers' review. - `every_key_in_the_code_is_in_the_english_catalog_and_no_other` now counts each importer note (the `warn("…")` literals in `crates/format/src/fig` and `crates/format/src/rest`) as a required key, not only as an allowed one. A new note that isn't in `en.toml` fails the check, and because Spanish is in `COMPLETE` it has to be translated there too, so the report can't fall behind again. `docs/translating.md` says so. - The importers' texts are unchanged. Notes built with `format!` (layer counts, type names, field names) still show in English, since the report matches whole notes. --- crates/ui-egui/locales/cs.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/de.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/en.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/es-419.toml | 3 +++ crates/ui-egui/locales/es.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/fr.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/id.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ja.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ko.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/pt-BR.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ru.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/sv.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/tr.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/locales/zh-Hans.toml | 30 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n_tests.rs | 10 +++++----- docs/translating.md | 4 ++++ 16 files changed, 402 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 48deda39..7121548d 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1508,13 +1508,25 @@ status = "draft — needs native review" "can edit the document" = "může upravovat dokument" "conditional prototype actions nested too deeply were skipped" = "podmíněné akce prototypu vnořené příliš hluboko byly přeskočeny" "conditional prototype actions with conditions that can't be evaluated were skipped" = "podmíněné akce prototypu s podmínkami, které nelze vyhodnotit, byly přeskočeny" +"connector label background was skipped: style data is missing" = "pozadí popisku spojnice bylo přeskočeno: chybí data stylu" +"connector label was skipped: background bounds are invalid" = "popisek spojnice byl přeskočen: hranice pozadí jsou neplatné" +"connector label was skipped: background transform is invalid" = "popisek spojnice byl přeskočen: transformace pozadí je neplatná" +"connector label was skipped: composed text transform is invalid" = "popisek spojnice byl přeskočen: složená transformace textu je neplatná" +"connector label was skipped: derived background bounds are missing" = "popisek spojnice byl přeskočen: chybí odvozené hranice pozadí" +"connector label was skipped: derived text bounds are missing" = "popisek spojnice byl přeskočen: chybí odvozené hranice textu" +"connector label was skipped: text bounds are invalid" = "popisek spojnice byl přeskočen: hranice textu jsou neplatné" +"connector label was skipped: text style data is missing" = "popisek spojnice byl přeskočen: chybí data stylu textu" +"connector label was skipped: text transform is invalid" = "popisek spojnice byl přeskočen: transformace textu je neplatná" "connector labels were skipped" = "popisky spojnic byly přeskočeny" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "vrstvy spojnic se importují jako statické snímky rámců nebo vektorů, s popisky, pokud existují; živé vazby koncových bodů se nezachovávají" +"connector snapshot vector data is missing; connector was left empty" = "chybí vektorová data snímku spojnice; spojnice zůstala prázdná" "connectors whose ends can't be placed are empty" = "spojnice, jejichž konce nelze umístit, jsou prázdné" "crop marks" = "ořezové značky" "damaged vector geometry was skipped" = "poškozená vektorová geometrie byla přeskočena" "damaged vector networks import as outlines" = "poškozené vektorové sítě se importují jako obrysy" "development: {path}" = "vývoj: {path}" "document children other than pages were skipped" = "potomci dokumentu, kteří nejsou stránkami, byli přeskočeni" +"duplicate grid track sizing entries use equal tracks" = "duplicitní položky velikosti stop mřížky používají stejně velké stopy" "e.g. count + 1" = "např. count + 1" "e.g. count >= 3 and not done" = "např. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky" @@ -1523,14 +1535,20 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy" "from {server}" = "ze serveru {server}" "gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "velikost stop mřížky není podporována nebo je poškozená; dotčené stopy mají stejnou velikost" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam velikostí stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost" "hidden" = "skryté" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filtry obrázku jsou nejednoznačné, protože jsou přítomny oba kontejnery filtrů; převod filtrů byl přeskočen" +"image filters use FigCraft's approximate renderer" = "filtry obrázku používají přibližné vykreslování FigCraftu" "instance swap properties whose default component isn't in the file were skipped" = "vlastnosti výměny instance, jejichž výchozí komponenta není v souboru, byly přeskočeny" "instances inside their own main component were imported as frames" = "instance uvnitř vlastní hlavní komponenty byly importovány jako rámce" "instances of components that aren't in the file show no layers" = "instance komponent, které nejsou v souboru, nezobrazují žádné vrstvy" "instances of components that aren't in the file were imported as frames" = "instance komponent, které nejsou v souboru, byly importovány jako rámce" "instances that would nest a component inside itself were imported as frames" = "instance, které by vnořily komponentu samu do sebe, byly importovány jako rámce" "instances without a main component were imported as empty frames" = "instance bez hlavní komponenty byly importovány jako prázdné rámce" +"invalid internal .fig text path data was ignored" = "neplatná interní data textu na cestě ze souboru .fig byla ignorována" "invalid or oversized vector networks import as outlines" = "neplatné nebo příliš velké vektorové sítě se importují jako obrysy" "invalid or oversized vector path data was skipped" = "neplatná nebo příliš velká data vektorových cest byla přeskočena" "just now" = "právě teď" @@ -1538,12 +1556,18 @@ status = "draft — needs native review" "key up triggers import as key presses" = "spouštěče uvolnění klávesy se importují jako stisky klávesy" "layers without a position were placed at their parent's origin" = "vrstvy bez pozice byly umístěny do počátku nadřazeného prvku" "layers without a type were skipped" = "vrstvy bez typu byly přeskočeny" +"legacy image filter container is unsupported; filter conversion was skipped" = "starší kontejner filtrů obrázku není podporován; převod filtrů byl přeskočen" "locked" = "zamknuté" +"malformed image filters container was skipped" = "poškozený kontejner filtrů obrázku byl přeskočen" +"malformed image paintFilter container was skipped" = "poškozený kontejner paintFilter obrázku byl přeskočen" +"malformed slot content assignment was skipped" = "poškozené přiřazení obsahu slotu bylo přeskočeno" "no bleed" = "bez spadávky" "nobody else here yet" = "zatím tu není nikdo další" +"non-default slot settings are not represented and were skipped" = "jiná než výchozí nastavení slotů nelze znázornit a byla přeskočena" "now" = "nyní" "or" = "nebo" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "spojení tahu v bodech byla zachována; přerušované tahy a tahy s proměnnou šířkou se kreslí se spojením vrstvy" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "klíče preferovaných komponent slotů nelze přiřadit k vrstvám souboru, byly přeskočeny" "prototype actions of unknown types were skipped" = "akce prototypu neznámých typů byly přeskočeny" "prototype actions without a destination in the file were skipped" = "akce prototypu bez cíle v souboru byly přeskočeny" "prototype expressions on variables that aren't in the variables JSON were skipped" = "výrazy prototypu s proměnnými, které nejsou v JSON proměnných, byly přeskočeny" @@ -1561,8 +1585,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "akce nastavení proměnné s hodnotami, které neodpovídají proměnné, byly přeskočeny" "set-variable actions without a value were skipped" = "akce nastavení proměnné bez hodnoty byly přeskočeny" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akce nastavení režimu proměnných u kolekcí nebo režimů, které nejsou v JSON proměnných, byly přeskočeny" +"slot content assignment target is missing or is not slot content" = "cíl přiřazení obsahu slotu chybí nebo není obsahem slotu" +"slot content assignment uses the empty GUID sentinel and was skipped" = "přiřazení obsahu slotu používá prázdné GUID jako zarážku a bylo přeskočeno" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "některé vektorové body odkazují na nastavení bodů, která v souboru chybí; používají spojení vrstvy a nezaoblené rohy" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "některé vektorové segmenty odkazují na nastavení stylu, která v souboru chybí; používají tah vrstvy" +"stale or invalid grid track sizing entries were ignored" = "zastaralé nebo neplatné položky velikosti stop mřížky byly ignorovány" "stars and polygons without point counts or geometry use the default point count" = "hvězdy a mnohoúhelníky bez počtu bodů nebo geometrie používají výchozí počet bodů" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "nastavení stylu některých vektorových segmentů nejsou podporována; tyto segmenty používají tah vrstvy" +"text path underline or strikethrough decoration is not rendered" = "podtržení ani přeškrtnutí textu na cestě se nevykresluje" "text style overrides refer to missing table entries" = "přepsání textových stylů odkazují na chybějící položky tabulky" "the file has no pages" = "soubor nemá žádné stránky" "the plugin" = "plugin" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 2e0ce547..0362f897 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1458,13 +1458,25 @@ status = "draft — needs native review" "can edit the document" = "kann das Dokument bearbeiten" "conditional prototype actions nested too deeply were skipped" = "zu tief verschachtelte bedingte Prototyp-Aktionen wurden übersprungen" "conditional prototype actions with conditions that can't be evaluated were skipped" = "bedingte Prototyp-Aktionen mit nicht auswertbaren Bedingungen wurden übersprungen" +"connector label background was skipped: style data is missing" = "Hintergrund der Verbinderbeschriftung wurde übersprungen: Stildaten fehlen" +"connector label was skipped: background bounds are invalid" = "Verbinderbeschriftung wurde übersprungen: Grenzen des Hintergrunds sind ungültig" +"connector label was skipped: background transform is invalid" = "Verbinderbeschriftung wurde übersprungen: Transformation des Hintergrunds ist ungültig" +"connector label was skipped: composed text transform is invalid" = "Verbinderbeschriftung wurde übersprungen: zusammengesetzte Texttransformation ist ungültig" +"connector label was skipped: derived background bounds are missing" = "Verbinderbeschriftung wurde übersprungen: abgeleitete Grenzen des Hintergrunds fehlen" +"connector label was skipped: derived text bounds are missing" = "Verbinderbeschriftung wurde übersprungen: abgeleitete Textgrenzen fehlen" +"connector label was skipped: text bounds are invalid" = "Verbinderbeschriftung wurde übersprungen: Textgrenzen sind ungültig" +"connector label was skipped: text style data is missing" = "Verbinderbeschriftung wurde übersprungen: Textstildaten fehlen" +"connector label was skipped: text transform is invalid" = "Verbinderbeschriftung wurde übersprungen: Texttransformation ist ungültig" "connector labels were skipped" = "Verbinderbeschriftungen wurden übersprungen" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "Verbinderebenen werden als statische Rahmen- oder Vektor-Momentaufnahmen importiert, mit Beschriftungen, sofern vorhanden; dynamische Bindungen der Endpunkte bleiben nicht erhalten" +"connector snapshot vector data is missing; connector was left empty" = "Vektordaten der Verbinder-Momentaufnahme fehlen; der Verbinder bleibt leer" "connectors whose ends can't be placed are empty" = "Verbinder, deren Enden sich nicht platzieren lassen, sind leer" "crop marks" = "Schnittmarken" "damaged vector geometry was skipped" = "beschädigte Vektorgeometrie wurde übersprungen" "damaged vector networks import as outlines" = "beschädigte Vektornetze werden als Umrisse importiert" "development: {path}" = "Entwicklung: {path}" "document children other than pages were skipped" = "Dokumentinhalte, die keine Seiten sind, wurden übersprungen" +"duplicate grid track sizing entries use equal tracks" = "doppelte Größeneinträge für Rasterspuren verwenden gleich große Spuren" "e.g. count + 1" = "z. B. count + 1" "e.g. count >= 3 and not done" = "z. B. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert" @@ -1473,26 +1485,38 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "gefüllte Bereiche eines Vektors mit eigener Füllung verwenden die Füllung der Ebene" "from {server}" = "von {server}" "gamepad triggers without exactly one known controller button were skipped" = "Gamepad-Auslöser ohne genau eine bekannte Controllertaste wurden übersprungen" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspuren hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "die Größe der Rasterspuren wird nicht unterstützt oder ist fehlerhaft; betroffene Spuren sind gleich groß" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspurgrößen hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe" "hidden" = "ausgeblendet" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "Bildfilter sind mehrdeutig, weil beide Filtercontainer vorhanden sind; die Filterumwandlung wurde übersprungen" +"image filters use FigCraft's approximate renderer" = "Bildfilter verwenden die näherungsweise Darstellung von FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "Instanztausch-Eigenschaften, deren Standardkomponente nicht in der Datei ist, wurden übersprungen" "instances inside their own main component were imported as frames" = "Instanzen innerhalb ihrer eigenen Hauptkomponente wurden als Rahmen importiert" "instances of components that aren't in the file show no layers" = "Instanzen von Komponenten, die nicht in der Datei sind, zeigen keine Ebenen" "instances of components that aren't in the file were imported as frames" = "Instanzen von Komponenten, die nicht in der Datei sind, wurden als Rahmen importiert" "instances that would nest a component inside itself were imported as frames" = "Instanzen, die eine Komponente in sich selbst verschachteln würden, wurden als Rahmen importiert" "instances without a main component were imported as empty frames" = "Instanzen ohne Hauptkomponente wurden als leere Rahmen importiert" +"invalid internal .fig text path data was ignored" = "ungültige interne .fig-Daten für Text auf Pfad wurden ignoriert" "invalid or oversized vector networks import as outlines" = "ungültige oder zu große Vektornetze werden als Umrisse importiert" "invalid or oversized vector path data was skipped" = "ungültige oder zu große Vektorpfaddaten wurden übersprungen" "key triggers without a supported key were skipped" = "Tastenauslöser ohne unterstützte Taste wurden übersprungen" "key up triggers import as key presses" = "Auslöser beim Loslassen einer Taste werden als Tastendruck importiert" "layers without a position were placed at their parent's origin" = "Ebenen ohne Position wurden am Ursprung ihres übergeordneten Elements platziert" "layers without a type were skipped" = "Ebenen ohne Typ wurden übersprungen" +"legacy image filter container is unsupported; filter conversion was skipped" = "der veraltete Bildfiltercontainer wird nicht unterstützt; die Filterumwandlung wurde übersprungen" "locked" = "gesperrt" +"malformed image filters container was skipped" = "fehlerhafter Bildfiltercontainer wurde übersprungen" +"malformed image paintFilter container was skipped" = "fehlerhafter paintFilter-Container des Bildes wurde übersprungen" +"malformed slot content assignment was skipped" = "fehlerhafte Zuweisung von Slot-Inhalt wurde übersprungen" "no bleed" = "kein Anschnitt" "nobody else here yet" = "noch niemand sonst hier" +"non-default slot settings are not represented and were skipped" = "vom Standard abweichende Slot-Einstellungen werden nicht abgebildet und wurden übersprungen" "now" = "jetzt" "or" = "oder" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "die Konturecken der Punkte wurden übernommen; gestrichelte Konturen und Konturen mit variabler Breite werden mit der Ecke der Ebene gezeichnet" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "bevorzugte Komponentenschlüssel von Slots lassen sich keinen Ebenen der Datei zuordnen und wurden übersprungen" "prototype actions of unknown types were skipped" = "Prototyp-Aktionen unbekannter Art wurden übersprungen" "prototype actions without a destination in the file were skipped" = "Prototyp-Aktionen ohne Ziel in der Datei wurden übersprungen" "prototype expressions on variables that aren't in the variables JSON were skipped" = "Prototyp-Ausdrücke mit Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" @@ -1510,8 +1534,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" "set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen" +"slot content assignment target is missing or is not slot content" = "das Ziel der Slot-Inhaltszuweisung fehlt oder ist kein Slot-Inhalt" +"slot content assignment uses the empty GUID sentinel and was skipped" = "die Slot-Inhaltszuweisung verwendet die leere GUID als Platzhalterwert und wurde übersprungen" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie übernehmen die Ecken der Ebenenkontur und bleiben ungerundet" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "einige Vektorsegmente verweisen auf Stileinstellungen, die in der Datei fehlen; sie verwenden die Kontur der Ebene" +"stale or invalid grid track sizing entries were ignored" = "veraltete oder ungültige Größeneinträge für Rasterspuren wurden ignoriert" "stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "Stileinstellungen einiger Vektorsegmente werden nicht unterstützt; diese Segmente verwenden die Kontur der Ebene" +"text path underline or strikethrough decoration is not rendered" = "Unter- oder Durchstreichung von Text auf Pfad wird nicht dargestellt" "text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge" "the file has no pages" = "die Datei hat keine Seiten" "the plugin" = "das Plugin" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 524ebc47..59b25d94 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1536,13 +1536,25 @@ name = "English" "can edit the document" = "can edit the document" "conditional prototype actions nested too deeply were skipped" = "conditional prototype actions nested too deeply were skipped" "conditional prototype actions with conditions that can't be evaluated were skipped" = "conditional prototype actions with conditions that can't be evaluated were skipped" +"connector label background was skipped: style data is missing" = "connector label background was skipped: style data is missing" +"connector label was skipped: background bounds are invalid" = "connector label was skipped: background bounds are invalid" +"connector label was skipped: background transform is invalid" = "connector label was skipped: background transform is invalid" +"connector label was skipped: composed text transform is invalid" = "connector label was skipped: composed text transform is invalid" +"connector label was skipped: derived background bounds are missing" = "connector label was skipped: derived background bounds are missing" +"connector label was skipped: derived text bounds are missing" = "connector label was skipped: derived text bounds are missing" +"connector label was skipped: text bounds are invalid" = "connector label was skipped: text bounds are invalid" +"connector label was skipped: text style data is missing" = "connector label was skipped: text style data is missing" +"connector label was skipped: text transform is invalid" = "connector label was skipped: text transform is invalid" "connector labels were skipped" = "connector labels were skipped" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" +"connector snapshot vector data is missing; connector was left empty" = "connector snapshot vector data is missing; connector was left empty" "connectors whose ends can't be placed are empty" = "connectors whose ends can't be placed are empty" "crop marks" = "crop marks" "damaged vector geometry was skipped" = "damaged vector geometry was skipped" "damaged vector networks import as outlines" = "damaged vector networks import as outlines" "development: {path}" = "development: {path}" "document children other than pages were skipped" = "document children other than pages were skipped" +"duplicate grid track sizing entries use equal tracks" = "duplicate grid track sizing entries use equal tracks" "e.g. count + 1" = "e.g. count + 1" "e.g. count >= 3 and not done" = "e.g. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines" @@ -1551,14 +1563,20 @@ name = "English" "filled areas of a vector with their own fill use the layer's fill" = "filled areas of a vector with their own fill use the layer's fill" "from {server}" = "from {server}" "gamepad triggers without exactly one known controller button were skipped" = "gamepad triggers without exactly one known controller button were skipped" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track list exceeds 1000 entries; the tracks use the default equal sizing" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "grid track sizing is unsupported or malformed; affected tracks use equal sizing" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" "hidden" = "hidden" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped" +"image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer" "instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped" "instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames" "instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers" "instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames" "instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames" "instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames" +"invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored" "invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines" "invalid or oversized vector path data was skipped" = "invalid or oversized vector path data was skipped" "just now" = "just now" @@ -1566,12 +1584,18 @@ name = "English" "key up triggers import as key presses" = "key up triggers import as key presses" "layers without a position were placed at their parent's origin" = "layers without a position were placed at their parent's origin" "layers without a type were skipped" = "layers without a type were skipped" +"legacy image filter container is unsupported; filter conversion was skipped" = "legacy image filter container is unsupported; filter conversion was skipped" "locked" = "locked" +"malformed image filters container was skipped" = "malformed image filters container was skipped" +"malformed image paintFilter container was skipped" = "malformed image paintFilter container was skipped" +"malformed slot content assignment was skipped" = "malformed slot content assignment was skipped" "no bleed" = "no bleed" "nobody else here yet" = "nobody else here yet" +"non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped" "now" = "now" "or" = "or" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "preferred slot component keys cannot be resolved to file nodes and were skipped" "prototype actions of unknown types were skipped" = "prototype actions of unknown types were skipped" "prototype actions without a destination in the file were skipped" = "prototype actions without a destination in the file were skipped" "prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype expressions on variables that aren't in the variables JSON were skipped" @@ -1589,8 +1613,14 @@ name = "English" "set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped" "set-variable actions without a value were skipped" = "set-variable actions without a value were skipped" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" +"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content" +"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" +"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored" "stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke" +"text path underline or strikethrough decoration is not rendered" = "text path underline or strikethrough decoration is not rendered" "text style overrides refer to missing table entries" = "text style overrides refer to missing table entries" "the file has no pages" = "the file has no pages" "the plugin" = "the plugin" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 74ce97a8..7756fba7 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -83,6 +83,9 @@ status = "draft — needs native review" "You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editarán en conjunto." "gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de control sin exactamente un botón de control conocido" "key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como presiones de tecla" +"non-default slot settings are not represented and were skipped" = "la configuración de ranura no predeterminada no se representa y se omitió" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a configuraciones de estilo que faltan en el archivo; usan el trazo de la capa" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "la configuración de estilo de algunos segmentos vectoriales no es compatible; esos segmentos usan el trazo de la capa" "{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en esta computadora; el archivo se queda en {server}" "{n} added" = { one = "{n} agregada", other = "{n} agregadas" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 872a3ab7..01c5813b 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1537,13 +1537,25 @@ status = "draft — needs native review" "can edit the document" = "puede editar el documento" "conditional prototype actions nested too deeply were skipped" = "se omitieron las acciones condicionales de prototipo anidadas a demasiada profundidad" "conditional prototype actions with conditions that can't be evaluated were skipped" = "se omitieron las acciones condicionales de prototipo con condiciones que no se pueden evaluar" +"connector label background was skipped: style data is missing" = "se omitió el fondo de la etiqueta del conector: faltan los datos de estilo" +"connector label was skipped: background bounds are invalid" = "se omitió la etiqueta del conector: los límites del fondo no son válidos" +"connector label was skipped: background transform is invalid" = "se omitió la etiqueta del conector: la transformación del fondo no es válida" +"connector label was skipped: composed text transform is invalid" = "se omitió la etiqueta del conector: la transformación compuesta del texto no es válida" +"connector label was skipped: derived background bounds are missing" = "se omitió la etiqueta del conector: faltan los límites derivados del fondo" +"connector label was skipped: derived text bounds are missing" = "se omitió la etiqueta del conector: faltan los límites derivados del texto" +"connector label was skipped: text bounds are invalid" = "se omitió la etiqueta del conector: los límites del texto no son válidos" +"connector label was skipped: text style data is missing" = "se omitió la etiqueta del conector: faltan los datos del estilo de texto" +"connector label was skipped: text transform is invalid" = "se omitió la etiqueta del conector: la transformación del texto no es válida" "connector labels were skipped" = "se omitieron las etiquetas de los conectores" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "las capas de conector se importan como instantáneas estáticas de marco o vector, con sus etiquetas si las hay; no se conservan los vínculos dinámicos de los extremos" +"connector snapshot vector data is missing; connector was left empty" = "faltan los datos vectoriales de la instantánea del conector; el conector quedó vacío" "connectors whose ends can't be placed are empty" = "los conectores cuyos extremos no se pueden colocar están vacíos" "crop marks" = "marcas de corte" "damaged vector geometry was skipped" = "se omitió la geometría vectorial dañada" "damaged vector networks import as outlines" = "las redes vectoriales dañadas se importan como contornos" "development: {path}" = "desarrollo: {path}" "document children other than pages were skipped" = "se omitieron los elementos del documento que no son páginas" +"duplicate grid track sizing entries use equal tracks" = "las entradas duplicadas de tamaño de pista de cuadrícula usan pistas iguales" "e.g. count + 1" = "p. ej. count + 1" "e.g. count >= 3 and not done" = "p. ej. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas" @@ -1552,14 +1564,20 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "las áreas rellenas de un vector con relleno propio usan el relleno de la capa" "from {server}" = "de {server}" "gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de mando sin exactamente un botón de mando conocido" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de pistas de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "el tamaño de las pistas de cuadrícula no es compatible o está mal formado; las pistas afectadas usan tamaños iguales" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de tamaños de pista de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado" "hidden" = "oculta" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros" +"image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo" "instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos" "instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas" "instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos" "instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos" "instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos" +"invalid internal .fig text path data was ignored" = "se ignoraron datos internos no válidos de texto en trazado del .fig" "invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos" "invalid or oversized vector path data was skipped" = "se omitieron los datos de trazados vectoriales no válidos o demasiado grandes" "just now" = "justo ahora" @@ -1567,12 +1585,18 @@ status = "draft — needs native review" "key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como pulsaciones de tecla" "layers without a position were placed at their parent's origin" = "las capas sin posición se colocaron en el origen de su elemento padre" "layers without a type were skipped" = "se omitieron las capas sin tipo" +"legacy image filter container is unsupported; filter conversion was skipped" = "el contenedor antiguo de filtros de imagen no es compatible; se omitió la conversión de filtros" "locked" = "bloqueada" +"malformed image filters container was skipped" = "se omitió el contenedor de filtros de imagen mal formado" +"malformed image paintFilter container was skipped" = "se omitió el contenedor paintFilter de imagen mal formado" +"malformed slot content assignment was skipped" = "se omitió una asignación de contenido de ranura mal formada" "no bleed" = "sin sangrado" "nobody else here yet" = "aún no hay nadie más" +"non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron" "now" = "ahora" "or" = "o" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "las claves de componentes preferidos de las ranuras no corresponden a capas del archivo y se omitieron" "prototype actions of unknown types were skipped" = "se omitieron las acciones de prototipo de tipos desconocidos" "prototype actions without a destination in the file were skipped" = "se omitieron las acciones de prototipo sin destino en el archivo" "prototype expressions on variables that aren't in the variables JSON were skipped" = "se omitieron las expresiones de prototipo con variables que no están en el JSON de variables" @@ -1590,8 +1614,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable" "set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" +"slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura" +"slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa" +"stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas" "stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "los ajustes de estilo de algunos segmentos vectoriales no son compatibles; esos segmentos usan el trazo de la capa" +"text path underline or strikethrough decoration is not rendered" = "el subrayado o tachado del texto en trazado no se dibuja" "text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan" "the file has no pages" = "el archivo no tiene páginas" "the plugin" = "el plugin" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index e9afd7e5..17a0d60b 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1449,13 +1449,25 @@ status = "draft — needs native review" "can edit the document" = "peut modifier le document" "conditional prototype actions nested too deeply were skipped" = "les actions de prototype conditionnelles trop profondément imbriquées ont été ignorées" "conditional prototype actions with conditions that can't be evaluated were skipped" = "les actions de prototype conditionnelles dont les conditions ne peuvent pas être évaluées ont été ignorées" +"connector label background was skipped: style data is missing" = "l’arrière-plan du libellé du connecteur a été ignoré : les données de style sont manquantes" +"connector label was skipped: background bounds are invalid" = "le libellé du connecteur a été ignoré : les limites de l’arrière-plan ne sont pas valides" +"connector label was skipped: background transform is invalid" = "le libellé du connecteur a été ignoré : la transformation de l’arrière-plan n’est pas valide" +"connector label was skipped: composed text transform is invalid" = "le libellé du connecteur a été ignoré : la transformation composée du texte n’est pas valide" +"connector label was skipped: derived background bounds are missing" = "le libellé du connecteur a été ignoré : les limites dérivées de l’arrière-plan sont manquantes" +"connector label was skipped: derived text bounds are missing" = "le libellé du connecteur a été ignoré : les limites dérivées du texte sont manquantes" +"connector label was skipped: text bounds are invalid" = "le libellé du connecteur a été ignoré : les limites du texte ne sont pas valides" +"connector label was skipped: text style data is missing" = "le libellé du connecteur a été ignoré : les données de style du texte sont manquantes" +"connector label was skipped: text transform is invalid" = "le libellé du connecteur a été ignoré : la transformation du texte n’est pas valide" "connector labels were skipped" = "les libellés des connecteurs ont été ignorés" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "les calques de connecteur sont importés comme des instantanés statiques de cadre ou de vecteur, avec leurs libellés s’il y en a ; les liaisons dynamiques des extrémités ne sont pas conservées" +"connector snapshot vector data is missing; connector was left empty" = "les données vectorielles de l’instantané du connecteur sont manquantes ; le connecteur reste vide" "connectors whose ends can't be placed are empty" = "les connecteurs dont les extrémités ne peuvent pas être placées sont vides" "crop marks" = "traits de coupe" "damaged vector geometry was skipped" = "la géométrie vectorielle endommagée a été ignorée" "damaged vector networks import as outlines" = "les réseaux vectoriels endommagés sont importés comme tracés" "development: {path}" = "développement : {path}" "document children other than pages were skipped" = "les enfants du document autres que des pages ont été ignorés" +"duplicate grid track sizing entries use equal tracks" = "les entrées de dimensionnement de piste de grille en double utilisent des pistes égales" "e.g. count + 1" = "p. ex. count + 1" "e.g. count >= 3 and not done" = "p. ex. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites" @@ -1464,26 +1476,38 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque" "from {server}" = "depuis {server}" "gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "le dimensionnement des pistes de grille n’est pas pris en charge ou est mal formé ; les pistes concernées ont un dimensionnement égal" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste de dimensionnement des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut" "hidden" = "masqué" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "les filtres d’image sont ambigus, car les deux conteneurs de filtres sont présents ; la conversion des filtres a été ignorée" +"image filters use FigCraft's approximate renderer" = "les filtres d’image utilisent le rendu approximatif de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "les propriétés de permutation d’instance dont le composant par défaut n’est pas dans le fichier ont été ignorées" "instances inside their own main component were imported as frames" = "les instances placées dans leur propre composant principal ont été importées comme cadres" "instances of components that aren't in the file show no layers" = "les instances de composants absents du fichier n’affichent aucun calque" "instances of components that aren't in the file were imported as frames" = "les instances de composants absents du fichier ont été importées comme cadres" "instances that would nest a component inside itself were imported as frames" = "les instances qui imbriqueraient un composant dans lui-même ont été importées comme cadres" "instances without a main component were imported as empty frames" = "les instances sans composant principal ont été importées comme cadres vides" +"invalid internal .fig text path data was ignored" = "les données internes non valides de texte sur tracé du .fig ont été ignorées" "invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés" "invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées" "key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés" "key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche" "layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent" "layers without a type were skipped" = "les calques sans type ont été ignorés" +"legacy image filter container is unsupported; filter conversion was skipped" = "l’ancien conteneur de filtres d’image n’est pas pris en charge ; la conversion des filtres a été ignorée" "locked" = "verrouillé" +"malformed image filters container was skipped" = "le conteneur de filtres d’image mal formé a été ignoré" +"malformed image paintFilter container was skipped" = "le conteneur paintFilter d’image mal formé a été ignoré" +"malformed slot content assignment was skipped" = "une affectation de contenu d’emplacement mal formée a été ignorée" "no bleed" = "sans fond perdu" "nobody else here yet" = "personne d’autre pour l’instant" +"non-default slot settings are not represented and were skipped" = "les réglages d’emplacement autres que ceux par défaut ne sont pas représentés et ont été ignorés" "now" = "maintenant" "or" = "ou" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "les jointures des points ont été conservées ; les contours en pointillés ou à largeur variable sont dessinés avec la jointure du calque" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "les clés de composants préférés des emplacements ne correspondent à aucun calque du fichier et ont été ignorées" "prototype actions of unknown types were skipped" = "les actions de prototype de types inconnus ont été ignorées" "prototype actions without a destination in the file were skipped" = "les actions de prototype sans destination dans le fichier ont été ignorées" "prototype expressions on variables that aren't in the variables JSON were skipped" = "les expressions de prototype portant sur des variables absentes du JSON des variables ont été ignorées" @@ -1501,8 +1525,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "les actions de définition de variable dont les valeurs ne correspondent pas à la variable ont été ignorées" "set-variable actions without a value were skipped" = "les actions de définition de variable sans valeur ont été ignorées" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "les actions de définition du mode de variable portant sur des collections ou des modes absents du JSON des variables ont été ignorées" +"slot content assignment target is missing or is not slot content" = "la cible de l’affectation de contenu d’emplacement est absente ou n’est pas un contenu d’emplacement" +"slot content assignment uses the empty GUID sentinel and was skipped" = "l’affectation de contenu d’emplacement utilise le GUID vide comme sentinelle et a été ignorée" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "certains points vectoriels renvoient à des réglages de point absents du fichier ; ils utilisent les jointures du calque et des angles simples" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "certains segments vectoriels renvoient à des réglages de style absents du fichier ; ils utilisent le contour du calque" +"stale or invalid grid track sizing entries were ignored" = "les entrées de dimensionnement de piste de grille obsolètes ou non valides ont été ignorées" "stars and polygons without point counts or geometry use the default point count" = "les étoiles et polygones sans nombre de pointes ni géométrie utilisent le nombre de pointes par défaut" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "les réglages de style de certains segments vectoriels ne sont pas pris en charge ; ces segments utilisent le contour du calque" +"text path underline or strikethrough decoration is not rendered" = "le soulignement ou le barré du texte sur tracé n’est pas affiché" "text style overrides refer to missing table entries" = "les remplacements de style de texte renvoient à des entrées de table manquantes" "the file has no pages" = "le fichier n’a aucune page" "the plugin" = "le plugin" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a19c1587..a01944fd 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1512,13 +1512,25 @@ status = "draft — needs native review" "can edit the document" = "dapat mengedit dokumen" "conditional prototype actions nested too deeply were skipped" = "tindakan prototype kondisional yang bersarang terlalu dalam dilewati" "conditional prototype actions with conditions that can't be evaluated were skipped" = "tindakan prototype kondisional dengan kondisi yang tidak dapat dievaluasi dilewati" +"connector label background was skipped: style data is missing" = "latar belakang label konektor dilewati: data gaya tidak ada" +"connector label was skipped: background bounds are invalid" = "label konektor dilewati: batas latar belakang tidak valid" +"connector label was skipped: background transform is invalid" = "label konektor dilewati: transformasi latar belakang tidak valid" +"connector label was skipped: composed text transform is invalid" = "label konektor dilewati: transformasi gabungan teks tidak valid" +"connector label was skipped: derived background bounds are missing" = "label konektor dilewati: batas turunan latar belakang tidak ada" +"connector label was skipped: derived text bounds are missing" = "label konektor dilewati: batas turunan teks tidak ada" +"connector label was skipped: text bounds are invalid" = "label konektor dilewati: batas teks tidak valid" +"connector label was skipped: text style data is missing" = "label konektor dilewati: data gaya teks tidak ada" +"connector label was skipped: text transform is invalid" = "label konektor dilewati: transformasi teks tidak valid" "connector labels were skipped" = "label konektor dilewati" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "layer konektor diimpor sebagai snapshot frame/vector statis beserta labelnya jika ada; binding dinamis titik ujung tidak dipertahankan" +"connector snapshot vector data is missing; connector was left empty" = "data vector snapshot konektor tidak ada; konektor dibiarkan kosong" "connectors whose ends can't be placed are empty" = "konektor yang ujungnya tidak dapat ditempatkan kosong" "crop marks" = "tanda potong" "damaged vector geometry was skipped" = "geometri vector yang rusak dilewati" "damaged vector networks import as outlines" = "jaringan vector yang rusak diimpor sebagai outline" "development: {path}" = "pengembangan: {path}" "document children other than pages were skipped" = "anak dokumen selain halaman dilewati" +"duplicate grid track sizing entries use equal tracks" = "entri ukuran track grid yang duplikat memakai track yang sama besar" "e.g. count + 1" = "mis. count + 1" "e.g. count >= 3 and not done" = "mis. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus" @@ -1527,14 +1539,20 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer" "from {server}" = "dari {server}" "gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ukuran track grid tidak didukung atau rusak; track yang terdampak memakai ukuran sama besar" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar ukuran track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan" "hidden" = "tersembunyi" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filter gambar ambigu karena kedua kontainer filter ada; konversi filter dilewati" +"image filters use FigCraft's approximate renderer" = "filter gambar memakai perenderan perkiraan FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "properti tukar instance yang component bawaannya tidak ada di file dilewati" "instances inside their own main component were imported as frames" = "instance di dalam main component-nya sendiri diimpor sebagai frame" "instances of components that aren't in the file show no layers" = "instance dari component yang tidak ada di file tidak menampilkan layer" "instances of components that aren't in the file were imported as frames" = "instance dari component yang tidak ada di file diimpor sebagai frame" "instances that would nest a component inside itself were imported as frames" = "instance yang akan menyarangkan component di dalam dirinya sendiri diimpor sebagai frame" "instances without a main component were imported as empty frames" = "instance tanpa main component diimpor sebagai frame kosong" +"invalid internal .fig text path data was ignored" = "data teks di path internal .fig yang tidak valid diabaikan" "invalid or oversized vector networks import as outlines" = "jaringan vector yang tidak valid atau terlalu besar diimpor sebagai outline" "invalid or oversized vector path data was skipped" = "data jalur vector yang tidak valid atau terlalu besar dilewati" "just now" = "baru saja" @@ -1542,12 +1560,18 @@ status = "draft — needs native review" "key up triggers import as key presses" = "pemicu saat tombol dilepas diimpor sebagai penekanan tombol" "layers without a position were placed at their parent's origin" = "layer tanpa posisi ditempatkan di titik asal induknya" "layers without a type were skipped" = "layer tanpa tipe dilewati" +"legacy image filter container is unsupported; filter conversion was skipped" = "kontainer filter gambar versi lama tidak didukung; konversi filter dilewati" "locked" = "terkunci" +"malformed image filters container was skipped" = "kontainer filter gambar yang rusak dilewati" +"malformed image paintFilter container was skipped" = "kontainer paintFilter gambar yang rusak dilewati" +"malformed slot content assignment was skipped" = "penetapan konten slot yang rusak dilewati" "no bleed" = "tanpa bleed" "nobody else here yet" = "belum ada orang lain di sini" +"non-default slot settings are not represented and were skipped" = "pengaturan slot non-default tidak dapat direpresentasikan dan dilewati" "now" = "sekarang" "or" = "atau" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "sambungan stroke per titik dipertahankan; stroke putus-putus atau berlebar variabel digambar dengan sambungan layer" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "kunci component pilihan slot tidak dapat dicocokkan dengan layer di file dan dilewati" "prototype actions of unknown types were skipped" = "tindakan prototype dengan tipe yang tidak dikenal dilewati" "prototype actions without a destination in the file were skipped" = "tindakan prototype tanpa tujuan di file dilewati" "prototype expressions on variables that aren't in the variables JSON were skipped" = "ekspresi prototype pada variabel yang tidak ada di JSON variabel dilewati" @@ -1565,8 +1589,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "tindakan atur variabel dengan nilai yang tidak cocok dengan variabelnya dilewati" "set-variable actions without a value were skipped" = "tindakan atur variabel tanpa nilai dilewati" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "tindakan atur mode variabel pada koleksi atau mode yang tidak ada di JSON variabel dilewati" +"slot content assignment target is missing or is not slot content" = "target penetapan konten slot tidak ada atau bukan konten slot" +"slot content assignment uses the empty GUID sentinel and was skipped" = "penetapan konten slot memakai GUID kosong sebagai penanda dan dilewati" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "beberapa titik vector merujuk ke pengaturan titik yang tidak ada di file; titik tersebut memakai sambungan layer dan sudut biasa" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "beberapa segmen vector merujuk ke pengaturan gaya yang tidak ada di file; segmen tersebut memakai stroke layer" +"stale or invalid grid track sizing entries were ignored" = "entri ukuran track grid yang usang atau tidak valid diabaikan" "stars and polygons without point counts or geometry use the default point count" = "bintang dan poligon tanpa jumlah titik atau geometri memakai jumlah titik bawaan" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "pengaturan gaya beberapa segmen vector tidak didukung; segmen tersebut memakai stroke layer" +"text path underline or strikethrough decoration is not rendered" = "garis bawah atau coretan pada teks di path tidak digambar" "text style overrides refer to missing table entries" = "penimpaan gaya teks merujuk ke entri tabel yang tidak ada" "the file has no pages" = "file tidak memiliki halaman" "the plugin" = "plugin" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index fb3a95e5..965f8ead 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1522,13 +1522,25 @@ status = "draft — needs native review" "can edit the document" = "ドキュメントを編集できる" "conditional prototype actions nested too deeply were skipped" = "入れ子が深すぎる条件付きプロトタイプアクションをスキップ" "conditional prototype actions with conditions that can't be evaluated were skipped" = "評価できない条件を持つ条件付きプロトタイプアクションをスキップ" +"connector label background was skipped: style data is missing" = "コネクタラベルの背景をスキップ:スタイルデータがない" +"connector label was skipped: background bounds are invalid" = "コネクタラベルをスキップ:背景の境界が無効" +"connector label was skipped: background transform is invalid" = "コネクタラベルをスキップ:背景の変形が無効" +"connector label was skipped: composed text transform is invalid" = "コネクタラベルをスキップ:合成したテキストの変形が無効" +"connector label was skipped: derived background bounds are missing" = "コネクタラベルをスキップ:算出した背景の境界がない" +"connector label was skipped: derived text bounds are missing" = "コネクタラベルをスキップ:算出したテキストの境界がない" +"connector label was skipped: text bounds are invalid" = "コネクタラベルをスキップ:テキストの境界が無効" +"connector label was skipped: text style data is missing" = "コネクタラベルをスキップ:テキストスタイルのデータがない" +"connector label was skipped: text transform is invalid" = "コネクタラベルをスキップ:テキストの変形が無効" "connector labels were skipped" = "コネクタのラベルをスキップ" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "コネクタレイヤーは静的なフレーム/ベクターのスナップショットとして(ラベルがあればラベルも)インポート。端点の動的なバインドは保持しない" +"connector snapshot vector data is missing; connector was left empty" = "コネクタのスナップショットのベクターデータがないため、コネクタは空のまま" "connectors whose ends can't be placed are empty" = "端点を配置できないコネクタは空" "crop marks" = "トンボ" "damaged vector geometry was skipped" = "破損したベクタージオメトリをスキップ" "damaged vector networks import as outlines" = "破損したベクターネットワークはアウトラインとしてインポート" "development: {path}" = "開発:{path}" "document children other than pages were skipped" = "ページ以外のドキュメントの子をスキップ" +"duplicate grid track sizing entries use equal tracks" = "重複したグリッドトラックのサイズ指定は均等なトラックを使用" "e.g. count + 1" = "例:count + 1" "e.g. count >= 3 and not done" = "例:count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート" @@ -1537,14 +1549,20 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "独自の塗りを持つベクターの塗りつぶし領域にはレイヤーの塗りを使用" "from {server}" = "{server} から" "gamepad triggers without exactly one known controller button were skipped" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "グリッドトラックのリストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "グリッドトラックのサイズ指定が未対応または不正なため、該当するトラックは均等サイズを使用" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "グリッドトラックのサイズ指定リストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用" "hidden" = "非表示" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "両方のフィルターコンテナがあり画像フィルターが曖昧なため、フィルターの変換をスキップ" +"image filters use FigCraft's approximate renderer" = "画像フィルターは FigCraft の近似レンダリングで描画" "instance swap properties whose default component isn't in the file were skipped" = "デフォルトのコンポーネントがファイルにないインスタンスの入れ替えプロパティをスキップ" "instances inside their own main component were imported as frames" = "自身のメインコンポーネント内にあるインスタンスはフレームとしてインポート" "instances of components that aren't in the file show no layers" = "ファイルにないコンポーネントのインスタンスにはレイヤーが表示されない" "instances of components that aren't in the file were imported as frames" = "ファイルにないコンポーネントのインスタンスはフレームとしてインポート" "instances that would nest a component inside itself were imported as frames" = "コンポーネントを自身の中に入れ子にしてしまうインスタンスはフレームとしてインポート" "instances without a main component were imported as empty frames" = "メインコンポーネントのないインスタンスは空のフレームとしてインポート" +"invalid internal .fig text path data was ignored" = "無効な .fig 内部のパス上テキストのデータを無視した" "invalid or oversized vector networks import as outlines" = "無効または大きすぎるベクターネットワークはアウトラインとしてインポート" "invalid or oversized vector path data was skipped" = "無効または大きすぎるベクターパスデータをスキップ" "just now" = "たった今" @@ -1552,12 +1570,18 @@ status = "draft — needs native review" "key up triggers import as key presses" = "キーを離すトリガーはキー押下としてインポート" "layers without a position were placed at their parent's origin" = "位置のないレイヤーは親の原点に配置" "layers without a type were skipped" = "タイプのないレイヤーをスキップ" +"legacy image filter container is unsupported; filter conversion was skipped" = "旧形式の画像フィルターコンテナは未対応のため、フィルターの変換をスキップ" "locked" = "ロック中" +"malformed image filters container was skipped" = "不正な画像フィルターコンテナをスキップ" +"malformed image paintFilter container was skipped" = "不正な画像の paintFilter コンテナをスキップ" +"malformed slot content assignment was skipped" = "不正なスロットコンテンツの割り当てをスキップ" "no bleed" = "裁ち落としなし" "nobody else here yet" = "まだ他に誰もいない" +"non-default slot settings are not represented and were skipped" = "デフォルト以外のスロット設定は再現できないためスキップ" "now" = "たった今" "or" = "または" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "ポイントごとの角の形状は保持した。破線や可変幅の線はレイヤーの角の形状で描画" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "スロットの優先コンポーネントキーをファイル内のレイヤーに解決できないためスキップ" "prototype actions of unknown types were skipped" = "不明なタイプのプロトタイプアクションをスキップ" "prototype actions without a destination in the file were skipped" = "ファイル内に移動先のないプロトタイプアクションをスキップ" "prototype expressions on variables that aren't in the variables JSON were skipped" = "変数 JSON にない変数を使うプロトタイプの式をスキップ" @@ -1575,8 +1599,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "変数に合わない値を持つ変数設定アクションをスキップ" "set-variable actions without a value were skipped" = "値のない変数設定アクションをスキップ" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "変数 JSON にないコレクションまたはモードに対する変数モード設定アクションをスキップ" +"slot content assignment target is missing or is not slot content" = "スロットコンテンツの割り当て先がないか、スロットコンテンツではない" +"slot content assignment uses the empty GUID sentinel and was skipped" = "スロットコンテンツの割り当てが空の GUID を番兵値として使用しているためスキップ" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "一部のベクターポイントがファイルにないポイント設定を参照している。それらのポイントはレイヤーの角の形状と丸めのない角を使用" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "一部のベクターセグメントがファイルにないスタイル設定を参照している。それらのセグメントはレイヤーの線を使用" +"stale or invalid grid track sizing entries were ignored" = "古いまたは無効なグリッドトラックのサイズ指定を無視した" "stars and polygons without point counts or geometry use the default point count" = "ポイント数もジオメトリもない星と多角形はデフォルトのポイント数を使用" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "一部のベクターセグメントのスタイル設定は未対応。それらのセグメントはレイヤーの線を使用" +"text path underline or strikethrough decoration is not rendered" = "パス上テキストの下線や取り消し線は描画しない" "text style overrides refer to missing table entries" = "テキストスタイルのオーバーライドが存在しないテーブル項目を参照している" "the file has no pages" = "ファイルにページがない" "the plugin" = "このプラグイン" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 7cc9bab5..649fc26c 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1449,13 +1449,25 @@ status = "draft — needs native review" "can edit the document" = "문서를 편집할 수 있음" "conditional prototype actions nested too deeply were skipped" = "너무 깊게 중첩된 조건부 프로토타입 액션을 건너뜀" "conditional prototype actions with conditions that can't be evaluated were skipped" = "평가할 수 없는 조건이 있는 조건부 프로토타입 액션을 건너뜀" +"connector label background was skipped: style data is missing" = "커넥터 레이블 배경을 건너뜀: 스타일 데이터가 없음" +"connector label was skipped: background bounds are invalid" = "커넥터 레이블을 건너뜀: 배경 경계가 잘못됨" +"connector label was skipped: background transform is invalid" = "커넥터 레이블을 건너뜀: 배경 변형이 잘못됨" +"connector label was skipped: composed text transform is invalid" = "커넥터 레이블을 건너뜀: 합성된 텍스트 변형이 잘못됨" +"connector label was skipped: derived background bounds are missing" = "커넥터 레이블을 건너뜀: 계산된 배경 경계가 없음" +"connector label was skipped: derived text bounds are missing" = "커넥터 레이블을 건너뜀: 계산된 텍스트 경계가 없음" +"connector label was skipped: text bounds are invalid" = "커넥터 레이블을 건너뜀: 텍스트 경계가 잘못됨" +"connector label was skipped: text style data is missing" = "커넥터 레이블을 건너뜀: 텍스트 스타일 데이터가 없음" +"connector label was skipped: text transform is invalid" = "커넥터 레이블을 건너뜀: 텍스트 변형이 잘못됨" "connector labels were skipped" = "커넥터 레이블을 건너뜀" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "커넥터 레이어는 정적인 프레임/벡터 스냅샷으로 가져오며 레이블이 있으면 함께 가져옴. 끝점의 동적 바인딩은 유지되지 않음" +"connector snapshot vector data is missing; connector was left empty" = "커넥터 스냅샷의 벡터 데이터가 없어 커넥터를 비워 둠" "connectors whose ends can't be placed are empty" = "끝점을 배치할 수 없는 커넥터는 비어 있음" "crop marks" = "재단선 표시" "damaged vector geometry was skipped" = "손상된 벡터 지오메트리를 건너뜀" "damaged vector networks import as outlines" = "손상된 벡터 네트워크는 윤곽선으로 가져옴" "development: {path}" = "개발: {path}" "document children other than pages were skipped" = "페이지가 아닌 문서 하위 항목을 건너뜀" +"duplicate grid track sizing entries use equal tracks" = "중복된 그리드 트랙 크기 항목은 균등한 트랙을 사용함" "e.g. count + 1" = "예: count + 1" "e.g. count >= 3 and not done" = "예: count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴" @@ -1464,26 +1476,38 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "자체 채우기가 있는 벡터의 채워진 영역은 레이어의 채우기를 사용함" "from {server}" = "{server}에서" "gamepad triggers without exactly one known controller button were skipped" = "알려진 컨트롤러 버튼이 정확히 하나가 아닌 게임패드 트리거를 건너뜀" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "그리드 트랙 목록이 1000개 항목을 넘어 트랙은 기본 균등 크기를 사용함" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "그리드 트랙 크기가 지원되지 않거나 잘못되어 해당 트랙은 균등 크기를 사용함" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "그리드 트랙 크기 목록이 1000개 항목을 넘어 트랙은 기본 균등 크기를 사용함" "hidden" = "숨김" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "필터 컨테이너가 둘 다 있어 이미지 필터가 모호하므로 필터 변환을 건너뜀" +"image filters use FigCraft's approximate renderer" = "이미지 필터는 FigCraft의 근사 렌더링을 사용함" "instance swap properties whose default component isn't in the file were skipped" = "기본 컴포넌트가 파일에 없는 인스턴스 교체 속성을 건너뜀" "instances inside their own main component were imported as frames" = "자체 메인 컴포넌트 안에 있는 인스턴스는 프레임으로 가져옴" "instances of components that aren't in the file show no layers" = "파일에 없는 컴포넌트의 인스턴스에는 레이어가 표시되지 않음" "instances of components that aren't in the file were imported as frames" = "파일에 없는 컴포넌트의 인스턴스는 프레임으로 가져옴" "instances that would nest a component inside itself were imported as frames" = "컴포넌트를 자기 안에 중첩하게 되는 인스턴스는 프레임으로 가져옴" "instances without a main component were imported as empty frames" = "메인 컴포넌트가 없는 인스턴스는 빈 프레임으로 가져옴" +"invalid internal .fig text path data was ignored" = "잘못된 .fig 내부 패스 위 텍스트 데이터를 무시함" "invalid or oversized vector networks import as outlines" = "잘못되었거나 너무 큰 벡터 네트워크는 윤곽선으로 가져옴" "invalid or oversized vector path data was skipped" = "잘못되었거나 너무 큰 벡터 경로 데이터를 건너뜀" "key triggers without a supported key were skipped" = "지원되는 키가 없는 키 트리거를 건너뜀" "key up triggers import as key presses" = "키를 뗄 때 트리거는 키 누름으로 가져옴" "layers without a position were placed at their parent's origin" = "위치가 없는 레이어는 상위 요소의 원점에 배치됨" "layers without a type were skipped" = "유형이 없는 레이어를 건너뜀" +"legacy image filter container is unsupported; filter conversion was skipped" = "레거시 이미지 필터 컨테이너는 지원되지 않아 필터 변환을 건너뜀" "locked" = "잠김" +"malformed image filters container was skipped" = "잘못된 형식의 이미지 필터 컨테이너를 건너뜀" +"malformed image paintFilter container was skipped" = "잘못된 형식의 이미지 paintFilter 컨테이너를 건너뜀" +"malformed slot content assignment was skipped" = "잘못된 형식의 슬롯 콘텐츠 할당을 건너뜀" "no bleed" = "도련 없음" "nobody else here yet" = "아직 다른 사람이 없음" +"non-default slot settings are not represented and were skipped" = "기본값이 아닌 슬롯 설정은 표현할 수 없어 건너뜀" "now" = "방금" "or" = "또는" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "점별 선 연결을 유지함. 파선이나 가변 너비 선은 레이어의 선 연결로 그려짐" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "슬롯의 기본 설정 컴포넌트 키를 파일의 레이어로 확인할 수 없어 건너뜀" "prototype actions of unknown types were skipped" = "알 수 없는 유형의 프로토타입 액션을 건너뜀" "prototype actions without a destination in the file were skipped" = "파일에 대상이 없는 프로토타입 액션을 건너뜀" "prototype expressions on variables that aren't in the variables JSON were skipped" = "변수 JSON에 없는 변수에 대한 프로토타입 수식을 건너뜀" @@ -1501,8 +1525,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "변수에 맞지 않는 값이 있는 변수 설정 액션을 건너뜀" "set-variable actions without a value were skipped" = "값이 없는 변수 설정 액션을 건너뜀" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "변수 JSON에 없는 컬렉션이나 모드에 대한 변수 모드 설정 액션을 건너뜀" +"slot content assignment target is missing or is not slot content" = "슬롯 콘텐츠 할당 대상이 없거나 슬롯 콘텐츠가 아님" +"slot content assignment uses the empty GUID sentinel and was skipped" = "슬롯 콘텐츠 할당이 빈 GUID를 센티넬 값으로 사용하여 건너뜀" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "일부 벡터 점이 파일에 없는 점 설정을 참조함. 해당 점은 레이어의 선 연결과 둥글리지 않은 모서리를 사용함" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "일부 벡터 세그먼트가 파일에 없는 스타일 설정을 참조함. 해당 세그먼트는 레이어의 선을 사용함" +"stale or invalid grid track sizing entries were ignored" = "오래되었거나 잘못된 그리드 트랙 크기 항목을 무시함" "stars and polygons without point counts or geometry use the default point count" = "점 개수나 지오메트리가 없는 별과 다각형은 기본 점 개수를 사용함" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "일부 벡터 세그먼트의 스타일 설정은 지원되지 않음. 해당 세그먼트는 레이어의 선을 사용함" +"text path underline or strikethrough decoration is not rendered" = "패스 위 텍스트의 밑줄 또는 취소선은 그려지지 않음" "text style overrides refer to missing table entries" = "텍스트 스타일 재정의가 없는 테이블 항목을 참조함" "the file has no pages" = "파일에 페이지가 없음" "the plugin" = "이 플러그인" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 30a3a186..c31b6947 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1448,13 +1448,25 @@ status = "draft — needs native review" "can edit the document" = "pode editar o documento" "conditional prototype actions nested too deeply were skipped" = "ações condicionais de protótipo aninhadas profundamente demais foram ignoradas" "conditional prototype actions with conditions that can't be evaluated were skipped" = "ações condicionais de protótipo com condições que não podem ser avaliadas foram ignoradas" +"connector label background was skipped: style data is missing" = "o fundo do rótulo do conector foi ignorado: faltam os dados de estilo" +"connector label was skipped: background bounds are invalid" = "o rótulo do conector foi ignorado: os limites do fundo são inválidos" +"connector label was skipped: background transform is invalid" = "o rótulo do conector foi ignorado: a transformação do fundo é inválida" +"connector label was skipped: composed text transform is invalid" = "o rótulo do conector foi ignorado: a transformação composta do texto é inválida" +"connector label was skipped: derived background bounds are missing" = "o rótulo do conector foi ignorado: faltam os limites derivados do fundo" +"connector label was skipped: derived text bounds are missing" = "o rótulo do conector foi ignorado: faltam os limites derivados do texto" +"connector label was skipped: text bounds are invalid" = "o rótulo do conector foi ignorado: os limites do texto são inválidos" +"connector label was skipped: text style data is missing" = "o rótulo do conector foi ignorado: faltam os dados de estilo do texto" +"connector label was skipped: text transform is invalid" = "o rótulo do conector foi ignorado: a transformação do texto é inválida" "connector labels were skipped" = "rótulos de conectores foram ignorados" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "camadas de conector são importadas como instantâneos estáticos de quadro ou vetor, com rótulos quando houver; os vínculos dinâmicos das pontas não são mantidos" +"connector snapshot vector data is missing; connector was left empty" = "faltam os dados vetoriais do instantâneo do conector; o conector ficou vazio" "connectors whose ends can't be placed are empty" = "conectores cujas pontas não podem ser posicionadas ficam vazios" "crop marks" = "marcas de corte" "damaged vector geometry was skipped" = "geometria vetorial danificada foi ignorada" "damaged vector networks import as outlines" = "redes vetoriais danificadas são importadas como contornos" "development: {path}" = "desenvolvimento: {path}" "document children other than pages were skipped" = "filhos do documento que não são páginas foram ignorados" +"duplicate grid track sizing entries use equal tracks" = "entradas duplicadas de dimensionamento de faixas da grade usam faixas iguais" "e.g. count + 1" = "por ex. count + 1" "e.g. count >= 3 and not done" = "por ex. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas" @@ -1463,26 +1475,38 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada" "from {server}" = "de {server}" "gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "o dimensionamento de faixas da grade não é compatível ou está malformado; as faixas afetadas usam dimensionamento igual" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de dimensionamento de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão" "hidden" = "oculta" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "os filtros de imagem são ambíguos porque os dois contêineres de filtro estão presentes; a conversão dos filtros foi ignorada" +"image filters use FigCraft's approximate renderer" = "os filtros de imagem usam a renderização aproximada do FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "propriedades de troca de instância cujo componente padrão não está no arquivo foram ignoradas" "instances inside their own main component were imported as frames" = "instâncias dentro do próprio componente principal foram importadas como quadros" "instances of components that aren't in the file show no layers" = "instâncias de componentes que não estão no arquivo não mostram camadas" "instances of components that aren't in the file were imported as frames" = "instâncias de componentes que não estão no arquivo foram importadas como quadros" "instances that would nest a component inside itself were imported as frames" = "instâncias que aninhariam um componente dentro dele mesmo foram importadas como quadros" "instances without a main component were imported as empty frames" = "instâncias sem componente principal foram importadas como quadros vazios" +"invalid internal .fig text path data was ignored" = "dados internos inválidos de texto em caminho do .fig foram ignorados" "invalid or oversized vector networks import as outlines" = "redes vetoriais inválidas ou grandes demais são importadas como contornos" "invalid or oversized vector path data was skipped" = "dados de caminho vetorial inválidos ou grandes demais foram ignorados" "key triggers without a supported key were skipped" = "gatilhos de tecla sem uma tecla compatível foram ignorados" "key up triggers import as key presses" = "gatilhos ao soltar uma tecla são importados como pressionamentos de tecla" "layers without a position were placed at their parent's origin" = "camadas sem posição foram colocadas na origem do elemento pai" "layers without a type were skipped" = "camadas sem tipo foram ignoradas" +"legacy image filter container is unsupported; filter conversion was skipped" = "o contêiner legado de filtros de imagem não é compatível; a conversão dos filtros foi ignorada" "locked" = "bloqueada" +"malformed image filters container was skipped" = "o contêiner de filtros de imagem malformado foi ignorado" +"malformed image paintFilter container was skipped" = "o contêiner paintFilter de imagem malformado foi ignorado" +"malformed slot content assignment was skipped" = "uma atribuição de conteúdo de slot malformada foi ignorada" "no bleed" = "sem sangria" "nobody else here yet" = "ainda não há mais ninguém" +"non-default slot settings are not represented and were skipped" = "configurações de slot diferentes do padrão não são representadas e foram ignoradas" "now" = "agora" "or" = "ou" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "as junções de traço por ponto foram mantidas; traços tracejados ou de largura variável são desenhados com a junção da camada" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "chaves de componentes preferidos de slots não correspondem a camadas do arquivo e foram ignoradas" "prototype actions of unknown types were skipped" = "ações de protótipo de tipos desconhecidos foram ignoradas" "prototype actions without a destination in the file were skipped" = "ações de protótipo sem destino no arquivo foram ignoradas" "prototype expressions on variables that aren't in the variables JSON were skipped" = "expressões de protótipo com variáveis que não estão no JSON de variáveis foram ignoradas" @@ -1500,8 +1524,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "ações de definir variável com valores que não cabem na variável foram ignoradas" "set-variable actions without a value were skipped" = "ações de definir variável sem valor foram ignoradas" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ações de definir modo de variável em coleções ou modos que não estão no JSON de variáveis foram ignoradas" +"slot content assignment target is missing or is not slot content" = "o destino da atribuição de conteúdo de slot está ausente ou não é conteúdo de slot" +"slot content assignment uses the empty GUID sentinel and was skipped" = "a atribuição de conteúdo de slot usa o GUID vazio como sentinela e foi ignorada" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "alguns pontos vetoriais se referem a configurações de ponto ausentes no arquivo; eles usam as junções da camada e cantos simples" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "alguns segmentos vetoriais se referem a configurações de estilo ausentes no arquivo; eles usam o traço da camada" +"stale or invalid grid track sizing entries were ignored" = "entradas de dimensionamento de faixas da grade obsoletas ou inválidas foram ignoradas" "stars and polygons without point counts or geometry use the default point count" = "estrelas e polígonos sem número de pontas nem geometria usam o número de pontas padrão" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "as configurações de estilo de alguns segmentos vetoriais não são compatíveis; esses segmentos usam o traço da camada" +"text path underline or strikethrough decoration is not rendered" = "o sublinhado ou tachado do texto em caminho não é desenhado" "text style overrides refer to missing table entries" = "substituições de estilo de texto se referem a entradas de tabela ausentes" "the file has no pages" = "o arquivo não tem páginas" "the plugin" = "o plugin" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index d039a14f..998eefda 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1510,13 +1510,25 @@ name = "Русский" "can edit the document" = "может редактировать документ" "conditional prototype actions nested too deeply were skipped" = "условные действия прототипа со слишком глубокой вложенностью пропущены" "conditional prototype actions with conditions that can't be evaluated were skipped" = "условные действия прототипа с условиями, которые нельзя вычислить, пропущены" +"connector label background was skipped: style data is missing" = "фон подписи соединительной линии пропущен: нет данных стиля" +"connector label was skipped: background bounds are invalid" = "подпись соединительной линии пропущена: недопустимые границы фона" +"connector label was skipped: background transform is invalid" = "подпись соединительной линии пропущена: недопустимая трансформация фона" +"connector label was skipped: composed text transform is invalid" = "подпись соединительной линии пропущена: недопустимая составная трансформация текста" +"connector label was skipped: derived background bounds are missing" = "подпись соединительной линии пропущена: нет вычисленных границ фона" +"connector label was skipped: derived text bounds are missing" = "подпись соединительной линии пропущена: нет вычисленных границ текста" +"connector label was skipped: text bounds are invalid" = "подпись соединительной линии пропущена: недопустимые границы текста" +"connector label was skipped: text style data is missing" = "подпись соединительной линии пропущена: нет данных стиля текста" +"connector label was skipped: text transform is invalid" = "подпись соединительной линии пропущена: недопустимая трансформация текста" "connector labels were skipped" = "подписи соединительных линий пропущены" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "соединительные линии импортируются как статичные снимки фреймов или векторов, с подписями, если они есть; динамические привязки концов не сохраняются" +"connector snapshot vector data is missing; connector was left empty" = "нет векторных данных снимка соединительной линии; соединительная линия оставлена пустой" "connectors whose ends can't be placed are empty" = "соединительные линии, концы которых нельзя разместить, пусты" "crop marks" = "метки обреза" "damaged vector geometry was skipped" = "повреждённая векторная геометрия пропущена" "damaged vector networks import as outlines" = "повреждённые векторные сети импортируются как контуры" "development: {path}" = "разработка: {path}" "document children other than pages were skipped" = "дочерние элементы документа, не являющиеся страницами, пропущены" +"duplicate grid track sizing entries use equal tracks" = "для повторяющихся записей размеров треков сетки используются равные треки" "e.g. count + 1" = "например, count + 1" "e.g. count >= 3 and not done" = "например, count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые" @@ -1525,14 +1537,20 @@ name = "Русский" "filled areas of a vector with their own fill use the layer's fill" = "залитые области вектора с собственной заливкой используют заливку слоя" "from {server}" = "с {server}" "gamepad triggers without exactly one known controller button were skipped" = "триггеры геймпада без ровно одной известной кнопки контроллера пропущены" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "список треков сетки превышает 1000 записей; треки получают стандартный равный размер" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "размеры треков сетки не поддерживаются или повреждены; затронутые треки получают равный размер" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "список размеров треков сетки превышает 1000 записей; треки получают стандартный равный размер" "hidden" = "скрыто" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "фильтры изображения неоднозначны, так как есть оба контейнера фильтров; преобразование фильтров пропущено" +"image filters use FigCraft's approximate renderer" = "фильтры изображения отрисовываются приблизительно средствами FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "свойства замены экземпляра, компонент по умолчанию которых отсутствует в файле, пропущены" "instances inside their own main component were imported as frames" = "экземпляры внутри собственного основного компонента импортированы как фреймы" "instances of components that aren't in the file show no layers" = "экземпляры компонентов, которых нет в файле, не показывают слоёв" "instances of components that aren't in the file were imported as frames" = "экземпляры компонентов, которых нет в файле, импортированы как фреймы" "instances that would nest a component inside itself were imported as frames" = "экземпляры, которые вложили бы компонент в самого себя, импортированы как фреймы" "instances without a main component were imported as empty frames" = "экземпляры без основного компонента импортированы как пустые фреймы" +"invalid internal .fig text path data was ignored" = "недопустимые внутренние данные текста на пути из .fig проигнорированы" "invalid or oversized vector networks import as outlines" = "недопустимые или слишком большие векторные сети импортируются как контуры" "invalid or oversized vector path data was skipped" = "недопустимые или слишком большие данные векторных путей пропущены" "just now" = "только что" @@ -1540,12 +1558,18 @@ name = "Русский" "key up triggers import as key presses" = "триггеры отпускания клавиши импортируются как нажатия клавиши" "layers without a position were placed at their parent's origin" = "слои без положения размещены в начале координат родителя" "layers without a type were skipped" = "слои без типа пропущены" +"legacy image filter container is unsupported; filter conversion was skipped" = "устаревший контейнер фильтров изображения не поддерживается; преобразование фильтров пропущено" "locked" = "заблокировано" +"malformed image filters container was skipped" = "повреждённый контейнер фильтров изображения пропущен" +"malformed image paintFilter container was skipped" = "повреждённый контейнер paintFilter изображения пропущен" +"malformed slot content assignment was skipped" = "повреждённое назначение содержимого слота пропущено" "no bleed" = "без вылета" "nobody else here yet" = "здесь пока больше никого нет" +"non-default slot settings are not represented and were skipped" = "нестандартные настройки слотов не передаются и пропущены" "now" = "сейчас" "or" = "или" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "ключи предпочтительных компонентов слотов не удалось сопоставить со слоями файла, они пропущены" "prototype actions of unknown types were skipped" = "действия прототипа неизвестных типов пропущены" "prototype actions without a destination in the file were skipped" = "действия прототипа без цели в файле пропущены" "prototype expressions on variables that aren't in the variables JSON were skipped" = "выражения прототипа с переменными, которых нет в JSON переменных, пропущены" @@ -1563,8 +1587,14 @@ name = "Русский" "set-variable actions with values that don't fit the variable were skipped" = "действия «Задать переменную» со значениями, не подходящими переменной, пропущены" "set-variable actions without a value were skipped" = "действия «Задать переменную» без значения пропущены" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены" +"slot content assignment target is missing or is not slot content" = "цель назначения содержимого слота отсутствует или не является содержимым слота" +"slot content assignment uses the empty GUID sentinel and was skipped" = "назначение содержимого слота использует пустой GUID-маркер и пропущено" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "некоторые векторные сегменты ссылаются на настройки стиля, которых нет в файле; они используют обводку слоя" +"stale or invalid grid track sizing entries were ignored" = "устаревшие или недопустимые записи размеров треков сетки проигнорированы" "stars and polygons without point counts or geometry use the default point count" = "звёзды и многоугольники без числа вершин или геометрии используют число вершин по умолчанию" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "настройки стиля некоторых векторных сегментов не поддерживаются; эти сегменты используют обводку слоя" +"text path underline or strikethrough decoration is not rendered" = "подчёркивание и зачёркивание текста на пути не отображаются" "text style overrides refer to missing table entries" = "переопределения текстовых стилей ссылаются на отсутствующие записи таблицы" "the file has no pages" = "в файле нет страниц" "the plugin" = "плагин" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 7ebc07c0..eea2d312 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1529,13 +1529,25 @@ name = "Svenska" "can edit the document" = "kan redigera dokumentet" "conditional prototype actions nested too deeply were skipped" = "villkorliga prototypåtgärder som var nästlade för djupt hoppades över" "conditional prototype actions with conditions that can't be evaluated were skipped" = "villkorliga prototypåtgärder med villkor som inte kan utvärderas hoppades över" +"connector label background was skipped: style data is missing" = "bakgrunden till etiketten på en kopplingslinje hoppades över: stildata saknas" +"connector label was skipped: background bounds are invalid" = "etiketten på en kopplingslinje hoppades över: bakgrundens gränser är ogiltiga" +"connector label was skipped: background transform is invalid" = "etiketten på en kopplingslinje hoppades över: bakgrundens transformering är ogiltig" +"connector label was skipped: composed text transform is invalid" = "etiketten på en kopplingslinje hoppades över: textens sammansatta transformering är ogiltig" +"connector label was skipped: derived background bounds are missing" = "etiketten på en kopplingslinje hoppades över: bakgrundens härledda gränser saknas" +"connector label was skipped: derived text bounds are missing" = "etiketten på en kopplingslinje hoppades över: textens härledda gränser saknas" +"connector label was skipped: text bounds are invalid" = "etiketten på en kopplingslinje hoppades över: textens gränser är ogiltiga" +"connector label was skipped: text style data is missing" = "etiketten på en kopplingslinje hoppades över: textens stildata saknas" +"connector label was skipped: text transform is invalid" = "etiketten på en kopplingslinje hoppades över: textens transformering är ogiltig" "connector labels were skipped" = "etiketter på kopplingslinjer hoppades över" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "kopplingslinjelager importeras som statiska ögonblicksbilder av ramar eller vektorer, med etiketter om de finns; dynamiska bindningar av ändpunkterna behålls inte" +"connector snapshot vector data is missing; connector was left empty" = "vektordata för kopplingslinjens ögonblicksbild saknas; kopplingslinjen lämnades tom" "connectors whose ends can't be placed are empty" = "kopplingslinjer vars ändar inte kan placeras är tomma" "crop marks" = "skärmärken" "damaged vector geometry was skipped" = "skadad vektorgeometri hoppades över" "damaged vector networks import as outlines" = "skadade vektornätverk importeras som former" "development: {path}" = "utveckling: {path}" "document children other than pages were skipped" = "underordnade objekt i dokumentet som inte är sidor hoppades över" +"duplicate grid track sizing entries use equal tracks" = "dubbla storleksposter för rutnätsspår använder lika stora spår" "e.g. count + 1" = "t.ex. count + 1" "e.g. count >= 3 and not done" = "t.ex. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer" @@ -1544,14 +1556,20 @@ name = "Svenska" "filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning" "from {server}" = "från {server}" "gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "storleken på rutnätsspår stöds inte eller är felaktig; berörda spår blir lika stora" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över storlekar på rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår" "hidden" = "dold" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bildfiltren är tvetydiga eftersom båda filterbehållarna finns; filterkonverteringen hoppades över" +"image filters use FigCraft's approximate renderer" = "bildfilter använder FigCrafts ungefärliga rendering" "instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över" "instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som ramar" "instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager" "instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som ramar" "instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som ramar" "instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma ramar" +"invalid internal .fig text path data was ignored" = "ogiltiga interna .fig-data för text på bana ignorerades" "invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former" "invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över" "just now" = "nyss" @@ -1559,12 +1577,18 @@ name = "Svenska" "key up triggers import as key presses" = "utlösare för släppt tangent importeras som tangenttryckningar" "layers without a position were placed at their parent's origin" = "lager utan position placerades i det överordnade objektets origo" "layers without a type were skipped" = "lager utan typ hoppades över" +"legacy image filter container is unsupported; filter conversion was skipped" = "den äldre bildfilterbehållaren stöds inte; filterkonverteringen hoppades över" "locked" = "låst" +"malformed image filters container was skipped" = "en felaktig bildfilterbehållare hoppades över" +"malformed image paintFilter container was skipped" = "en felaktig paintFilter-behållare för bild hoppades över" +"malformed slot content assignment was skipped" = "en felaktig tilldelning av fackinnehåll hoppades över" "no bleed" = "inget utfall" "nobody else here yet" = "ingen annan här än" +"non-default slot settings are not represented and were skipped" = "fackinställningar som avviker från standard återges inte och hoppades över" "now" = "nu" "or" = "eller" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "punkternas hörntyper behölls; streckade konturer och konturer med variabel bredd ritas med lagrets hörntyp" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "nycklar för föredragna komponenter i fack kan inte kopplas till lager i filen och hoppades över" "prototype actions of unknown types were skipped" = "prototypåtgärder av okända typer hoppades över" "prototype actions without a destination in the file were skipped" = "prototypåtgärder utan mål i filen hoppades över" "prototype expressions on variables that aren't in the variables JSON were skipped" = "prototyputtryck med variabler som inte finns i variabel-JSON:en hoppades över" @@ -1582,8 +1606,14 @@ name = "Svenska" "set-variable actions with values that don't fit the variable were skipped" = "åtgärder för att ange variabel med värden som inte passar variabeln hoppades över" "set-variable actions without a value were skipped" = "åtgärder för att ange variabel utan värde hoppades över" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över" +"slot content assignment target is missing or is not slot content" = "målet för tilldelningen av fackinnehåll saknas eller är inte fackinnehåll" +"slot content assignment uses the empty GUID sentinel and was skipped" = "tilldelningen av fackinnehåll använder det tomma GUID:t som markörvärde och hoppades över" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "vissa vektorpunkter hänvisar till punktinställningar som saknas i filen; de använder lagrets hörntyper och vanliga hörn" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "vissa vektorsegment hänvisar till stilinställningar som saknas i filen; de använder lagrets kontur" +"stale or invalid grid track sizing entries were ignored" = "inaktuella eller ogiltiga storleksposter för rutnätsspår ignorerades" "stars and polygons without point counts or geometry use the default point count" = "stjärnor och polygoner utan antal punkter eller geometri använder standardantalet punkter" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "stilinställningar för vissa vektorsegment stöds inte; de segmenten använder lagrets kontur" +"text path underline or strikethrough decoration is not rendered" = "understrykning eller genomstrykning av text på bana ritas inte" "text style overrides refer to missing table entries" = "åsidosättningar av textstilar hänvisar till tabellposter som saknas" "the file has no pages" = "filen har inga sidor" "the plugin" = "pluginet" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 94475f3b..1695864c 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1512,13 +1512,25 @@ name = "Türkçe" "can edit the document" = "belgeyi düzenleyebilir" "conditional prototype actions nested too deeply were skipped" = "çok derin iç içe geçmiş koşullu prototip eylemleri atlandı" "conditional prototype actions with conditions that can't be evaluated were skipped" = "değerlendirilemeyen koşullara sahip koşullu prototip eylemleri atlandı" +"connector label background was skipped: style data is missing" = "bağlayıcı etiketinin arka planı atlandı: stil verileri eksik" +"connector label was skipped: background bounds are invalid" = "bağlayıcı etiketi atlandı: arka plan sınırları geçersiz" +"connector label was skipped: background transform is invalid" = "bağlayıcı etiketi atlandı: arka plan dönüşümü geçersiz" +"connector label was skipped: composed text transform is invalid" = "bağlayıcı etiketi atlandı: birleştirilmiş metin dönüşümü geçersiz" +"connector label was skipped: derived background bounds are missing" = "bağlayıcı etiketi atlandı: türetilmiş arka plan sınırları eksik" +"connector label was skipped: derived text bounds are missing" = "bağlayıcı etiketi atlandı: türetilmiş metin sınırları eksik" +"connector label was skipped: text bounds are invalid" = "bağlayıcı etiketi atlandı: metin sınırları geçersiz" +"connector label was skipped: text style data is missing" = "bağlayıcı etiketi atlandı: metin stili verileri eksik" +"connector label was skipped: text transform is invalid" = "bağlayıcı etiketi atlandı: metin dönüşümü geçersiz" "connector labels were skipped" = "bağlayıcı etiketleri atlandı" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "bağlayıcı katmanları, varsa etiketleriyle birlikte statik çerçeve/vektör anlık görüntüleri olarak içe aktarılır; uç noktaların dinamik bağlantıları korunmaz" +"connector snapshot vector data is missing; connector was left empty" = "bağlayıcı anlık görüntüsünün vektör verileri eksik; bağlayıcı boş bırakıldı" "connectors whose ends can't be placed are empty" = "uçları yerleştirilemeyen bağlayıcılar boş" "crop marks" = "kesim işaretleri" "damaged vector geometry was skipped" = "hasarlı vektör geometrisi atlandı" "damaged vector networks import as outlines" = "hasarlı vektör ağları ana hat olarak içe aktarılır" "development: {path}" = "geliştirme: {path}" "document children other than pages were skipped" = "belgenin sayfa olmayan alt öğeleri atlandı" +"duplicate grid track sizing entries use equal tracks" = "yinelenen ızgara izi boyutlandırma girdileri eşit izler kullanır" "e.g. count + 1" = "ör. count + 1" "e.g. count >= 3 and not done" = "ör. count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır" @@ -1527,14 +1539,20 @@ name = "Türkçe" "filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır" "from {server}" = "kaynak: {server}" "gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ızgara izi boyutlandırması desteklenmiyor veya bozuk; etkilenen izler eşit boyutlandırma kullanır" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi boyutlandırma listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır" "hidden" = "gizli" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "iki filtre kapsayıcısı da bulunduğu için görüntü filtreleri belirsiz; filtre dönüştürme atlandı" +"image filters use FigCraft's approximate renderer" = "görüntü filtreleri FigCraft'ın yaklaşık işlemesini kullanır" "instance swap properties whose default component isn't in the file were skipped" = "varsayılan bileşeni dosyada olmayan örnek değiştirme özellikleri atlandı" "instances inside their own main component were imported as frames" = "kendi ana bileşeninin içindeki örnekler çerçeve olarak içe aktarıldı" "instances of components that aren't in the file show no layers" = "dosyada olmayan bileşenlerin örnekleri katman göstermez" "instances of components that aren't in the file were imported as frames" = "dosyada olmayan bileşenlerin örnekleri çerçeve olarak içe aktarıldı" "instances that would nest a component inside itself were imported as frames" = "bir bileşeni kendi içine yerleştirecek örnekler çerçeve olarak içe aktarıldı" "instances without a main component were imported as empty frames" = "ana bileşeni olmayan örnekler boş çerçeve olarak içe aktarıldı" +"invalid internal .fig text path data was ignored" = "yol üzerindeki metne ait geçersiz dahili .fig verileri yok sayıldı" "invalid or oversized vector networks import as outlines" = "geçersiz veya çok büyük vektör ağları ana hat olarak içe aktarılır" "invalid or oversized vector path data was skipped" = "geçersiz veya çok büyük vektör yolu verileri atlandı" "just now" = "az önce" @@ -1542,12 +1560,18 @@ name = "Türkçe" "key up triggers import as key presses" = "tuş bırakma tetikleyicileri tuşa basma olarak içe aktarılır" "layers without a position were placed at their parent's origin" = "konumu olmayan katmanlar üst öğelerinin başlangıç noktasına yerleştirildi" "layers without a type were skipped" = "türü olmayan katmanlar atlandı" +"legacy image filter container is unsupported; filter conversion was skipped" = "eski görüntü filtresi kapsayıcısı desteklenmiyor; filtre dönüştürme atlandı" "locked" = "kilitli" +"malformed image filters container was skipped" = "bozuk görüntü filtreleri kapsayıcısı atlandı" +"malformed image paintFilter container was skipped" = "bozuk görüntü paintFilter kapsayıcısı atlandı" +"malformed slot content assignment was skipped" = "bozuk yuva içeriği ataması atlandı" "no bleed" = "taşma payı yok" "nobody else here yet" = "burada henüz başka kimse yok" +"non-default slot settings are not represented and were skipped" = "varsayılan dışındaki yuva ayarları aktarılamıyor ve atlandı" "now" = "şimdi" "or" = "veya" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "nokta başına kontur birleşimleri korundu; kesikli veya değişken genişlikli konturlar katmanın birleşimiyle çizilir" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "yuvaların tercih edilen bileşen anahtarları dosyadaki katmanlarla eşleştirilemedi ve atlandı" "prototype actions of unknown types were skipped" = "bilinmeyen türdeki prototip eylemleri atlandı" "prototype actions without a destination in the file were skipped" = "dosyada hedefi olmayan prototip eylemleri atlandı" "prototype expressions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki prototip ifadeleri atlandı" @@ -1565,8 +1589,14 @@ name = "Türkçe" "set-variable actions with values that don't fit the variable were skipped" = "değişkene uymayan değerlere sahip değişken ayarlama eylemleri atlandı" "set-variable actions without a value were skipped" = "değeri olmayan değişken ayarlama eylemleri atlandı" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonlar veya modlar üzerindeki değişken modu ayarlama eylemleri atlandı" +"slot content assignment target is missing or is not slot content" = "yuva içeriği atamasının hedefi eksik veya yuva içeriği değil" +"slot content assignment uses the empty GUID sentinel and was skipped" = "yuva içeriği ataması boş GUID'i işaretçi olarak kullanıyor ve atlandı" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "bazı vektör noktaları dosyada eksik olan nokta ayarlarına başvuruyor; bu noktalar katmanın birleşimlerini ve düz köşeleri kullanır" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "bazı vektör segmentleri dosyada eksik olan stil ayarlarına başvuruyor; bu segmentler katmanın konturunu kullanır" +"stale or invalid grid track sizing entries were ignored" = "eski veya geçersiz ızgara izi boyutlandırma girdileri yok sayıldı" "stars and polygons without point counts or geometry use the default point count" = "nokta sayısı veya geometrisi olmayan yıldızlar ve çokgenler varsayılan nokta sayısını kullanır" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "bazı vektör segmentlerinin stil ayarları desteklenmiyor; bu segmentler katmanın konturunu kullanır" +"text path underline or strikethrough decoration is not rendered" = "yol üzerindeki metinde altı çizili veya üstü çizili süsleme çizilmez" "text style overrides refer to missing table entries" = "metin stili geçersiz kılmaları eksik tablo girdilerine başvuruyor" "the file has no pages" = "dosyada sayfa yok" "the plugin" = "eklenti" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 9883a2c2..31be5611 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1450,13 +1450,25 @@ status = "draft — needs native review" "can edit the document" = "可编辑文档" "conditional prototype actions nested too deeply were skipped" = "已跳过嵌套过深的条件原型操作" "conditional prototype actions with conditions that can't be evaluated were skipped" = "已跳过条件无法求值的条件原型操作" +"connector label background was skipped: style data is missing" = "已跳过连接线标签背景:缺少样式数据" +"connector label was skipped: background bounds are invalid" = "已跳过连接线标签:背景边界无效" +"connector label was skipped: background transform is invalid" = "已跳过连接线标签:背景变换无效" +"connector label was skipped: composed text transform is invalid" = "已跳过连接线标签:组合后的文本变换无效" +"connector label was skipped: derived background bounds are missing" = "已跳过连接线标签:缺少推算的背景边界" +"connector label was skipped: derived text bounds are missing" = "已跳过连接线标签:缺少推算的文本边界" +"connector label was skipped: text bounds are invalid" = "已跳过连接线标签:文本边界无效" +"connector label was skipped: text style data is missing" = "已跳过连接线标签:缺少文本样式数据" +"connector label was skipped: text transform is invalid" = "已跳过连接线标签:文本变换无效" "connector labels were skipped" = "已跳过连接线标签" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "连接线图层导入为静态的画框/矢量快照,有标签时一并导入;不保留端点的动态绑定" +"connector snapshot vector data is missing; connector was left empty" = "缺少连接线快照的矢量数据;连接线保留为空" "connectors whose ends can't be placed are empty" = "无法放置端点的连接线为空" "crop marks" = "裁切标记" "damaged vector geometry was skipped" = "已跳过损坏的矢量几何" "damaged vector networks import as outlines" = "损坏的矢量网络导入为轮廓" "development: {path}" = "开发:{path}" "document children other than pages were skipped" = "已跳过非页面的文档子项" +"duplicate grid track sizing entries use equal tracks" = "重复的网格轨道尺寸条目使用等分轨道" "e.g. count + 1" = "例如 count + 1" "e.g. count >= 3 and not done" = "例如 count >= 3 and not done" "elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线" @@ -1465,26 +1477,38 @@ status = "draft — needs native review" "filled areas of a vector with their own fill use the layer's fill" = "具有自身填充的矢量填充区域使用图层的填充" "from {server}" = "来自 {server}" "gamepad triggers without exactly one known controller button were skipped" = "已跳过没有恰好一个已知手柄按钮的游戏手柄触发器" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "网格轨道列表超过 1000 个条目;轨道使用默认的等分尺寸" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "网格轨道尺寸不受支持或格式错误;受影响的轨道使用等分尺寸" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "网格轨道尺寸列表超过 1000 个条目;轨道使用默认的等分尺寸" "hidden" = "已隐藏" "iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "两个滤镜容器同时存在,图片滤镜有歧义;已跳过滤镜转换" +"image filters use FigCraft's approximate renderer" = "图片滤镜使用 FigCraft 的近似渲染" "instance swap properties whose default component isn't in the file were skipped" = "已跳过默认组件不在文件中的实例替换属性" "instances inside their own main component were imported as frames" = "位于自身主组件内的实例已导入为画框" "instances of components that aren't in the file show no layers" = "文件中没有的组件的实例不显示图层" "instances of components that aren't in the file were imported as frames" = "文件中没有的组件的实例已导入为画框" "instances that would nest a component inside itself were imported as frames" = "会将组件嵌套到自身内部的实例已导入为画框" "instances without a main component were imported as empty frames" = "没有主组件的实例已导入为空画框" +"invalid internal .fig text path data was ignored" = "已忽略无效的 .fig 内部路径文字数据" "invalid or oversized vector networks import as outlines" = "无效或过大的矢量网络导入为轮廓" "invalid or oversized vector path data was skipped" = "已跳过无效或过大的矢量路径数据" "key triggers without a supported key were skipped" = "已跳过没有受支持按键的按键触发器" "key up triggers import as key presses" = "松开按键触发器导入为按下按键" "layers without a position were placed at their parent's origin" = "没有位置的图层已放在其父级的原点" "layers without a type were skipped" = "已跳过没有类型的图层" +"legacy image filter container is unsupported; filter conversion was skipped" = "不支持旧版图片滤镜容器;已跳过滤镜转换" "locked" = "已锁定" +"malformed image filters container was skipped" = "已跳过格式错误的图片滤镜容器" +"malformed image paintFilter container was skipped" = "已跳过格式错误的图片 paintFilter 容器" +"malformed slot content assignment was skipped" = "已跳过格式错误的插槽内容分配" "no bleed" = "无出血" "nobody else here yet" = "这里还没有其他人" +"non-default slot settings are not represented and were skipped" = "非默认的插槽设置无法表示,已跳过" "now" = "刚刚" "or" = "或" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "已保留各点的描边转角;虚线描边或可变宽度描边以图层的转角绘制" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "插槽的首选组件键无法对应到文件中的图层,已跳过" "prototype actions of unknown types were skipped" = "已跳过未知类型的原型操作" "prototype actions without a destination in the file were skipped" = "已跳过在文件中没有目标的原型操作" "prototype expressions on variables that aren't in the variables JSON were skipped" = "已跳过使用变量 JSON 中没有的变量的原型表达式" @@ -1502,8 +1526,14 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "已跳过值与变量不符的设置变量操作" "set-variable actions without a value were skipped" = "已跳过没有值的设置变量操作" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "已跳过针对变量 JSON 中没有的集合或模式的设置变量模式操作" +"slot content assignment target is missing or is not slot content" = "插槽内容分配的目标缺失或不是插槽内容" +"slot content assignment uses the empty GUID sentinel and was skipped" = "插槽内容分配使用空 GUID 作为哨兵值,已跳过" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "部分矢量点引用了文件中缺失的点设置;这些点使用图层的转角和普通角" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "部分矢量段引用了文件中缺失的样式设置;这些段使用图层的描边" +"stale or invalid grid track sizing entries were ignored" = "已忽略过时或无效的网格轨道尺寸条目" "stars and polygons without point counts or geometry use the default point count" = "没有点数或几何的星形和多边形使用默认点数" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "部分矢量段的样式设置不受支持;这些段使用图层的描边" +"text path underline or strikethrough decoration is not rendered" = "不绘制路径文字的下划线或删除线" "text style overrides refer to missing table entries" = "文本样式覆盖引用了缺失的表条目" "the file has no pages" = "文件没有页面" "the plugin" = "插件" diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 16a06a90..5bdf2094 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -166,8 +166,9 @@ fn used_keys() -> BTreeSet { } /// The importers' fixed notes: the string literals passed to `warn` in the `.fig` and REST import -/// sources. The import report shows a note translated when the catalog has it, so an entry for one -/// counts as used, and an entry for a note the importers no longer give shows up as unused. +/// sources. The import report shows a note translated when the catalog has it, so each one is a key +/// `en.toml` (and every complete language) must have, and an entry for a note the importers no +/// longer give shows up as unused. fn importer_notes() -> BTreeSet { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../format/src"); let mut keys = BTreeSet::new(); @@ -249,11 +250,10 @@ fn english() -> &'static Lang { #[test] fn every_key_in_the_code_is_in_the_english_catalog_and_no_other() { - let used: BTreeSet = used_keys().into_iter().chain(dynamic_keys()).collect(); + let used: BTreeSet = used_keys().into_iter().chain(dynamic_keys()).chain(importer_notes()).collect(); let en: BTreeSet = english().ui.iter().map(|(k, _)| k.to_string()).collect(); let missing: Vec<_> = used.difference(&en).collect(); - let notes = importer_notes(); - let unused: Vec<_> = en.difference(&used).filter(|k| !notes.contains(*k)).collect(); + let unused: Vec<_> = en.difference(&used).collect(); assert!( missing.is_empty(), "keys missing from locales/en.toml:\n{}", diff --git a/docs/translating.md b/docs/translating.md index 3aa42d65..0e94d20d 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -112,3 +112,7 @@ add the key to `en.toml` and to each complete language (Spanish and Russian); th Labels from the engine and document crates (tool names, blend modes, easing curves…) are looked up at display with `tr(x.label())`; they are listed in `dynamic_labels` in `i18n_tests.rs`. + +The importers' fixed notes (the string literals passed to `warn` in `crates/format/src/fig` and +`crates/format/src/rest`) are keys too: the import report shows them in the UI language, so a new +note goes into `en.toml` and each complete language. Notes built with `format!` show in English. From 9a69f79861fae1cb80e6f21fa45b73b91a4627ac Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 22:39:10 +0700 Subject: [PATCH 175/245] #552: write and read space evenly as SPACE_EVENLY_CSS The format's SPACE_EVENLY is its older name for equal gaps without edge space, so files keep reading it as space between; true space evenly is SPACE_EVENLY_CSS, which the export now writes. --- .../src/fig/auto_layout_sizing_tests.rs | 31 +++++++++++++++++-- crates/format/src/fig/export.rs | 4 +-- crates/format/src/fig/map.rs | 5 +-- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/auto_layout_sizing_tests.rs b/crates/format/src/fig/auto_layout_sizing_tests.rs index 99730f70..29580afb 100644 --- a/crates/format/src/fig/auto_layout_sizing_tests.rs +++ b/crates/format/src/fig/auto_layout_sizing_tests.rs @@ -1,8 +1,8 @@ //! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in //! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and -//! text that hugs by its resize mode. +//! text that hugs by its resize mode. Also how distributed main-axis alignment reads. -use figcraft_doc::{Document, Sizing}; +use figcraft_doc::{Document, MainAlign, Sizing}; use serde_json::{Value, json}; use super::kiwi::{Kv, Schema}; @@ -11,6 +11,7 @@ const SCHEMA: &str = " enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE enum StackMode NONE HORIZONTAL VERTICAL enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE +enum StackJustify MIN CENTER MAX SPACE_EVENLY SPACE_BETWEEN SPACE_AROUND SPACE_EVENLY_CSS enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT struct GUID uint sessionID, uint localID struct Vector float x, float y @@ -18,7 +19,7 @@ struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 message ParentIndex GUID guid, string position message TextData string characters message SymbolData GUID symbolID -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, StackJustify stackPrimaryAlignItems, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData message Message NodeChange[] nodeChanges "; @@ -191,3 +192,27 @@ fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() { assert_eq!(sizing(&doc, name), want, "{name}"); } } + +#[test] +fn distributed_alignment_reads_the_older_evenly_name_as_space_between() { + let rows = [ + ("Between", "SPACE_BETWEEN", MainAlign::SpaceBetween), + ("Older evenly", "SPACE_EVENLY", MainAlign::SpaceBetween), + ("Around", "SPACE_AROUND", MainAlign::SpaceAround), + ("Evenly", "SPACE_EVENLY_CSS", MainAlign::SpaceEvenly), + ]; + let nodes = rows + .iter() + .enumerate() + .flat_map(|(i, (name, align, _))| { + let mut fields = stack("HORIZONTAL", None, None); + fields.push(("stackPrimaryAlignItems", Kv::Enum(align))); + component(10 + 2 * i as u64, 50.0 * i as f64, name, fields, None) + }) + .collect(); + let doc = import(nodes); + for (name, _, want) in rows { + let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")); + assert_eq!(n.layout.as_ref().map(|l| l.main_align), Some(want), "{name}"); + } +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index ca9e2682..cac29be3 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -73,7 +73,7 @@ enum StrokeJoin MITER=0 BEVEL=1 ROUND=2 enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4 enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3 enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3 -enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 +enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 SPACE_EVENLY_CSS=6 enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5 enum StackSize FIXED=0 RESIZE_TO_FIT=1 enum StackWrap NO_WRAP=0 WRAP=1 @@ -1371,7 +1371,7 @@ impl<'d> Exporter<'d> { MainAlign::Max => "MAX", MainAlign::SpaceBetween => "SPACE_BETWEEN", MainAlign::SpaceAround => "SPACE_AROUND", - MainAlign::SpaceEvenly => "SPACE_EVENLY", + MainAlign::SpaceEvenly => "SPACE_EVENLY_CSS", }; let cross = match l.cross_align { CrossAlign::Min => "MIN", diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index a113f902..2ebe1ee9 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1405,8 +1405,9 @@ impl<'a> Mapper<'a> { Some("CENTER") => "CENTER", Some("MAX") => "MAX", Some("SPACE_AROUND") => "SPACE_AROUND", - Some("SPACE_EVENLY") => "SPACE_EVENLY", - Some("SPACE_BETWEEN" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN", + // `SPACE_EVENLY` is the format's older name for equal gaps without edge space. + Some("SPACE_EVENLY_CSS") => "SPACE_EVENLY", + Some("SPACE_BETWEEN" | "SPACE_EVENLY") => "SPACE_BETWEEN", _ => "MIN", }; o.insert("primaryAxisAlignItems".into(), json!(main)); From 68b19422adf0dbf605a472c6c0af6f5a4d23067e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 22:44:09 +0700 Subject: [PATCH 176/245] M1: REST import reads grid track sizes (#465) gridColumnsSizing and gridRowsSizing are CSS grid-template strings: px tracks are fixed, fr tracks fill, auto/min-content/max-content hug, minmax(_, Nfr) fills and repeat(n, ...) expands; other tracks stay equal (1fr). Tracks past the axis's own count are left out. Files from .fig keep reading the tracks from their source. --- crates/format/src/rest/grid_track_tests.rs | 42 ++++++++++++ crates/format/src/rest/mod.rs | 2 + crates/format/src/rest/props.rs | 78 ++++++++++++++++++++-- docs/file-format-parity.md | 5 +- 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 crates/format/src/rest/grid_track_tests.rs diff --git a/crates/format/src/rest/grid_track_tests.rs b/crates/format/src/rest/grid_track_tests.rs new file mode 100644 index 00000000..a11780ca --- /dev/null +++ b/crates/format/src/rest/grid_track_tests.rs @@ -0,0 +1,42 @@ +//! REST import of grid track sizes (`gridColumnsSizing`, `gridRowsSizing`: CSS +//! `grid-template-columns`/`-rows` strings). Fixtures are synthetic, written by hand. + +use super::*; +use figcraft_doc::GridTrack; +use serde_json::json; + +fn tracks(columns: Value, rows: Value) -> (Vec, Vec) { + let file = json!({"name": "Fixture", "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + {"id": "1", "name": "Grid", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 400, "y": 200}, + "layoutMode": "GRID", "gridColumnCount": 7, "gridRowCount": 2, "gridColumnsSizing": columns, "gridRowsSizing": rows} + ]} + ]}}); + let (doc, _) = import(&file, &no_images).unwrap(); + let layout = doc.nodes().find(|n| n.name == "Grid").unwrap().layout.clone().unwrap(); + (layout.column_tracks, layout.row_tracks) +} + +#[test] +fn css_track_strings_set_the_track_sizes() { + let (cols, rows) = tracks(json!("120px 2fr auto"), json!("repeat(2, minmax(0, 1fr))")); + assert_eq!(cols, [GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]); + assert_eq!(rows, [GridTrack::fill(1.0); 2]); + let (cols, rows) = tracks(json!(" repeat(2, 40px 1.5fr) minmax(64px, 64px) "), json!("min-content max-content")); + assert_eq!(cols, [GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(64.0)]); + assert_eq!(rows, [GridTrack::hug(); 2]); +} + +#[test] +fn unsupported_or_junk_tracks_are_equal_tracks() { + // Ranges the grid can't express, units it doesn't have and junk keep their place as 1fr. + let (cols, rows) = tracks(json!("minmax(10px, 80px) 5em -3px 0fr NaNpx repeat(x, 1px) 10px"), json!(7)); + assert_eq!(cols, [[GridTrack::default(); 6].as_slice(), &[GridTrack::fixed(10.0)]].concat()); + assert!(rows.is_empty(), "a non-string is ignored"); + // Tracks past the grid's own count are left out; huge repeats and deep nesting stay bounded. + let (cols, rows) = tracks(json!(format!("repeat(999999999999, 1px) {}", "minmax(".repeat(10_000))), json!("1px 2px 3px")); + assert_eq!(cols, [GridTrack::fixed(1.0); 7]); + assert_eq!(rows, [GridTrack::fixed(1.0), GridTrack::fixed(2.0)]); + let (cols, rows) = tracks(json!("1e9px"), json!("( ) )) repeat(2,)")); + assert_eq!((cols, rows.len()), (vec![GridTrack::default()], 2)); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 953522e1..be5c7157 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -29,6 +29,8 @@ mod fig_sources; #[cfg(test)] mod gamepad_tests; #[cfg(test)] +mod grid_track_tests; +#[cfg(test)] mod import_regression_tests; pub(crate) mod props; pub(crate) mod proto; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 1003682b..efb7710d 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -424,6 +424,7 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { let pad = |k: &str| len(v, k).unwrap_or(0.0); let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32); let grid = mode == LayoutMode::Grid; + let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2)); Some(AutoLayout { mode, wrap: text(v, "layoutWrap") == Some("WRAP"), @@ -448,15 +449,84 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween, _ => ContentAlign::Auto, }, - grid_columns: count("gridColumnCount", 2), - grid_rows: count("gridRowCount", 2), + grid_columns: columns, + grid_rows: rows, strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false), reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false), - column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() }, - row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() }, + column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns), + row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows), }) } +/// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS +/// `grid-template-*` string (at most `count` tracks, the axis's own). +fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec { + match (grid, fig_tracks) { + (false, _) => Vec::new(), + (true, true) => fig_grid_tracks(v, fig_axis), + (true, false) => text(v, css_key).map(|css| css_grid_tracks(css, count as usize)).unwrap_or_default(), + } +} + +/// The first `max` tracks of a CSS `grid-template-columns`/`-rows` value: `120px`, `2fr`, `auto` +/// (hug), `minmax(0, 1fr)` and `repeat(3, …)`; any other track is an equal (1fr) one. +fn css_grid_tracks(css: &str, max: usize) -> Vec { + let mut out = Vec::new(); + for item in css_split(css, char::is_whitespace) { + let repeated = item.strip_prefix("repeat(").and_then(|r| r.strip_suffix(')')).and_then(|r| { + let (count, tracks) = r.split_once(',')?; + Some((count.trim().parse::().ok()?, css_split(tracks, char::is_whitespace))) + }); + let (count, tracks) = repeated.unwrap_or((1, vec![item])); + for t in tracks.iter().cycle().take(count.saturating_mul(tracks.len())) { + if out.len() >= max { + return out; + } + out.push(css_track(t)); + } + } + out +} + +fn css_track(t: &str) -> GridTrack { + let Some(args) = t.strip_prefix("minmax(").and_then(|r| r.strip_suffix(')')) else { return css_size(t).unwrap_or_default() }; + // A flexible maximum is the track's share whatever its minimum; other ranges only when both ends agree. + match css_split(args, |c| c == ',').iter().map(|a| css_size(a.trim())).collect::>()[..] { + [_, Some(max)] if max.sizing == Sizing::Fill => max, + [Some(min), Some(max)] if min == max => min, + _ => GridTrack::default(), + } +} + +fn css_size(t: &str) -> Option { + let n = |s: &str| s.parse::().ok().filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n)); + if let Some(px) = t.strip_suffix("px").and_then(n) { + Some(GridTrack::fixed(px)) + } else if let Some(fr) = t.strip_suffix("fr").and_then(n) { + (fr > 0.0).then(|| GridTrack::fill(fr)) + } else { + matches!(t, "auto" | "min-content" | "max-content").then(GridTrack::hug) + } +} + +/// `s` split at the `sep` characters outside parentheses, empty parts dropped. +fn css_split(s: &str, sep: fn(char) -> bool) -> Vec<&str> { + let (mut parts, mut depth, mut start) = (Vec::new(), 0usize, 0); + for (i, c) in s.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + c if depth == 0 && sep(c) => { + parts.extend(s.get(start..i).filter(|p| !p.trim().is_empty())); + start = i + c.len_utf8(); + } + _ => {} + } + } + parts.extend(s.get(start..).filter(|p| !p.trim().is_empty())); + parts +} + fn fig_grid_tracks(v: &Value, axis: &str) -> Vec { v.get(crate::rest::FIG_SOURCE_FIELD) .and_then(|source| source.get("gridTracks")) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 96293b34..b65f7729 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (`.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -19,7 +19,7 @@ and prototype interactions are still unchecked there. |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | -| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | +| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | From 91c80a19cc22fedf5ff451a7b73ab7a0f2396a6c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 22:53:54 +0700 Subject: [PATCH 177/245] M7: find macOS's downloadable CJK system fonts (#406) Current macOS keeps PingFang and other on-demand fonts in its asset store, which the font scan never walked, so Chinese text showed as boxes. The scan now also walks the com_apple_MobileAsset_Font* folders of /System/Library/AssetsV2 (and the older /System/Library/Assets) with the same depth, size and file checks, and Hiragino Sans GB and Heiti SC (preinstalled) join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not run on a Mac. --- crates/text/src/fontdb.rs | 19 ++++++++++++++++- crates/text/src/fontdb_tests.rs | 38 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 ++ docs/localization-parity.md | 7 ++++-- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d5ff4190..a6d52d67 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -406,7 +406,7 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { } /// Families tried, in order, for characters a text's own font lacks. -const FALLBACK_FAMILIES: [&str; 18] = [ +const FALLBACK_FAMILIES: [&str; 20] = [ "Inter", "Helvetica Neue", "Arial", @@ -416,6 +416,8 @@ const FALLBACK_FAMILIES: [&str; 18] = [ "BIZ UDPGothic", "Hiragino Sans", "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", "Noto Sans CJK SC", "Microsoft YaHei", "Apple SD Gothic Neo", @@ -691,11 +693,17 @@ fn system_font_dirs() -> Vec { let mut v: Vec = Vec::new(); #[cfg(target_os = "macos")] { + // Subfolders (`Supplemental`) are walked too. v.push("/System/Library/Fonts".into()); v.push("/Library/Fonts".into()); if let Some(h) = std::env::var_os("HOME") { v.push(std::path::PathBuf::from(h).join("Library/Fonts")); } + // Fonts the system downloads (PingFang and other CJK faces on current versions) live in + // its asset store; `Assets` is the older versions' store. + for store in ["/System/Library/AssetsV2", "/System/Library/Assets"] { + v.extend(font_asset_dirs(std::path::Path::new(store))); + } } #[cfg(target_os = "windows")] { @@ -714,6 +722,15 @@ fn system_font_dirs() -> Vec { v } +/// The font folders (`com_apple_MobileAsset_Font*`, each holding `.asset/AssetData/*.ttc`) in +/// the macOS asset store `store`: only these are walked, not the store's voices, dictionaries and +/// models. +#[cfg(any(target_os = "macos", test))] +fn font_asset_dirs(store: &std::path::Path) -> Vec { + let Ok(rd) = std::fs::read_dir(store) else { return Vec::new() }; + rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font"))).map(|e| e.path()).collect() +} + // Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts. // Ignore relative XDG paths, as required by the XDG Base Directory Specification. #[cfg(all(unix, not(target_os = "macos")))] diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index ea762be6..96ce7842 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -70,6 +70,44 @@ fn the_scan_reads_names_like_a_whole_file_read() { } } +#[test] +fn macos_font_assets_are_found_in_the_asset_store() { + // A store laid out like macOS's: fonts downloaded on demand sit in `.asset/AssetData` + // folders under `com_apple_MobileAsset_Font*`; other assets are never walked. Untested on a Mac. + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id()))); + let store = tmp.0.join("AssetsV2"); + let fonts = store.join("com_apple_MobileAsset_Font7"); + let data = |kind: &std::path::Path| kind.join("0f1e2d.asset").join("AssetData"); + for dir in [data(&fonts), data(&store.join("com_apple_MobileAsset_VoiceServices"))] { + std::fs::create_dir_all(dir).unwrap(); + } + // A file under a font-like name is listed but walks to nothing. + std::fs::write(store.join("com_apple_MobileAsset_Font7.plist"), b"not a folder").unwrap(); + let found = font_asset_dirs(&store); + assert!(found.contains(&fonts) && !found.iter().any(|d| d.ends_with("com_apple_MobileAsset_VoiceServices")), "{found:?}"); + assert!(font_asset_dirs(&tmp.0.join("missing")).is_empty()); + // With an installed font to copy in (none on CI), the scan finds it only in the font folder. + let installed = system_font_dirs() + .into_iter() + .find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf"))); + let Some(font) = installed else { return }; + for kind in ["com_apple_MobileAsset_Font7", "com_apple_MobileAsset_VoiceServices"] { + std::fs::copy(&font, data(&store.join(kind)).join("Font.ttf")).unwrap(); + } + let mut pending = BTreeMap::new(); + for dir in found { + scan_dir(&dir, 0, &mut pending); + } + let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect(); + assert!(!paths.is_empty() && paths.iter().all(|p| p.starts_with(&fonts)), "{paths:?}"); +} + #[cfg(unix)] #[test] fn the_scan_skips_named_pipes() { diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index c7fb1f98..fd3ecf8c 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -37,6 +37,8 @@ const CHINESE: &[&str] = &[ "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 15d49be9..50fcd4ec 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -68,7 +68,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v - **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app adds one installed face per script to the UI's fallbacks (Windows: Yu Gothic UI, - Meiryo, Malgun Gothic, Microsoft YaHei; macOS: Hiragino Sans, Apple SD Gothic Neo, PingFang SC; + Meiryo, Malgun Gothic, Microsoft YaHei; macOS: Hiragino Sans, Apple SD Gothic Neo, PingFang SC, + Hiragino Sans GB, Heiti SC, also from the system's downloaded font assets + (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*`, where current versions keep PingFang); Linux and BSD: Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's BIZ UDPGothic first), the UI language's script first; Han without kana outside a Japanese or Korean UI reads as Chinese, and a Chinese UI puts its Chinese face (Microsoft YaHei, PingFang SC, Noto Sans @@ -118,6 +120,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | From 03bb164b5cc0fc6990681712e81563f2130f119f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:05:03 +0700 Subject: [PATCH 178/245] M4: Esc after a pen path stays on the same vector layer (#760) Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path joins the same layer; another Escape leaves edit mode. Entering edit mode on a layer that already is a vector no longer records an empty undo step. --- crates/engine/src/lib.rs | 2 + crates/engine/src/pen_end_tests.rs | 72 ++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 35 +++++++++++---- crates/engine/src/vectoredit.rs | 19 ++++---- crates/mcp/src/headless.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 4 +- docs/ui-parity.md | 5 ++- 7 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 crates/engine/src/pen_end_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2b09c5c3..82d495b4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1686,6 +1686,8 @@ mod panels_behaviour_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] +mod pen_end_tests; +#[cfg(test)] mod pen_overlay_tests; #[cfg(test)] mod pixel_preview_tests; diff --git a/crates/engine/src/pen_end_tests.rs b/crates/engine/src/pen_end_tests.rs new file mode 100644 index 00000000..a5d24f61 --- /dev/null +++ b/crates/engine/src/pen_end_tests.rs @@ -0,0 +1,72 @@ +//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen, +//! so the next path is drawn into the same layer; another Escape leaves edit mode. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use crate::vectoredit::VTool; +use figcraft_doc::NodeKind; + +fn click(s: &mut Session, x: f64, y: f64) { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); + } +} + +fn layers(s: &Session) -> usize { + s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len() +} + +/// (points, segments) of vector `id`. +fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()), + k => panic!("not a vector: {k:?}"), + } +} + +/// A two-point pen path, ended with `end`. +fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + click(&mut s, 100.0, 100.0); + click(&mut s, 200.0, 100.0); + let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap(); + let undo = s.undo_depth().0; + end(&mut s); + assert!(s.tool.pen.is_none(), "the path ended"); + assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step"); + let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector"); + assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end"); + (s, id) +} + +#[test] +fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() { + // Escape, and Enter (its key runs `pen_end`). + let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()]; + for end in ends { + let (mut s, id) = drawn(end); + // The next path goes into the same layer, unconnected to the first. + click(&mut s, 100.0, 300.0); + click(&mut s, 200.0, 300.0); + assert_eq!(layers(&s), 1, "no new layer"); + assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network"); + // Escape ends that path, the next one leaves edit mode. + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_some()); + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode"); + assert_eq!(s.ds().unwrap().selection, vec![id]); + } +} + +#[test] +fn escape_before_a_segment_leaves_nothing_to_edit() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + click(&mut s, 100.0, 100.0); + s.escape().unwrap(); + assert!(s.tool.pen.is_none()); + assert!(s.ds().unwrap().vector_edit.is_none()); + assert_eq!(layers(&s), 0); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dbda1894..a5b61ca2 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2051,13 +2051,7 @@ impl Session { if self.tool.pen.is_none() && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) { - self.set_tool(ToolId::Move); - crate::vectoredit::begin(self, id)?; - crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; - return crate::vectoredit::set_state(self, |e| { - e.anchor = Some(v); - e.vertices = vec![v]; - }); + return self.pen_edit(id, Some(v)); } let pen = self.tool.pen.get_or_insert_with(PenPath::default); // Clicking the first anchor closes the path. @@ -2175,7 +2169,30 @@ impl Session { Ok(()) } - /// Finish the pen path (Enter/Escape/double-click). + /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting + /// a new path in the same layer at the next click). + fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + crate::vectoredit::set_state(self, |e| { + e.anchor = anchor; + e.vertices = anchor.into_iter().collect(); + }) + } + + /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the + /// pen, so the next path joins the same vector (another Escape leaves edit mode). + pub fn pen_end(&mut self) -> Result<()> { + let id = self.tool.pen.as_ref().and_then(|p| p.id); + self.pen_finish()?; + match id { + Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), + _ => Ok(()), + } + } + + /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). pub fn pen_finish(&mut self) -> Result<()> { let pen = self.tool.pen.take(); self.tool.overlay.pen.clear(); @@ -2320,7 +2337,7 @@ impl Session { return Ok(()); } if self.tool.pen.is_some() { - return self.pen_finish(); + return self.pen_end(); } if self.tool.busy() { self.tool.gesture = Gesture::None; diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index f923de80..a3fbf803 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -508,16 +508,15 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> { let before = s.ds()?.doc.clone(); let mut edited = Vec::new(); let mut first_err = None; - { - let doc = s.doc_mut()?; - for id in ids.iter().take(MAX_EDITED) { - if edited.contains(id) { - continue; - } - match ensure_vector(doc, *id) { - Ok(()) => edited.push(*id), - Err(e) => first_err = first_err.or(Some(e)), - } + for id in ids.iter().take(MAX_EDITED) { + if edited.contains(id) { + continue; + } + // Vectors are left untouched, so editing only them records no undo step. + let is_vector = before.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Vector { .. })); + match if is_vector { Ok(()) } else { ensure_vector(s.doc_mut()?, *id) } { + Ok(()) => edited.push(*id), + Err(e) => first_err = first_err.or(Some(e)), } } let Some((&id, rest)) = edited.split_first() else { diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index f3610c86..fcdb21a3 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -160,7 +160,7 @@ impl Headless { self.execute("text.edit.begin", &json!({"id": self.single_text()}))?; } "select.children" if self.session.tool.pen.is_some() => { - let _ = self.session.pen_finish(); + let _ = self.session.pen_end(); } // Enter on shapes edits them as vectors (several at once). "select.children" if self.vector_targets().is_some() => { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 09b2c57b..7a38bc79 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -287,9 +287,9 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if m.is_none() && !repeat && opacity_digit(app, key, now) { return true; } - // Enter while the pen is drawing finishes it. + // Enter while the pen is drawing ends the path as Escape does. if key == Key::Enter && app.session.tool.pen.is_some() { - let _ = app.session.pen_finish(); + let _ = app.session.pen_end(); return true; } false diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..2261cfd9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Escape or Enter after a pen path stays on the same vector (#760); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -81,7 +81,7 @@ interaction feel not yet compared. | Frame (presets panel, swap orientation), section, slice | done | | | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | -| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`) | +| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From a672a6650fd8da3b5cfe043ecdaf60e624d16076 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 23:23:25 +0700 Subject: [PATCH 179/245] fix(ui): glass settings: label the light intensity, keep labels in their fields (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glass effect's light intensity field had no label, and "Refraction 80%" and "Dispersion 50%" ran into the edge of their fields. The field is labelled "Intensity" in every catalog. Text labels of number fields take their measured width instead of 7 points per character, and a label too long for its field ends in "…" before the value, which is clipped to its own area. This changes every text-labelled number field: Latin labels sit a little tighter, CJK labels get their real width. Refs #256 ("There is one parameter that is not signed in the glass effect editing options. The inscriptions also go beyond their borders."). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/field_tests.rs | 79 +++++++++++++++++++++++++++++ crates/ui-egui/src/right.rs | 2 +- crates/ui-egui/src/widgets.rs | 18 +++++-- 16 files changed, 106 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 7121548d..98051314 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -634,6 +634,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Výměna instance (žádná jiná komponenta)" "Instances" = "Instance" "Instant" = "Okamžitě" +"Intensity" = "Intenzita" "Interaction" = "Interakce" "Interactions" = "Interakce" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps." diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 0362f897..07669c1a 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -619,6 +619,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Instanztausch (keine andere Komponente)" "Instances" = "Instanzen" "Instant" = "Sofort" +"Intensity" = "Intensität" "Interaction" = "Interaktion" "Interactions" = "Interaktionen" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-Design und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps." diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 59b25d94..b8ae921e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -647,6 +647,7 @@ name = "English" "Instance swap (no other component)" = "Instance swap (no other component)" "Instances" = "Instances" "Instant" = "Instant" +"Intensity" = "Intensity" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 01c5813b..405c9836 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -648,6 +648,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Intercambio de instancia (no hay otro componente)" "Instances" = "Instancias" "Instant" = "Instantánea" +"Intensity" = "Intensidad" "Interaction" = "Interacción" "Interactions" = "Interacciones" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft." diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 17a0d60b..5eb84e9e 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -615,6 +615,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Permutation d’instance (aucun autre composant)" "Instances" = "Instances" "Instant" = "Instantané" +"Intensity" = "Intensité" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft." diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a01944fd..65a9939c 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -638,6 +638,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Tukar instance (tidak ada component lain)" "Instances" = "Instance" "Instant" = "Instan" +"Intensity" = "Intensitas" "Interaction" = "Interaksi" "Interactions" = "Interaksi" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps." diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 965f8ead..def8ebc4 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -643,6 +643,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "インスタンスの入れ替え(他のコンポーネントなし)" "Instances" = "インスタンス" "Instant" = "即時" +"Intensity" = "強さ" "Interaction" = "インタラクション" "Interactions" = "インタラクション" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "インターフェースデザインとプロトタイピング。Rust でゼロから書かれた、無料でオープンソースのクリーンルーム設計のデザインツール。ArtCraft Crafting Apps の一つ。" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 649fc26c..3e191623 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -615,6 +615,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "인스턴스 교체(다른 컴포넌트 없음)" "Instances" = "인스턴스" "Instant" = "즉시" +"Intensity" = "강도" "Interaction" = "인터랙션" "Interactions" = "인터랙션" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "인터페이스 디자인과 프로토타이핑. Rust로 처음부터 작성한 무료 오픈 소스 클린룸 디자인 도구이며 ArtCraft Crafting Apps의 하나." diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c31b6947..aabc8806 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -614,6 +614,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Troca de instância (nenhum outro componente)" "Instances" = "Instâncias" "Instant" = "Instantâneo" +"Intensity" = "Intensidade" "Interaction" = "Interação" "Interactions" = "Interações" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft." diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 998eefda..7ec18507 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -635,6 +635,7 @@ name = "Русский" "Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)" "Instances" = "Экземпляры" "Instant" = "Мгновенно" +"Intensity" = "Интенсивность" "Interaction" = "Взаимодействие" "Interactions" = "Взаимодействия" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps." diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index eea2d312..d57bb25a 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -645,6 +645,7 @@ name = "Svenska" "Instance swap (no other component)" = "Instansbyte (ingen annan komponent)" "Instances" = "Instanser" "Instant" = "Direkt" +"Intensity" = "Intensitet" "Interaction" = "Interaktion" "Interactions" = "Interaktioner" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps." diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 1695864c..a9da62ef 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -638,6 +638,7 @@ name = "Türkçe" "Instance swap (no other component)" = "Örnek değiştirme (başka bileşen yok)" "Instances" = "Örnekler" "Instant" = "Anında" +"Intensity" = "Yoğunluk" "Interaction" = "Etkileşim" "Interactions" = "Etkileşimler" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası." diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 31be5611..a90c1c03 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -616,6 +616,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "实例替换(无其他组件)" "Instances" = "实例" "Instant" = "即时" +"Intensity" = "强度" "Interaction" = "交互" "Interactions" = "交互" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "界面设计与原型制作。一款以 Rust 从零编写的免费、开源、净室设计工具,属于 ArtCraft Crafting Apps 系列。" diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 492fdc01..14158972 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -473,3 +473,82 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { let (_, _, w, h) = geom(&u, a); assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60"); } + +/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where +/// they show (within their clip). +fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> { + fn walk(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(String, bool, egui::Rect)>) { + match shape { + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, clip, out)), + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.galley.elided, t.visual_bounding_rect().intersect(clip))), + _ => {} + } + } + let mut out = Vec::new(); + shapes.iter().for_each(|c| walk(&c.shape, c.clip_rect, &mut out)); + out +} + +/// The glass effect's light intensity field is labelled (#256): drawn in the Design panel with the +/// effect's settings open. +#[test] +fn the_glass_light_intensity_field_has_a_label() { + let mut s = Session::new(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("effect.add", &json!({"type": "glass"})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true)); + u.frame(vec![]); + let shapes = u.frame_output(vec![]).shapes; + let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; + let labels: Vec = painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, ..)| text).collect(); + assert_eq!(labels, ["Intensity"]); +} + +/// A glass settings field's width in the Design panel at its default width. +const GLASS_FIELD_W: f32 = 98.0; + +/// The glass settings' labels leave their values room in every language, and are cut only when +/// they would crowd them (#256). +#[test] +fn glass_labels_fit_beside_their_values_in_every_language() { + use crate::i18n::{self, Text}; + let keys = ["Light|glass", "Intensity", "Refraction", "Depth", "Dispersion", "Frost", "Splay"]; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + let width = |s: &str| ui.painter().layout_no_wrap(s.to_string(), crate::theme::regular(11.0), egui::Color32::WHITE).size().x; + for lang in i18n::languages() { + for key in keys { + let label = lang.ui.iter().find(|(k, _)| *k == key).map_or(key, |(_, t)| match t { + Text::Plain(s) | Text::Plural { other: s, .. } => s, + }); + let (lead, value) = (crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W), width("100%")); + assert!(lead + value <= GLASS_FIELD_W, "{}: no room for the value beside {label:?}", lang.code); + // Cut off only when it would crowd the value's 32 points (es "Profundidad"). + if 12.0 + width(label) <= GLASS_FIELD_W - 32.0 { + assert!(lead >= 12.0 + width(label), "{}: {label:?} is cut off", lang.code); + } + } + } + }); + out.textures_delta.clear(); +} + +#[test] +fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { + let ctx = egui::Context::default(); + let mut field = egui::Rect::NOTHING; + let mut out = ctx.run_ui(Default::default(), |ui| { + let lead = crate::widgets::Lead::Text("A label much wider than its field"); + let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0); + field = ui.min_rect(); + }); + out.textures_delta.clear(); + let texts = painted_texts(&out.shapes); + let [(_, label_cut, l), (value, value_cut, v)] = texts.as_slice() else { panic!("the label and the value: {texts:?}") }; + assert!(*label_cut, "the label ends in an ellipsis"); + assert_eq!((value.as_str(), *value_cut), ("80%", false), "the value shows whole"); + assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}"); + assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}"); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 47f3e5d3..31272ff2 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1887,7 +1887,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & row(ui, 32.0, |ui| { let ed = widgets::number_field(ui, Id::new(("fx_gl", i)), Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half, 1.0); edit(app, ed, &|v| with(&|gl| gl.light_angle = v.clamp(-180.0, 180.0))); - let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::None, pct(g.light_intensity), "%", half, 1.0); + let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half, 1.0); edit(app, ed, &|v| with(&|gl| gl.light_intensity = unit(v))); }); row(ui, 32.0, |ui| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b4413545..4763b47c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -202,7 +202,7 @@ fn number_field_impl( let mut out: Option = None; let lead_w = match lead { Lead::None | Lead::Named(_) => 6.0, - Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0), + Lead::Text(s) => text_lead_width(ui, s, width), Lead::Icon(..) => 24.0, }; let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H)); @@ -223,7 +223,7 @@ fn number_field_impl( } match lead { Lead::Text(s) => { - ui.painter().text(pos2(lead_rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary); + crate::bidi::paint_name(ui.painter(), pos2(lead_rect.min.x + 8.0, rect.center().y), s, regular(11.0), t.text_secondary, lead_w - 12.0); } Lead::Icon(i, _) => { icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary) @@ -347,7 +347,7 @@ fn number_field_impl( None => empty.to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; - ui.painter().text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); + ui.painter().with_clip_rect(edit_rect).text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); } } // The variable button shows while the pointer is over a bindable field. @@ -369,6 +369,13 @@ fn number_field_impl( (out, bind) } +/// The width a text label takes at the start of a field `field_w` wide: the label as drawn plus +/// its padding, leaving the value room for a few characters (a longer label ends in an ellipsis). +pub(crate) fn text_lead_width(ui: &Ui, label: &str, field_w: f32) -> f32 { + let text = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), Color32::PLACEHOLDER).size().x; + (12.0 + text).min(field_w - 32.0).max(24.0) +} + /// A field bound to a variable: the lead, then the variable's name in a chip; the chip opens the /// picker and the detach button unbinds. /// A field showing the variable `name` bound to it in `rect`: click to change, detach to unbind. @@ -383,8 +390,9 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O let lead_w = match lead { Lead::None | Lead::Named(_) => 4.0, Lead::Text(s) => { - ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary); - (12.0 + s.chars().count() as f32 * 7.0).max(24.0) + let w = text_lead_width(ui, s, rect.width()); + crate::bidi::paint_name(ui.painter(), pos2(rect.min.x + 8.0, rect.center().y), s, regular(11.0), t.text_secondary, w - 12.0); + w } Lead::Icon(i, _) => { icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 13.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary); From 1c16aeae094e81ad08a399e05cb4f3cb3ed5c59d Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 23:25:51 +0700 Subject: [PATCH 180/245] fix(canvas): the Pencil previews its stroke in its own colour and weight (#256) While drawing, the Pencil previewed its stroke in the theme's text colour, 2 points wide: white in dark theme, so over a white shape nothing showed until the mouse was let go. The preview now uses the colour and weight the Pencil draws with (one constant, PENCIL_STROKE, black at 2 pt), scaled by the zoom, at least 1 pt. It doesn't show pen pressure or a brush, which only the finished vector has. Refs #256 ("The 'draw' tool is not visible when you are drawing. You can see it after you release the mouse."). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/tools.rs | 7 +++-- crates/ui-egui/src/canvas.rs | 4 ++- crates/ui-egui/src/canvas_fixes_tests.rs | 33 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dbda1894..2674552e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -661,6 +661,9 @@ fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool /// handles: the canvas draws just its outline (the handles still answer just outside it). pub const SMALL_BOX_PX: f64 = 16.0; +/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). +pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); + /// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { let [a, b, _, d] = corners; @@ -2210,8 +2213,8 @@ impl Session { let mut local = fitted.clone(); local.apply_affine(pw.inverse()); let _ = d; - let mut props = - json!({"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); + let (color, weight) = PENCIL_STROKE; + let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); // Pen pressure becomes the stroke's width profile (brushes follow it too). if let Some(points) = pressure_profile(points, pressure) { props["widthProfile"] = json!(points); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bc184270..d7928b76 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1128,7 +1128,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if !ov.pen.is_empty() { let pts: Vec = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect(); if app.session.tool.tool == ToolId::Pencil { - painter.add(Shape::line(pts, stroke(t.text, 2.0))); + // The stroke it will make, in its colour and weight (at least a point when zoomed out). + let (color, weight) = figcraft_engine::tools::PENCIL_STROKE; + painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0)))); } else { for [x0, y0, x1, y1] in &ov.pen_handles { bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 3b593dae..4cd32d56 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -128,3 +128,36 @@ fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() { let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent)); assert!(band, "a curve leaving along the out handle"); } + +/// While drawing, the Pencil shows the stroke it will make, in its colour and weight (#256): in a +/// dark theme the theme's text colour was white, so over a white shape nothing showed until the +/// stroke was let go. +#[test] +fn the_pencil_previews_its_stroke_in_its_colour_and_weight() { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pencil"})); + let (color, weight) = figcraft_engine::tools::PENCIL_STROKE; + let preview = |shapes: &[Shape]| { + shapes.iter().find_map(|s| match s { + Shape::Path(p) if !p.closed && p.points.len() >= 3 && p.stroke.color == egui::epaint::ColorMode::Solid(crate::theme::c32(color)) => { + Some(p.stroke.width) + } + _ => None, + }) + }; + let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y); + let start = at(&u, 10.0, 10.0); + u.button(start, true); + for i in 1..=6 { + let p = at(&u, 10.0 + 12.0 * f64::from(i), 10.0 + 6.0 * f64::from(i % 2)); + u.frame(vec![egui::Event::PointerMoved(p)]); + } + let zoom = u.app.session.ds().unwrap().view.zoom; + let w = preview(&flat(&u.frame_output(vec![]).shapes)).expect("a preview while drawing"); + assert!((w - (weight * zoom) as f32).abs() < 0.01, "{w} for {weight} at {zoom}"); + // Zoomed far out it stays visible. + u.app.session.ds_mut().unwrap().view.zoom = 0.05; + let p = at(&u, 120.0, 40.0); + let w = preview(&flat(&u.frame_output(vec![egui::Event::PointerMoved(p)]).shapes)).expect("still previewing"); + assert!((w - 1.0).abs() < 0.01, "at least a point: {w}"); +} From e03c2a65f7a28e43a18e6bb0196e9c3a8ad14bca Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:43:32 +0700 Subject: [PATCH 181/245] #766: check a label before a widget's clickable part on a double-click --- crates/engine/src/tools.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dba3eaf7..d5485c9c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1859,6 +1859,20 @@ impl Session { if self.tool.tool == ToolId::Pen { return self.pen_finish(); } + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } // Quick clicks on a widget's clickable part are clicks, one by one. if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) @@ -1875,17 +1889,6 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } - // A frame's or section's name label: rename it there (the canvas puts a field over it). - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) - && !(ev.shift || ev.cmd || ev.alt) - && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) - { - if sel != [id] { - self.set_selection(vec![id]); - } - self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); - return Ok(()); - } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. From f33ffb4367af045b202afac6024e01c06cbdb29e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:43:58 +0700 Subject: [PATCH 182/245] #766: keep the label rename in layers::Rename like the layers panel --- crates/ui-egui/src/frame_label_tests.rs | 4 ++-- crates/ui-egui/src/frame_labels.rs | 27 +++++++++---------------- crates/ui-egui/src/lib.rs | 9 +++------ 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index d7f2371a..dbb981bd 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -108,7 +108,7 @@ fn double_clicking_a_label_renames_the_frame_there() { let before = u.app.session.doc().unwrap().world_bounds(a).unwrap(); double_click_label(&mut u, a); assert_eq!(u.app.session.selection(), vec![a]); - assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(a), "a field over the label"); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the label"); // The name starts selected: typing replaces it, Enter keeps it. u.frame(vec![Event::Text("Sign in".into())]); u.frame(key(egui::Key::Enter)); @@ -127,7 +127,7 @@ fn double_clicking_a_label_renames_the_frame_there() { fn escape_leaves_a_label_rename_with_the_old_name() { let (mut u, _, b) = setup(); double_click_label(&mut u, b); - assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(b), "rotated labels rename too"); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(b), "rotated labels rename too"); u.frame(vec![Event::Text("Nope".into())]); u.frame(key(egui::Key::Escape)); u.frame(vec![]); diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 43dd712d..79557141 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -151,18 +151,13 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl))) } -/// Rename `id`'s name label on the canvas: a field over the label from the next frame on. -pub fn start_rename(app: &mut FigApp, id: NodeId) { - // The field takes the focus once it's drawn (as the pages list does). - app.ui.rename_label = Some((id, true)); -} - -/// The field over a label being renamed (see [`start_rename`]): Enter or a click elsewhere -/// renames the layer, Escape keeps the name. +/// The field over a frame's or section's name label being renamed (`UiState::rename_label`, from a +/// double-click on the label): Enter or a click elsewhere renames the layer, Escape keeps the name. +/// The rename ends when the field lets go of the keyboard or the label is gone. pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { - let Some((id, start)) = app.ui.rename_label else { return }; - let quad = app.session.tool.labels.iter().find(|(i, _)| *i == id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y))); - let name = app.session.doc().ok().and_then(|d| d.node(id)).map(|n| n.name.clone()); + let Some(r) = app.ui.rename_label else { return }; + let quad = app.session.tool.labels.iter().find(|(i, _)| *i == r.id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y))); + let name = app.session.doc().ok().and_then(|d| d.node(r.id)).map(|n| n.name.clone()); let (Some(quad), Some(name)) = (quad, name) else { app.ui.rename_label = None; return; @@ -171,14 +166,10 @@ pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { let font = regular(11.0); let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); - let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", id)), &name, rect, font, start); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start); + app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false }); if let Some(new) = done { - app.ui.rename_label = None; - app.run("object.rename", serde_json::json!({"ids": [id], "name": new})); - } else if !open && !start { - app.ui.rename_label = None; - } else { - app.ui.rename_label = Some((id, false)); + app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new})); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6141b3dd..80a4b784 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -430,9 +430,8 @@ pub struct UiState { /// The layer being renamed in the layers panel. pub rename: Option, pub rename_page: Option<(NodeId, String)>, - /// The frame or section whose name label on the canvas is being renamed (a double-click on - /// it), and whether the rename just started (its field takes the keyboard when next drawn). - pub rename_label: Option<(NodeId, bool)>, + /// The frame or section whose name label on the canvas is being renamed (a double-click on it). + pub rename_label: Option, pub main_menu: bool, pub palette: Option, /// The Actions palette's tab. @@ -966,9 +965,7 @@ impl FigApp { "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), "canvas.renameLabel" => { - if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) { - frame_labels::start_rename(self, id); - } + self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true }); } "layers.rename" => { let ids: Vec = From a7da760f9c21d9ec48b58e699cafae538f0f880e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:44:41 +0700 Subject: [PATCH 183/245] #766: drop the progress-doc edits (the maintainer adds those) --- docs/roadmap.md | 1 - docs/ui-parity.md | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index b89f1842..8d63a469 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,7 +73,6 @@ blockers. | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | M1: double-clicking a frame's or section's name on the canvas renames it in place (#757; Enter commits, Escape cancels, one undo step). 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). 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. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d2b3c9e2..0cccf008 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (renaming a frame or section from its name on the canvas (#757); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -98,7 +98,7 @@ interaction feel not yet compared. |---|---|---| | 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 | double-click a frame's or section's name to rename it in place: Enter or a click elsewhere keeps the name, Escape restores it, one undo step (#757, `frame_label_tests`) | +| Frame labels (rotated, right-to-left), size badge | done | | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | | 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 | @@ -172,7 +172,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Frame and section names on the canvas rename on a double-click (#757): a field over the label with the name selected; Enter or a click elsewhere commits (`object.rename`, one undo step), Escape cancels. Rotated labels too. Not compared side by side with Figma | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 76f0edce08dec541c8f266971608dbdb6891d890 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:44:41 +0700 Subject: [PATCH 184/245] #766: port the engine test, a section and click-elsewhere UI case, and the control-protocol note --- crates/engine/src/frame_label_tests.rs | 24 +++++++++++++++++++++++- crates/ui-egui/src/frame_label_tests.rs | 24 +++++++++++++++++++++++- docs/control-protocol.md | 3 ++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs index 8e9ede7b..3da9522f 100644 --- a/crates/engine/src/frame_label_tests.rs +++ b/crates/engine/src/frame_label_tests.rs @@ -1,6 +1,7 @@ //! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it, //! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it -//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing. +//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked, +//! hidden, deleted or other pages' frames do nothing. use figcraft_doc::NodeId; use figcraft_geom::Point; @@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() { let flat = [Point::new(1.0, 1.0); 4]; assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None); } + +#[test] +fn double_clicking_a_label_asks_to_rename_its_frame() { + let (mut s, f, q) = setup(); + let c = center(&q); + let before = s.doc().unwrap().world_bounds(f).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] { + s.pointer(&ev(kind, c)).unwrap(); + } + assert_eq!(s.selection(), vec![f]); + let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"])); + assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label"); + assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet"); + // Other tools ignore the label. + s.ui_requests.clear(); + s.set_tool(crate::tools::ToolId::Hand); + s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap(); + assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel")); +} diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index dbb981bd..687669e8 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -1,6 +1,6 @@ //! Frame name labels on the canvas, driven through egui headless: an upright frame's label sits //! just above its top-left corner, a rotated frame's runs along its turned top edge, and pressing -//! either label selects and drags its frame. +//! either label selects and drags its frame; double-clicking one renames the frame or section there. use egui::{Event, Pos2, pos2, vec2}; use figcraft_doc::NodeId; @@ -134,3 +134,25 @@ fn escape_leaves_a_label_rename_with_the_old_name() { assert_eq!(name(&u, b), "Turned"); assert!(u.app.ui.rename_label.is_none()); } + +#[test] +fn a_sections_label_renames_it_and_a_click_elsewhere_commits() { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Area"})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let undo = u.app.session.ds().unwrap().history.undo.len(); + double_click_label(&mut u, a); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the section's label"); + assert_eq!(u.app.session.selection(), vec![a]); + u.frame(vec![Event::Text("Zone".into())]); + let at = mid(&label(&u, a)); + let away = pos2(at.x + 200.0, at.y + 500.0); + u.button(away, true); + u.button(away, false); + u.frame(vec![]); + assert_eq!(name(&u, a), "Zone", "a click on the canvas commits"); + assert!(u.app.ui.rename_label.is_none()); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3a06874e..0dc52a1e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -198,7 +198,8 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d (`x`, `y`) or screen pixels (`sx`, `sy`). `pressure` (0–1) is pen pressure (the Pencil records it); a drag's pressure goes from `pressure` to `pressureTo`. Returns `{selection, tool}`. With the Move and Scale tools, a press on a frame's or section's name label (as the canvas last drew it, along a rotated -frame's top edge) selects and drags that frame. +frame's top edge) selects and drags that frame; a double-click there renames the frame in place in +the desktop app (the name field opens over the label). ```json {"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}} From 7b9ebaacbb971fb4f4098156ecf67e2aaa7c8182 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 23:50:48 +0700 Subject: [PATCH 185/245] =?UTF-8?q?fix(ui):=20fitting=20Intensity=20transl?= =?UTF-8?q?ations;=20cut=20values=20end=20in=20"=E2=80=A6",=20cut=20labels?= =?UTF-8?q?=20show=20on=20hover=20(#256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: - ru and pt-BR "Intensity" were too long for the glass field and ended in "…"; they are now "Яркость" and "Brilho", which fit. - A value too long for its field ends in "…" instead of being clipped, so "100000" never shows as "10". - A label cut short shows whole on hover. - The every-language test now checks the Intensity label fits whole in each catalog (it fails with the old ru translation); the panel test checks the label isn't cut and that the field is the 98.5 points the language test measures against. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/src/field_tests.rs | 33 ++++++++++++++----------------- crates/ui-egui/src/widgets.rs | 10 ++++++++-- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index aabc8806..d970db14 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -614,7 +614,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Troca de instância (nenhum outro componente)" "Instances" = "Instâncias" "Instant" = "Instantâneo" -"Intensity" = "Intensidade" +"Intensity" = "Brilho" "Interaction" = "Interação" "Interactions" = "Interações" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft." diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 7ec18507..dc850023 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -635,7 +635,7 @@ name = "Русский" "Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)" "Instances" = "Экземпляры" "Instant" = "Мгновенно" -"Intensity" = "Интенсивность" +"Intensity" = "Яркость" "Interaction" = "Взаимодействие" "Interactions" = "Взаимодействия" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps." diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 14158972..9ddc48e5 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -502,34 +502,31 @@ fn the_glass_light_intensity_field_has_a_label() { u.frame(vec![]); let shapes = u.frame_output(vec![]).shapes; let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; - let labels: Vec = painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, ..)| text).collect(); - assert_eq!(labels, ["Intensity"]); + let labels: Vec<(String, bool)> = + painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect(); + assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short"); + let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect; + assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against"); } /// A glass settings field's width in the Design panel at its default width. -const GLASS_FIELD_W: f32 = 98.0; +const GLASS_FIELD_W: f32 = 98.5; -/// The glass settings' labels leave their values room in every language, and are cut only when -/// they would crowd them (#256). +/// The light intensity label this fix adds fits whole beside its value in every language (#256). +/// Other glass labels may end in "…" in some languages (es "Profundidad"); a longer +/// "Intensity" translation fails here. #[test] -fn glass_labels_fit_beside_their_values_in_every_language() { +fn the_intensity_label_fits_whole_in_every_language() { use crate::i18n::{self, Text}; - let keys = ["Light|glass", "Intensity", "Refraction", "Depth", "Dispersion", "Frost", "Splay"]; let ctx = egui::Context::default(); let mut out = ctx.run_ui(Default::default(), |ui| { let width = |s: &str| ui.painter().layout_no_wrap(s.to_string(), crate::theme::regular(11.0), egui::Color32::WHITE).size().x; for lang in i18n::languages() { - for key in keys { - let label = lang.ui.iter().find(|(k, _)| *k == key).map_or(key, |(_, t)| match t { - Text::Plain(s) | Text::Plural { other: s, .. } => s, - }); - let (lead, value) = (crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W), width("100%")); - assert!(lead + value <= GLASS_FIELD_W, "{}: no room for the value beside {label:?}", lang.code); - // Cut off only when it would crowd the value's 32 points (es "Profundidad"). - if 12.0 + width(label) <= GLASS_FIELD_W - 32.0 { - assert!(lead >= 12.0 + width(label), "{}: {label:?} is cut off", lang.code); - } - } + let label = lang.ui.iter().find(|(k, _)| *k == "Intensity").map_or("Intensity", |(_, t)| match t { + Text::Plain(s) | Text::Plural { other: s, .. } => s, + }); + let lead = crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W); + assert!(lead >= 12.0 + width(label), "{}: {label:?} would end in \"…\"", lang.code); } }); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4763b47c..c415842d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -223,7 +223,12 @@ fn number_field_impl( } match lead { Lead::Text(s) => { - crate::bidi::paint_name(ui.painter(), pos2(lead_rect.min.x + 8.0, rect.center().y), s, regular(11.0), t.text_secondary, lead_w - 12.0); + let galley = crate::bidi::name_galley(ui.painter(), s, regular(11.0), t.text_secondary, lead_w - 12.0); + // A label cut short shows whole on hover. + if galley.elided { + lead_resp.clone().on_hover_text(s); + } + ui.painter().galley(pos2(lead_rect.min.x + 8.0, rect.center().y - galley.size().y / 2.0), galley, t.text_secondary); } Lead::Icon(i, _) => { icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary) @@ -347,7 +352,8 @@ fn number_field_impl( None => empty.to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; - ui.painter().with_clip_rect(edit_rect).text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); + // A value too long for its room ends in "…", so a cut number never reads as another. + crate::bidi::paint_name(ui.painter(), pos2(edit_rect.min.x, rect.center().y), &s, regular(11.0), col, edit_rect.width()); } } // The variable button shows while the pointer is over a bindable field. From 913634bec8f8bec9b09bfbff05e78e9cdab70916 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 00:13:49 +0700 Subject: [PATCH 186/245] #385: outline modes and soft-proof stay with their file; one toggle table for the menu's check marks --- crates/engine/src/cmd/view.rs | 108 ++++++++++------------ crates/engine/src/view_behaviour_tests.rs | 5 + 2 files changed, 52 insertions(+), 61 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 61c592e0..69b938e9 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -22,31 +22,33 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul /// One of a view's on/off settings. type Slot = fn(&mut ViewState) -> &mut bool; -/// The View menu's toggles the preferences keep: files opened or made later start with them. -const KEPT: &[(&str, Slot)] = &[ - ("view.pixelGrid", |v| &mut v.pixel_grid), - ("view.layoutGrids", |v| &mut v.layout_grids), - ("view.rulers", |v| &mut v.rulers), - ("view.slices", |v| &mut v.show_slices), - ("view.comments", |v| &mut v.comments), - ("view.multiplayerCursors", |v| &mut v.multiplayer_cursors), - ("view.outlines", |v| &mut v.outlines), - ("view.outlinesSelection", |v| &mut v.outlines_selection), - ("view.outlinesHiddenLayers", |v| &mut v.outline_hidden), - ("view.outlinesObjectBounds", |v| &mut v.outline_bounds), - ("view.maskOutlines", |v| &mut v.mask_outlines), - ("view.frameOutlines", |v| &mut v.frame_outlines), - ("view.proofSrgb", |v| &mut v.proof_srgb), - ("view.snapGeometry", |v| &mut v.snap_geometry), - ("view.snapObjects", |v| &mut v.snap_objects), - ("view.snapPixel", |v| &mut v.snap_pixel), +/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and +/// whether the preferences keep each: files opened or made later start with the kept ones. Outline +/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one. +const TOGGLES: &[(&str, Slot, bool)] = &[ + ("view.pixelGrid", |v| &mut v.pixel_grid, true), + ("view.layoutGrids", |v| &mut v.layout_grids, true), + ("view.rulers", |v| &mut v.rulers, true), + ("view.slices", |v| &mut v.show_slices, true), + ("view.comments", |v| &mut v.comments, true), + ("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true), + ("view.outlines", |v| &mut v.outlines, false), + ("view.outlinesSelection", |v| &mut v.outlines_selection, false), + ("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true), + ("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true), + ("view.maskOutlines", |v| &mut v.mask_outlines, true), + ("view.frameOutlines", |v| &mut v.frame_outlines, true), + ("view.proofSrgb", |v| &mut v.proof_srgb, false), + ("view.snapGeometry", |v| &mut v.snap_geometry, true), + ("view.snapObjects", |v| &mut v.snap_objects, true), + ("view.snapPixel", |v| &mut v.snap_pixel, true), ]; -/// A View toggle the preferences keep: the file's view changes and the preferences remember it. -fn kept(s: &mut Session, p: &Value, id: &str) -> Result { - let Some((_, f)) = KEPT.iter().find(|(k, _)| *k == id) else { return Err(bad(id, "not a View toggle")) }; - let r = flag(s, p, *f)?; - if let Some(on) = r.get("on").and_then(Value::as_bool) { +/// View toggle `id`: the file's view changes, and the preferences remember a kept one. +fn toggle(s: &mut Session, p: &Value, id: &str) -> Result { + let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) }; + let r = flag(s, p, f)?; + if keep && let Some(on) = r.get("on").and_then(Value::as_bool) { s.prefs.view.insert(id.to_string(), on); } Ok(r) @@ -54,7 +56,7 @@ fn kept(s: &mut Session, p: &Value, id: &str) -> Result { /// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are). pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) { - for (id, f) in KEPT { + for (id, f, _) in TOGGLES.iter().filter(|t| t.2) { if let Some(on) = prefs.view.get(*id) { *f(v) = *on; } @@ -64,27 +66,11 @@ pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) { /// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command /// that isn't one. pub fn checked(s: &Session, id: &str) -> Option { - let v = &s.ds().ok()?.view; - Some(match id { - "view.pixelGrid" => v.pixel_grid, - "view.layoutGrids" => v.layout_grids, - "view.rulers" => v.rulers, - "view.slices" => v.show_slices, - "view.comments" => v.comments, - "view.multiplayerCursors" => v.multiplayer_cursors, - "view.outlines" => v.outlines, - "view.outlinesSelection" => v.outlines_selection, - "view.outlinesHiddenLayers" => v.outline_hidden, - "view.outlinesObjectBounds" => v.outline_bounds, - "view.maskOutlines" => v.mask_outlines, - "view.pixelPreview" => v.pixel_preview, - "view.frameOutlines" => v.frame_outlines, - "view.proofSrgb" => v.proof_srgb, - "view.snapGeometry" => v.snap_geometry, - "view.snapObjects" => v.snap_objects, - "view.snapPixel" => v.snap_pixel, - _ => return None, - }) + let mut v = s.ds().ok()?.view.clone(); + if id == "view.pixelPreview" { + return Some(v.pixel_preview); + } + TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v)) } /// Pixel preview: toggles, or turns on at a resolution of 1x or 2x. @@ -277,46 +263,46 @@ pub fn specs() -> Vec { spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)), spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)), spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)), - spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| kept(s, p, "view.pixelGrid")), - spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| kept(s, p, "view.layoutGrids")), - spec!("view.rulers", "Rulers", "{on?}", false, |s, p| kept(s, p, "view.rulers")), - spec!("view.slices", "Slices", "{on?}", false, |s, p| kept(s, p, "view.slices")), - spec!("view.comments", "Comments", "{on?}", false, |s, p| kept(s, p, "view.comments")), + spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")), + spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")), + spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")), + spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")), + spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")), spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| { - kept(s, p, "view.multiplayerCursors") + toggle(s, p, "view.multiplayerCursors") }), spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan), - spec!("view.outlines", "Outlines", "{on?}", false, |s, p| kept(s, p, "view.outlines")), + spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { - kept(s, p, "view.outlinesSelection") + toggle(s, p, "view.outlinesSelection") }), spec!( "view.outlinesHiddenLayers", "Include hidden layers in outlines", "{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there", false, - |s, p| { kept(s, p, "view.outlinesHiddenLayers") } + |s, p| { toggle(s, p, "view.outlinesHiddenLayers") } ), spec!( "view.outlinesObjectBounds", "Include object bounds in outlines", "{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer", false, - |s, p| { kept(s, p, "view.outlinesObjectBounds") } + |s, p| { toggle(s, p, "view.outlinesObjectBounds") } ), - spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| kept(s, p, "view.maskOutlines")), + spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), - spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| kept(s, p, "view.frameOutlines")), + spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")), spec!( "view.proofSrgb", "Soft-proof sRGB", "{on?} mark the colours of a Display P3 file that sRGB screens and files can't show", false, - |s, p| { kept(s, p, "view.proofSrgb") } + |s, p| { toggle(s, p, "view.proofSrgb") } ), - spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| kept(s, p, "view.snapGeometry")), - spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| kept(s, p, "view.snapObjects")), - spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| kept(s, p, "view.snapPixel")), + spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")), + spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")), + spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")), spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)), spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| { s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")})); diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index f8d9750c..7f9e5a84 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -756,6 +756,10 @@ fn view_toggles_are_kept_for_the_next_launch() { for (cmd, on, _, _) in &rows { ex(&mut s, cmd, json!({"on": on})); } + // Modes stay with their file: a new one never opens in outline mode or soft-proofing. + for cmd in ["view.outlines", "view.outlinesSelection", "view.proofSrgb"] { + ex(&mut s, cmd, json!({"on": true})); + } // The app writes its preferences as JSON and reads them back at the next launch. let saved = serde_json::to_value(&s.prefs).unwrap(); let mut next = Session::empty(); @@ -765,4 +769,5 @@ fn view_toggles_are_kept_for_the_next_launch() { for (cmd, on, get, why) in rows { assert_eq!(get(view), on, "{cmd}: {why}"); } + assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}"); } From 694c3114e8ff852e187019a38b05f0111bceae27 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 00:22:13 +0700 Subject: [PATCH 187/245] #758: drop the progress-doc edits (the maintainer adds those) --- ROADMAP.md | 2 -- docs/file-format-parity.md | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a97fc784..3908e591 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,7 +131,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-17** · Missing fonts (M7, #752): fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables) are listed in a Missing fonts window after the user opens the file (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Their text still draws in a fallback font. 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. @@ -317,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Missing fonts (M7, #752): fonts a file names that aren't installed are listed after the user opens it, from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. 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/docs/file-format-parity.md b/docs/file-format-parity.md index 72294f68..96293b34 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (`.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -159,13 +159,12 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing fonts on open | listed, with replacement | listed (Missing fonts window after an open, Text ▸ Missing fonts…, `text.missingFonts`, `figcraft-cli info`); no replacement; text draws in a fallback font | +| Missing-font replacement on open | yes | warnings only (REST import) | ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Missing fonts (#752): the fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables in any mode) are listed when the user opens it (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. The row claimed REST import warnings no code produced. | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | From 7ee2772da8697b5353cf3bf30fa576b8f037362a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 00:22:21 +0700 Subject: [PATCH 188/245] #758: ask whether the installed fonts are indexed only while a list waits for them --- crates/ui-egui/src/missing_fonts.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs index 6c07068d..b6c79477 100644 --- a/crates/ui-egui/src/missing_fonts.rs +++ b/crates/ui-egui/src/missing_fonts.rs @@ -60,14 +60,17 @@ pub fn request(app: &mut FigApp) { } pub fn show(app: &mut FigApp, ctx: &egui::Context) { - let known = figcraft_text::installed_fonts_known(); - if let Some(uid) = app.ui.missing_fonts.as_ref().filter(|m| m.indexing && known).map(|m| m.uid) { - list(app, uid); - } - if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && known { - let uid = app.ui.font_checks.remove(0); - if list(app, uid).is_some_and(|f| f.is_empty()) { - app.ui.missing_fonts = None; + // Whether the installed fonts are indexed is asked only while a list waits for it. + let waiting = app.ui.missing_fonts.as_ref().filter(|m| m.indexing).map(|m| m.uid); + if (waiting.is_some() || !app.ui.font_checks.is_empty()) && figcraft_text::installed_fonts_known() { + if let Some(uid) = waiting { + list(app, uid); + } + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() { + let uid = app.ui.font_checks.remove(0); + if list(app, uid).is_some_and(|f| f.is_empty()) { + app.ui.missing_fonts = None; + } } } if app.ui.missing_fonts.as_ref().is_some_and(|m| !app.session.docs.iter().any(|d| d.uid == m.uid)) { From cbf6d2df415e63e1be800e24dda4dbc79f821539 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 00:33:32 +0700 Subject: [PATCH 189/245] #762: drop the progress-doc edit (the maintainer adds those) --- docs/ui-parity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a28749bf..0cccf008 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -172,7 +172,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-17 | patch | macOS shortcut keycaps draw ⌃ ⌥ ↵ ⌫ ⇥ ⇞ ⇟ in menus and tooltips (they were boxes): the desktop app adds a system face with them (Lucida Grande, Apple Symbols, Segoe UI Symbol, DejaVu Sans…) after the UI fonts. Checked in the app on macOS. The web build on a Mac still shows boxes (no system fonts there). Summary and hours unchanged | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 7fd936c8ba55819ebe7b52bd7b5becdecbe3c229 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 00:36:36 +0700 Subject: [PATCH 190/245] #762: one system fallback helper for scripts and shortcut glyphs, a face added once; the glyph test skips when this machine's fonts lack some --- crates/ui-egui/src/bidi.rs | 17 ++++------------- crates/ui-egui/src/theme.rs | 34 ++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 915c7e11..58c5df93 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -274,8 +274,9 @@ fn word_at(text: &str, i: usize) -> (usize, usize) { (a, b) } -/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data. -pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontData)> { +/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data; +/// `taken` as for [`crate::theme::system_fallbacks`]. +pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec>) -> Vec<(String, egui::FontData)> { // Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with). const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}']; const FAMILIES: [&str; 11] = [ @@ -291,17 +292,7 @@ pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontD "FreeSans", "Noto Sans", ]; - let mut out: Vec<(String, egui::FontData)> = Vec::new(); - let mut taken: Vec> = Vec::new(); - for c in SAMPLES { - if have(c) || taken.iter().any(|f| f.has_char(c)) { - continue; - } - let Some(face) = figcraft_text::face_with_char(c, &FAMILIES) else { continue }; - out.push((format!("system-{}-{}", face.family, face.style), crate::theme::font_data(&face))); - taken.push(face); - } - out + crate::theme::system_fallbacks(SAMPLES, &FAMILIES, have, taken) } #[cfg(test)] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 933ebece..a4fa8754 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -273,8 +273,9 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { .filter_map(|f| figcraft_text::find_face(f, "Regular")) .any(|x| x.has_char(c)) }; - extra.extend(crate::bidi::script_fallbacks(&embedded)); - extra.extend(key_symbol_fallbacks(&embedded)); + let mut taken = Vec::new(); + extra.extend(crate::bidi::script_fallbacks(&embedded, &mut taken)); + extra.extend(key_symbol_fallbacks(&embedded, &mut taken)); extra.extend(crate::cjk::faces(cjk, lang, &embedded).iter().map(|f| (format!("system-{}-{}", f.family, f.style), font_data(f)))); } extra.extend(craft(true)); @@ -291,18 +292,30 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { /// The glyphs macOS shortcuts are written with ([`figcraft_engine::keymap::display`]). const KEY_SYMBOLS: &str = "⌃⌥⇧⌘⌫↵⇥⇞⇟←→↑↓"; +/// System families that have them, in order of preference. +const KEY_FAMILIES: [&str; 6] = ["Lucida Grande", "Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols 2", "FreeSans"]; /// System faces for the shortcut glyphs the UI's own fonts lack (egui's default face has ⌘ but -/// not ⌥ or ↵). -fn key_symbol_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, FontData)> { - const FAMILIES: [&str; 6] = ["Lucida Grande", "Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols 2", "FreeSans"]; +/// not ⌥ or ↵); `taken` as for [`system_fallbacks`]. +fn key_symbol_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec>) -> Vec<(String, FontData)> { + system_fallbacks(KEY_SYMBOLS.chars(), &KEY_FAMILIES, have, taken) +} + +/// System faces, the first of `families` that has each, for the `samples` neither the UI's own +/// fonts (`have`) nor the faces already `taken` draw. Each face found joins `taken`, so a face that +/// several lists name is added once. +pub(crate) fn system_fallbacks( + samples: impl IntoIterator, + families: &[&str], + have: &dyn Fn(char) -> bool, + taken: &mut Vec>, +) -> Vec<(String, FontData)> { let mut out = Vec::new(); - let mut taken: Vec> = Vec::new(); - for c in KEY_SYMBOLS.chars() { + for c in samples { if have(c) || taken.iter().any(|f| f.has_char(c)) { continue; } - let Some(face) = figcraft_text::face_with_char(c, &FAMILIES) else { continue }; + let Some(face) = figcraft_text::face_with_char(c, families) else { continue }; out.push((format!("system-{}-{}", face.family, face.style), font_data(&face))); taken.push(face); } @@ -360,8 +373,9 @@ mod tests { #[test] fn ui_font_draws_every_mac_shortcut_glyph() { figcraft_text::scan_system_fonts(); - if key_symbol_fallbacks(&|_| false).is_empty() { - eprintln!("no system face with shortcut glyphs; skipping"); + let lacking: String = KEY_SYMBOLS.chars().filter(|c| figcraft_text::face_with_char(*c, &KEY_FAMILIES).is_none()).collect(); + if !lacking.is_empty() { + eprintln!("no system face here draws {lacking}; skipping"); return; } let ctx = Context::default(); From 89c5d3dfe6d788ca8ba0e1c2480fdb18247af8ec Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:33:28 +0200 Subject: [PATCH 191/245] Keep View toggles such as Rulers in the preferences Fixes #379 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/view.rs | 74 ++++++++++++++++++----- crates/engine/src/lib.rs | 18 ++++++ crates/engine/src/view_behaviour_tests.rs | 24 ++++++++ crates/ui-egui/src/lib.rs | 2 + 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index de69e690..61c592e0 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -19,6 +19,48 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul Ok(json!({"on": *slot})) } +/// One of a view's on/off settings. +type Slot = fn(&mut ViewState) -> &mut bool; + +/// The View menu's toggles the preferences keep: files opened or made later start with them. +const KEPT: &[(&str, Slot)] = &[ + ("view.pixelGrid", |v| &mut v.pixel_grid), + ("view.layoutGrids", |v| &mut v.layout_grids), + ("view.rulers", |v| &mut v.rulers), + ("view.slices", |v| &mut v.show_slices), + ("view.comments", |v| &mut v.comments), + ("view.multiplayerCursors", |v| &mut v.multiplayer_cursors), + ("view.outlines", |v| &mut v.outlines), + ("view.outlinesSelection", |v| &mut v.outlines_selection), + ("view.outlinesHiddenLayers", |v| &mut v.outline_hidden), + ("view.outlinesObjectBounds", |v| &mut v.outline_bounds), + ("view.maskOutlines", |v| &mut v.mask_outlines), + ("view.frameOutlines", |v| &mut v.frame_outlines), + ("view.proofSrgb", |v| &mut v.proof_srgb), + ("view.snapGeometry", |v| &mut v.snap_geometry), + ("view.snapObjects", |v| &mut v.snap_objects), + ("view.snapPixel", |v| &mut v.snap_pixel), +]; + +/// A View toggle the preferences keep: the file's view changes and the preferences remember it. +fn kept(s: &mut Session, p: &Value, id: &str) -> Result { + let Some((_, f)) = KEPT.iter().find(|(k, _)| *k == id) else { return Err(bad(id, "not a View toggle")) }; + let r = flag(s, p, *f)?; + if let Some(on) = r.get("on").and_then(Value::as_bool) { + s.prefs.view.insert(id.to_string(), on); + } + Ok(r) +} + +/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are). +pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) { + for (id, f) in KEPT { + if let Some(on) = prefs.view.get(*id) { + *f(v) = *on; + } + } +} + /// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command /// that isn't one. pub fn checked(s: &Session, id: &str) -> Option { @@ -235,46 +277,46 @@ pub fn specs() -> Vec { spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)), spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)), spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)), - spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)), - spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)), - spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)), - spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), - spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), + spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| kept(s, p, "view.pixelGrid")), + spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| kept(s, p, "view.layoutGrids")), + spec!("view.rulers", "Rulers", "{on?}", false, |s, p| kept(s, p, "view.rulers")), + spec!("view.slices", "Slices", "{on?}", false, |s, p| kept(s, p, "view.slices")), + spec!("view.comments", "Comments", "{on?}", false, |s, p| kept(s, p, "view.comments")), spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| { - flag(s, p, |v| &mut v.multiplayer_cursors) + kept(s, p, "view.multiplayerCursors") }), spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan), - spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), + spec!("view.outlines", "Outlines", "{on?}", false, |s, p| kept(s, p, "view.outlines")), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { - flag(s, p, |v| &mut v.outlines_selection) + kept(s, p, "view.outlinesSelection") }), spec!( "view.outlinesHiddenLayers", "Include hidden layers in outlines", "{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there", false, - |s, p| { flag(s, p, |v| &mut v.outline_hidden) } + |s, p| { kept(s, p, "view.outlinesHiddenLayers") } ), spec!( "view.outlinesObjectBounds", "Include object bounds in outlines", "{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer", false, - |s, p| { flag(s, p, |v| &mut v.outline_bounds) } + |s, p| { kept(s, p, "view.outlinesObjectBounds") } ), - spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)), + spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| kept(s, p, "view.maskOutlines")), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), - spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), + spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| kept(s, p, "view.frameOutlines")), spec!( "view.proofSrgb", "Soft-proof sRGB", "{on?} mark the colours of a Display P3 file that sRGB screens and files can't show", false, - |s, p| { flag(s, p, |v| &mut v.proof_srgb) } + |s, p| { kept(s, p, "view.proofSrgb") } ), - spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)), - spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)), - spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)), + spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| kept(s, p, "view.snapGeometry")), + spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| kept(s, p, "view.snapObjects")), + spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| kept(s, p, "view.snapPixel")), spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)), spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| { s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")})); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f49d3fc6..d0fc9386 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -372,6 +372,8 @@ pub struct DocState { /// The last duplicate while its copies are the selection: Duplicate repeats its offset (see /// [`cmd::edit::Duplicated`]; not saved). pub(crate) last_duplicate: Option, + /// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]). + view_from_prefs: bool, } /// A version shown on the canvas in place of the document: read-only until it's restored or the @@ -431,6 +433,7 @@ impl DocState { guide: None, rotation_origin: None, last_duplicate: None, + view_from_prefs: false, }; st.zoom_to_fit(); st @@ -663,6 +666,10 @@ pub struct Prefs { /// `widget:`. #[serde(skip_serializing_if = "Vec::is_empty")] pub recent_actions: Vec, + /// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id: + /// files opened or made later start with them. Missing: the toggle's default. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub view: BTreeMap, } /// Recently run actions kept for the Actions palette (newest first). @@ -697,6 +704,7 @@ impl Default for Prefs { home_sort: "modified".into(), home_order: "desc".into(), recent_actions: Vec::new(), + view: BTreeMap::new(), } } } @@ -848,6 +856,14 @@ impl Session { s } + /// Files new to the session start with the View toggles the preferences keep. + pub fn apply_view_prefs(&mut self) { + for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) { + cmd::view::apply_kept(&self.prefs, &mut ds.view); + ds.view_from_prefs = true; + } + } + pub fn ds(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) } @@ -889,6 +905,7 @@ impl Session { return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into())); } branches::sync(self); + self.apply_view_prefs(); if textpreview::COMMANDS.contains(&id) { // A live preview is no edit: no undo step, normalisation or rollback here. let r = guard::run(id, || (spec.run)(self, params)); @@ -914,6 +931,7 @@ impl Session { match &result { Ok(_) => { self.after_command(spec, before); + self.apply_view_prefs(); if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index 27bc3771..f8d9750c 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -742,3 +742,27 @@ fn alt_measures_between_layers() { assert_eq!(measured(&mut s, p, m), want, "{why}"); } } + +#[test] +fn view_toggles_are_kept_for_the_next_launch() { + let mut s = Session::new(); + // (command, turned to, ViewState field, derivation) + let rows: Vec<(&str, bool, fn(&ViewState) -> bool, &str)> = vec![ + ("view.rulers", true, |v| v.rulers, "rulers shown stay shown"), + ("view.pixelGrid", false, |v| v.pixel_grid, "a hidden pixel grid stays hidden"), + ("view.layoutGrids", false, |v| v.layout_grids, "hidden layout grids stay hidden"), + ("view.snapPixel", false, |v| v.snap_pixel, "snapping to the pixel grid off stays off"), + ]; + for (cmd, on, _, _) in &rows { + ex(&mut s, cmd, json!({"on": on})); + } + // The app writes its preferences as JSON and reads them back at the next launch. + let saved = serde_json::to_value(&s.prefs).unwrap(); + let mut next = Session::empty(); + next.prefs = serde_json::from_value(saved).unwrap(); + ex(&mut next, "file.new", json!({})); + let view = &next.ds().unwrap().view; + for (cmd, on, get, why) in rows { + assert_eq!(get(view), on, "{cmd}: {why}"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8f1996ca..70e581f4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1357,6 +1357,8 @@ impl FigApp { } self.background_done(&ctx); } + // Files that arrived without a command (joined, recovered) take the kept View toggles too. + self.session.apply_view_prefs(); windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); self.frame(ui); From 42242e3c8b88e556282926e09c223bc809aaa1e7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 18 Oct 2026 01:41:15 +0700 Subject: [PATCH 192/245] M1: IME composition in text layers (#747) (#755) - Text editing on the canvas now tells the platform it wants the IME on **every** frame of the edit. Before, it only did so on frames that brought input events. - The candidate window opens at the caret. Before, it opened at the canvas corner: the desktop integration places it by `IMEOutput.rect`, which was the whole canvas. - Keys that reach the editor while a composition is in progress belong to the IME. Enter, Escape, Backspace, arrows and shortcuts don't act on the text then, so Enter picking a candidate doesn't add a newline. - New test `crates/ui-egui/src/ime_tests.rs`. - Docs: the IME rows in `docs/ui-parity.md` and `ROADMAP.md` now say it hasn't been checked on a Mac. Fixes #747. --- ROADMAP.md | 2 +- crates/ui-egui/src/ime_tests.rs | 87 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/shortcuts.rs | 15 ++++-- docs/ui-parity.md | 5 +- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/ime_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3908e591..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,7 +117,7 @@ French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and I Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, -IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). +IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md). ## Upcoming diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs new file mode 100644 index 00000000..a6db0cbd --- /dev/null +++ b/crates/ui-egui/src/ime_tests.rs @@ -0,0 +1,87 @@ +//! IME composition in a text layer, driven through egui headless with the events macOS sends for +//! Pinyin ("nihao", then a candidate picked): the platform keeps the IME on every frame of the +//! edit (an idle frame too) with the candidate window at the caret, the composition shows without +//! entering the document, keys pressed while composing are the IME's, and the commit lands once. + +use egui::{Event, ImeEvent, Key, Modifiers, RawInput, Rect}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +fn preedit(text: &str) -> Event { + let n = text.chars().count(); + Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: (n > 0).then_some(n..n) }) +} + +#[expect(deprecated, reason = "egui still delivers these, and the editor must ignore them")] +fn enabled_disabled() -> (Event, Event) { + (Event::Ime(ImeEvent::Enabled), Event::Ime(ImeEvent::Disabled)) +} + +fn text(u: &Ui, id: NodeId) -> String { + match &u.app.session.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + } +} + +/// One frame; the IME output it leaves for the platform (`None` turns the IME off). +fn ime(u: &mut Ui, events: Vec) -> Rect { + let ime = u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing"); + assert_eq!(ime.rect, ime.cursor_rect, "the desktop places candidates by `rect`: the caret"); + ime.cursor_rect +} + +fn composing(u: &Ui) -> Option { + u.app.session.ds().unwrap().text_edit.as_ref().expect("still editing").preedit.as_ref().map(|p| p.text.clone()) +} + +#[test] +fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { + // The text is measured with a font: without craft-fonts a system one, scanned here rather than + // by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = NodeId(s.execute("text.create", &json!({"text": "Hi", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + // On a machine without any font nothing lays out and every caret sits at the start. + let fonts = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + // An idle frame (the caret blinking) keeps the IME on, or macOS drops the composition. + let caret = ime(&mut u, vec![]); + let ds = u.app.session.ds().unwrap(); + let b = ds.doc.world_bounds(t).unwrap(); + let layer = Rect::from_two_pos(u.app.to_screen(b.x0, b.y0), u.app.to_screen(b.x1, b.y1)).expand(1.0); + assert!(layer.contains(caret.center()), "candidates open at the caret ({caret:?} in {layer:?})"); + + let (enabled, disabled) = enabled_disabled(); + ime(&mut u, vec![enabled, preedit("n")]); + ime(&mut u, vec![]); + let mid = ime(&mut u, vec![preedit("ni")]); + assert!(!fonts || mid.min.x > caret.min.x, "the IME cursor follows the composition"); + // A key the platform leaks while composing (Backspace) belongs to the IME, not the text. + ime(&mut u, vec![key(Key::Backspace, true), preedit("nih"), key(Key::Backspace, false)]); + ime(&mut u, vec![preedit("nihao")]); + assert_eq!(composing(&u).as_deref(), Some("nihao")); + assert_eq!(text(&u, t), "Hi", "the composition isn't in the document yet"); + + // Enter confirms the candidate: no newline, editing goes on. + let after = ime(&mut u, vec![key(Key::Enter, true), preedit(""), Event::Ime(ImeEvent::Commit("你好".into())), disabled, key(Key::Enter, false)]); + assert_eq!(text(&u, t), "Hi你好"); + assert_eq!(composing(&u), None); + let te = u.app.session.ds().unwrap().text_edit.clone().unwrap(); + assert_eq!((te.caret, te.anchor), (4, 4)); + assert!(!fonts || after.min.x > caret.min.x, "the caret moved past the committed text"); + ime(&mut u, vec![]); + assert_eq!(text(&u, t), "Hi你好", "committed once"); + + // Keys are the editor's again once the composition is over. + ime(&mut u, vec![key(Key::Enter, true), key(Key::Enter, false), Event::Text("!".into())]); + assert_eq!(text(&u, t), "Hi你好\n!"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..ceea7005 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,8 @@ mod home_tests; #[cfg(test)] mod idle_tests; #[cfg(test)] +mod ime_tests; +#[cfg(test)] mod import_report_tests; #[cfg(test)] mod layers_behaviour_tests; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 09b2c57b..f68a24f3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -102,14 +102,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe return; } let events: Vec = ctx.input(|i| i.events.clone()); - if events.is_empty() { - return; - } + // Every frame while editing, even one without input: a frame without the IME output turns the + // platform's IME off, which drops the composition (macOS then types plain letters). let editing = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); if editing { text_keys(app, ctx, &events); return; } + if events.is_empty() { + return; + } // An image-only clipboard brings no paste event: paste from the key then. The browser sends // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); @@ -448,6 +450,10 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) { } } Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { + // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). + if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { + continue; + } if (m.command || m.alt || m.ctrl) && text_shortcut(app, *key, *physical_key, *m) { skip_text = true; continue; @@ -532,7 +538,8 @@ fn ime_output(app: &FigApp, ctx: &Context) { ctx.output_mut(|o| { o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, - rect: app.canvas.rect, + // The desktop places the candidate window by `rect` (the web by `cursor_rect`). + rect: cursor_rect, cursor_rect, should_interrupt_composition: false, }) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..64bda29f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -85,7 +85,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | -| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From bc5a2b8fa76a63d93b6000e92c5d445d670e6f42 Mon Sep 17 00:00:00 2001 From: Natnael-Dev Date: Sat, 17 Oct 2026 21:45:51 +0300 Subject: [PATCH 193/245] fix(proto): delete prototype interaction without deleting component (#767) When an interaction noodle or interaction editor was selected in the Prototype tab, pressing Delete or Backspace (or executing edit.delete) targeted the selected layer/component instead of deleting the prototype connection. - Intercept Backspace and Delete in shortcuts::key_event and run_binding when an interaction is selected in the Prototype tab to remove the interaction using proto.interaction.remove rather than deleting the layer. - Ensure clicking on empty canvas or outside noodles/handles deselects the active interaction noodle/editor. - Close the interaction editor when navigating away from the Prototype tab. - Add regression tests in proto_editor_tests verifying interaction deletion with Delete/Backspace keeps the component intact, and Escape closes the interaction editor before deselecting the component. --- crates/ui-egui/src/proto_canvas.rs | 3 ++ crates/ui-egui/src/proto_editor_tests.rs | 61 ++++++++++++++++++++++++ crates/ui-egui/src/proto_panel.rs | 43 +++++++++++++++-- crates/ui-egui/src/right.rs | 3 ++ crates/ui-egui/src/shortcuts.rs | 20 ++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 6310c05c..1187944d 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -272,6 +272,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { crate::proto_panel::open_editor(&ctx, Some((n.source, n.index))); return true; } + if pressed && crate::proto_panel::editor(&ctx).is_some() { + crate::proto_panel::open_editor(&ctx, None); + } false } diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index 2372c20e..31988a35 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -226,3 +226,64 @@ fn the_presenter_inspects_the_layer_under_the_pointer() { assert_eq!(presenter(&u)["inspected"], json!(a)); assert_eq!(u.app.inspect()["presentOptions"]["inspect"], json!(true)); } + +#[test] +fn deleting_a_prototype_interaction_removes_the_interaction_not_the_component() { + let (s, _a, b, btn) = setup(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + + assert!(u.app.session.doc().unwrap().node(btn).is_some()); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1); + assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0))); + + // Pressing Delete when an interaction is selected should delete the interaction, NOT the component. + u.key(pos2(100.0, 100.0), egui::Key::Delete); + + assert!(u.app.session.doc().unwrap().node(btn).is_some(), "Bug #767 regression: component was deleted when deleting prototype interaction!"); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0, "the interaction should be removed"); + assert_eq!(crate::proto_panel::editor(&u.ctx), None); + + // Re-add interaction and test Backspace as well. + u.run("proto.interaction.add", json!({"id": btn, "dest": b})); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + u.key(pos2(100.0, 100.0), egui::Key::Backspace); + assert!(u.app.session.doc().unwrap().node(btn).is_some()); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0); + assert_eq!(crate::proto_panel::editor(&u.ctx), None); + + // When NO interaction is active, Delete deletes the component as normal. + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert!(u.app.session.doc().unwrap().node(btn).is_none(), "when no interaction is selected, Delete removes the layer"); +} + +#[test] +fn escape_closes_prototype_editor_without_deselecting_layer() { + let (s, a, _b, btn) = setup(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + + assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0))); + assert_eq!(u.app.session.selection(), vec![btn]); + + // First Escape closes the interaction editor; layer remains selected. + u.key(pos2(100.0, 100.0), egui::Key::Escape); + assert_eq!(crate::proto_panel::editor(&u.ctx), None); + assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected"); + + // Second Escape steps out to parent frame. + u.key(pos2(100.0, 100.0), egui::Key::Escape); + assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame"); + + // Third Escape deselects all. + u.key(pos2(100.0, 100.0), egui::Key::Escape); + assert!(u.app.session.selection().is_empty(), "selection cleared on third escape"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 47312e99..20546008 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -40,6 +40,44 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> { ctx.data(|d| d.get_temp(editor_id())) } +/// Whether an interaction is selected (its editor is open). +pub fn has_selected_interaction(app: &FigApp, ctx: &egui::Context) -> bool { + let Some((node, index)) = editor(ctx) else { return false }; + let Ok(ds) = app.session.ds() else { return false }; + ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_some() +} + +/// Delete the selected interaction (when one is active in the Prototype tab). +/// Returns true if an interaction was deleted. +pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> bool { + if app.ui.right_tab != crate::RightTab::Prototype { + return false; + } + let Some((node, index)) = editor(ctx) else { return false }; + let Ok(ds) = app.session.ds() else { return false }; + if ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_none() { + open_editor(ctx, None); + return false; + } + open_editor(ctx, None); + let ids = targets_for(app, node); + app.run("proto.interaction.remove", json!({"ids": ids, "index": index})); + true +} + +/// Close the interaction editor if open. Returns true if it was open. +pub fn close_editor(ctx: &egui::Context) -> bool { + if capturing(ctx) || crate::variable_ui::picker_open(ctx) { + return false; + } + if editor(ctx).is_some() { + open_editor(ctx, None); + true + } else { + false + } +} + const TRIGGERS: [&str; 10] = [ tr_noop("On click"), tr_noop("On drag"), @@ -607,8 +645,6 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod #[allow(clippy::too_many_arguments)] fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) { let t = Tokens::get(ctx); - // Escape in a field (it cancels the edit) doesn't close the editor. - let typing = ctx.egui_wants_keyboard_input(); let width = 280.0; let screen = ctx.content_rect(); let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); @@ -652,8 +688,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N }); }); }); - let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !typing && !capturing(ctx) && !crate::variable_ui::picker_open(ctx); - if close || escape { + if close { open_editor(ctx, None); } } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 47f3e5d3..3e2d70c3 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -135,6 +135,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { } ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary }); if resp.clicked() { + if app.ui.right_tab != tab { + crate::proto_panel::open_editor(ui.ctx(), None); + } app.ui.right_tab = tab; } x = r.max.x + 2.0; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 09b2c57b..2408b8f1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -210,6 +210,11 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { pub fn run_binding(app: &mut FigApp, id: &str) { match id { "select.none" => { + if let Some(ctx) = &app.ui_context { + if crate::proto_panel::close_editor(ctx) { + return; + } + } if let Err(e) = app.session.escape() { app.toast(e.to_string()); } @@ -218,6 +223,14 @@ pub fn run_binding(app: &mut FigApp, id: &str) { "edit.delete" if app.session.tool.pen.is_some() => { let _ = app.session.pen_finish(); } + "edit.delete" + if app.ui.right_tab == crate::RightTab::Prototype + && app.ui_context.as_ref().is_some_and(|ctx| crate::proto_panel::has_selected_interaction(app, ctx)) => + { + if let Some(ctx) = app.ui_context.clone() { + let _ = crate::proto_panel::delete_selected_interaction(app, &ctx); + } + } // Enter with a shape or frame tool chosen places one in the middle of the view. "select.children" if app.session.tool.tool.draws_shape() => { app.run("tool.place", json!({})); @@ -228,6 +241,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) { } fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool { + if key == Key::Escape && crate::proto_panel::close_editor(ctx) { + return true; + } if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) { return true; } @@ -238,6 +254,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) { return true; } + // An interaction selected in the Prototype tab: Delete removes that interaction, not the component. + if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && crate::proto_panel::delete_selected_interaction(app, ctx) { + return true; + } // Vector edit mode and the shape builder have their own keys (tools, nudging points, delete, // join, Enter to finish); the rest fall through. match app.session.tool_key(key.name(), m.shift, m.alt, m.command) { From 1e7983a5509e85b34b73d29a2b9e72ed623b68a2 Mon Sep 17 00:00:00 2001 From: Natnael-Dev Date: Sat, 17 Oct 2026 21:47:40 +0300 Subject: [PATCH 194/245] docs(evidence): record verification evidence for #767 fix --- evidence/fix-delete-prototype-interaction.md | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 evidence/fix-delete-prototype-interaction.md diff --git a/evidence/fix-delete-prototype-interaction.md b/evidence/fix-delete-prototype-interaction.md new file mode 100644 index 00000000..54808f9e --- /dev/null +++ b/evidence/fix-delete-prototype-interaction.md @@ -0,0 +1,64 @@ +# Evidence: Fix Delete Prototype Interaction (#767) + +## Task Summary +- **Target Issue:** upstream [#767](https://github.com/storytold/figcraft/issues/767) - "When deleting a prototype, it also deletes the component." +- **PR:** [#776](https://github.com/storytold/figcraft/pull/776) +- **Branch:** `fix/delete-prototype-interaction` +- **Head Commit:** `bc5a2b8` + +## Root Cause +When an interaction noodle or interaction editor was clicked/selected in the Prototype tab, `proto_canvas.rs` set document selection to `[n.source]` (the layer). When Delete or Backspace was pressed, `shortcuts.rs` dispatched `edit.delete` without checking if an interaction was active, deleting the component instead of the interaction connection. + +## Implemented Fix +- Added `has_selected_interaction`, `delete_selected_interaction`, and `close_editor` in `crates/ui-egui/src/proto_panel.rs`. +- Handled `Key::Delete` and `Key::Backspace` in `shortcuts::key_event` and `run_binding` when in `RightTab::Prototype` with an active interaction, removing the interaction with `proto.interaction.remove` rather than deleting the layer. +- Added canvas deselect on click in `crates/ui-egui/src/proto_canvas.rs` and tab-switch close in `crates/ui-egui/src/right.rs`. +- Handled `Key::Escape` to close the interaction editor first before stepping out / deselecting layers. +- Added comprehensive headless regression tests in `crates/ui-egui/src/proto_editor_tests.rs`. + +## Raw Verification Evidence + +### 1. Test Execution (`cargo test -p figcraft-ui-egui --lib -- proto`) +```text +running 21 tests +test proto_editor_tests::the_builder_takes_variable_operator_value_conditions ... ok +test proto_canvas::tests::curves_leave_the_facing_side ... ok +test perf_tests::prototype_sources_follow_edits_and_undo ... ok +test proto_editor_tests::deleting_a_prototype_interaction_removes_the_interaction_not_the_component ... ok +test proto_editor_tests::the_presenter_inspects_the_layer_under_the_pointer ... ok +test present::tail_tests::prototypes_without_gamepad_triggers_dont_poll ... ok +test proto_responsive_tests::custom_sizes_present_as_a_bare_screen ... ok +test present::tail_tests::widgets_react_to_clicks_in_the_presented_prototype ... ok +test proto_editor_tests::a_click_where_nothing_reacts_flashes_the_hotspots ... ok +test proto_responsive_tests::prototype_panel_shows_colours_custom_sizes_and_responsive ... ok +test proto_editor_tests::escape_closes_prototype_editor_without_deselecting_layer ... ok +test proto_editor_tests::the_interaction_editor_shows_conditionals_and_picks_their_variables ... ok +test proto_responsive_tests::library_collections_show_in_the_mode_picker_under_their_library ... ok +test proto_reorder_tests::actions_inside_a_conditional_block_drag_within_their_block ... ok +test proto_reorder_tests::escape_cancels_an_action_drag_and_keeps_the_editor_open ... ok +test proto_reorder_tests::dragging_an_action_row_reorders_the_actions_in_one_step ... ok +test field_tests::scrubbing_a_prototype_delay_sets_it_once_when_let_go ... ok +test proto_editor_tests::typed_conditions_are_checked_and_errors_show_until_fixed ... ok +test proto_responsive_tests::without_a_device_a_responsive_frame_fills_the_window_at_100_percent ... ok +test proto_responsive_tests::responsive_frames_take_the_devices_screen_live ... ok +test idle_tests::the_prototype_curve_preview_plays_then_rests ... ok + +test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 500 filtered out; finished in 0.15s +``` +Exit code: 0 +Passed: 21 +Failed: 0 +Skipped: 0 + +### 2. Format Verification (`cargo fmt --check`) +```text +Exit code: 0 +No formatting diffs found. +``` + +### 3. Git Status & PR +```text +Branch: fix/delete-prototype-interaction +Pull Request: https://github.com/storytold/figcraft/pull/776 +Target: storytold/figcraft:main +``` From 154baa4467f33bde2e26aff9f30cb9cf5c9dfa78 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 01:49:02 +0700 Subject: [PATCH 195/245] M7: a rename selects the whole name, so typing replaces it --- crates/ui-egui/src/layers_rename_tests.rs | 25 +++++++++++++++++++ crates/ui-egui/src/widgets.rs | 30 ++++++++++++++++------- docs/ui-parity.md | 5 ++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 4f5c6af8..c4b9211c 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -170,3 +170,28 @@ fn a_rename_scrolled_out_of_view_goes_on() { press(&mut u, Key::Enter, Modifiers::NONE); assert_eq!(name(&u, top), "Far"); } + +#[test] +fn a_key_typed_in_the_frame_a_rename_starts_replaces_the_name() { + // The app draws no frame between the double-click's last release (or F2, or File ▸ Rename) and + // the first key, so the name field first shows in the frame that types into it. + let (mut u, a, _, _, c) = setup(); + let p = u.row_at(c); + for _ in 0..2 { + u.button(p, true); + u.button(p, false); + } + type_text(&mut u, "Hero"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(name(&u, c), "Hero", "a double-click rename"); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + press(&mut u, Key::F2, Modifiers::NONE); + type_text(&mut u, "Base"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(name(&u, a), "Base", "an F2 rename"); + crate::menus::invoke(&mut u.app, "file.rename"); + type_text(&mut u, "Brand kit"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(u.app.session.doc().unwrap().name, "Brand kit", "File ▸ Rename"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b4413545..3a3f2322 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -501,7 +501,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str if started { editing = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(id.with("te"))); - select_all_next(ui, id.with("te")); + select_all_next(ui, id.with("te"), value); } let mut out = None; match editing { @@ -593,21 +593,33 @@ pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) { ui.data_mut(|d| d.remove::(pending)); return; } - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), r.id); - ui.ctx().request_repaint(); + // A key typed in the frame the edit started already replaced the selection laid beforehand. + if r.changed() { + ui.data_mut(|d| d.remove::(pending)); + return; } + select_all(ui, r.id, text); + ui.ctx().request_repaint(); if !down { ui.data_mut(|d| d.remove::(pending)); } } /// Has [`select_all_on_focus`] select all of editor `te_id` from now on (an edit that starts -/// with the editor already focused, which then gains no focus). -fn select_all_next(ui: &Ui, te_id: Id) { +/// with the editor already focused, which then gains no focus). Call it before adding the editor: +/// the text is selected at once, so a key typed in the frame the edit starts (the frame after a +/// double-click or F2 is often the one with the first key) replaces it. +fn select_all_next(ui: &Ui, te_id: Id, text: &str) { ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true)); + select_all(ui, te_id, text); +} + +/// Selects all of `text` in editor `te_id`. +fn select_all(ui: &Ui, te_id: Id, text: &str) { + let mut state = egui::TextEdit::load_state(ui.ctx(), te_id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), te_id); } fn select_all_id(te_id: Id) -> Id { @@ -1015,7 +1027,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu if started { buf = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(te_id)); - select_all_next(ui, te_id); + select_all_next(ui, te_id, value); } match buf { Some(mut text) => { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..c4d3bedd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -27,7 +27,7 @@ interaction feel not yet compared. | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | -| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click (`widgets::select_all_on_focus`, `select_all_tests`) | +| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 37419afe146f19134170887dd2b23b97e64f00ac Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 13:19:13 -0600 Subject: [PATCH 196/245] Open only web links and local libraries from a document A document carries things the app acts on for whoever opens it. Three of them were taken as given. Prototype Open link and Dev Mode dev resources both reached the platform's opener with any scheme. In the web build the opener is window.open, so a saved document could run script in the app's own origin; on the desktop any scheme went to the system's handler. Both now pass through the new engine module `doc_urls`, which allows http, https and mailto and nothing else. It is an allow-list on purpose: a list of known-bad schemes is outgrown by every handler a platform adds. A figcraft:// invite link joined the session at once. The system hands the app whatever such link was followed, which may come from a page or a message, so the Join dialog now opens with the link filled in and the person confirms. A library link stored in a document was loaded from wherever it pointed. Only a path on this computer is followed now, so opening someone else's file cannot make the app contact their server (which on Windows would also send credentials). Tests: each change has one that fails without it, verified by reverting. The prototype test fires through an afterDelay trigger, since no click is needed, and keeps a positive case so a refusal cannot pass vacuously. --- Cargo.lock | 6 ++ Cargo.toml | 3 + crates/engine/Cargo.toml | 1 + crates/engine/src/doc_urls.rs | 48 +++++++++++++ crates/engine/src/doc_urls_tests.rs | 87 +++++++++++++++++++++++ crates/engine/src/lib.rs | 5 ++ crates/engine/src/library_path_tests.rs | 30 ++++++++ crates/engine/src/library_server.rs | 7 ++ crates/ui-egui/src/dev_mode.rs | 5 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live.rs | 12 ++-- crates/ui-egui/src/live_join_tests.rs | 11 +-- crates/ui-egui/src/present.rs | 4 +- crates/ui-egui/src/present_links_tests.rs | 71 ++++++++++++++++++ 14 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/doc_urls.rs create mode 100644 crates/engine/src/doc_urls_tests.rs create mode 100644 crates/engine/src/library_path_tests.rs create mode 100644 crates/ui-egui/src/present_links_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9baacd58..a61d87cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,6 +856,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "crc32fast" version = "1.5.2" @@ -1511,6 +1516,7 @@ name = "figcraft-engine" version = "0.6.0" dependencies = [ "base64 0.22.1", + "craft-docpath", "figcraft-collab", "figcraft-color", "figcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index 8f125ac3..fe089328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" } figcraft-collab-net = { path = "crates/collab-net" } figcraft-plugin = { path = "crates/plugin" } +# Which paths stored in a document may be followed (craft-libs). +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } + # foundation kurbo = { version = "0.13", features = ["serde"] } thiserror = "2" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f610c38b..545d872c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -20,6 +20,7 @@ figcraft-render = { workspace = true } figcraft-format = { workspace = true } figcraft-svg = { workspace = true } figcraft-pdf = { workspace = true } +craft-docpath = { workspace = true } figcraft-plugin = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/doc_urls.rs b/crates/engine/src/doc_urls.rs new file mode 100644 index 00000000..3b8417cd --- /dev/null +++ b/crates/engine/src/doc_urls.rs @@ -0,0 +1,48 @@ +//! Which URLs stored in a document may be handed to the browser or the system opener. +//! +//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link +//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end +//! up at the platform's opener, so a document that someone else made chooses what that opener is +//! asked to do. Only the schemes a browser would follow on a plain link are allowed: +//! +//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in +//! the app's own origin, so a saved document becomes stored XSS. +//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials. +//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown +//! schemes to whichever application registered them. +//! +//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known +//! dangerous schemes is outgrown by every new handler a platform adds. + +/// The schemes a URL stored in a document may use. +const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"]; + +/// Whether `url`, stored in a document, may be opened. +/// +/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is +/// refused: the opener would resolve it against the app's own page on the web. Control characters +/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:` +/// reaches a `javascript:` handler), and `http`/`https` must name a host. +pub fn openable(url: &str) -> bool { + let url = url.trim(); + if url.is_empty() || url.chars().any(char::is_control) { + return false; + } + let Some((scheme, rest)) = url.split_once(':') else { return false }; + // An RFC 3986 scheme, so a stray colon in text cannot look like one. + let mut chars = scheme.chars(); + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) { + return false; + } + if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) { + return false; + } + if scheme.eq_ignore_ascii_case("mailto") { + // `mailto:` names an address. + return !rest.is_empty(); + } + // `http` and `https` name a host, so the authority is required: `http:x` is not a web link, + // and `http:///p` has no host. + let Some(after) = rest.strip_prefix("//") else { return false }; + !after.is_empty() && !after.starts_with('/') +} diff --git a/crates/engine/src/doc_urls_tests.rs b/crates/engine/src/doc_urls_tests.rs new file mode 100644 index 00000000..d42029a0 --- /dev/null +++ b/crates/engine/src/doc_urls_tests.rs @@ -0,0 +1,87 @@ +//! A URL stored in a document reaches the browser or the system opener, so only the schemes a +//! browser follows on a plain link are allowed ([`crate::doc_urls`]). + +use crate::doc_urls::openable; + +#[test] +fn web_links_are_opened() { + for url in [ + "http://example.com", + "https://example.com", + "https://example.com/a/b?c=d#e", + "HTTPS://EXAMPLE.COM", + "HtTp://example.com", + "mailto:a@b.c", + "mailto:a@b.c?subject=hi", + " https://example.com ", + "https://user:pw@example.com:8443/p", + ] { + assert!(openable(url), "{url:?} should open"); + } +} + +#[test] +fn script_and_handler_schemes_are_refused() { + for url in [ + // Runs in the app's own origin on the web build: a saved document would be stored XSS. + "javascript:alert(1)", + "JavaScript:alert(1)", + "jAvAsCrIpT:alert(document.cookie)", + "vbscript:msgbox(1)", + "data:text/html,", + "blob:https://example.com/x", + // Reach the file system; a UNC host also sends Windows credentials. + "file:///etc/passwd", + "file://attacker.example/share/x", + "smb://attacker.example/share/x", + "webdav://attacker.example/x", + // Windows application handlers. + "ms-msdt:/id PCWDiagnostic", + "ms-officecmd:x", + "search-ms:query=x", + "shell:startup", + // Anything else a platform may have registered. + "tel:+1234", + "sms:+1234", + "ssh://host", + "ftp://host/x", + "figcraft://join?host=127.0.0.1", + "zoommtg://zoom.us/join?x", + "view-source:https://example.com", + "jar:https://example.com/x!/y", + ] { + assert!(!openable(url), "{url:?} must be refused"); + } +} + +#[test] +fn a_scheme_is_required_and_control_characters_are_refused() { + for url in [ + // No scheme: the web opener would resolve these against the app's own page. + "", + " ", + "example.com", + "/etc/passwd", + "//example.com", + "\\\\host\\share\\x", + "about", + // Browsers ignore some control characters inside a scheme, so a smuggled one must not + // leave `javascript` looking like an unknown scheme that we then refuse by luck. + "java\tscript:alert(1)", + "java\nscript:alert(1)", + "java\rscript:alert(1)", + "javascript\u{0}:alert(1)", + "https://example.com/\u{0}", + // A colon in ordinary text is not a scheme. + "see page 2: the diagram", + "1https://example.com", + // The allowed schemes still need their own part. + "mailto:", + "http:", + "http:example.com", + "http:///p", + "https://", + ] { + assert!(!openable(url), "{url:?} must be refused"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2b09c5c3..80de797e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,6 +26,7 @@ pub mod dev_resources; pub mod devcompare; pub mod devinspect; pub mod distance; +pub mod doc_urls; pub mod drop; pub mod file_access; pub mod guard; @@ -1576,6 +1577,8 @@ mod devfocus_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] +mod doc_urls_tests; +#[cfg(test)] mod double_click_tests; #[cfg(test)] mod draw_tools_tests; @@ -1636,6 +1639,8 @@ mod invite_links_tests; #[cfg(test)] mod keymap_tests; #[cfg(test)] +mod library_path_tests; +#[cfg(test)] mod library_range_styles_tests; #[cfg(test)] mod library_variables_tests; diff --git a/crates/engine/src/library_path_tests.rs b/crates/engine/src/library_path_tests.rs new file mode 100644 index 00000000..f15105dc --- /dev/null +++ b/crates/engine/src/library_path_tests.rs @@ -0,0 +1,30 @@ +//! A library link is stored in the document, so it is followed only on this computer +//! ([`crate::library_server`]): a path on another server would make opening someone else's file +//! contact that server, and on Windows send the credentials of whoever opened it. + +use crate::Session; + +#[test] +fn a_library_is_opened_only_from_this_computer() { + let mut s = Session::new(); + for path in [ + r"\\attacker.example\share\lib.figcraft", + "//attacker.example/share/lib.figcraft", + "file://attacker.example/share/lib.figcraft", + r"\\?\UNC\attacker.example\share\lib.figcraft", + r"\\.\pipe\lib", + "https://attacker.example/lib.figcraft", + ] { + let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused")); + assert!(e.to_string().contains("only from this computer"), "{path}: {e}"); + } +} + +#[test] +fn a_local_library_path_is_still_followed() { + let mut s = Session::new(); + // Refused for being absent, not for where it is: the gate let it through. + let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft"); + let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist"); + assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}"); +} diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index dbd97a0b..99d5f29a 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -129,6 +129,13 @@ impl Session { /// document uses the library. pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result> { if !is_server_link(path) { + // A library link is stored in the document, so opening someone else's file would + // otherwise reach whatever path it names. Only a path on this computer is followed: a + // network share would make opening a document contact that server, and on Windows + // send the credentials of whoever opened it. + if !craft_docpath::is_local(path) { + return Err(EngineError::Other(format!("{path}: a library is opened only from this computer"))); + } return self.libraries.load(path); } let have = match self.libraries.cached(path) { diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 0fedccd7..f5c7e40f 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -635,8 +635,9 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: }; ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &below, text_w), regular(10.0), t.text_secondary); crate::a11y::link(&resp, name); - // Opened only on a click, never fetched. - if resp.on_hover_text(url.as_str()).clicked() { + // Opened only on a click, never fetched, and a dev resource's URL comes from the + // document, so only a web link opens. + if resp.on_hover_text(url.as_str()).clicked() && figcraft_engine::doc_urls::openable(&url) { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); } if let Some(i) = item.index { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..4dba320c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -171,6 +171,8 @@ mod plugins_browse_tests; #[cfg(test)] mod popup_width_tests; #[cfg(test)] +mod present_links_tests; +#[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 35af5d14..513060ab 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -1047,12 +1047,14 @@ pub fn open_join(app: &mut FigApp, link: &str) { app.ui.live.focus_join = true; } -/// An invite link the app was opened with (`figcraft figcraft://join?…`): join at once; a link -/// that can't be used shows in the Join dialog with the reason. +/// An invite link the app was opened with (`figcraft figcraft://join?…`): the Join dialog opens +/// with the link filled in, so joining is the person's own choice. +/// +/// The system hands the app any `figcraft://` link that is followed, which may be a page, a +/// message or a document rather than something the person meant to act on, so the link is shown +/// and confirmed rather than acted on. pub fn open_invite(app: &mut FigApp, link: &str) { - if app.run("collab.join", json!({"link": link.trim()})).is_none() { - open_join(app, link.trim()); - } + open_join(app, link.trim()); } /// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index d7452163..61567ce0 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -47,12 +47,14 @@ fn escape_closes_the_join_dialog_and_keeps_the_selection() { } #[test] -fn opening_the_app_with_an_invite_link_joins() { +fn opening_the_app_with_an_invite_link_asks_before_joining() { let (mut h, _) = Harness::new(); let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; crate::live::open_invite(&mut h.app, &format!(" {link} ")); - assert_eq!(h.app.session.live().status, Status::Connecting); - assert!(h.app.ui.live.join.is_none(), "no dialog: it joins at once"); + // The system hands the app whatever `figcraft://` link was followed, which may come from a + // page or a message, so the link is shown and confirmed rather than acted on. + assert_eq!(h.app.ui.live.join.as_deref(), Some(link), "the dialog opens with the link filled in"); + assert_eq!(h.app.session.live().status, Status::Off, "nothing is joined until the person confirms"); } /// The browser's link: joins, can't host; joining fails here to show the dialog path. @@ -82,7 +84,8 @@ fn a_link_that_cant_be_used_opens_the_dialog_with_it() { h.app.session.live.install(Box::new(Browser)); crate::live::open_invite(&mut h.app, "figcraft://nope"); assert_eq!(h.app.ui.live.join.as_deref(), Some("figcraft://nope"), "the link stays in the field to fix"); - assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("not a live session invite"))); + // The link is not tried until the person presses Join, so its reason shows then, not now. + assert!(h.app.ui.last_error.is_none()); assert_eq!(h.app.session.live().status, Status::Off); } diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 863b540f..cc78c86b 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -865,7 +865,9 @@ impl Presenter { } Action::CloseOverlay => self.close_overlay(now), Action::OpenUrl { url } => { - if !url.trim().is_empty() { + // The URL comes from the document and this action fires from whatever trigger the + // prototype sets, including a delay, so no click is implied: only a web link opens. + if figcraft_engine::doc_urls::openable(url) { self.urls.push(url.clone()); } true diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs new file mode 100644 index 00000000..8223890c --- /dev/null +++ b/crates/ui-egui/src/present_links_tests.rs @@ -0,0 +1,71 @@ +//! A prototype's Open link action carries a URL from the document and fires from whatever trigger +//! the prototype sets, including a delay, so the player hands the opener only a web link +//! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web +//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. + +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::Services; +use crate::present_tests::{Ui as Harness, id}; + +/// Records every URL the player asks the platform to open. +#[derive(Default)] +struct Opened(Arc>>); + +impl Services for Opened { + fn open_url(&mut self, url: &str) { + if let Ok(mut v) = self.0.lock() { + v.push(url.to_string()); + } + } +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +/// A presented frame whose layer opens `url` 100ms after the screen appears — no click anywhere. +fn opens_after_a_delay(url: &str) -> Vec { + let mut s = Session::new(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); + let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f}))); + ex( + &mut s, + "proto.interaction.add", + json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}), + ); + let seen = Arc::new(Mutex::new(Vec::new())); + let mut u = Harness::new(s); + u.app.services = Box::new(Opened(seen.clone())); + u.run("proto.present", json!({"start": f})); + // Well past the delay. + u.frames(40, Vec::new); + seen.lock().map(|v| v.clone()).unwrap_or_default() +} + +#[test] +fn a_prototype_opens_web_links() { + assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]); + assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]); +} + +#[test] +fn a_prototype_cannot_open_a_script_or_handler_url() { + for url in [ + // In the web build the opener is `window.open`, which runs this in the app's own origin. + "javascript:alert(document.cookie)", + "JavaScript:alert(1)", + "data:text/html,", + // Reaches the file system; a UNC host also sends Windows credentials. + "file:///etc/passwd", + "smb://attacker.example/share/x", + // Handed to an application handler by the desktop. + "ms-msdt:/id PCWDiagnostic", + "figcraft://join?host=127.0.0.1:47900&file=f&token=t", + ] { + assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener"); + } +} From f7188de93dfe820a1e434af58551b3eb23c1d97b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 13:35:44 -0600 Subject: [PATCH 197/245] Drop a needless borrow in the dev-resource link gate clippy::needless_borrow; `url` is already a reference. Missed on review because a workspace clippy run aborts at the first failing crate, which was figcraft-format. --- crates/ui-egui/src/dev_mode.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index f5c7e40f..d3456088 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -637,7 +637,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: crate::a11y::link(&resp, name); // Opened only on a click, never fetched, and a dev resource's URL comes from the // document, so only a web link opens. - if resp.on_hover_text(url.as_str()).clicked() && figcraft_engine::doc_urls::openable(&url) { + if resp.on_hover_text(url.as_str()).clicked() && figcraft_engine::doc_urls::openable(url) { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); } if let Some(i) = item.index { From c7acd1a81d48f1bf49071a55877e012a395c5b6e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 13:35:44 -0600 Subject: [PATCH 198/245] chore: clear the rustc 1.96 clippy lints that fail xtask ci Unrelated to the security changes in this branch; `main` already failed `clippy --workspace --all-targets -- -D warnings` without them. Eight clippy::nonminimal_bool sites across seven files, each taking clippy's own suggested rewrite. Every rewrite is pure boolean algebra with the negation kept outside, so the float comparisons and their NaN behaviour are untouched: !A && !B becomes !(A || B), and !opt.is_some_and(f) becomes opt.is_none_or(!f). Two sites needed explicit parentheses the suggestion omitted, where the rewritten term is the left operand of an && and || binds looser. designcraft cleared the same lint in bc840130; this is figcraft's equivalent. --- apps/figcraft-server/src/config.rs | 2 +- crates/collab-net/src/invite.rs | 2 +- crates/engine/src/cmd/component.rs | 4 +--- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/components.rs | 2 +- crates/format/src/fig/export.rs | 15 ++++++++------- crates/format/src/fig/map.rs | 2 +- 7 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index 101c7815..d5f4f761 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -182,7 +182,7 @@ impl Config { return Err("`idle_days` must be from 0 to 3650".into()); } let terms = self.terms_url.trim(); - if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) { + if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) { return Err("`terms_url` must be a web address (https://…)".into()); } if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 { diff --git a/crates/collab-net/src/invite.rs b/crates/collab-net/src/invite.rs index bfa2e606..617ad518 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result, String> { fn with_port(authority: &str, default: u16) -> String { let has_port = match authority.rsplit_once(':') { // `[v6]` alone has colons inside the brackets only. - Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'), + Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'), None => false, }; if has_port { authority.to_string() } else { format!("{authority}:{default}") } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index a8901f24..ff7a56df 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -691,9 +691,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { let groups: BTreeSet = n .overrides .iter() - .filter(|g| { - !matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name") - }) + .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) .cloned() .collect(); for g in &groups { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 804c7819..a4769a35 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1356,7 +1356,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result { const CMD: &str = "fill.pattern"; let src = req_id(CMD, p, "source")?; let doc = s.doc()?; - if !doc.node(src).is_some_and(|n| !n.is_page()) { + if doc.node(src).is_none_or(|n| n.is_page()) { return Err(bad(CMD, "`source` must be a layer")); } if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index ff582b8a..56efc5c9 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -1498,7 +1498,7 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo let gs: Vec<&'static str> = groups .iter() .copied() - .filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) + .filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) .collect(); if gs.is_empty() { continue; diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index ca9e2682..45234bd8 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1736,7 +1736,7 @@ impl<'d> Exporter<'d> { let sp = &n.stroke; let (w, h, weight) = (n.width, n.height, sp.weight); let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none(); - if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) { + if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) { return; } let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9); @@ -2217,12 +2217,13 @@ fn outlined(n: &Node) -> Option<(Node, String)> { NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle ); let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible); - if !shape - || sp.brush.is_some() - || !sp.profile().is_some_and(one_sided) - || !(sp.weight.is_finite() && sp.weight > 0.0) - || !shown(&n.strokes) - || shown(&n.fills) + if !(shape + && sp.brush.is_none() + && sp.profile().is_some_and(one_sided) + && sp.weight.is_finite() + && sp.weight > 0.0 + && shown(&n.strokes) + && !shown(&n.fills)) { return None; } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 25540ff6..d9aba17e 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -2045,7 +2045,7 @@ impl<'a> Mapper<'a> { self.warn("slot content assignment uses the empty GUID sentinel and was skipped"); continue; } - if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) { + if self.nodes.get(&target).is_none_or(|n| n.get("isSlotContent").and_then(Kv::bool) != Some(true)) { self.warn("slot content assignment target is missing or is not slot content"); continue; } From dd980c884fae132703ae26612751a459e8e55f46 Mon Sep 17 00:00:00 2001 From: Yarila Date: Sat, 17 Oct 2026 22:00:16 +0500 Subject: [PATCH 199/245] M7: search known files and templates from Home --- ROADMAP.md | 3 +- crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/locales/ru.toml | 4 + crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/home.rs | 187 ++++++++++++++++++++++++++++++- crates/ui-egui/src/home_tests.rs | 77 +++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/online.rs | 2 +- docs/control-protocol.md | 5 +- docs/gaps.md | 3 +- docs/ui-parity.md | 4 +- 12 files changed, 287 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3908e591..5a77b095 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-17 · **Change:** minor (Home search across known files and starter templates; 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) 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 @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state; no server-wide query. 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/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 59b25d94..27b8ec3d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -830,6 +830,7 @@ name = "English" "No contributor data was built into this copy." = "No contributor data was built into this copy." "No device" = "No device" "No fonts match" = "No fonts match" +"No files or templates match your search" = "No files or templates match your search" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." "No layer changes to show." = "No layer changes to show." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." @@ -1175,6 +1176,8 @@ name = "English" "Search actions, layers and tools" = "Search actions, layers and tools" "Search assets" = "Search assets" "Search comments" = "Search comments" +"Search files and templates" = "Search files and templates" +"Search results" = "Search results" "Search fonts" = "Search fonts" "Search layers" = "Search layers" "Search shortcuts" = "Search shortcuts" @@ -1403,6 +1406,7 @@ name = "English" "Truncate with an ellipsis" = "Truncate with an ellipsis" "Try again" = "Try again" "Try another search or filter." = "Try another search or filter." +"Try another search, or browse templates." = "Try another search, or browse templates." "Try another word, or look at every kind." = "Try another word, or look at every kind." "Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once" "Two digits" = "Two digits" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 01c5813b..8c28e149 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -831,6 +831,7 @@ status = "draft — needs native review" "No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores." "No device" = "Sin dispositivo" "No fonts match" = "Ninguna fuente coincide" +"No files or templates match your search" = "Ningún archivo ni plantilla coincide con tu búsqueda" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace." "No layer changes to show." = "No hay cambios de capas que mostrar." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo." @@ -1176,6 +1177,8 @@ status = "draft — needs native review" "Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" "Search comments" = "Buscar comentarios" +"Search files and templates" = "Buscar archivos y plantillas" +"Search results" = "Resultados de búsqueda" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" "Search shortcuts" = "Buscar atajos" @@ -1404,6 +1407,7 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "Truncar con puntos suspensivos" "Try again" = "Reintentar" "Try another search or filter." = "Prueba otra búsqueda u otro filtro." +"Try another search, or browse templates." = "Prueba otra búsqueda o explora las plantillas." "Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos." "Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez" "Two digits" = "Dos dígitos" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 998eefda..a0675ee0 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -818,6 +818,7 @@ name = "Русский" "No contributor data was built into this copy." = "В этой сборке нет данных об участниках." "No device" = "Без устройства" "No fonts match" = "Подходящих шрифтов нет" +"No files or templates match your search" = "Файлы и шаблоны по запросу не найдены" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой." "No layer changes to show." = "Нет изменений слоёв для показа." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку." @@ -1156,6 +1157,8 @@ name = "Русский" "Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" +"Search files and templates" = "Поиск файлов и шаблонов" +"Search results" = "Результаты поиска" "Search fonts" = "Поиск шрифтов" "Search layers" = "Поиск слоёв" "Search shortcuts" = "Поиск сочетаний клавиш" @@ -1381,6 +1384,7 @@ name = "Русский" "Truncate with an ellipsis" = "Обрезать с многоточием" "Try again" = "Повторить" "Try another search or filter." = "Попробуйте другой запрос или фильтр." +"Try another search, or browse templates." = "Попробуйте другой запрос или просмотрите шаблоны." "Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории." "Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу" "Two digits" = "Две цифры" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a66e1ed9..a4a8dd15 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -15,7 +15,7 @@ //! for the frames that follow (all false: released); `gamepad` presses a controller button in the //! prototype playing (presenter or preview pane) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) -//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` +//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` //! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or //! returned as base64 `data` //! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a @@ -429,6 +429,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) { app.ui.home_view.section = section; } + if let Some(query) = p.get("homeSearch").and_then(Value::as_str) { + app.ui.home_view.file_query = query.chars().take(256).collect(); + } if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) { view.store(&mut app.session.prefs); } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 856b018e..3ea25576 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -4,6 +4,7 @@ //! the documents themselves, searched and filtered by kind). use std::collections::BTreeMap; +use std::path::Path; use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_engine::templates::{Kind, TEMPLATES, Template}; @@ -51,6 +52,8 @@ impl Section { #[derive(Default)] pub struct Home { pub section: Section, + /// Searches known files and starter templates together from the home sidebar. + pub file_query: String, /// The Templates search text and kind filter. pub query: String, pub kind: Option, @@ -143,7 +146,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height())); ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); sidebar(app, ui, &t, left); - if app.ui.home_view.section == Section::Online { + if app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == Section::Online { // Your online files lay themselves out in the area (and scroll their own list). let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0)); crate::online::section(app, ui, area, &t); @@ -155,9 +158,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| { egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(GAP, 0.0); - match app.ui.home_view.section { - Section::Templates => templates(app, ui, &t), - _ => recents(app, ui, &t), + if app.ui.home_view.file_query.trim().is_empty() { + match app.ui.home_view.section { + Section::Templates => templates(app, ui, &t), + _ => recents(app, ui, &t), + } + } else { + search_results(app, ui, &t); + opened_join(app); } }); }); @@ -198,10 +206,39 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); y += 48.0; + let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { + ui.painter().rect_filled(search, CornerRadius::same(5), t.field); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(search.min.x + 18.0, search.center().y), vec2(16.0, 16.0)), + Icon::Search, + t.icon_secondary, + ); + let text_rect = Rect::from_min_max(pos2(search.min.x + 36.0, search.center().y - 8.0), pos2(search.max.x - 8.0, search.center().y + 8.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect)); + let response = child.add( + egui::TextEdit::singleline(&mut app.ui.home_view.file_query) + .id(Id::new("home_file_search")) + .frame(egui::Frame::NONE) + .font(regular(12.0)) + .hint_text(tr("Search files and templates")) + .vertical_align(egui::Align::Center) + .desired_width(text_rect.width()), + ); + widgets::select_all_on_focus(ui, &response, &app.ui.home_view.file_query); + a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); + if response.has_focus() { + ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + } else if ui.rect_contains_pointer(search) { + ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + } + }); + y += 32.0 + 4.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); - let current = app.ui.home_view.section == section; + let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section; a11y::tab(&resp, tr(label), current); if current || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover }); @@ -211,11 +248,151 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text); if resp.clicked() { app.ui.home_view.section = section; + app.ui.home_view.file_query.clear(); } y += 34.0; } } +fn file_matches(name: &str, location: &str, query: &str) -> bool { + name.to_lowercase().contains(query) || location.to_lowercase().contains(query) +} + +/// A search result with a name and optional location on separate lines. The larger hit target +/// keeps a short results list readable on the wide home screen. +fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), 64.0), Sense::hover()); + let resp = ui.interact(r, id, Sense::click()); + a11y::button(&resp, row.name); + ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.field }); + ui.painter().rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); + focus_ring(ui, t, r, &resp, 8); + if let Some((icon, tint)) = row.mark { + let mark = Rect::from_center_size(pos2(r.min.x + 29.0, r.center().y), vec2(20.0, 20.0)); + icons::paint(ui.painter(), mark, icon, tint); + } + let width = (r.width() - 78.0).max(1.0); + let name = widgets::elide_font(ui, row.name, width, semibold(13.0)); + let name_y = if row.detail.is_empty() { r.center().y } else { r.min.y + 23.0 }; + ui.painter().text(pos2(r.min.x + 58.0, name_y), Align2::LEFT_CENTER, name, semibold(13.0), row.ink); + if !row.detail.is_empty() { + let detail = widgets::elide_start_font(ui, row.detail, width, regular(row.detail_size)); + ui.painter().text(pos2(r.min.x + 58.0, r.min.y + 44.0), Align2::LEFT_CENTER, detail, regular(row.detail_size), row.detail_ink); + } + if !row.detail.is_empty() { resp.on_hover_text(row.detail).clicked() } else { resp.clicked() } +} + +/// One search over known files and starter templates. It never sends a query or owner token to a +/// server; online files are opened through their existing owner-token command only on click. +fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { + heading(ui, t, tr("Search results")); + let query = app.ui.home_view.file_query.trim().to_lowercase(); + let mut count = 0; + let mut switch = None; + let open_paths: Vec<&str> = app.session.docs.iter().filter_map(|d| d.path.as_deref()).collect(); + + let open: Vec<_> = + app.session.docs.iter().enumerate().filter(|(_, d)| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), &query)).collect(); + if !open.is_empty() { + subheading(ui, t, tr("Open files")); + for (index, d) in open { + count += 1; + let row = FileRow { + mark: Some((Icon::Logo, ACCENT)), + name: &d.doc.name, + ink: t.text, + detail: d.path.as_deref().unwrap_or_default(), + detail_size: 11.0, + detail_ink: t.text_secondary, + }; + if search_file_card(ui, t, ui.id().with(("search_open", d.uid)), &row) { + switch = Some(index); + } + ui.add_space(8.0); + } + } + + let recent: Vec<(String, String)> = app + .session + .prefs + .recent_files + .iter() + .filter(|p| !open_paths.contains(&p.as_str())) + .filter_map(|path| { + let p = Path::new(path); + let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); + file_matches(&name, path, &query).then(|| (path.clone(), name)) + }) + .collect(); + let mut open_recent = None; + if !recent.is_empty() { + subheading(ui, t, tr("Recent files")); + for (path, name) in &recent { + count += 1; + let row = FileRow { + mark: Some((Icon::Page, t.icon_secondary)), + name, + ink: t.text, + detail: path, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; + if search_file_card(ui, t, ui.id().with(("search_recent", path)), &row) { + open_recent = Some(path.clone()); + } + ui.add_space(8.0); + } + } + + let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect(); + let mut open_online = None; + if !online.is_empty() { + subheading(ui, t, tr("Online files")); + for f in &online { + count += 1; + let host = figcraft_engine::online::server_host(&f.server); + let icon = crate::online::mark(f.shared.is_some()); + let row = FileRow { + mark: Some((icon, if f.shared.is_some() { t.success } else { t.icon_secondary })), + name: &f.name, + ink: t.text, + detail: host, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; + if search_file_card(ui, t, ui.id().with(("search_online", &f.server, &f.file)), &row) { + open_online = Some(f.clone()); + } + ui.add_space(8.0); + } + } + let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect(); + if !templates.is_empty() { + subheading(ui, t, tr("Templates")); + template_grid(app, ui, t, &templates); + } + if count == 0 && templates.is_empty() { + ui.label(egui::RichText::new(tr("No files or templates match your search")).font(semibold(13.0)).color(t.text)); + ui.add_space(6.0); + widgets::label(ui, tr("Try another search, or browse templates."), t.text_secondary); + ui.add_space(16.0); + if widgets::button(ui, tr("Show all templates"), false, true).clicked() { + app.ui.home_view.file_query.clear(); + app.ui.home_view.section = Section::Templates; + } + } + if let Some(index) = switch { + app.run("doc.switch", json!({"index": index})); + app.ui.home = false; + } else if let Some(path) = open_recent { + if app.run_user("file.open", json!({"path": path})).is_some() { + app.ui.home = false; + } + } else if let Some(file) = open_online { + crate::online::open(app, &file); + } +} + /// A ring around a card or row that has the keyboard. fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) { if resp.has_focus() { diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index a975bec2..db052303 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -6,6 +6,7 @@ use egui::accesskit::Role; use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape}; use figcraft_engine::Session; +use figcraft_engine::online::OnlineFile; use figcraft_engine::templates::TEMPLATES; use serde_json::json; @@ -184,6 +185,82 @@ fn typing_in_the_search_field_filters() { assert!(u.app.session.docs.is_empty(), "typing opens nothing"); } +#[test] +fn home_search_finds_known_files_and_opens_an_existing_tab() { + let mut session = Session::new(); + session.execute("file.rename", &json!({"name": "North Trail"})).unwrap(); + session.docs[0].path = Some("C:/designs/North Trail.figcraft".into()); + session.prefs.recent_files = + vec!["C:/designs/North Trail.figcraft".into(), "C:/designs/South Trail.figcraft".into(), "C:/designs/Other.figcraft".into()]; + session.prefs.online_files.push(OnlineFile { + server: "wss://share.example/".into(), + file: "trail".into(), + name: "Trail Live".into(), + owner: "test-only-secret".into(), + ..OnlineFile::default() + }); + let mut u = Ui::new(session); + u.app.ui.home = true; + u.app.ui.home_view.file_query = " TRAIL ".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "Search results")); + assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live")); + assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret")); + assert_eq!(texts(&s).iter().filter(|(name, pos, _)| name == "North Trail" && pos.y > 90.0).count(), 1, "an open file is not repeated in recents"); + assert_eq!(u.app.inspect()["homeSearch"], " TRAIL "); + + u.app.ui.home_view.file_query = "nothing matches".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "No files or templates match your search")); + assert!(!has_text(&s, "Trail Live") && !has_text(&s, "South Trail")); + assert!(!texts(&s).iter().any(|(name, pos, _)| name == "North Trail" && pos.y > 90.0)); + + u.app.ui.home_view.file_query = "north".into(); + let s = shapes(&mut u); + click(&mut u, text_center(&s, "North Trail")); + assert!(!u.app.ui.home, "choosing an open result shows its editor tab"); + assert_eq!(u.app.session.docs.len(), 1); +} + +#[test] +fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { + let mut u = first_run(); + u.app.session.prefs.recent_files = vec!["C:/designs/Glacier.figcraft".into(), "C:/designs/Forest.figcraft".into()]; + let s = shapes(&mut u); + click(&mut u, text_center(&s, "Search files and templates")); + u.frame(vec![Event::Text("glacier".into())]); + let s = shapes(&mut u); + assert_eq!(u.app.ui.home_view.file_query, "glacier"); + assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest")); + click(&mut u, text_center(&s, "Recents")); + assert!(u.app.ui.home_view.file_query.is_empty()); + assert!(has_text(&shapes(&mut u), "Forest")); +} + +#[test] +fn home_search_includes_templates_and_opens_a_matching_card() { + let mut u = first_run(); + u.app.ui.home_view.file_query = "MOBILE".into(); + let s = settle(&mut u); + assert!(has_text(&s, "Templates") && has_text(&s, "Mobile app")); + assert!(!has_text(&s, "Dashboard")); + click(&mut u, text_center(&s, "Mobile app")); + assert_eq!(u.app.session.doc().unwrap().name, "Mobile app"); + assert!(!u.app.ui.home); +} + +#[test] +fn empty_home_search_can_browse_all_templates() { + let mut u = first_run(); + u.app.ui.home_view.file_query = "no such design".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "No files or templates match your search")); + click(&mut u, text_center(&s, "Show all templates")); + assert!(u.app.ui.home_view.file_query.is_empty()); + assert_eq!(u.app.ui.home_view.section, Section::Templates); + assert!(has_text(&settle(&mut u), "Dashboard")); +} + #[test] fn thumbnails_are_cached_per_theme() { let mut u = first_run(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..b2d6831f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1656,6 +1656,7 @@ impl FigApp { "lastError": self.ui.last_error, "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); + state["homeSearch"] = json!(self.ui.home_view.file_query); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state } diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/online.rs index 261424cb..2f054986 100644 --- a/crates/ui-egui/src/online.rs +++ b/crates/ui-egui/src/online.rs @@ -72,7 +72,7 @@ fn manage(app: &mut FigApp, ctx: &egui::Context, f: (&str, &str), action: &str, } /// Open recorded file `f` as its owner; its tab shows once the file arrives. -fn open(app: &mut FigApp, f: &OnlineFile) { +pub(crate) fn open(app: &mut FigApp, f: &OnlineFile) { let Some(r) = app.run("collab.openFile", json!({"server": f.server, "file": f.file})) else { return }; if r["alreadyOpen"] == true && !r["document"].is_null() { app.ui.home = false; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3a06874e..c9bf9de0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -247,12 +247,13 @@ separate calls. Returns `{queued}`. Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening `online` asks each server once what became of your online files), `homeRecentsView` (`grid|list`), `homeRecentsSort` (`modified|name|created`) and `homeRecentsOrder` (`desc|asc`): how the home screen shows its recent files (kept in the preferences; `ui.inspect` reports them under `homeRecents` with each file and whether its picture is in), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such -as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage +as `"Plugins"` to open the main menu with it open), `homeSearch` (text, at most 256 characters: filters locally known open, recent and online files plus starter templates in Home; an empty string returns to the selected section), +`pluginsWindow` (bool: Plugins ▸ Manage plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the directory listed last: its install confirmation shows, as its Install button would; `null` closes it), `pluginForm` (`{id, command?}`: start that plugin command like its menu item, opening diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..85ed33ad 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Home search across known files and starter templates; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -444,6 +444,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Home sidebar searches known open, recent and online files plus starter templates. Accounts and server-wide discovery remain in gap 2; estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..40568c6c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Home sidebar file and template search; Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -22,6 +22,7 @@ interaction feel not yet compared. | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | | Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | +| Home search | done (ours) | A field in the left sidebar filters open tabs, recent local files and saved online files by name, path or server, plus starter templates by name, description, kind and size. Matching file cards and template cards open through their existing actions; selecting a section clears the query. It does not search other people's projects or files that have never been opened here (`ui.set {homeSearch}`; `ui.inspect.homeSearch`). | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | @@ -172,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Home sidebar search over known open, recent and online files plus starter templates, with a no-results state. No server-wide search. Estimates unchanged. | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 1f0d9a9107bfd611aa4ee13b5c973948826c4a98 Mon Sep 17 00:00:00 2001 From: Yarila Date: Sat, 17 Oct 2026 22:30:03 +0500 Subject: [PATCH 200/245] M7: show file previews in Home search --- ROADMAP.md | 2 +- crates/ui-egui/src/home.rs | 60 ++++++++++++------------------ crates/ui-egui/src/home_recents.rs | 52 +++++++++++++++++++++++++- crates/ui-egui/src/home_tests.rs | 29 +++++++++++++++ docs/ui-parity.md | 4 +- 5 files changed, 107 insertions(+), 40 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a77b095..3e9319d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,7 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state; no server-wide query. Numbers unchanged. | +| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state. Local results reuse recent-file previews and file-type icons; no server-wide query. 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/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 3ea25576..77d1a1a3 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -12,6 +12,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y; +use crate::home_recents::SearchHit; use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; @@ -258,8 +259,8 @@ fn file_matches(name: &str, location: &str, query: &str) -> bool { name.to_lowercase().contains(query) || location.to_lowercase().contains(query) } -/// A search result with a name and optional location on separate lines. The larger hit target -/// keeps a short results list readable on the wide home screen. +/// An online search result with a name and optional location on separate lines. Local files use +/// the Recent files cards below so their stored preview and file-type icon are also shown here. fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), 64.0), Sense::hover()); let resp = ui.interact(r, id, Sense::click()); @@ -269,7 +270,8 @@ fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { focus_ring(ui, t, r, &resp, 8); if let Some((icon, tint)) = row.mark { let mark = Rect::from_center_size(pos2(r.min.x + 29.0, r.center().y), vec2(20.0, 20.0)); - icons::paint(ui.painter(), mark, icon, tint); + ui.painter().rect_filled(mark, CornerRadius::same(4), t.field); + icons::paint(ui.painter(), mark.shrink(2.0), icon, tint); } let width = (r.width() - 78.0).max(1.0); let name = widgets::elide_font(ui, row.name, width, semibold(13.0)); @@ -293,30 +295,15 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { let open: Vec<_> = app.session.docs.iter().enumerate().filter(|(_, d)| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), &query)).collect(); - if !open.is_empty() { - subheading(ui, t, tr("Open files")); - for (index, d) in open { - count += 1; - let row = FileRow { - mark: Some((Icon::Logo, ACCENT)), - name: &d.doc.name, - ink: t.text, - detail: d.path.as_deref().unwrap_or_default(), - detail_size: 11.0, - detail_ink: t.text_secondary, - }; - if search_file_card(ui, t, ui.id().with(("search_open", d.uid)), &row) { - switch = Some(index); - } - ui.add_space(8.0); - } - } + let open_hits: Vec = + open.iter().map(|(_, d)| SearchHit { key: format!("open:{}", d.uid), path: d.path.clone(), name: d.doc.name.clone() }).collect(); let recent: Vec<(String, String)> = app .session .prefs .recent_files .iter() + .filter(|p| figcraft_engine::file_access::allowed(p, figcraft_engine::file_access::Access::Read)) .filter(|p| !open_paths.contains(&p.as_str())) .filter_map(|path| { let p = Path::new(path); @@ -324,23 +311,24 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { file_matches(&name, path, &query).then(|| (path.clone(), name)) }) .collect(); + let recent_hits: Vec = + recent.iter().map(|(path, name)| SearchHit { key: format!("recent:{path}"), path: Some(path.clone()), name: name.clone() }).collect(); + let preview_paths: Vec = open_hits.iter().chain(&recent_hits).filter_map(|hit| hit.path.clone()).collect(); + let reduce_motion = app.reduce_motion(); + app.ui.home_view.recents.prepare_search(ui.ctx(), &preview_paths, ui.input(|i| i.time)); + if !open_hits.is_empty() { + subheading(ui, t, tr("Open files")); + count += open_hits.len(); + if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &open_hits, reduce_motion) { + switch = open.get(hit).map(|(index, _)| *index); + } + } let mut open_recent = None; - if !recent.is_empty() { + if !recent_hits.is_empty() { subheading(ui, t, tr("Recent files")); - for (path, name) in &recent { - count += 1; - let row = FileRow { - mark: Some((Icon::Page, t.icon_secondary)), - name, - ink: t.text, - detail: path, - detail_size: 11.0, - detail_ink: t.text_secondary, - }; - if search_file_card(ui, t, ui.id().with(("search_recent", path)), &row) { - open_recent = Some(path.clone()); - } - ui.add_space(8.0); + count += recent_hits.len(); + if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &recent_hits, reduce_motion) { + open_recent = recent_hits.get(hit).and_then(|hit| hit.path.clone()); } } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 1796eb02..8ccc3cec 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -504,6 +504,12 @@ impl Recents { paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() } + /// Share the Recent files thumbnail cache with Home search. The existing loader keeps file + /// reads off the UI thread and only requests pictures for cards that are on screen. + pub(crate) fn prepare_search(&mut self, ctx: &egui::Context, paths: &[String], time: f64) { + self.refresh(ctx, paths, time); + } + /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. pub fn inspect(&self, prefs: &Prefs) -> Value { let paths: Vec = listed(&prefs.recent_files).take(SHOWN).cloned().collect(); @@ -724,7 +730,8 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: /// The square file-type mark before a name. fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - match FileKind::of(Path::new(path)) { + let kind = if path.is_empty() { FileKind::FigCraft } else { FileKind::of(Path::new(path)) }; + match kind { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); @@ -741,6 +748,49 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { } } +/// A file found by the Home sidebar search. A tab without a disk path has no stored preview yet. +pub(crate) struct SearchHit { + pub key: String, + pub path: Option, + pub name: String, +} + +/// Search results use the same preview, file-type icon and footer as Recent files. A single click +/// still opens the result, as it did before the visual change. +pub(crate) fn search_grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[SearchHit], reduce_motion: bool) -> Option { + let (columns, card_w) = grid_columns(ui.available_width()); + let (picture_h, card_h) = card_heights(card_w); + let (time, now) = (ui.input(|i| i.time), figcraft_engine::cmd::comment::now_secs()); + let mut chosen = None; + let mut fading = false; + for (row_index, row) in files.chunks(columns).enumerate() { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), card_h), Sense::hover()); + for (column, file) in row.iter().enumerate() { + let rect = Rect::from_min_size(pos2(r.min.x + column as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h)); + let resp = ui.interact(rect, ui.id().with(("search_file_card", &file.key)), Sense::click()); + let stat = file.path.as_deref().and_then(|path| recents.files.get(path).and_then(|k| k.stat)); + let shown = Shown { path: file.path.clone().unwrap_or_default(), name: file.name.clone(), stat }; + a11y::named_button(&resp, &spoken(&shown, now)); + let resp = if let Some(path) = file.path.as_deref() { resp.on_hover_text(path) } else { resp }; + let visible = ui.is_rect_visible(rect); + let picture = file.path.as_deref().and_then(|path| recents.picture(path, visible, time)); + if visible { + let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a }); + fading |= alpha < 1.0; + card(ui, t, rect, picture_h, &resp, false, &shown, stat.unwrap_or_default(), picture, alpha, now); + } + if resp.clicked() { + chosen = Some(row_index * columns + column); + } + } + ui.add_space(GAP); + } + if fading { + ui.ctx().request_repaint(); + } + chosen +} + /// The name a card or row has for screen readers: "name, Edited 3 hours ago". fn spoken(file: &Shown, now: u64) -> String { let detail = file.detail(now); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index db052303..cf34e5b3 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -222,6 +222,35 @@ fn home_search_finds_known_files_and_opens_an_existing_tab() { assert_eq!(u.app.session.docs.len(), 1); } +#[test] +fn home_search_shows_a_saved_files_preview() { + let dir = std::env::temp_dir().join(format!("figcraft-home-search-preview-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Search preview.figcraft"); + let image = image::RgbaImage::from_pixel(64, 32, image::Rgba([30, 140, 210, 255])); + let mut png = Vec::new(); + image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let doc = figcraft_doc::Document::new("Search preview"); + std::fs::write(&path, figcraft_format::save_bytes(&doc, Some(&png)).unwrap()).unwrap(); + + let mut session = Session::empty(); + session.prefs.recent_files.push(path.to_string_lossy().into_owned()); + let mut u = Ui::new(session); + u.app.ui.home_view.file_query = "Search preview".into(); + for _ in 0..400 { + u.frame(vec![]); + if u.app.inspect()["homeRecents"]["files"][0]["picture"] == "picture" { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let s = shapes(&mut u); + assert!(has_text(&s, "Search preview")); + assert!(s.iter().any(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "the saved preview is drawn"); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(dir); +} + #[test] fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { let mut u = first_run(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 40568c6c..13f89118 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,7 +22,7 @@ interaction feel not yet compared. | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | | Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | -| Home search | done (ours) | A field in the left sidebar filters open tabs, recent local files and saved online files by name, path or server, plus starter templates by name, description, kind and size. Matching file cards and template cards open through their existing actions; selecting a section clears the query. It does not search other people's projects or files that have never been opened here (`ui.set {homeSearch}`; `ui.inspect.homeSearch`). | +| Home search | done (ours) | A field in the left sidebar filters open tabs, recent local files and saved online files by name, path or server, plus starter templates by name, description, kind and size. Local file results reuse the Recent files thumbnail and file-type icon; online files have an icon but no local preview. Matching cards open through their existing actions; selecting a section clears the query. It does not search other people's projects or files that have never been opened here (`ui.set {homeSearch}`; `ui.inspect.homeSearch`). | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | @@ -173,7 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar search over known open, recent and online files plus starter templates, with a no-results state. No server-wide search. Estimates unchanged. | +| 2026-10-17 | minor | Home sidebar search over known open, recent and online files plus starter templates, with a no-results state. Local results reuse the Recent files previews and file-type icons. No server-wide search. Estimates unchanged. | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 1fe78aec6679b49791aae326dcde93e86e400da3 Mon Sep 17 00:00:00 2001 From: Yarila Date: Sat, 17 Oct 2026 22:01:33 +0500 Subject: [PATCH 201/245] M7: respect theme in presenter controls and backdrop --- ROADMAP.md | 1 + crates/doc/src/proto.rs | 11 +++++++++++ crates/engine/src/cmd/proto.rs | 1 + crates/engine/src/proto_present_tests.rs | 15 +++++++++++++++ crates/ui-egui/src/present.rs | 17 +++++------------ crates/ui-egui/src/proto_panel.rs | 7 ++++++- docs/ui-parity.md | 1 + 7 files changed, 40 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..74953dd5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | The presenter device and scale menus follow the active UI theme. The default presentation backdrop uses the theme canvas token, while a background explicitly selected in Prototype settings remains saved. 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/doc/src/proto.rs b/crates/doc/src/proto.rs index a844774c..e89a3ed7 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -803,6 +803,10 @@ impl DeviceRotation { pub struct PrototypeSettings { pub device: String, pub background: figcraft_color::Color, + /// Distinguishes a user-selected colour from the legacy default so the presenter can use + /// its theme canvas token until a background is explicitly chosen. + #[serde(skip_serializing_if = "is_false")] + pub background_custom: bool, /// Applies to devices that turn (phones and tablets). pub rotation: DeviceRotation, /// The device's colour: one of its model's [`Device::colors`] by name (empty or one the model @@ -823,6 +827,7 @@ impl Default for PrototypeSettings { Self { device: "None".into(), background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E), + background_custom: false, rotation: DeviceRotation::Portrait, color: String::new(), custom_size: PrototypeSettings::CUSTOM_DEFAULT, @@ -859,6 +864,12 @@ pub struct Device { } impl PrototypeSettings { + /// Whether presentation should use the UI theme's canvas token. Old files only stored a + /// colour, so their original default is also treated as automatic. + pub fn uses_theme_background(&self) -> bool { + !self.background_custom && self.background == Self::default().background + } + /// The devices to pick from ("None" presents the frame at its own size). pub const DEVICES: [Device; 19] = [ Device { diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index be6907b5..438290e6 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -187,6 +187,7 @@ fn settings(s: &mut Session, p: &Value) -> Result { let mut next = prototype.clone(); if let Some(c) = bg { next.background = c; + next.background_custom = true; } if let Some(d) = dev { next.device = d; diff --git a/crates/engine/src/proto_present_tests.rs b/crates/engine/src/proto_present_tests.rs index 9d2bc48b..c48f3ead 100644 --- a/crates/engine/src/proto_present_tests.rs +++ b/crates/engine/src/proto_present_tests.rs @@ -16,6 +16,21 @@ fn settings(s: &Session) -> PrototypeSettings { } } +#[test] +fn prototype_background_uses_the_theme_until_a_colour_is_chosen() { + let mut s = Session::new(); + assert!(settings(&s).uses_theme_background()); + // Choosing even the old default explicitly must keep it through a file round trip. + s.execute("proto.settings", &json!({"background": "#1E1E1E"})).unwrap(); + assert!(!settings(&s).uses_theme_background()); + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap(); + let NodeKind::Page { prototype, .. } = &doc.node(doc.pages[0]).unwrap().kind else { panic!("not a page") }; + assert!(!prototype.uses_theme_background()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(settings(&s).uses_theme_background()); +} + #[test] fn device_rotation_turns_phones_and_tablets_and_is_saved() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index cc78c86b..7ce5e9b7 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1788,8 +1788,7 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option prototype.clone(), _ => proto::PrototypeSettings::default(), }; - let bg = settings.background; + let bg = if settings.uses_theme_background() { + let canvas = Tokens::get(ui.ctx()).canvas; + figcraft_color::Color::from_rgb8_const(canvas.r(), canvas.g(), canvas.b()) + } else { + settings.background + }; let model = settings.model(); // Phones and tablets turn: a toggle beside the device picker. let rotates = model.is_some_and(|d| d.rotates); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dcda2c4e..188b2168 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -174,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look |---|---|---| | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | +| 2026-10-17 | minor | Presenter device and scale menus use the active theme; the default backdrop uses the theme canvas token, preserving explicit Prototype background choices. Checked in the Windows app. Estimates unchanged. | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From c846f157dcdced43444f536cef5d1d5fb32bac5b Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 18 Oct 2026 07:34:38 +0700 Subject: [PATCH 202/245] fix(ui): SVG and PDF export rows offer no size (#256) (#768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): SVG and PDF export rows offer no size (#256) The size picker (1x, 2x, 512w…) showed on every export row, but SVG and PDF are vector and their export ignores it (export_bytes_with). It now shows for PNG, JPG and WebP only, and the suffix field takes its width. Refs #256 ("exporting to SVG is offered in different resolutions, although this should not be the case"). Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ui): export size field as an Option; test WebP too (#256) From review: the size field's width is an Option instead of a negative width, and the test checks WebP as well as JPG. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/export_panel.rs | 23 ++++++++++-------- crates/ui-egui/src/export_preview_tests.rs | 28 +++++++++++++++++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index ade5df29..8617c35a 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -60,20 +60,23 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } } -/// One export setting: size (typed or picked), suffix, format, advanced options and remove; a -/// PDF's print settings under it. +/// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix, +/// format, advanced options and remove; a PDF's print settings under it. fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) { row(ui, 32.0, |ui| { // Tighter spacing so the advanced options and remove buttons fit beside the fields. ui.spacing_mut().item_spacing.x = 4.0; - let (size_w, format_w, button, gap) = (52.0, 54.0, 22.0, ui.spacing().item_spacing.x); - let suffix_w = (ui.available_width() - size_w - format_w - button * 2.0 - gap * 4.0).max(32.0); - let presets: Vec = - ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect(); - if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets) - && ExportSize::parse(&s) != Some(e.size) - { - app.run("export.update", json!({"ids": ids, "index": i, "size": s})); + let (format_w, button, gap) = (54.0, 22.0, ui.spacing().item_spacing.x); + let size_w = e.format.is_raster().then_some(52.0); + let suffix_w = (ui.available_width() - size_w.map_or(0.0, |w| w + gap) - format_w - button * 2.0 - gap * 3.0).max(32.0); + if let Some(size_w) = size_w { + let presets: Vec = + ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect(); + if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets) + && ExportSize::parse(&s) != Some(e.size) + { + app.run("export.update", json!({"ids": ids, "index": i, "size": s})); + } } if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) && sfx != e.suffix diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index 3d536a01..d57e294d 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -1,6 +1,7 @@ //! The Export section's Preview row: a slice paints nothing itself, so its preview must show the //! page content under it — the same thing exporting the slice writes — and not a blank -//! transparency checkerboard next to a correct export. +//! transparency checkerboard next to a correct export. And its setting rows: a size only for +//! images. use figcraft_doc::{Document, NodeId}; use figcraft_engine::Session; @@ -50,3 +51,28 @@ fn other_layers_preview_themselves() { assert_eq!([pix.width(), pix.height()], [200, 100], "the layer's own area"); assert!(paints(&pix)); } + +/// SVG and PDF are vector: their export rows offer no size, which their export ignores (#256). +#[test] +fn export_rows_offer_a_size_for_images_only() { + let mut s = Session::new(); + let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some(); + u.frame(vec![]); + assert!(size_menu(&u), "PNG has sizes"); + for format in ["svg", "pdf"] { + u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format})); + u.frame(vec![]); + u.frame(vec![]); + assert!(!size_menu(&u), "{format} is vector: no size"); + } + for format in ["jpg", "webp"] { + u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format})); + u.frame(vec![]); + u.frame(vec![]); + assert!(size_menu(&u), "{format} has sizes again"); + } +} From 84a86e1d841e8ac74df20a6077e02f733303aad6 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 18 Oct 2026 07:35:47 +0700 Subject: [PATCH 203/245] fix(ui): Rotation gets its own icon, not Corner radius's (#256) (#769) * fix(ui): Rotation gets its own icon, not Corner radius's (#256) The Rotation field showed the corner radius icon, so the two fields looked alike. Rotation now shows an angle: two rays from a vertex with the arc between them, drawn in code like the rest of the icon set. Refs #256 ("The icon for rotating objects is the same as for rounding corners"). Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ui): the angle icon is drawn unhinted and centred; its test sits with the icons (#256) From review: the rays and the arc are both unhinted, so the arc meets the rays at the 14 px field size; the glyph moves up half a unit to centre it like the other leads; the test moves from a11y_tests.rs to the icon tests. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/icons.rs | 45 +++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/right.rs | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 867b0894..fa4fb66e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,7 @@ icons! { FlipH, FlipV, CornerRadius, + Angle, IndependentCorners, LinkOn, LinkOff, @@ -694,6 +695,12 @@ fn draw(p: &P, icon: Icon) { p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]); } CornerRadius => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + Angle => { + // Two rays from a vertex, the arc between them marking the angle (all unhinted, so + // the arc meets the rays at every size). + p.path(&[(13.5, 13.0), (2.5, 13.0), (10.5, 3.0)], false); + p.path(&arc_pts(2.5, 13.0, 6.0, (-10.0f32).atan2(8.0), 0.0).collect::>(), false); + } IndependentCorners => { for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] { p.corner(x, y, dx, dy, 4.0, 2.0); @@ -1181,4 +1188,42 @@ mod tests { assert_eq!(triangulate(&rev).len(), (arrow.len() - 2) * 3); assert!(triangulate(&arrow[..2]).is_empty()); } + + /// The points painted inside `area`, relative to the top left of what's painted there (in + /// quarter points), so the same icon gives the same marks wherever its field puts it. + fn marks(shapes: &[egui::epaint::ClippedShape], area: Rect) -> Vec<(i32, i32)> { + fn walk(shape: &Shape, out: &mut Vec) { + match shape { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + Shape::LineSegment { points, .. } => out.extend(points), + Shape::Path(p) => out.extend(&p.points), + Shape::Circle(c) => out.push(c.center), + _ => {} + } + } + let mut pts = Vec::new(); + shapes.iter().for_each(|c| walk(&c.shape, &mut pts)); + pts.retain(|p| area.contains(*p)); + let x0 = pts.iter().map(|p| p.x).fold(f32::INFINITY, f32::min); + let y0 = pts.iter().map(|p| p.y).fold(f32::INFINITY, f32::min); + let mut out: Vec<(i32, i32)> = pts.into_iter().map(|p| (((p.x - x0) * 4.0).round() as i32, ((p.y - y0) * 4.0).round() as i32)).collect(); + out.sort_unstable(); + out.dedup(); + out + } + + /// The Design panel's Rotation field shows an icon of its own, not Corner radius's (#256). + #[test] + fn rotation_and_corner_radius_fields_show_different_icons() { + let mut s = figcraft_engine::Session::new(); + let r = s.execute("shape.rectangle", &serde_json::json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].clone(); + s.execute("select.set", &serde_json::json!({"ids": [r]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.frame(vec![]); + let shapes = u.frame_output(vec![]).shapes; + let lead = |name: &str| u.ctx.read_response(egui::Id::new(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown")); + let (rotation, radius) = (marks(&shapes, lead("pos_rot")), marks(&shapes, lead("ap_rad"))); + assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); + assert_ne!(rotation, radius); + } } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 47f3e5d3..0483f022 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -657,7 +657,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, tr("Rotation")), rot, "°", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); app.field_of(&ids, "rotation", e, rot_of, |v| json!(v)); let items = [ (Icon::Rotate, tr("Rotate 90° right")), From 47438dd4a2e8b49a7e6bb3dfd480262dadc37f12 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 17 Oct 2026 21:43:28 -0400 Subject: [PATCH 204/245] Release 0.7.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a61d87cc..86bccfa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1413,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.6.0" +version = "0.7.0" dependencies = [ "arboard", "eframe", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1492,7 +1492,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.6.0" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -1501,7 +1501,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.6.0" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "serde", @@ -1627,7 +1627,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.6.0" +version = "0.7.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1645,7 +1645,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.6.0" +version = "0.7.0" dependencies = [ "crc32fast", "ctrlc", @@ -1669,7 +1669,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1685,7 +1685,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1699,7 +1699,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "egui", @@ -1724,7 +1724,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.6.0" +version = "0.7.0" dependencies = [ "eframe", "egui", @@ -5934,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index fe089328..932da46f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From e9be17427f06695c1cb1074b2ce993b903b65893 Mon Sep 17 00:00:00 2001 From: sanches Date: Sun, 18 Oct 2026 05:14:35 +0300 Subject: [PATCH 205/245] M9: French catalog at 100% of the strings (still a draft) The 91 UI strings, 12 command labels and 2 menu wordings the strings added since the French catalog was drafted left in English are translated in the catalog's conventions (NBSP before : ! and inside guillemets, one/other plurals, 0 singular): starter templates and the Templates tab, the home screen's recents table with its sorting and dates in the French way, dropping files to open or import, the Getting started tips with sharing by code, the Playground's bound-variable chip, smart-selection spacing, outline mode's options, the converting and panel-width commands. Appended to each section; still requires native review. --- ROADMAP.md | 5 +- contributors/people.toml | 3 + crates/ui-egui/locales/fr.toml | 111 +++++++++++++++++++++++++++++++++ docs/gaps.md | 11 ++-- docs/localization-parity.md | 5 +- 5 files changed, 126 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..ecde012e 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 (French at 100% of the strings, an unreviewed draft; language table re-measured; preceding: 2026-10-17 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; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -103,7 +103,7 @@ native speakers' reviews of the six language drafts need people. | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| French | fr | 95% | draft (needs native review) | +| French | fr | 100% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | | Japanese | ja | 99% | draft (needs native review) | @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | French at 100% of the strings (gap 5, `fr.toml`): what the strings added since the draft left in English in the French catalog — the templates and their kinds with descriptions, the home screen's recents table (sorting, "Show in folder", the dates in the French way), dropping files to open or import, the Getting started tips with sharing by code, the Playground's bound-variable chip, smart-selection spacing, outline mode's options, the converting and panel-width tail — is translated: 91 UI strings, 12 command labels and 2 menu wordings, appended to the sections in the catalog's conventions (NBSP before : ! and inside « », one/other plurals with 0 singular). Still a draft awaiting native review; the language table row and per-area docs re-measured; 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/contributors/people.toml b/contributors/people.toml index ace49f7b..8b14373e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -53,3 +53,6 @@ sync_github_name = false [people.krinry] display_name = "krinry" sync_github_name = true + +[people.pechnikov2026-star] +sync_github_name = true diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 17a0d60b..78530540 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1637,6 +1637,99 @@ status = "draft — needs native review" "⌘ scroll" = "⌘ défilement" "⌥ drag" = "⌥ glisser" +# Strings translated since the draft (2026-10-18), appended; a native review is still pending. +"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page" +"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture" +"A to Z" = "A à Z" +"Adding to {name}" = "Ajout dans {name}" +"Alphabetical" = "Alphabétique" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau" +"Apps" = "Applications" +"Apr" = "avr." +"Aug" = "août" +"Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable." +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Styles de couleur et de texte, variables Clair et Sombre, boutons, champs et cartes" +"Convert to frame" = "Convertir en cadre" +"Convert to section" = "Convertir en section" +"Created" = "Création" +"Dashboard" = "Tableau de bord" +"Date created" = "Date de création" +"Dec" = "déc." +"Design system starter" = "Base de système de design" +"Draw a frame" = "Tracer un cadre" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image." +"Drop to import" = "Déposer pour importer" +"Drop to open" = "Déposer pour ouvrir" +"Edited {when}" = "Modifié {when}" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporter les cadres et les calques en PNG, JPG, SVG ou PDF" +"Feb" = "févr." +"File not found" = "Fichier introuvable" +"Four screens of a trail app with components and a prototype flow" = "Quatre écrans d’une application de randonnée avec des composants et un flux de prototype" +"Getting started" = "Premiers pas" +"Greyscale components and two example screens to sketch a flow with" = "Composants en niveaux de gris et deux écrans d’exemple pour esquisser un flux" +"Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)" +"Hide when publishing" = "Masquer à la publication" +"Icon components on a 24 px grid with keylines and a size preview" = "Composants d’icônes sur une grille de 24 px avec repères et aperçu de la taille" +"Icon set starter" = "Base de jeu d’icônes" +"Jan" = "janv." +"Jul" = "juil." +"Jun" = "juin" +"Kits" = "Kits" +"Landing page" = "Page de destination" +"Last modified" = "Dernière modification" +"Link frames on the Prototype tab, then present the flow" = "Reliez les cadres dans l’onglet Prototype, puis présentez le flux" +"Make a component" = "Créer un composant" +"Mar" = "mars" +"May" = "mai" +"Mobile app" = "Application mobile" +"Newest first" = "Plus récent d’abord" +"No other pages" = "Aucune autre page" +"No templates match your search" = "Aucun modèle ne correspond à votre recherche" +"Nov" = "nov." +"Oct" = "oct." +"Oldest first" = "Plus ancien d’abord" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière" +"Opening the file…" = "Ouverture du fichier…" +"Opening {n} files as new files" = { one = "Ouverture comme nouveau fichier", other = "Ouverture de {n} fichiers comme nouveaux fichiers" } +"Order" = "Ordre" +"Other" = "Autre" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" +"Playground" = "Bac à sable" +"Plugin" = "Plugin" +"Plugins & widgets" = "Plugins et widgets" +"Presentation slides" = "Diapositives de présentation" +"Remove from recents" = "Retirer des récents" +"Search templates" = "Rechercher des modèles" +"See all templates" = "Voir tous les modèles" +"Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change" +"Sep" = "sept." +"Share with a code" = "Partager avec un code" +"Show all templates" = "Afficher tous les modèles" +"Show in Explorer" = "Afficher dans l’Explorateur" +"Show in Finder" = "Afficher dans le Finder" +"Show in folder" = "Afficher dans le dossier" +"Social and slides" = "Réseaux sociaux et diapositives" +"Social post sizes" = "Formats de publications sociales" +"Sort by" = "Trier par" +"Sort by {column}" = "Trier par {column}" +"Start from a template" = "Commencer avec un modèle" +"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" +"Templates" = "Modèles" +"The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé." +"The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé." +"The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé." +"Try another word, or look at every kind." = "Essayez un autre mot, ou regardez chaque type." +"Turn a layer into a component, reuse it, and change every copy at once" = "Transformez un calque en composant, réutilisez-le et modifiez toutes les copies d’un coup" +"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode" +"Websites" = "Sites web" +"What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier" +"Widget" = "Widget" +"Wireframe kit" = "Kit de wireframes" +"Z to A" = "Z à A" +"just now" = "à l’instant" +"yesterday" = "hier" +"{month} {day}, {year}" = "{day} {month} {year}" + [commands] "align.bottom" = "Aligner en bas" "align.hcenter" = "Aligner les centres horizontalement" @@ -2193,6 +2286,20 @@ status = "draft — needs native review" "window.focus" = "Mettre la fenêtre au premier plan" "window.new" = "Nouvelle fenêtre" +# Commands translated since the draft (2026-10-18), appended. +"component.hideWhenPublishing" = "Masquer à la publication" +"component.playground" = "Bac à sable de composant" +"file.newFromTemplate" = "Nouveau à partir d’un modèle" +"help.gettingStarted" = "Premiers pas" +"object.convertToFrame" = "Convertir en cadre" +"object.convertToGroup" = "Convertir en groupe" +"object.convertToSection" = "Convertir en section" +"style.bind" = "Lier une variable dans un style" +"template.list" = "Lister les modèles" +"view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours" +"view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours" +"view.panelWidths" = "Réinitialiser la largeur des panneaux" + [menu] "bool.exclude" = "Exclure" "bool.flatten" = "Aplatir" @@ -2223,3 +2330,7 @@ status = "draft — needs native review" "view.themeDark" = "Sombre" "view.themeLight" = "Clair" "view.themeSystem" = "Suivre le système" + +# Menu wordings translated since the draft (2026-10-18), appended. +"view.outlinesHiddenLayers" = "Inclure les calques masqués" +"view.outlinesObjectBounds" = "Inclure les limites des objets" diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..68ce570e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (French at 100% of the strings as an unreviewed draft; preceding: the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; French complete as an unreviewed draft; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -187,9 +187,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in `crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31 menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, - Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese, - Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they - were drafted show English), with `es-419.toml` a Latin American overlay listing only what + Spanish is held to completeness by the check, and French (2026-10-18) covers every string as a + draft; the German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese machine drafts + cover 95–99% (strings added since they were drafted show English), with `es-419.toml` a Latin + American overlay listing only what differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals) covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 50fcd4ec..8f6f748f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (French at 100% of the strings, still an unreviewed draft; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -90,7 +90,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| French | fr | 2,272 / 2,272 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | @@ -120,6 +120,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | The French draft catches up to 100%: `fr.toml` translates the 91 UI strings, 12 command labels (`file.newFromTemplate`, `template.list`, `help.gettingStarted`, `component.playground`, `component.hideWhenPublishing`, `object.convertTo{Frame,Group,Section}`, `style.bind`, `view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `view.panelWidths`) and 2 menu wordings (the two outlines options) that the strings added since it was drafted left in English: starter templates and the Templates tab, the home screen's recents table with its sorting and "Show in folder", dropping files to open or import, the Getting started tips, sharing with a code, a rendered component's Playground bound-variable chip, smart-selection spacing and the outline options, and the dates on the recent cards (months abbreviated the French way, "A à Z"/"Z à A"); appended to each section in the catalog's conventions (NBSP before `:` `!` and inside « », one/other plurals with 0 counting as singular). Still a draft, awaiting native review; the language table row is re-measured | | 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | From cf6bd4eeb29a925f3dced1f420f03a6c588d2a28 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 21:49:32 +0700 Subject: [PATCH 206/245] M7: list the fonts a file uses that aren't installed (#752) Text in a family that isn't installed was drawn in a fallback font without a word. Now: - `text.missingFonts` lists the families a document names that no installed or built-in font provides: text layers and their styled ranges, text styles, and string variables bound to a font family (every mode, through aliases), with the number of text layers. - The desktop app shows a Missing fonts window after the user opens a file that has any: once installed fonts are indexed (before that every one of them would count as missing) and after any import report is closed. Files named on the command line count. Programmatic opens and the browser build never open it by themselves. Text > Missing fonts... shows it for the file in front; Escape closes it. - `figcraft-cli info` prints `missing font` lines (`missingFonts` with --json). - file-format-parity: the row claimed REST import warnings that no code produced. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/figcraft-cli/src/files.rs | 6 ++ apps/figcraft-cli/tests/fonts.rs | 22 ++++ apps/figcraft/src/main.rs | 3 + crates/engine/src/catalog.rs | 4 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/fonts.rs | 100 ++++++++++++++++++ crates/engine/src/fonts_tests.rs | 123 ++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/text/src/fontdb.rs | 5 + crates/text/src/lib.rs | 4 +- crates/ui-egui/locales/en.toml | 5 + crates/ui-egui/locales/es.toml | 6 ++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/import_report_tests.rs | 2 +- crates/ui-egui/src/lib.rs | 11 ++ crates/ui-egui/src/missing_fonts.rs | 102 ++++++++++++++++++ crates/ui-egui/src/missing_fonts_tests.rs | 112 ++++++++++++++++++++ docs/cli.md | 4 +- docs/file-format-parity.md | 3 +- docs/mcp.md | 2 + 20 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/fonts.rs create mode 100644 crates/engine/src/fonts_tests.rs create mode 100644 crates/ui-egui/src/missing_fonts.rs create mode 100644 crates/ui-egui/src/missing_fonts_tests.rs diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3e08474a..65b19ee4 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames})) }) .collect(); + let missing = figcraft_engine::fonts::missing(&doc); let v = json!({ "file": path, "name": doc.name, @@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { "variables": doc.variables.variables.len(), "images": doc.images.len(), "notes": notes, + "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), }); if as_json { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult { println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); } } + for m in &missing { + let layers = if m.layers == 1 { "text layer" } else { "text layers" }; + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + } Ok(ExitCode::SUCCESS) } diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index c5901ac5..ef5c638f 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() { assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family"); assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back"); } + +#[test] +fn info_lists_the_fonts_a_file_uses_that_are_missing() { + let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let (script, file) = (d.join("make.json"), d.join("doc.figcraft")); + let text = + |family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}}); + std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap(); + let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli")); + let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let out = cli().arg("info").arg(&file).arg("--json").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}])); + + let out = cli().arg("info").arg(&file).output().unwrap(); + let shown = String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 10147c2c..fe89d0a7 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -161,6 +161,7 @@ fn main() -> eframe::Result { log::error!("{f}: {e}"); } } + let opened: Vec = session.docs.iter().map(|d| d.uid).collect(); // Documents a crashed or killed run left unsaved come back; this run keeps its own copies. let recovery_root = services::config_dir().map(|d| d.join("Recovery")); // The preferences first: they hold the online files a recovered document may be one of. @@ -194,6 +195,8 @@ fn main() -> eframe::Result { let mut fig = FigApp::new(session, Box::new(svc)); fig.automation_roots = roots; fig.ui.home = false; + // Files named on the command line are the user's too: their missing fonts are listed. + fig.ui.font_checks = opened; fig.note_recovered(recovered.copies); if let Some(link) = &args.join { figcraft_ui_egui::live::open_invite(&mut fig, link); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c8f34fc1..4b6cd3fc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + m!("Text", SEP, ""), + m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align horizontal centers", "align.hcenter"), @@ -287,7 +289,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"]; +const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b942fc25..f011238d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -80,6 +80,7 @@ fn registry() -> &'static Registry { list.extend(page::specs()); list.extend(view::specs()); list.extend(text::specs()); + list.extend(crate::fonts::specs()); list.extend(styles::specs()); list.extend(image::specs()); list.extend(variables::specs()); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 00000000..e20d4d40 --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,100 @@ +//! Fonts a document names that this machine doesn't have (#752): text layers, their styled +//! ranges, text styles and the string variables bound to a font family. Text in such a family +//! is drawn in a fallback font, so the user is told which ones are missing. + +use std::collections::BTreeMap; + +use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::{Result, Session, spec}; + +/// A family the document names that no installed or built-in font provides. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + /// As the document spells it. + pub family: String, + /// Text layers that use it (0: only a text style or a variable names it). + pub layers: usize, +} + +/// The families `doc` names that the font database doesn't have, by name. On the desktop the +/// installed fonts are indexed in the background: ask once that's done +/// (`figcraft_text::system_fonts_scanned`), or every installed family counts as missing. +pub fn missing(doc: &Document) -> Vec { + // Lower-case family → (spelling, layers). + let mut used: BTreeMap = BTreeMap::new(); + let mut add = |family: &str, layer: bool| { + let family = family.trim(); + if family.is_empty() { + return; + } + let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0)); + e.1 += usize::from(layer); + }; + for n in doc.nodes() { + if let NodeKind::Text(t) = &n.kind { + let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect(); + families.push(&t.style.font_family); + families.sort_by_key(|f| f.trim().to_lowercase()); + families.dedup_by_key(|f| f.trim().to_lowercase()); + for f in families { + add(f, true); + } + } + if let Some(var) = n.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + for st in &doc.styles { + if let StyleKind::Text { style } = &st.kind { + add(&style.font_family, false); + } + if let Some(var) = st.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() +} + +/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +fn string_values(vars: &Variables, id: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![(id.to_string(), 0)]; + while let Some((id, depth)) = todo.pop() { + let Some(var) = vars.get(&id) else { continue }; + for v in var.values.values() { + match v { + VariableValue::String(s) => out.push(s.clone()), + VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + _ => {} + } + } + } + out +} + +fn missing_fonts(s: &mut Session, _: &Value) -> Result { + let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); + s.request_ui("dialog.missingFonts", json!({})); + Ok(json!({"families": families})) +} + +pub fn specs() -> Vec { + vec![spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + )] +} + +#[cfg(test)] +#[path = "fonts_tests.rs"] +mod tests; diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs new file mode 100644 index 00000000..6ba925b3 --- /dev/null +++ b/crates/engine/src/fonts_tests.rs @@ -0,0 +1,123 @@ +use serde_json::json; + +use super::*; +use crate::params::id_of; +use figcraft_doc::{Style, StyleRun, TextStyle}; + +fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId { + let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind { + t.style.font_family = family.into(); + } + id +} + +fn families(s: &mut Session) -> Vec<(String, u64)> { + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect() +} + +/// An installed family (built in, or found by the system scan), if this machine has any. +fn installed() -> Option { + figcraft_text::scan_system_fonts(); + figcraft_text::family_names().into_iter().next() +} + +#[test] +fn lists_families_of_layers_ranges_styles_and_variables_once_each() { + let mut s = Session::new(); + text(&mut s, "FigCraft Missing A"); + let b = text(&mut s, "figcraft missing a"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind { + let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() }; + t.runs.push(StyleRun { start: 0, end: 3, style }); + } + let doc = s.doc_mut().unwrap(); + doc.styles.push(Style { + id: "S:1".into(), + name: "Heading".into(), + description: String::new(), + kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + bound: Default::default(), + }); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap(); + s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap(); + let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone(); + s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var); + + assert_eq!( + families(&mut s), + vec![ + ("FigCraft Missing A".to_string(), 2), + ("FigCraft Missing B".to_string(), 1), + ("FigCraft Missing C".to_string(), 0), + ("FigCraft Missing D".to_string(), 0), + ("FigCraft Missing H".to_string(), 0), + ] + ); + assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts")); +} + +#[test] +fn installed_families_are_not_listed() { + let Some(family) = installed() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + text(&mut s, &family.to_uppercase()); + text(&mut s, "FigCraft Missing E"); + assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]); +} + +#[test] +fn a_family_found_only_by_the_system_scan_is_not_missing() { + figcraft_text::scan_system_fonts(); + let embedded: Vec = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect(); + let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else { + eprintln!("skipped: no system fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + assert!(families(&mut s).is_empty(), "{family} is installed"); +} + +#[test] +fn blank_families_and_aliases_are_handled() { + let mut s = Session::new(); + text(&mut s, " "); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap(); + let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone(); + let t = text(&mut s, "FigCraft Missing G"); + let doc = s.doc_mut().unwrap(); + // A variable that only aliases another, and one aliasing itself. + let mut alias = doc.variables.get(&base).unwrap().clone(); + alias.id = "V:alias".into(); + alias.name = "Alias".into(); + for v in alias.values.values_mut() { + *v = VariableValue::Alias(base.clone()); + } + let mut cycle = alias.clone(); + cycle.id = "V:cycle".into(); + cycle.name = "Cycle".into(); + for v in cycle.values.values_mut() { + *v = VariableValue::Alias("V:cycle".into()); + } + doc.variables.variables.push(alias); + doc.variables.variables.push(cycle); + doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); + let other = text(&mut s, "FigCraft Missing G"); + s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); + // The bound layer takes the value its alias resolves to; the cycle resolves to nothing. + assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); +} + +#[test] +fn needs_an_open_document() { + let mut s = Session::empty(); + assert!(s.execute("text.missingFonts", &json!({})).is_err()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2254269e..508e70f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod distance; pub mod doc_urls; pub mod drop; pub mod file_access; +pub mod fonts; pub mod guard; pub mod guides; pub mod inspect; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index a6d52d67..8d1840b1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -852,6 +852,11 @@ pub fn family_names() -> Vec { db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families() } +/// The global db has `family` (embedded, loaded or indexed; any case). +pub fn has_family(family: &str) -> bool { + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).has_family(family) +} + /// Style names of a family in the global db. pub fn family_styles(family: &str) -> Vec { db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 27456d82..721471ad 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,8 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, - scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, + no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 59b25d94..4003891b 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -91,6 +91,7 @@ name = "English" "All components" = "All components" "All fonts" = "All fonts" "All supported properties" = "All supported properties" +"All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" @@ -771,6 +772,7 @@ name = "English" "Mirror angle and length" = "Mirror angle and length" "Mirroring" = "Mirroring" "Missing" = "Missing" +"Missing fonts" = "Missing fonts" "Missing layer" = "Missing layer" "Missing style" = "Missing style" "Missing variable" = "Missing variable" @@ -1374,6 +1376,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." @@ -1593,6 +1596,7 @@ name = "English" "nobody else here yet" = "nobody else here yet" "non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped" "now" = "now" +"only in a text style or variable" = "only in a text style or variable" "or" = "or" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "preferred slot component keys cannot be resolved to file nodes and were skipped" @@ -1700,6 +1704,7 @@ name = "English" "{n} replies" = { one = "{n} reply", other = "{n} replies" } "{n} rows" = { one = "{n} row", other = "{n} rows" } "{n} styles" = { one = "{n} style", other = "{n} styles" } +"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } "{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 01c5813b..b442d6d3 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -93,6 +93,7 @@ status = "draft — needs native review" "All comments" = "Todos los comentarios" "All components" = "Todos los componentes" "All fonts" = "Todas las fuentes" +"All the fonts this file uses are installed" = "Todas las fuentes que usa este archivo están instaladas" "All supported properties" = "Todas las propiedades compatibles" "All variables" = "Todas las variables" "Alpha" = "Alfa" @@ -774,6 +775,7 @@ status = "draft — needs native review" "Missing" = "No encontrado" "Missing layer" = "Capa no encontrada" "Missing style" = "Estilo no encontrado" +"Missing fonts" = "Fuentes que faltan" "Missing variable" = "Variable no encontrada" "Miter" = "Inglete" "Miter angle" = "Ángulo de inglete" @@ -1375,6 +1377,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." "This frame isn't on the branch." = "Este marco no está en la rama." @@ -1594,6 +1597,7 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron" "now" = "ahora" +"only in a text style or variable" = "solo en un estilo de texto o una variable" "or" = "o" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "las claves de componentes preferidos de las ranuras no corresponden a capas del archivo y se omitieron" @@ -1701,6 +1705,7 @@ status = "draft — needs native review" "{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" } "{n} rows" = { one = "{n} fila", other = "{n} filas" } "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } +"{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } "{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } @@ -2126,6 +2131,7 @@ status = "draft — needs native review" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" "text.maxLines" = "Máximo de líneas" +"text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" "text.onPath" = "Texto en trazado" "text.paragraphIndent" = "Sangría de párrafo" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 39001b56..448501ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -151,6 +151,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.is_some() || u.batch_rename.is_some() || u.import_report.is_some() + || u.missing_fonts.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel @@ -164,6 +165,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() + || u.missing_fonts.take().is_some() { return; } @@ -178,6 +180,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); crate::import_report::show(app, ctx); + crate::missing_fonts::show(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 8742ccd3..a2411582 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -108,7 +108,7 @@ fn escape_closes_the_import_report_like_other_simple_dialogs() { assert!(u.app.ui.import_report.is_none(), "Escape closes the report"); } -fn shown_texts(shapes: Vec) -> Vec { +pub(crate) fn shown_texts(shapes: Vec) -> Vec { fn walk(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d1be479..b3a705ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod live; pub mod live_text; pub mod measure_overlay; pub mod menus; +pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; @@ -149,6 +150,8 @@ mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; #[cfg(test)] +mod missing_fonts_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -506,6 +509,9 @@ pub struct UiState { pub pending_open_frames: u8, /// The latest successful file import's summary and compatibility notes. pub import_report: Option, + /// Files the user opened whose fonts are yet to be checked (by document uid). + pub font_checks: Vec, + pub missing_fonts: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -633,6 +639,8 @@ impl Default for UiState { pending_opens: Vec::new(), pending_open_frames: 0, import_report: None, + font_checks: Vec::new(), + missing_fonts: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -761,6 +769,7 @@ impl FigApp { if let Some(result) = &result { import_report::capture(self, id, ¶ms, result); } + missing_fonts::check_later(self, id, result.as_ref()); result } @@ -875,6 +884,7 @@ impl FigApp { self.ui.about_tab = dialogs::AboutTab::About; } "dialog.preferences" => self.ui.preferences = true, + "dialog.missingFonts" => missing_fonts::request(self), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, @@ -1661,6 +1671,7 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state } } diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs new file mode 100644 index 00000000..8236ca90 --- /dev/null +++ b/crates/ui-egui/src/missing_fonts.rs @@ -0,0 +1,102 @@ +//! The Missing fonts window (#752): the fonts a file uses that aren't installed, whose text is +//! drawn in a fallback font. It opens by itself after the user opens a file that has any (once +//! the installed fonts are indexed and any import report is closed), and from Text ▸ Missing fonts. + +use egui::RichText; +use figcraft_engine::fonts::{Missing, missing}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, trn}; +use crate::theme::{Tokens, regular}; + +#[derive(Clone, Debug)] +pub struct Shown { + /// The document it lists: the window goes when that document closes. + pub uid: u64, + pub families: Vec, +} + +impl Shown { + pub fn inspect(&self) -> Value { + json!({"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>()}) + } +} + +/// After a file the user opened (or imported into) is in front: look for missing fonts in it +/// once that can be told ([`show`]). +pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { + if result.is_some() + && matches!(command, "file.open" | "file.openRecent" | "file.import") + && let Ok(ds) = app.session.ds() + { + app.ui.font_checks.push(ds.uid); + } +} + +/// The window for document `uid`, listing what it misses (even nothing, when asked for). +fn list(app: &mut FigApp, uid: u64) -> Option> { + let ds = app.session.docs.iter().find(|d| d.uid == uid)?; + let families = missing(&ds.doc); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone() }); + Some(families) +} + +/// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. +pub fn request(app: &mut FigApp) { + if let Ok(uid) = app.session.ds().map(|d| d.uid) { + list(app, uid); + } +} + +/// Installed fonts are indexed: before that, every one of them would count as missing. The +/// browser has only its built-in fonts, so files there don't open the window by themselves. +fn fonts_known() -> bool { + !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let uid = app.ui.font_checks.remove(0); + if list(app, uid).is_some_and(|f| f.is_empty()) { + app.ui.missing_fonts = None; + } + } + if app.ui.missing_fonts.as_ref().is_some_and(|m| !app.session.docs.iter().any(|d| d.uid == m.uid)) { + app.ui.missing_fonts = None; + } + let Some(shown) = &app.ui.missing_fonts else { return }; + let t = Tokens::get(ctx); + let mut open = true; + let mut close_clicked = false; + crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + if shown.families.is_empty() { + ui.label(tr("All the fonts this file uses are installed")); + } else { + ui.label( + RichText::new(tr( + "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them.", + )) + .color(t.text_secondary) + .font(regular(11.0)), + ); + ui.add_space(6.0); + egui::ScrollArea::vertical().id_salt("missing_fonts").max_height(260.0).show(ui, |ui| { + for m in &shown.families { + ui.horizontal(|ui| { + ui.label(RichText::new(&m.family).strong()); + let used = + if m.layers == 0 { tr("only in a text style or variable").to_string() } else { trn("{n} text layers", m.layers, &[]) }; + ui.label(RichText::new(used).color(t.text_secondary)); + }); + } + }); + } + ui.add_space(12.0); + close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + }); + if !open || close_clicked { + app.ui.missing_fonts = None; + } +} diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs new file mode 100644 index 00000000..ba86cc7e --- /dev/null +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -0,0 +1,112 @@ +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::import_report::Report; +use crate::layers_drag_tests::Ui; + +/// A saved file with text in `families` (the first two layers share the first family). +fn saved(name: &str, families: &[&str]) -> String { + let mut s = Session::new(); + for (i, f) in families.iter().chain(families.first()).enumerate() { + s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 40 * i, "props": {"fontFamily": f}})).unwrap(); + } + let path = std::env::temp_dir().join(format!("figcraft-missing-fonts-{name}-{}.figcraft", std::process::id())); + let path = path.to_string_lossy().into_owned(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + path +} + +/// The installed fonts are indexed, as the desktop app does at startup; false when this machine +/// opted out (nothing would ever count as known). +fn fonts_indexed() -> bool { + figcraft_text::scan_system_fonts(); + figcraft_text::system_fonts_scanned() +} + +fn listed(u: &Ui) -> Value { + u.app.inspect()["missingFonts"].clone() +} + +#[test] +fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let installed = figcraft_text::family_names().into_iter().next(); + let mut families = vec!["FigCraft Missing UI"]; + families.extend(installed.as_deref()); + let path = saved("open", &families); + let mut u = Ui::new(Session::new()); + // Programmatic opens (control channel, MCP, scripts) never open the window. + u.app.run("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}]})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); + + // It goes with its file. + u.app.run("file.close", json!({})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); +} + +#[test] +fn a_file_without_missing_fonts_opens_no_window() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let Some(installed) = figcraft_text::family_names().into_iter().next() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let path = saved("clean", &[&installed]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + assert!(u.app.ui.font_checks.is_empty(), "the check ran"); +} + +#[test] +fn it_waits_for_the_import_report_to_close() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let path = saved("report", &["FigCraft Missing Report"]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + let mut report = + Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic"}})).unwrap(); + report.uid = u.app.session.ds().unwrap().uid; + u.app.ui.import_report = Some(report); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null, "the import report comes first"); + u.app.ui.import_report = None; + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Report"); +} + +#[test] +fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + let mut u = Ui::new(Session::new()); + assert!(u.app.run("text.missingFonts", json!({})).is_some()); + assert_eq!(listed(&u), json!({"families": []})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); + let escape = + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + u.frame(escape); + assert_eq!(listed(&u), Value::Null, "Escape closes it"); +} diff --git a/docs/cli.md b/docs/cli.md index 7b35c2d5..7af73fd6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,7 +29,9 @@ be open to the public: [server.md](server.md#running-a-public-server)). See [ser ### `info [--json]` Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs -of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). +of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). Fonts the +file names that aren't installed are listed as `missing font` lines (`missingFonts: [{family, +layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b65f7729..54e2dd25 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -159,7 +159,7 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | warnings only (REST import) | +| Missing fonts on open | listed, with replacement | listed (Missing fonts window after an open, Text ▸ Missing fonts…, `text.missingFonts`, `figcraft-cli info`); no replacement; text draws in a fallback font | ## Revision history @@ -167,6 +167,7 @@ each. |---|---|---| | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | +| 2026-10-17 | minor | Missing fonts (#752): the fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables in any mode) are listed when the user opens it (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. The row claimed REST import warnings no code produced. | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index e63283bd..a84bf05b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -233,6 +233,8 @@ Or in a project's `.mcp.json`: user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies); `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). + `text.missingFonts` lists the families the document names that aren't installed + (`{families: [{family, layers}]}`); their text draws in a fallback font. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline From db43dc5a4a25fc0b908286da451d6ee413ea5243 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 22:06:03 +0700 Subject: [PATCH 207/245] M7: missing fonts window only after a file opens; wait for the font scan (#752) From review: - The check ran after any file.open/openRecent/import result, so a pasted or dropped image, reopening a file that was already open, or Clear recent brought the window back. It now runs only for results with a `summary` (a file opened, or pages imported), and never in the browser. - Text > Missing fonts... before the startup scan finished listed installed fonts as missing. It now says it is checking the installed fonts and lists them once that is done (`figcraft_text::installed_fonts_known`, also true when system fonts are opted out, where checks used to wait forever). - ROADMAP progress log and file-format-parity status line. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/engine/src/fonts.rs | 2 +- crates/engine/src/fonts_tests.rs | 5 ++- crates/text/src/fontdb.rs | 10 +++++ crates/text/src/lib.rs | 2 +- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/missing_fonts.rs | 38 +++++++++++------- crates/ui-egui/src/missing_fonts_tests.rs | 47 ++++++++++++++++++++--- docs/file-format-parity.md | 2 +- 10 files changed, 87 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..7cef08a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Missing fonts (M7, #752): fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables) are listed in a Missing fonts window after the user opens the file (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Their text still draws in a fallback font. 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. @@ -317,6 +318,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 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 | Missing fonts (M7, #752): fonts a file names that aren't installed are listed after the user opens it, from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. 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. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index e20d4d40..3548124d 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -21,7 +21,7 @@ pub struct Missing { /// The families `doc` names that the font database doesn't have, by name. On the desktop the /// installed fonts are indexed in the background: ask once that's done -/// (`figcraft_text::system_fonts_scanned`), or every installed family counts as missing. +/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing. pub fn missing(doc: &Document) -> Vec { // Lower-case family → (spelling, layers). let mut used: BTreeMap = BTreeMap::new(); diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 6ba925b3..a6e2d213 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -112,7 +112,10 @@ fn blank_families_and_aliases_are_handled() { doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); let other = text(&mut s, "FigCraft Missing G"); s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); - // The bound layer takes the value its alias resolves to; the cycle resolves to nothing. + // The bound layer takes the value its alias resolves to (bindings are applied after the next + // command); the cycle resolves to nothing. + let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() }; + assert_eq!(bound.style.font_family, "FigCraft Missing F"); assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8d1840b1..f9eda2c6 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -893,6 +893,16 @@ fn system_fonts_opted_out() -> bool { std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") } +/// Every font text can use is known: the system fonts were indexed, or there are none to index +/// (opted out, or the web). Until then a family the scan would find counts as missing. +pub fn installed_fonts_known() -> bool { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + return system_fonts_scanned(); + } + true +} + /// Index the system fonts the first time the font database is used (headless apps: a run that /// lays out no text never pays for the scan). The desktop app scans in the background instead. pub fn scan_system_fonts_on_demand() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 721471ad..683350f6 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,7 +17,7 @@ pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 4003891b..41c5b9d6 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -198,6 +198,7 @@ name = "English" "C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Can view" = "Can view" +"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b442d6d3..db4fba3e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -201,6 +201,7 @@ status = "draft — needs native review" "C or ⇧C" = "C o ⇧C" "CMYK colour" = "Color CMYK" "Can view" = "Puede ver" +"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…" "Checking the server…" = "Comprobando el servidor…" "Clear from this list" = "Borrar de esta lista" "Code or invite link" = "Código o enlace de invitación" diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs index 8236ca90..6c07068d 100644 --- a/crates/ui-egui/src/missing_fonts.rs +++ b/crates/ui-egui/src/missing_fonts.rs @@ -15,19 +15,26 @@ pub struct Shown { /// The document it lists: the window goes when that document closes. pub uid: u64, pub families: Vec, + /// Asked for before the installed fonts were indexed: the list comes once they are. + pub indexing: bool, } impl Shown { pub fn inspect(&self) -> Value { - json!({"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>()}) + json!({ + "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), + "indexing": self.indexing, + }) } } -/// After a file the user opened (or imported into) is in front: look for missing fonts in it -/// once that can be told ([`show`]). +/// After the user opened a file, or imported pages into one (results with a `summary`; not a +/// placed image or SVG, a file that was open already, or Clear recent): look for missing fonts in +/// it once that can be told ([`show`]). Not in the browser, which has only its built-in fonts. pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { - if result.is_some() + if !cfg!(target_arch = "wasm32") && matches!(command, "file.open" | "file.openRecent" | "file.import") + && result.is_some_and(|r| r.get("summary").is_some()) && let Ok(ds) = app.session.ds() { app.ui.font_checks.push(ds.uid); @@ -38,25 +45,26 @@ pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { fn list(app: &mut FigApp, uid: u64) -> Option> { let ds = app.session.docs.iter().find(|d| d.uid == uid)?; let families = missing(&ds.doc); - app.ui.missing_fonts = Some(Shown { uid, families: families.clone() }); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone(), indexing: false }); Some(families) } /// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. pub fn request(app: &mut FigApp) { - if let Ok(uid) = app.session.ds().map(|d| d.uid) { + let Ok(uid) = app.session.ds().map(|d| d.uid) else { return }; + if figcraft_text::installed_fonts_known() { list(app, uid); + } else { + app.ui.missing_fonts = Some(Shown { uid, families: Vec::new(), indexing: true }); } } -/// Installed fonts are indexed: before that, every one of them would count as missing. The -/// browser has only its built-in fonts, so files there don't open the window by themselves. -fn fonts_known() -> bool { - !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() -} - pub fn show(app: &mut FigApp, ctx: &egui::Context) { - if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let known = figcraft_text::installed_fonts_known(); + if let Some(uid) = app.ui.missing_fonts.as_ref().filter(|m| m.indexing && known).map(|m| m.uid) { + list(app, uid); + } + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && known { let uid = app.ui.font_checks.remove(0); if list(app, uid).is_some_and(|f| f.is_empty()) { app.ui.missing_fonts = None; @@ -71,7 +79,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut close_clicked = false; crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); - if shown.families.is_empty() { + if shown.indexing { + ui.label(tr("Checking the fonts installed on this computer…")); + } else if shown.families.is_empty() { ui.label(tr("All the fonts this file uses are installed")); } else { ui.label( diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs index ba86cc7e..2b11260a 100644 --- a/crates/ui-egui/src/missing_fonts_tests.rs +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -16,11 +16,10 @@ fn saved(name: &str, families: &[&str]) -> String { path } -/// The installed fonts are indexed, as the desktop app does at startup; false when this machine -/// opted out (nothing would ever count as known). +/// The installed fonts are indexed, as the desktop app does at startup. fn fonts_indexed() -> bool { figcraft_text::scan_system_fonts(); - figcraft_text::system_fonts_scanned() + figcraft_text::installed_fonts_known() } fn listed(u: &Ui) -> Value { @@ -42,11 +41,12 @@ fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { u.app.run("file.open", json!({"path": path})); u.frame(vec![]); assert_eq!(listed(&u), Value::Null); + u.app.run("file.close", json!({})); u.app.run_user("file.open", json!({"path": path})); std::fs::remove_file(&path).unwrap(); u.frame(vec![]); - assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}]})); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}], "indexing": false})); u.frame(vec![]); let shown = u.frame_output(vec![]).shapes; assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); @@ -99,9 +99,10 @@ fn it_waits_for_the_import_report_to_close() { #[test] fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + assert!(fonts_indexed()); let mut u = Ui::new(Session::new()); assert!(u.app.run("text.missingFonts", json!({})).is_some()); - assert_eq!(listed(&u), json!({"families": []})); + assert_eq!(listed(&u), json!({"families": [], "indexing": false})); u.frame(vec![]); let shown = u.frame_output(vec![]).shapes; assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); @@ -110,3 +111,39 @@ fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() u.frame(escape); assert_eq!(listed(&u), Value::Null, "Escape closes it"); } + +#[test] +fn asked_for_before_the_fonts_are_indexed_it_lists_them_once_they_are() { + assert!(fonts_indexed()); + let path = saved("indexing", &["FigCraft Missing Indexing"]); + let mut u = Ui::new(Session::new()); + u.app.run("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + // As Text ▸ Missing fonts leaves it while the startup scan is still running. + let uid = u.app.session.ds().unwrap().uid; + u.app.ui.missing_fonts = Some(crate::missing_fonts::Shown { uid, families: Vec::new(), indexing: true }); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2}], "indexing": false})); +} + +#[test] +fn placing_images_or_clearing_recents_does_not_bring_it_back() { + assert!(fonts_indexed()); + let path = saved("place", &["FigCraft Missing Place"]); + let svg = std::env::temp_dir().join(format!("figcraft-missing-fonts-place-{}.svg", std::process::id())); + std::fs::write(&svg, r#""#).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Place"); + u.app.ui.missing_fonts = None; + // A placed SVG, the same file opened again (it only comes to the front), Clear recent. + assert!(u.app.run_user("file.import", json!({"path": svg.to_string_lossy()})).is_some()); + assert!(u.app.run_user("file.open", json!({"path": path})).is_some()); + u.app.run_user("file.openRecent", json!({"clear": true})); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_file(&svg).unwrap(); + u.frame(vec![]); + assert!(u.app.ui.font_checks.is_empty()); + assert_eq!(listed(&u), Value::Null); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 54e2dd25..900559df 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's From 9b489a052ef963a2d601481bbd010e00fb0296ac Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:34:42 +0700 Subject: [PATCH 208/245] #776: drop the evidence log --- evidence/fix-delete-prototype-interaction.md | 64 -------------------- 1 file changed, 64 deletions(-) delete mode 100644 evidence/fix-delete-prototype-interaction.md diff --git a/evidence/fix-delete-prototype-interaction.md b/evidence/fix-delete-prototype-interaction.md deleted file mode 100644 index 54808f9e..00000000 --- a/evidence/fix-delete-prototype-interaction.md +++ /dev/null @@ -1,64 +0,0 @@ -# Evidence: Fix Delete Prototype Interaction (#767) - -## Task Summary -- **Target Issue:** upstream [#767](https://github.com/storytold/figcraft/issues/767) - "When deleting a prototype, it also deletes the component." -- **PR:** [#776](https://github.com/storytold/figcraft/pull/776) -- **Branch:** `fix/delete-prototype-interaction` -- **Head Commit:** `bc5a2b8` - -## Root Cause -When an interaction noodle or interaction editor was clicked/selected in the Prototype tab, `proto_canvas.rs` set document selection to `[n.source]` (the layer). When Delete or Backspace was pressed, `shortcuts.rs` dispatched `edit.delete` without checking if an interaction was active, deleting the component instead of the interaction connection. - -## Implemented Fix -- Added `has_selected_interaction`, `delete_selected_interaction`, and `close_editor` in `crates/ui-egui/src/proto_panel.rs`. -- Handled `Key::Delete` and `Key::Backspace` in `shortcuts::key_event` and `run_binding` when in `RightTab::Prototype` with an active interaction, removing the interaction with `proto.interaction.remove` rather than deleting the layer. -- Added canvas deselect on click in `crates/ui-egui/src/proto_canvas.rs` and tab-switch close in `crates/ui-egui/src/right.rs`. -- Handled `Key::Escape` to close the interaction editor first before stepping out / deselecting layers. -- Added comprehensive headless regression tests in `crates/ui-egui/src/proto_editor_tests.rs`. - -## Raw Verification Evidence - -### 1. Test Execution (`cargo test -p figcraft-ui-egui --lib -- proto`) -```text -running 21 tests -test proto_editor_tests::the_builder_takes_variable_operator_value_conditions ... ok -test proto_canvas::tests::curves_leave_the_facing_side ... ok -test perf_tests::prototype_sources_follow_edits_and_undo ... ok -test proto_editor_tests::deleting_a_prototype_interaction_removes_the_interaction_not_the_component ... ok -test proto_editor_tests::the_presenter_inspects_the_layer_under_the_pointer ... ok -test present::tail_tests::prototypes_without_gamepad_triggers_dont_poll ... ok -test proto_responsive_tests::custom_sizes_present_as_a_bare_screen ... ok -test present::tail_tests::widgets_react_to_clicks_in_the_presented_prototype ... ok -test proto_editor_tests::a_click_where_nothing_reacts_flashes_the_hotspots ... ok -test proto_responsive_tests::prototype_panel_shows_colours_custom_sizes_and_responsive ... ok -test proto_editor_tests::escape_closes_prototype_editor_without_deselecting_layer ... ok -test proto_editor_tests::the_interaction_editor_shows_conditionals_and_picks_their_variables ... ok -test proto_responsive_tests::library_collections_show_in_the_mode_picker_under_their_library ... ok -test proto_reorder_tests::actions_inside_a_conditional_block_drag_within_their_block ... ok -test proto_reorder_tests::escape_cancels_an_action_drag_and_keeps_the_editor_open ... ok -test proto_reorder_tests::dragging_an_action_row_reorders_the_actions_in_one_step ... ok -test field_tests::scrubbing_a_prototype_delay_sets_it_once_when_let_go ... ok -test proto_editor_tests::typed_conditions_are_checked_and_errors_show_until_fixed ... ok -test proto_responsive_tests::without_a_device_a_responsive_frame_fills_the_window_at_100_percent ... ok -test proto_responsive_tests::responsive_frames_take_the_devices_screen_live ... ok -test idle_tests::the_prototype_curve_preview_plays_then_rests ... ok - -test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 500 filtered out; finished in 0.15s -``` -Exit code: 0 -Passed: 21 -Failed: 0 -Skipped: 0 - -### 2. Format Verification (`cargo fmt --check`) -```text -Exit code: 0 -No formatting diffs found. -``` - -### 3. Git Status & PR -```text -Branch: fix/delete-prototype-interaction -Pull Request: https://github.com/storytold/figcraft/pull/776 -Target: storytold/figcraft:main -``` From d712f2ffd14c74f285433c7e99be1a32737d9db5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:34:43 +0700 Subject: [PATCH 209/245] #776: collapse the nested if in select.none (clippy) --- crates/ui-egui/src/shortcuts.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 50fee385..695457af 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -212,10 +212,10 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { pub fn run_binding(app: &mut FigApp, id: &str) { match id { "select.none" => { - if let Some(ctx) = &app.ui_context { - if crate::proto_panel::close_editor(ctx) { - return; - } + if let Some(ctx) = &app.ui_context + && crate::proto_panel::close_editor(ctx) + { + return; } if let Err(e) = app.session.escape() { app.toast(e.to_string()); From 0f8e29bc9fd804f13d957bcf9c3ab6342fd709ff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:35:34 +0700 Subject: [PATCH 210/245] #772: drop the progress-doc edits (maintainer adds them) --- ROADMAP.md | 1 - docs/ui-parity.md | 1 - 2 files changed, 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 74953dd5..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | The presenter device and scale menus follow the active UI theme. The default presentation backdrop uses the theme canvas token, while a background explicitly selected in Prototype settings remains saved. 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/docs/ui-parity.md b/docs/ui-parity.md index 188b2168..dcda2c4e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -174,7 +174,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look |---|---|---| | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | -| 2026-10-17 | minor | Presenter device and scale menus use the active theme; the default backdrop uses the theme canvas token, preserving explicit Prototype background choices. Checked in the Windows app. Estimates unchanged. | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 1768be296522671489f755e0c9c566eb3b411ef0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:35:38 +0700 Subject: [PATCH 211/245] #772: leave the presentation backdrop as the file sets it (owner decision) --- crates/doc/src/proto.rs | 11 ----------- crates/engine/src/cmd/proto.rs | 1 - crates/engine/src/proto_present_tests.rs | 15 --------------- crates/ui-egui/src/present.rs | 3 +-- crates/ui-egui/src/proto_panel.rs | 7 +------ 5 files changed, 2 insertions(+), 35 deletions(-) diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index e89a3ed7..a844774c 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -803,10 +803,6 @@ impl DeviceRotation { pub struct PrototypeSettings { pub device: String, pub background: figcraft_color::Color, - /// Distinguishes a user-selected colour from the legacy default so the presenter can use - /// its theme canvas token until a background is explicitly chosen. - #[serde(skip_serializing_if = "is_false")] - pub background_custom: bool, /// Applies to devices that turn (phones and tablets). pub rotation: DeviceRotation, /// The device's colour: one of its model's [`Device::colors`] by name (empty or one the model @@ -827,7 +823,6 @@ impl Default for PrototypeSettings { Self { device: "None".into(), background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E), - background_custom: false, rotation: DeviceRotation::Portrait, color: String::new(), custom_size: PrototypeSettings::CUSTOM_DEFAULT, @@ -864,12 +859,6 @@ pub struct Device { } impl PrototypeSettings { - /// Whether presentation should use the UI theme's canvas token. Old files only stored a - /// colour, so their original default is also treated as automatic. - pub fn uses_theme_background(&self) -> bool { - !self.background_custom && self.background == Self::default().background - } - /// The devices to pick from ("None" presents the frame at its own size). pub const DEVICES: [Device; 19] = [ Device { diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 438290e6..be6907b5 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -187,7 +187,6 @@ fn settings(s: &mut Session, p: &Value) -> Result { let mut next = prototype.clone(); if let Some(c) = bg { next.background = c; - next.background_custom = true; } if let Some(d) = dev { next.device = d; diff --git a/crates/engine/src/proto_present_tests.rs b/crates/engine/src/proto_present_tests.rs index c48f3ead..9d2bc48b 100644 --- a/crates/engine/src/proto_present_tests.rs +++ b/crates/engine/src/proto_present_tests.rs @@ -16,21 +16,6 @@ fn settings(s: &Session) -> PrototypeSettings { } } -#[test] -fn prototype_background_uses_the_theme_until_a_colour_is_chosen() { - let mut s = Session::new(); - assert!(settings(&s).uses_theme_background()); - // Choosing even the old default explicitly must keep it through a file round trip. - s.execute("proto.settings", &json!({"background": "#1E1E1E"})).unwrap(); - assert!(!settings(&s).uses_theme_background()); - let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); - let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap(); - let NodeKind::Page { prototype, .. } = &doc.node(doc.pages[0]).unwrap().kind else { panic!("not a page") }; - assert!(!prototype.uses_theme_background()); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(settings(&s).uses_theme_background()); -} - #[test] fn device_rotation_turns_phones_and_tablets_and_is_saved() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 7ce5e9b7..36eceb86 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -2088,8 +2088,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) p.clock = now; let bar = Rect::from_min_size(rect.min, vec2(rect.width(), chrome.bar_h)); let area = Rect::from_min_max(pos2(rect.min.x, bar.max.y), rect.max); - let settings = p.doc.as_ref().map(|d| page_settings(d, p.current)).unwrap_or_default(); - let bg = if settings.uses_theme_background() { Tokens::get(ui.ctx()).canvas } else { crate::theme::doc_c32(ui.ctx(), settings.background) }; + let bg = crate::theme::doc_c32(ui.ctx(), p.doc.as_ref().map(|d| page_settings(d, p.current)).unwrap_or_default().background); let r = chrome.round; ui.painter().rect_filled(area, CornerRadius { nw: 0, ne: 0, sw: r, se: r }, bg); let action = top_bar(p, ui, bar, now, chrome); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 3bec9f87..47312e99 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -401,12 +401,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { Some(NodeKind::Page { prototype, .. }) => prototype.clone(), _ => proto::PrototypeSettings::default(), }; - let bg = if settings.uses_theme_background() { - let canvas = Tokens::get(ui.ctx()).canvas; - figcraft_color::Color::from_rgb8_const(canvas.r(), canvas.g(), canvas.b()) - } else { - settings.background - }; + let bg = settings.background; let model = settings.model(); // Phones and tablets turn: a toggle beside the device picker. let rotates = model.is_some_and(|d| d.rotates); From d828e6379ef0b99d6a26670bd3f9c72ad0ab9681 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:36:06 +0700 Subject: [PATCH 212/245] #783: drop the progress-doc edits (maintainer adds them) --- ROADMAP.md | 5 ++--- docs/gaps.md | 11 +++++------ docs/localization-parity.md | 5 ++--- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ecde012e..8a587286 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-18 · **Change:** minor (French at 100% of the strings, an unreviewed draft; language table re-measured; preceding: 2026-10-17 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; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community 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) 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 @@ -103,7 +103,7 @@ native speakers' reviews of the six language drafts need people. | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | +| French | fr | 95% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | | Japanese | ja | 99% | draft (needs native review) | @@ -316,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | French at 100% of the strings (gap 5, `fr.toml`): what the strings added since the draft left in English in the French catalog — the templates and their kinds with descriptions, the home screen's recents table (sorting, "Show in folder", the dates in the French way), dropping files to open or import, the Getting started tips with sharing by code, the Playground's bound-variable chip, smart-selection spacing, outline mode's options, the converting and panel-width tail — is translated: 91 UI strings, 12 command labels and 2 menu wordings, appended to the sections in the catalog's conventions (NBSP before : ! and inside « », one/other plurals with 0 singular). Still a draft awaiting native review; the language table row and per-area docs re-measured; 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/docs/gaps.md b/docs/gaps.md index 68ce570e..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (French at 100% of the strings as an unreviewed draft; preceding: the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; French complete as an unreviewed draft; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -187,10 +187,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in `crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31 menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, - Spanish is held to completeness by the check, and French (2026-10-18) covers every string as a - draft; the German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese machine drafts - cover 95–99% (strings added since they were drafted show English), with `es-419.toml` a Latin - American overlay listing only what + Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese, + Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they + were drafted show English), with `es-419.toml` a Latin American overlay listing only what differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals) covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 8f6f748f..50fcd4ec 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (French at 100% of the strings, still an unreviewed draft; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -90,7 +90,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,272 / 2,272 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | @@ -120,7 +120,6 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | The French draft catches up to 100%: `fr.toml` translates the 91 UI strings, 12 command labels (`file.newFromTemplate`, `template.list`, `help.gettingStarted`, `component.playground`, `component.hideWhenPublishing`, `object.convertTo{Frame,Group,Section}`, `style.bind`, `view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `view.panelWidths`) and 2 menu wordings (the two outlines options) that the strings added since it was drafted left in English: starter templates and the Templates tab, the home screen's recents table with its sorting and "Show in folder", dropping files to open or import, the Getting started tips, sharing with a code, a rendered component's Playground bound-variable chip, smart-selection spacing and the outline options, and the dates on the recent cards (months abbreviated the French way, "A à Z"/"Z à A"); appended to each section in the catalog's conventions (NBSP before `:` `!` and inside « », one/other plurals with 0 counting as singular). Still a draft, awaiting native review; the language table row is re-measured | | 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | From d654a14720c5775685c218e1ff2bdc686be542d7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:37:21 +0700 Subject: [PATCH 213/245] #783: put the new French rows in their sorted places --- crates/ui-egui/locales/fr.toml | 216 ++++++++++++++++----------------- 1 file changed, 105 insertions(+), 111 deletions(-) diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 78530540..6aeaf50c 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -19,7 +19,10 @@ status = "draft — needs native review" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre" +"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée" +"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture" +"A to Z" = "A à Z" "About" = "À propos" "About FigCraft" = "À propos de FigCraft" "About this server" = "À propos de ce serveur" @@ -65,6 +68,7 @@ status = "draft — needs native review" "Add variant with a new value" = "Ajouter une variante avec une nouvelle valeur" "Added" = "Ajouté" "Added the library \"{name}\"" = "Bibliothèque « {name} » ajoutée" +"Adding to {name}" = "Ajout dans {name}" "Adjust" = "Réglages" "Adjustment presets" = "Préréglages" "Advanced" = "Avancé" @@ -90,7 +94,9 @@ status = "draft — needs native review" "All supported properties" = "Toutes les propriétés prises en charge" "All variables" = "Toutes les variables" "Alpha" = "Alpha" +"Alphabetical" = "Alphabétique" "Alt+drag" = "Alt+glisser" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau" "An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session" "Anchor" = "Ancre" "Android" = "Android" @@ -109,6 +115,8 @@ status = "draft — needs native review" "Apply variable" = "Appliquer une variable" "Apply variable to text" = "Appliquer une variable au texte" "Apply variable to visibility" = "Appliquer une variable à la visibilité" +"Apps" = "Applications" +"Apr" = "avr." "Arc" = "Arc" "Arc ratio" = "Rapport de l’arc" "Arc start" = "Début de l’arc" @@ -120,6 +128,7 @@ status = "draft — needs native review" "As typed" = "Tel que saisi" "Asking the server…" = "Interrogation du serveur…" "Assets" = "Ressources" +"Aug" = "août" "Auto" = "Auto" "Auto (space around)" = "Auto (espace autour)" "Auto (space between)" = "Auto (espace entre)" @@ -164,6 +173,7 @@ status = "draft — needs native review" "Bottom" = "Bas" "Bottom left radius" = "Rayon inférieur gauche" "Bottom right radius" = "Rayon inférieur droit" +"Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable." "Branch" = "Branche" "Branch archived" = "Branche archivée" "Branch merged" = "Branche fusionnée" @@ -250,6 +260,7 @@ status = "draft — needs native review" "Color values in this file are in this color space." = "Les valeurs de couleur de ce fichier sont dans cet espace colorimétrique." "Color variables" = "Variables de couleur" "Colour" = "Couleur" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Styles de couleur et de texte, variables Clair et Sombre, boutons, champs et cartes" "Column" = "Colonne" "Column gap" = "Espacement des colonnes" "Column sizes" = "Tailles des colonnes" @@ -286,6 +297,8 @@ status = "draft — needs native review" "Contributors" = "Contributeurs" "Contributors: {people} · Commits: {commits}" = "Contributeurs : {people} · Commits : {commits}" "Controller" = "Manette" +"Convert to frame" = "Convertir en cadre" +"Convert to section" = "Convertir en section" "Cool" = "Froid" "Copied as CSS" = "Copié en CSS" "Copied as PNG" = "Copié en PNG" @@ -319,6 +332,7 @@ status = "draft — needs native review" "Create property…" = "Créer une propriété…" "Create style" = "Créer un style" "Create style from the selected text" = "Créer un style à partir du texte sélectionné" +"Created" = "Création" "Crop" = "Recadrer" "Crop marks" = "Traits de coupe" "Ctrl+arrows" = "Ctrl+flèches" @@ -333,6 +347,9 @@ status = "draft — needs native review" "Dark" = "Sombre" "Darken" = "Assombrir" "Dash" = "Tirets" +"Dashboard" = "Tableau de bord" +"Date created" = "Date de création" +"Dec" = "déc." "Decoration style" = "Style de décoration" "Decoration" = "Décoration" "Deep select" = "Sélection en profondeur" @@ -359,6 +376,7 @@ status = "draft — needs native review" "Description" = "Description" "Description (optional)" = "Description (facultative)" "Design" = "Design" +"Design system starter" = "Base de système de design" "Desktop" = "Ordinateur" "Destination" = "Destination" "Detach alias" = "Détacher l’alias" @@ -394,8 +412,12 @@ status = "draft — needs native review" "Drafts" = "Brouillons" "Drag the stroke to add a width point" = "Faites glisser le contour pour ajouter un point de largeur" "Draw" = "Dessin" +"Draw a frame" = "Tracer un cadre" "Drawing tools" = "Outils de dessin" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image." "Drop shadow" = "Ombre portée" +"Drop to import" = "Déposer pour importer" +"Drop to open" = "Déposer pour ouvrir" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Double licence MIT OR Apache-2.0. Réalisé par l’équipe et la communauté ArtCraft." "Duo" = "Duo" "Duplicate" = "Dupliquer" @@ -412,6 +434,7 @@ status = "draft — needs native review" "Edit style" = "Modifier le style" "Edit variable" = "Modifier la variable" "Edit with the builder" = "Modifier avec l’assistant" +"Edited {when}" = "Modifié {when}" "Effect settings" = "Réglages de l’effet" "Effect styles" = "Styles d’effet" "Effects" = "Effets" @@ -442,6 +465,7 @@ status = "draft — needs native review" "Expand" = "Développer" "Export" = "Exporter" "Export all" = "Tout exporter" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporter les cadres et les calques en PNG, JPG, SVG ou PDF" "Export page as PNG" = "Exporter la page en PNG" "Export settings" = "Réglages d’exportation" "Export variables" = "Exporter les variables" @@ -461,11 +485,13 @@ status = "draft — needs native review" "F" = "F" "Fade" = "Fondu" "False" = "Faux" +"Feb" = "févr." "Fetching from the server…" = "Récupération depuis le serveur…" "FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé." "File" = "Fichier" "File name" = "Nom du fichier" +"File not found" = "Fichier introuvable" "File published" = "Fichier publié" "Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire." "Files nobody opens for {n} days are deleted." = { one = "Les fichiers que personne n’ouvre pendant {n} jour sont supprimés.", other = "Les fichiers que personne n’ouvre pendant {n} jours sont supprimés." } @@ -512,6 +538,7 @@ status = "draft — needs native review" "Font size" = "Taille de police" "Font style" = "Style de police" "Forget on this computer" = "Oublier sur cet ordinateur" +"Four screens of a trail app with components and a prototype flow" = "Quatre écrans d’une application de randonnée avec des composants et un flux de prototype" "Fractions" = "Fractions" "Frame" = "Cadre" "Frame selection" = "Encadrer la sélection" @@ -525,6 +552,7 @@ status = "draft — needs native review" "Gap between columns" = "Espacement entre les colonnes" "Gap between items" = "Espacement entre les éléments" "Gap between rows" = "Espacement entre les lignes" +"Getting started" = "Premiers pas" "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indiquez l’adresse d’un serveur ouvert au public, ou l’adresse d’un serveur avec sa clé (`figcraft-server key` l’affiche)." "Glass" = "Verre" "Go to main component" = "Aller au composant principal" @@ -533,6 +561,7 @@ status = "draft — needs native review" "Gradient" = "Dégradé" "Gray" = "Gris" "Green" = "Vert" +"Greyscale components and two example screens to sketch a flow with" = "Composants en niveaux de gris et deux écrans d’exemple pour esquisser un flux" "Grid" = "Grille" "Grid ({size})" = "Grille ({size})" "Grid styles" = "Styles de grille" @@ -554,6 +583,8 @@ status = "draft — needs native review" "Hide contents" = "Masquer le contenu" "Hide from publishing" = "Exclure de la publication" "Hide section contents" = "Masquer le contenu de la section" +"Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)" +"Hide when publishing" = "Masquer à la publication" "Highlight layers on hover" = "Mettre en évidence les calques au survol" "Highlights" = "Hautes lumières" "Hold Alt" = "Maintenir Alt" @@ -575,6 +606,8 @@ status = "draft — needs native review" "Hue" = "Teinte" "Hug" = "Ajuster" "Hug contents" = "Ajuster au contenu" +"Icon components on a 24 px grid with keylines and a size preview" = "Composants d’icônes sur une grille de 24 px avec repères et aperçu de la taille" +"Icon set starter" = "Base de jeu d’icônes" "If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message." "If" = "Si" "If {condition}" = "Si {condition}" @@ -624,6 +657,7 @@ status = "draft — needs native review" "Invite link" = "Lien d’invitation" "Invite link copied" = "Lien d’invitation copié" "It asks permission to:" = "Il demande l’autorisation de :" +"Jan" = "janv." "Join" = "Rejoindre" "Join code copied" = "Code d’accès copié" "Join code or invite link" = "Code d’accès ou lien d’invitation" @@ -634,6 +668,8 @@ status = "draft — needs native review" "Joined {file}" = "Connecté à {file}" "Joining…" = "Connexion…" "Join|stroke" = "Jointure" +"Jul" = "juil." +"Jun" = "juin" "Just now" = "À l’instant" "Justify" = "Justifier" "Keep appearance" = "Conserver l’apparence" @@ -643,11 +679,14 @@ status = "draft — needs native review" "Key/Gamepad" = "Touche/manette" "Key/Gamepad {key}" = "Touche/manette {key}" "Keyboard shortcuts" = "Raccourcis clavier" +"Kits" = "Kits" +"Landing page" = "Page de destination" "Language" = "Langue" "Lasso" = "Lasso" "Last" = "Dernier" "Last change: {note}" = "Dernière modification : {note}" "Last commit" = "Dernier commit" +"Last modified" = "Dernière modification" "Last on top" = "Dernier au-dessus" "Layer blur" = "Flou de calque" "Layer name" = "Nom du calque" @@ -686,6 +725,7 @@ status = "draft — needs native review" "Link" = "Lien" "Link copied" = "Lien copié" "Link docs, code or tickets for developers." = "Liez de la documentation, du code ou des tickets pour les développeurs." +"Link frames on the Prototype tab, then present the flow" = "Reliez les cadres dans l’onglet Prototype, puis présentez le flux" "Link this component to its code component: instances then show your real snippet with their values." = "Reliez ce composant à son composant de code : les instances affichent alors votre véritable extrait avec leurs valeurs." "List spacing" = "Espacement de liste" "List style" = "Style de liste" @@ -703,7 +743,9 @@ status = "draft — needs native review" "Main" = "Principal" "Main component deleted" = "Composant principal supprimé" "Main menu" = "Menu principal" +"Make a component" = "Créer un composant" "Make slot" = "Créer un emplacement" +"Mar" = "mars" "Margin" = "Marge" "Mark all as read" = "Tout marquer comme lu" "Mark as resolved" = "Marquer comme résolu" @@ -716,6 +758,7 @@ status = "draft — needs native review" "Max W" = "L max." "Max lines" = "Lignes max." "Maximize" = "Agrandir" +"May" = "mai" "Measure to the selection" = "Mesurer jusqu’à la sélection" "Merge into main" = "Fusionner dans le principal" "Merge regions" = "Fusionner les régions" @@ -737,6 +780,7 @@ status = "draft — needs native review" "Mixed" = "Mixte" "Mixed fonts" = "Polices mixtes" "Mixed interactions: click + to replace them" = "Interactions mixtes : cliquez sur + pour les remplacer" +"Mobile app" = "Application mobile" "Mode" = "Mode" "Model" = "Modèle" "Models" = "Modèles" @@ -766,6 +810,7 @@ status = "draft — needs native review" "Navigate to" = "Naviguer vers" "Nested instance" = "Instance imbriquée" "New design file" = "Nouveau fichier de design" +"Newest first" = "Plus récent d’abord" "Next frame" = "Cadre suivant" "Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active" "Next ready for dev" = "Prêt pour le dev suivant" @@ -799,6 +844,7 @@ status = "draft — needs native review" "No model credits were built into this copy." = "Cette copie ne contient aucun crédit de modèle." "No number variables fit here." = "Aucune variable numérique ne convient ici." "No number variables yet. Create them in Local variables." = "Aucune variable numérique pour l’instant. Créez-en dans Variables locales." +"No other pages" = "Aucune autre page" "No paints" = "Aucune peinture" "No plugins on {server}." = "Aucun plugin sur {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aucun plugin pour l’instant. Importez un plugin en développement depuis son figcraft-plugin.json." @@ -812,6 +858,7 @@ status = "draft — needs native review" "No string variables yet. Create them in Local variables." = "Aucune variable de chaîne pour l’instant. Créez-en dans Variables locales." "No styles yet." = "Aucun style pour l’instant." "No styles yet. Click + to create one from the selection." = "Aucun style pour l’instant. Cliquez sur + pour en créer un à partir de la sélection." +"No templates match your search" = "Aucun modèle ne correspond à votre recherche" "No text styles yet. Click + to create one from the selected text." = "Aucun style de texte pour l’instant. Cliquez sur + pour en créer un à partir du texte sélectionné." "No variables fit here." = "Aucune variable ne convient ici." "No variables yet. Create them in Local variables." = "Aucune variable pour l’instant. Créez-en dans Variables locales." @@ -831,6 +878,7 @@ status = "draft — needs native review" "Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici." "Nothing to export" = "Rien à exporter" +"Nov" = "nov." "Now" = "Maintenant" "Nudge (Shift: big nudge)" = "Décaler (Maj : grand décalage)" "Nudge amount" = "Pas de décalage" @@ -839,13 +887,16 @@ status = "draft — needs native review" "Number ↓" = "Numéro ↓" "Numbered list" = "Liste numérotée" "Object" = "Objet" +"Oct" = "oct." "Off" = "Non" "Offered first when filling the slot (any content is allowed)" = "Proposés en premier pour remplir l’emplacement (tout contenu est autorisé)" "Offered first when swapping the instance (any component is allowed)" = "Proposés en premier pour permuter l’instance (tout composant est autorisé)" "Offset" = "Décalage" +"Oldest first" = "Plus ancien d’abord" "On click" = "Au clic" "On drag" = "Au glissement" "On this page" = "Sur cette page" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière" "Online" = "En ligne" "Online files" = "Fichiers en ligne" "Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local." @@ -870,9 +921,13 @@ status = "draft — needs native review" "Open variables ({count})" = "Ouvrir les variables ({count})" "Opened as a new document" = "Ouvert comme nouveau document" "Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet" +"Opening the file…" = "Ouverture du fichier…" +"Opening {n} files as new files" = { one = "Ouverture comme nouveau fichier", other = "Ouverture de {n} fichiers comme nouveaux fichiers" } "Open|comments" = "Ouverts" "Options" = "Options" "Orange" = "Orange" +"Order" = "Ordre" +"Other" = "Autre" "Other apps may not open every .fig file exported here." = "D’autres applications risquent de ne pas ouvrir tous les fichiers .fig exportés ici." "Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous" "Others type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code" @@ -893,6 +948,7 @@ status = "draft — needs native review" "Page" = "Page" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" "Placed on the current page" = "Placé sur la page actuelle" "Pages: {pages}" = "Pages : {pages}" "Paint bucket" = "Pot de peinture" @@ -930,7 +986,10 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "Placer une forme ou un cadre (outil choisi)" "Place automatically" = "Placer automatiquement" "Place image" = "Placer une image" +"Playground" = "Bac à sable" +"Plugin" = "Plugin" "Plugins" = "Plugins" +"Plugins & widgets" = "Plugins et widgets" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Les plugins s’exécutent dans le bac à sable de FigCraft, sans fichiers, réseau ni horloge. N’installez que des plugins de confiance." "Plus darker" = "Plus foncé" "Plus lighter" = "Plus clair" @@ -947,6 +1006,7 @@ status = "draft — needs native review" "Present or preview" = "Présenter ou prévisualiser" "Present this flow" = "Présenter ce flux" "Presentation" = "Présentation" +"Presentation slides" = "Diapositives de présentation" "Presenting: comments" = "Présentation : commentaires" "Presenting: cycle the size options" = "Présentation : parcourir les options de taille" "Presenting: full screen" = "Présentation : plein écran" @@ -1003,6 +1063,7 @@ status = "draft — needs native review" "Remove auto layout" = "Retirer la mise en page auto" "Remove dev resource" = "Retirer la ressource de développement" "Remove export setting" = "Retirer le réglage d’exportation" +"Remove from recents" = "Retirer des récents" "Remove from server" = "Retirer du serveur" "Remove from server…" = "Retirer du serveur…" "Remove guide" = "Retirer le repère" @@ -1115,15 +1176,18 @@ status = "draft — needs native review" "Search layers" = "Rechercher des calques" "Search plugins" = "Rechercher des plugins" "Search shortcuts" = "Rechercher des raccourcis" +"Search templates" = "Rechercher des modèles" "Section" = "Section" "Section color" = "Couleur de la section" "Sections" = "Sections" +"See all templates" = "Voir tous les modèles" "Select a frame and add a starting point to present it as a flow." = "Sélectionnez un cadre et ajoutez un point de départ pour le présenter comme un flux." "Select a frame to preview it" = "Sélectionnez un cadre pour le prévisualiser" "Select a layer to see its properties and code" = "Sélectionnez un calque pour voir ses propriétés et son code" "Select all" = "Tout sélectionner" "Select all with same" = "Sélectionner tout ce qui a le même" "Select layer" = "Sélectionner le calque" +"Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change" "Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style" "Select layers with this color" = "Sélectionner les calques de cette couleur" "Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent" @@ -1136,6 +1200,7 @@ status = "draft — needs native review" "Send backward" = "Reculer" "Send report" = "Envoyer le signalement" "Send to back" = "Mettre à l’arrière-plan" +"Sep" = "sept." "Server address" = "Adresse du serveur" "Server address (ws://host:47900/)" = "Adresse du serveur (ws://host:47900/)" "Server for online sharing" = "Serveur pour le partage en ligne" @@ -1150,6 +1215,7 @@ status = "draft — needs native review" "Shape tools" = "Outils de forme" "Share" = "Partager" "Share this file" = "Partager ce fichier" +"Share with a code" = "Partager avec un code" "Share: live session, export" = "Partager : session en direct, exportation" "Shared" = "Partagé" "Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier" @@ -1159,6 +1225,7 @@ status = "draft — needs native review" "Sharing online · {people}" = "Partage en ligne · {people}" "Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul" "Show" = "Afficher" +"Show all templates" = "Afficher tous les modèles" "Show as grid" = "Afficher en grille" "Show as list" = "Afficher en liste" "Show autosaves" = "Afficher les sauvegardes auto" @@ -1167,6 +1234,9 @@ status = "draft — needs native review" "Show device frame" = "Afficher le cadre de l’appareil" "Show dimensions on objects" = "Afficher les dimensions sur les objets" "Show hotspot hints" = "Afficher les zones interactives" +"Show in Explorer" = "Afficher dans l’Explorateur" +"Show in Finder" = "Afficher dans le Finder" +"Show in folder" = "Afficher dans le dossier" "Show only this on the canvas" = "Afficher uniquement ceci sur le canevas" "Show open" = "Afficher les ouverts" "Show section contents" = "Afficher le contenu de la section" @@ -1191,13 +1261,17 @@ status = "draft — needs native review" "Small caps" = "Petites capitales" "Smart animate" = "Animation intelligente" "Snap to pixel grid" = "Aimanter à la grille de pixels" +"Social and slides" = "Réseaux sociaux et diapositives" "Social media" = "Réseaux sociaux" +"Social post sizes" = "Formats de publications sociales" "Soft light" = "Lumière douce" "Solid" = "Continu" "Some variants have the same property values: give each one a unique combination" = "Certaines variantes ont les mêmes valeurs de propriété : donnez à chacune une combinaison unique" "Sort" = "Trier" +"Sort by" = "Trier par" "Sort by date" = "Trier par date" "Sort by page" = "Trier par page" +"Sort by {column}" = "Trier par {column}" "Space around" = "Espace autour" "Space between" = "Espace entre" "Space drag" = "Espace et glisser" @@ -1216,9 +1290,11 @@ status = "draft — needs native review" "Start" = "Début" "Start ascending from / stop descending at" = "Compter vers le haut depuis / vers le bas jusqu’à" "Start from a blank canvas" = "Commencer avec un canevas vide" +"Start from a template" = "Commencer avec un modèle" "Start on path" = "Début sur le tracé" "Start point" = "Point de départ" "Start sharing" = "Commencer le partage" +"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" "Starting to share…" = "Démarrage du partage…" "Status" = "Statut" "Sticky" = "Collant" @@ -1267,6 +1343,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "Indiquez à l’opérateur du serveur ce qui ne va pas" "Temperature" = "Température" "Template" = "Modèle" +"Templates" = "Modèles" "Terms" = "Conditions" "Text" = "Texte" "Text align" = "Alignement du texte" @@ -1276,6 +1353,9 @@ status = "draft — needs native review" "Texts" = "Textes" "Texture" = "Texture" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Le canevas affiche les couleurs en sRGB ; les fichiers PNG, JPG et WebP exportés sont balisés Display P3." +"The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé." +"The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé." +"The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis celui-ci, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont." "The file now holds another library" = "Le fichier contient désormais une autre bibliothèque" "The file now holds another library (\"{name}\")" = "Le fichier contient désormais une autre bibliothèque (« {name} »)" @@ -1324,6 +1404,8 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "Tronquer avec des points de suspension" "Try again" = "Réessayer" "Try another search or filter." = "Essayez une autre recherche ou un autre filtre." +"Try another word, or look at every kind." = "Essayez un autre mot, ou regardez chaque type." +"Turn a layer into a component, reuse it, and change every copy at once" = "Transformez un calque en composant, réutilisez-le et modifiez toutes les copies d’un coup" "Two digits" = "Deux chiffres" "Type" = "Type" "Type settings" = "Réglages typographiques" @@ -1362,6 +1444,7 @@ status = "draft — needs native review" "Use the layer's fill" = "Utiliser le remplissage du calque" "Used in {files} · {instances}" = "Utilisé dans {files} · {instances}" "Username" = "Nom d’utilisateur" +"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode" "Value" = "Valeur" "Variable" = "Variable" "Variable collections" = "Collections de variables" @@ -1400,21 +1483,25 @@ status = "draft — needs native review" "Web" = "Web" "WebSocket URL" = "URL WebSocket" "WebSocket URL copied" = "URL WebSocket copiée" +"Websites" = "Sites web" "Wedge" = "Coin" "Weight" = "Graisse" "What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev" "What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement." "What it's for" = "À quoi il sert" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant." +"What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier" "While hovering" = "Pendant le survol" "While pressing" = "Pendant l’appui" "White" = "Blanc" "Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers" "Why it changed" = "Pourquoi cela a changé" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Largeur" "Width point" = "Point de largeur" "Width profile" = "Profil de largeur" +"Wireframe kit" = "Kit de wireframes" "With the layers' export settings" = "Avec les réglages d’exportation des calques" "Wrap" = "Renvoi à la ligne" "Wrap in new section" = "Envelopper dans une nouvelle section" @@ -1429,6 +1516,7 @@ status = "draft — needs native review" "Your name in comments" = "Votre nom dans les commentaires" "Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées." "Z" = "Z" +"Z to A" = "Z à A" "Zoom" = "Zoom" "Zoom around the pointer" = "Zoomer autour du pointeur" "Zoom device to fill window" = "Zoomer l’appareil pour remplir la fenêtre" @@ -1492,6 +1580,7 @@ status = "draft — needs native review" "invalid internal .fig text path data was ignored" = "les données internes non valides de texte sur tracé du .fig ont été ignorées" "invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés" "invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées" +"just now" = "à l’instant" "key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés" "key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche" "layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent" @@ -1559,9 +1648,11 @@ status = "draft — needs native review" "very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés" "video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées" "video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés" +"yesterday" = "hier" "{instances} in {files}" = "{instances} dans {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif." "{length} bleed" = "fond perdu de {length}" +"{month} {day}, {year}" = "{day} {month} {year}" "{name}\nClick to apply to the selection" = "{name}\nCliquez pour l’appliquer à la sélection" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nFaites glisser sur le canevas, ou cliquez pour le placer au centre de la vue.\nMaintenez Alt (Option) en le déposant sur une instance pour la permuter." "{name} (from {library})" = "{name} (de {library})" @@ -1637,99 +1728,6 @@ status = "draft — needs native review" "⌘ scroll" = "⌘ défilement" "⌥ drag" = "⌥ glisser" -# Strings translated since the draft (2026-10-18), appended; a native review is still pending. -"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page" -"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture" -"A to Z" = "A à Z" -"Adding to {name}" = "Ajout dans {name}" -"Alphabetical" = "Alphabétique" -"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau" -"Apps" = "Applications" -"Apr" = "avr." -"Aug" = "août" -"Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable." -"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Styles de couleur et de texte, variables Clair et Sombre, boutons, champs et cartes" -"Convert to frame" = "Convertir en cadre" -"Convert to section" = "Convertir en section" -"Created" = "Création" -"Dashboard" = "Tableau de bord" -"Date created" = "Date de création" -"Dec" = "déc." -"Design system starter" = "Base de système de design" -"Draw a frame" = "Tracer un cadre" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image." -"Drop to import" = "Déposer pour importer" -"Drop to open" = "Déposer pour ouvrir" -"Edited {when}" = "Modifié {when}" -"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporter les cadres et les calques en PNG, JPG, SVG ou PDF" -"Feb" = "févr." -"File not found" = "Fichier introuvable" -"Four screens of a trail app with components and a prototype flow" = "Quatre écrans d’une application de randonnée avec des composants et un flux de prototype" -"Getting started" = "Premiers pas" -"Greyscale components and two example screens to sketch a flow with" = "Composants en niveaux de gris et deux écrans d’exemple pour esquisser un flux" -"Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)" -"Hide when publishing" = "Masquer à la publication" -"Icon components on a 24 px grid with keylines and a size preview" = "Composants d’icônes sur une grille de 24 px avec repères et aperçu de la taille" -"Icon set starter" = "Base de jeu d’icônes" -"Jan" = "janv." -"Jul" = "juil." -"Jun" = "juin" -"Kits" = "Kits" -"Landing page" = "Page de destination" -"Last modified" = "Dernière modification" -"Link frames on the Prototype tab, then present the flow" = "Reliez les cadres dans l’onglet Prototype, puis présentez le flux" -"Make a component" = "Créer un composant" -"Mar" = "mars" -"May" = "mai" -"Mobile app" = "Application mobile" -"Newest first" = "Plus récent d’abord" -"No other pages" = "Aucune autre page" -"No templates match your search" = "Aucun modèle ne correspond à votre recherche" -"Nov" = "nov." -"Oct" = "oct." -"Oldest first" = "Plus ancien d’abord" -"One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière" -"Opening the file…" = "Ouverture du fichier…" -"Opening {n} files as new files" = { one = "Ouverture comme nouveau fichier", other = "Ouverture de {n} fichiers comme nouveaux fichiers" } -"Order" = "Ordre" -"Other" = "Autre" -"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" -"Playground" = "Bac à sable" -"Plugin" = "Plugin" -"Plugins & widgets" = "Plugins et widgets" -"Presentation slides" = "Diapositives de présentation" -"Remove from recents" = "Retirer des récents" -"Search templates" = "Rechercher des modèles" -"See all templates" = "Voir tous les modèles" -"Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change" -"Sep" = "sept." -"Share with a code" = "Partager avec un code" -"Show all templates" = "Afficher tous les modèles" -"Show in Explorer" = "Afficher dans l’Explorateur" -"Show in Finder" = "Afficher dans le Finder" -"Show in folder" = "Afficher dans le dossier" -"Social and slides" = "Réseaux sociaux et diapositives" -"Social post sizes" = "Formats de publications sociales" -"Sort by" = "Trier par" -"Sort by {column}" = "Trier par {column}" -"Start from a template" = "Commencer avec un modèle" -"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" -"Templates" = "Modèles" -"The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé." -"The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé." -"The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé." -"Try another word, or look at every kind." = "Essayez un autre mot, ou regardez chaque type." -"Turn a layer into a component, reuse it, and change every copy at once" = "Transformez un calque en composant, réutilisez-le et modifiez toutes les copies d’un coup" -"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode" -"Websites" = "Sites web" -"What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier" -"Widget" = "Widget" -"Wireframe kit" = "Kit de wireframes" -"Z to A" = "Z à A" -"just now" = "à l’instant" -"yesterday" = "hier" -"{month} {day}, {year}" = "{day} {month} {year}" - [commands] "align.bottom" = "Aligner en bas" "align.hcenter" = "Aligner les centres horizontalement" @@ -1819,8 +1817,10 @@ status = "draft — needs native review" "component.editProperty" = "Modifier la propriété de composant" "component.exposeInstance" = "Exposer l’instance imbriquée" "component.goToMain" = "Aller au composant principal" +"component.hideWhenPublishing" = "Masquer à la publication" "component.list" = "Lister les composants" "component.makeSlot" = "Créer un emplacement" +"component.playground" = "Bac à sable de composant" "component.properties" = "Propriétés du composant" "component.pushChanges" = "Transmettre les modifications au composant principal" "component.removeSlot" = "Retirer l’emplacement" @@ -1879,6 +1879,7 @@ status = "draft — needs native review" "file.exportSvg" = "Exporter la sélection en SVG" "file.import" = "Importer" "file.new" = "Nouveau fichier de design" +"file.newFromTemplate" = "Nouveau à partir d’un modèle" "file.open" = "Ouvrir" "file.openRecent" = "Ouvrir un fichier récent" "file.placeImage" = "Placer une image" @@ -1906,6 +1907,7 @@ status = "draft — needs native review" "guide.remove" = "Retirer le repère" "guide.select" = "Sélectionner le repère" "help.about" = "À propos de FigCraft" +"help.gettingStarted" = "Premiers pas" "help.page" = "Page d’aide" "help.shortcuts" = "Raccourcis clavier" "image.adjust" = "Régler l’image" @@ -1959,6 +1961,9 @@ status = "draft — needs native review" "node.reparent" = "Déplacer le calque dans" "node.set" = "Définir les propriétés" "object.batchRename" = "Renommer par lot" +"object.convertToFrame" = "Convertir en cadre" +"object.convertToGroup" = "Convertir en groupe" +"object.convertToSection" = "Convertir en section" "object.flipH" = "Retourner horizontalement" "object.flipV" = "Retourner verticalement" "object.frameSelection" = "Encadrer la sélection" @@ -2079,6 +2084,7 @@ status = "draft — needs native review" "stroke.set" = "Définir le contour" "stroke.widthProfile" = "Profil de largeur" "style.apply" = "Appliquer le style" +"style.bind" = "Lier une variable dans un style" "style.create" = "Créer un style" "style.delete" = "Supprimer le style" "style.detach" = "Détacher le style" @@ -2087,6 +2093,7 @@ status = "draft — needs native review" "style.move" = "Déplacer les styles" "style.renameFolder" = "Renommer le dossier de styles" "style.update" = "Modifier le style" +"template.list" = "Lister les modèles" "text.alignCenter" = "Centrer" "text.alignJustify" = "Justifier" "text.alignLeft" = "Aligner à gauche" @@ -2252,8 +2259,11 @@ status = "draft — needs native review" "view.nextPage" = "Page suivante" "view.nextRegion" = "Activer la région suivante" "view.outlines" = "Contours" +"view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours" +"view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours" "view.outlinesSelection" = "Contours de la sélection" "view.pan" = "Faire défiler la vue" +"view.panelWidths" = "Réinitialiser la largeur des panneaux" "view.pixelGrid" = "Grille de pixels" "view.pixelPreview" = "Aperçu des pixels" "view.prevFrame" = "Zoomer sur le cadre précédent" @@ -2286,20 +2296,6 @@ status = "draft — needs native review" "window.focus" = "Mettre la fenêtre au premier plan" "window.new" = "Nouvelle fenêtre" -# Commands translated since the draft (2026-10-18), appended. -"component.hideWhenPublishing" = "Masquer à la publication" -"component.playground" = "Bac à sable de composant" -"file.newFromTemplate" = "Nouveau à partir d’un modèle" -"help.gettingStarted" = "Premiers pas" -"object.convertToFrame" = "Convertir en cadre" -"object.convertToGroup" = "Convertir en groupe" -"object.convertToSection" = "Convertir en section" -"style.bind" = "Lier une variable dans un style" -"template.list" = "Lister les modèles" -"view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours" -"view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours" -"view.panelWidths" = "Réinitialiser la largeur des panneaux" - [menu] "bool.exclude" = "Exclure" "bool.flatten" = "Aplatir" @@ -2325,12 +2321,10 @@ status = "draft — needs native review" "view.design" = "Design" "view.layers" = "Calques" "view.outlines" = "Afficher les contours" +"view.outlinesHiddenLayers" = "Inclure les calques masqués" +"view.outlinesObjectBounds" = "Inclure les limites des objets" "view.outlinesSelection" = "Afficher les contours de la sélection" "view.prototype" = "Prototype" "view.themeDark" = "Sombre" "view.themeLight" = "Clair" "view.themeSystem" = "Suivre le système" - -# Menu wordings translated since the draft (2026-10-18), appended. -"view.outlinesHiddenLayers" = "Inclure les calques masqués" -"view.outlinesObjectBounds" = "Inclure les limites des objets" From 6a334fa3541f364aebe786bd97949bdb04ac6ce2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:37:58 +0700 Subject: [PATCH 214/245] #765: drop the progress-doc edits (maintainer adds them) --- ROADMAP.md | 3 +-- docs/gaps.md | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3732103c..8a587286 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 (Home search across known files and starter templates; 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-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) 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 @@ -316,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state. Local results reuse recent-file previews and file-type icons; no server-wide query. 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/docs/gaps.md b/docs/gaps.md index 85ed33ad..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Home search across known files and starter templates; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -444,7 +444,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar searches known open, recent and online files plus starter templates. Accounts and server-wide discovery remain in gap 2; estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From f90becadab1085c60e1d0dcd17d176597759889e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:52:32 +0700 Subject: [PATCH 215/245] #765: search results use Home's own Open files rows and Recent files section (the full list stays known, so pictures and the selection survive a search); search_grid, SearchHit, prepare_search and search_file_card go; the field is widgets::text_input and Escape clears it --- crates/ui-egui/src/home.rs | 158 +++++++---------------------- crates/ui-egui/src/home_recents.rs | 72 ++++--------- crates/ui-egui/src/home_tests.rs | 56 +++++++++- 3 files changed, 107 insertions(+), 179 deletions(-) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 77d1a1a3..236e2786 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -4,7 +4,6 @@ //! the documents themselves, searched and filtered by kind). use std::collections::BTreeMap; -use std::path::Path; use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_engine::templates::{Kind, TEMPLATES, Template}; @@ -12,7 +11,6 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::home_recents::SearchHit; use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; @@ -207,35 +205,16 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); y += 48.0; - let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); + let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { - ui.painter().rect_filled(search, CornerRadius::same(5), t.field); - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(search.min.x + 18.0, search.center().y), vec2(16.0, 16.0)), - Icon::Search, - t.icon_secondary, - ); - let text_rect = Rect::from_min_max(pos2(search.min.x + 36.0, search.center().y - 8.0), pos2(search.max.x - 8.0, search.center().y + 8.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect)); - let response = child.add( - egui::TextEdit::singleline(&mut app.ui.home_view.file_query) - .id(Id::new("home_file_search")) - .frame(egui::Frame::NONE) - .font(regular(12.0)) - .hint_text(tr("Search files and templates")) - .vertical_align(egui::Align::Center) - .desired_width(text_rect.width()), - ); - widgets::select_all_on_focus(ui, &response, &app.ui.home_view.file_query); + let response = + widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates")); a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); - if response.has_focus() { - ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); - } else if ui.rect_contains_pointer(search) { - ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.home_view.file_query.clear(); } }); - y += 32.0 + 4.0; + y += widgets::FIELD_H + 12.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); @@ -259,78 +238,13 @@ fn file_matches(name: &str, location: &str, query: &str) -> bool { name.to_lowercase().contains(query) || location.to_lowercase().contains(query) } -/// An online search result with a name and optional location on separate lines. Local files use -/// the Recent files cards below so their stored preview and file-type icon are also shown here. -fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), 64.0), Sense::hover()); - let resp = ui.interact(r, id, Sense::click()); - a11y::button(&resp, row.name); - ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.field }); - ui.painter().rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); - focus_ring(ui, t, r, &resp, 8); - if let Some((icon, tint)) = row.mark { - let mark = Rect::from_center_size(pos2(r.min.x + 29.0, r.center().y), vec2(20.0, 20.0)); - ui.painter().rect_filled(mark, CornerRadius::same(4), t.field); - icons::paint(ui.painter(), mark.shrink(2.0), icon, tint); - } - let width = (r.width() - 78.0).max(1.0); - let name = widgets::elide_font(ui, row.name, width, semibold(13.0)); - let name_y = if row.detail.is_empty() { r.center().y } else { r.min.y + 23.0 }; - ui.painter().text(pos2(r.min.x + 58.0, name_y), Align2::LEFT_CENTER, name, semibold(13.0), row.ink); - if !row.detail.is_empty() { - let detail = widgets::elide_start_font(ui, row.detail, width, regular(row.detail_size)); - ui.painter().text(pos2(r.min.x + 58.0, r.min.y + 44.0), Align2::LEFT_CENTER, detail, regular(row.detail_size), row.detail_ink); - } - if !row.detail.is_empty() { resp.on_hover_text(row.detail).clicked() } else { resp.clicked() } -} - /// One search over known files and starter templates. It never sends a query or owner token to a /// server; online files are opened through their existing owner-token command only on click. fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Search results")); let query = app.ui.home_view.file_query.trim().to_lowercase(); - let mut count = 0; - let mut switch = None; - let open_paths: Vec<&str> = app.session.docs.iter().filter_map(|d| d.path.as_deref()).collect(); - - let open: Vec<_> = - app.session.docs.iter().enumerate().filter(|(_, d)| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), &query)).collect(); - let open_hits: Vec = - open.iter().map(|(_, d)| SearchHit { key: format!("open:{}", d.uid), path: d.path.clone(), name: d.doc.name.clone() }).collect(); - - let recent: Vec<(String, String)> = app - .session - .prefs - .recent_files - .iter() - .filter(|p| figcraft_engine::file_access::allowed(p, figcraft_engine::file_access::Access::Read)) - .filter(|p| !open_paths.contains(&p.as_str())) - .filter_map(|path| { - let p = Path::new(path); - let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); - file_matches(&name, path, &query).then(|| (path.clone(), name)) - }) - .collect(); - let recent_hits: Vec = - recent.iter().map(|(path, name)| SearchHit { key: format!("recent:{path}"), path: Some(path.clone()), name: name.clone() }).collect(); - let preview_paths: Vec = open_hits.iter().chain(&recent_hits).filter_map(|hit| hit.path.clone()).collect(); - let reduce_motion = app.reduce_motion(); - app.ui.home_view.recents.prepare_search(ui.ctx(), &preview_paths, ui.input(|i| i.time)); - if !open_hits.is_empty() { - subheading(ui, t, tr("Open files")); - count += open_hits.len(); - if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &open_hits, reduce_motion) { - switch = open.get(hit).map(|(index, _)| *index); - } - } - let mut open_recent = None; - if !recent_hits.is_empty() { - subheading(ui, t, tr("Recent files")); - count += recent_hits.len(); - if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &recent_hits, reduce_motion) { - open_recent = recent_hits.get(hit).and_then(|hit| hit.path.clone()); - } - } + let mut count = open_files(app, ui, t, &query); + count += crate::home_recents::section(app, ui, t, &query); let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect(); let mut open_online = None; @@ -348,10 +262,9 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { detail_size: 11.0, detail_ink: t.text_secondary, }; - if search_file_card(ui, t, ui.id().with(("search_online", &f.server, &f.file)), &row) { + if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) { open_online = Some(f.clone()); } - ui.add_space(8.0); } } let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect(); @@ -369,14 +282,7 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { app.ui.home_view.section = Section::Templates; } } - if let Some(index) = switch { - app.run("doc.switch", json!({"index": index})); - app.ui.home = false; - } else if let Some(path) = open_recent { - if app.run_user("file.open", json!({"path": path})).is_some() { - app.ui.home = false; - } - } else if let Some(file) = open_online { + if let Some(file) = open_online { crate::online::open(app, &file); } } @@ -455,22 +361,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { ui.add_space(28.0); getting_started(app, ui, t); } - if !app.session.docs.is_empty() { - subheading(ui, t, tr("Open files")); - let mut switch = None; - for (i, d) in app.session.docs.iter().enumerate() { - let detail = d.path.as_deref().unwrap_or_default(); - let row = - FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; - if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { - switch = Some(i); - } - } - if let Some(i) = switch { - app.run("doc.switch", json!({"index": i})); - app.ui.home = false; - } - } + open_files(app, ui, t, ""); if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() { // A first run: nothing to reopen yet, so offer something to start from. subheading(ui, t, tr("Start from a template")); @@ -482,7 +373,32 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } return; } - crate::home_recents::section(app, ui, t); + crate::home_recents::section(app, ui, t, ""); +} + +/// The Open files list: the documents whose name or path has `query` (lower case; all for ""), a +/// row each; choosing one shows its tab. How many are listed. +fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { + let hit = |d: &&figcraft_engine::DocState| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), query); + let count = app.session.docs.iter().filter(hit).count(); + if count == 0 { + return 0; + } + subheading(ui, t, tr("Open files")); + let mut switch = None; + for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) { + let detail = d.path.as_deref().unwrap_or_default(); + let row = + FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; + if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { + switch = Some(i); + } + } + if let Some(i) = switch { + app.run("doc.switch", json!({"index": i})); + app.ui.home = false; + } + count } /// The file joined from the home screen (a code, or one of your online files) opened in a tab of diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 8ccc3cec..aeeb0f27 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -504,12 +504,6 @@ impl Recents { paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() } - /// Share the Recent files thumbnail cache with Home search. The existing loader keeps file - /// reads off the UI thread and only requests pictures for cards that are on screen. - pub(crate) fn prepare_search(&mut self, ctx: &egui::Context, paths: &[String], time: f64) { - self.refresh(ctx, paths, time); - } - /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. pub fn inspect(&self, prefs: &Prefs) -> Value { let paths: Vec = listed(&prefs.recent_files).take(SHOWN).cloned().collect(); @@ -560,16 +554,27 @@ enum Action { View(View), } -/// The Recent files heading, its toolbar and the files as cards or a table. -pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { - let paths: Vec = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect(); +/// The Recent files heading, its toolbar and the files as cards or a table. With a `query` (lower +/// case, from the Home search) only the files not open whose path has it, from every recent file; +/// nothing at all when none has it. How many files are shown. +pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { + let searching = !query.is_empty(); + // A search reaches every recent file; the files shown without one stay known through it. + let paths: Vec = listed(&app.session.prefs.recent_files).take(if searching { usize::MAX } else { SHOWN }).cloned().collect(); let time = ui.input(|i| i.time); let now = figcraft_engine::cmd::comment::now_secs(); let reduce_motion = app.reduce_motion(); let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs)); let state = &mut app.ui.home_view.recents; state.refresh(ui.ctx(), &paths, time); - let files = sorted(state.shown(&paths), sort); + let mut files = sorted(state.shown(&paths), sort); + if searching { + let docs = &app.session.docs; + files.retain(|f| f.path.to_lowercase().contains(query) && !docs.iter().any(|d| d.path.as_deref() == Some(f.path.as_str()))); + if files.is_empty() { + return 0; + } + } let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some())); let mut action = toolbar(ui, t, view, sort, with_created); ui.add_space(16.0); @@ -599,6 +604,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } None => {} } + files.len() } /// "Recent files", and at the right the sort menu and the grid and list buttons. @@ -730,8 +736,7 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: /// The square file-type mark before a name. fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - let kind = if path.is_empty() { FileKind::FigCraft } else { FileKind::of(Path::new(path)) }; - match kind { + match FileKind::of(Path::new(path)) { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); @@ -748,49 +753,6 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { } } -/// A file found by the Home sidebar search. A tab without a disk path has no stored preview yet. -pub(crate) struct SearchHit { - pub key: String, - pub path: Option, - pub name: String, -} - -/// Search results use the same preview, file-type icon and footer as Recent files. A single click -/// still opens the result, as it did before the visual change. -pub(crate) fn search_grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[SearchHit], reduce_motion: bool) -> Option { - let (columns, card_w) = grid_columns(ui.available_width()); - let (picture_h, card_h) = card_heights(card_w); - let (time, now) = (ui.input(|i| i.time), figcraft_engine::cmd::comment::now_secs()); - let mut chosen = None; - let mut fading = false; - for (row_index, row) in files.chunks(columns).enumerate() { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), card_h), Sense::hover()); - for (column, file) in row.iter().enumerate() { - let rect = Rect::from_min_size(pos2(r.min.x + column as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h)); - let resp = ui.interact(rect, ui.id().with(("search_file_card", &file.key)), Sense::click()); - let stat = file.path.as_deref().and_then(|path| recents.files.get(path).and_then(|k| k.stat)); - let shown = Shown { path: file.path.clone().unwrap_or_default(), name: file.name.clone(), stat }; - a11y::named_button(&resp, &spoken(&shown, now)); - let resp = if let Some(path) = file.path.as_deref() { resp.on_hover_text(path) } else { resp }; - let visible = ui.is_rect_visible(rect); - let picture = file.path.as_deref().and_then(|path| recents.picture(path, visible, time)); - if visible { - let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a }); - fading |= alpha < 1.0; - card(ui, t, rect, picture_h, &resp, false, &shown, stat.unwrap_or_default(), picture, alpha, now); - } - if resp.clicked() { - chosen = Some(row_index * columns + column); - } - } - ui.add_space(GAP); - } - if fading { - ui.ctx().request_repaint(); - } - chosen -} - /// The name a card or row has for screen readers: "name, Edited 3 hours ago". fn spoken(file: &Shown, now: u64) -> String { let detail = file.detail(now); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index cf34e5b3..c493a0a1 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -202,7 +202,7 @@ fn home_search_finds_known_files_and_opens_an_existing_tab() { let mut u = Ui::new(session); u.app.ui.home = true; u.app.ui.home_view.file_query = " TRAIL ".into(); - let s = shapes(&mut u); + let s = settle(&mut u); assert!(has_text(&s, "Search results")); assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live")); assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret")); @@ -258,12 +258,62 @@ fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { let s = shapes(&mut u); click(&mut u, text_center(&s, "Search files and templates")); u.frame(vec![Event::Text("glacier".into())]); - let s = shapes(&mut u); + let s = settle(&mut u); assert_eq!(u.app.ui.home_view.file_query, "glacier"); assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest")); click(&mut u, text_center(&s, "Recents")); assert!(u.app.ui.home_view.file_query.is_empty()); - assert!(has_text(&shapes(&mut u), "Forest")); + let s = shapes(&mut u); + assert!(has_text(&s, "Forest")); + // Escape in the field clears the search too. + click(&mut u, text_center(&s, "Search files and templates")); + u.frame(vec![Event::Text("forest".into())]); + assert_eq!(u.app.ui.home_view.file_query, "forest"); + u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(u.app.ui.home_view.file_query.is_empty(), "Escape clears the search"); +} + +/// A search keeps what the recent files it doesn't match know: back from it, their pictures and +/// the selection are still there (nothing is read again). +#[test] +fn a_search_keeps_the_other_recent_files_pictures_and_the_selection() { + let dir = std::env::temp_dir().join(format!("figcraft-home-search-cache-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(8, 8, image::Rgba([30, 140, 210, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let paths: Vec = ["Alpine", "Basin"] + .into_iter() + .map(|name| { + let path = dir.join(format!("{name}.figcraft")); + std::fs::write(&path, figcraft_format::save_bytes(&figcraft_doc::Document::new(name), Some(&png)).unwrap()).unwrap(); + path.to_string_lossy().into_owned() + }) + .collect(); + let mut session = Session::empty(); + session.prefs.recent_files = paths.clone(); + let mut u = Ui::new(session); + let pictures = |u: &mut Ui| -> Vec { + u.app.inspect()["homeRecents"]["files"].as_array().unwrap().iter().map(|f| f["picture"].clone()).collect() + }; + for _ in 0..400 { + u.frame(vec![]); + if pictures(&mut u).iter().all(|p| p == "picture") { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(pictures(&mut u).iter().all(|p| p == "picture"), "both pictures are in"); + u.app.ui.home_view.recents.selected = Some(paths[1].clone()); + u.app.ui.home_view.file_query = "alpine".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "Alpine") && !has_text(&s, "Basin")); + u.app.ui.home_view.file_query.clear(); + u.frame(vec![]); + assert!(pictures(&mut u).iter().all(|p| p == "picture"), "no picture is read again"); + assert_eq!(u.app.ui.home_view.recents.selected.as_deref(), Some(paths[1].as_str()), "the selection stays"); + let _ = std::fs::remove_dir_all(dir); } #[test] From 55a0712b117a14d3d5713e44ece22f34ba0030cb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 12:06:04 +0700 Subject: [PATCH 216/245] #758: leave the progress docs to the maintainer --- ROADMAP.md | 2 -- docs/file-format-parity.md | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7cef08a3..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,7 +131,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-17** · Missing fonts (M7, #752): fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables) are listed in a Missing fonts window after the user opens the file (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Their text still draws in a fallback font. 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. @@ -318,7 +317,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 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 | Missing fonts (M7, #752): fonts a file names that aren't installed are listed after the user opens it, from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. 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. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 900559df..b65f7729 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -159,7 +159,7 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing fonts on open | listed, with replacement | listed (Missing fonts window after an open, Text ▸ Missing fonts…, `text.missingFonts`, `figcraft-cli info`); no replacement; text draws in a fallback font | +| Missing-font replacement on open | yes | warnings only (REST import) | ## Revision history @@ -167,7 +167,6 @@ each. |---|---|---| | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | -| 2026-10-17 | minor | Missing fonts (#752): the fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables in any mode) are listed when the user opens it (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. The row claimed REST import warnings no code produced. | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | From ac606a2d43882cf0f3ef3eb2930139b1657b9cce Mon Sep 17 00:00:00 2001 From: sanches Date: Sun, 18 Oct 2026 08:02:15 +0300 Subject: [PATCH 217/245] M9: Dutch catalog, machine-drafted (nl.toml) --- ROADMAP.md | 17 +- crates/ui-egui/locales/nl.toml | 2331 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n_tests.rs | 13 +- docs/gaps.md | 24 +- docs/localization-parity.md | 53 +- docs/translating.md | 14 +- 6 files changed, 2397 insertions(+), 55 deletions(-) create mode 100644 crates/ui-egui/locales/nl.toml diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..a8af7b49 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 (Dutch draft catalog complete at 100% of the strings, machine-drafted and awaiting native review; language table re-measured over 2,315 entries; preceding: 2026-10-17 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) 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 @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Dutch, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -103,6 +103,7 @@ native speakers' reviews of the six language drafts need people. | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | +| Dutch | nl | 100% | draft (needs native review) | | French | fr | 95% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | @@ -111,9 +112,14 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Percentages measured 2026-10-18 over 2,315 entries (1,718 UI keys, 566 command labels and 31 menu +wordings; strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by +VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish +(`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, +Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ +French, German, Dutch, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian +drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -126,7 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +4. Languages: native reviews of the es, fr, nl, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log @@ -316,6 +322,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Dutch draft catalog (gap 5): `nl.toml` translates every UI string (1,718 keys; 41 with plural forms, one/other), every command label (566) and every menu wording (31) — 2,315 entries, machine-drafted in one style pass and marked as a draft awaiting native review; established tool terms (frame, auto layout, component, variant, prototype, overlay, widget, plugin, branch, constraints) and blend-mode, brush-preset and boolean names stay English, informal je register, infinitive menu labels, months abbreviated the Dutch way (jan., feb., mrt., apr., mei, …), `%` without a space and ordinary quotes. Preferences ▸ Language lists Nederlands; System maps any Dutch tag (nl-NL, nl…) to it, and the language-tag tests that used Dutch as a no-catalog example switched to Hindi. String counts re-measured from all catalogs: 1,718 UI keys, 566 command labels and 31 menu wordings (2,315 entries); the other drafts read 95–99% on the new basis. 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/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml new file mode 100644 index 00000000..c8eb0697 --- /dev/null +++ b/crates/ui-egui/locales/nl.toml @@ -0,0 +1,2331 @@ +# FigCraft interface strings: Dutch (Nederlands). +# +# Draft — needs native review: machine-drafted from the English catalog. Established tool terms +# (frame, auto layout, component, variant, prototype, overlay, widget, plugin, branch, constraints) +# and blend-mode, brush-preset and boolean names stay as they are. See docs/translating.md. + +[meta] +name = "Nederlands" +status = "draft — needs native review" + +[ui] + +"% of all commits" = "% van alle commits" +"(no longer published)" = "(niet langer gepubliceerd)" +"({n} warnings: some entries were skipped)" = { one = "({n} waarschuwing: sommige items zijn overgeslagen)", other = "({n} waarschuwingen: sommige items zijn overgeslagen)" } +"+ Create variable" = "+ Variabele maken" +".fig (experimental)" = ".fig (experimental)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} regels (Δ {delta}), +{bin_added} / −{bin_deleted} binaire assets, {first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Er bestaat al een .figcraft-bestand met deze naam. Kies de volledige naam om de vervanging te bevestigen." +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Een verzameling van dit bestand met deze modi en variabelen: overschrijf hun waarden per modus en voeg er eigen variabelen toe" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Een losse laag wordt uitgelijnd op zijn frame: selecteer meer lagen, of één in een frame" +"A responsive product page: hero, features, figures, a quote and a footer" = "Een responsive productpagina: hero, features, afbeeldingen, een citaat en een footer" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Een veer speelt tot hij tot rust komt: zijn massa, stijfheid en demping bepalen hoe lang" +"A title, an agenda, a big figure, a chart and a closing slide" = "Een titel, een agenda, een grote afbeelding, een grafiek en een afsluitende dia" +"A to Z" = "A tot Z" +"About" = "Over" +"About FigCraft" = "Over FigCraft" +"About this server" = "Over deze server" +"Action" = "Actie" +"Actions" = "Acties" +"Actions for {name}" = "Acties voor {name}" +"Actions…" = "Acties…" +"Active" = "Actief" +"Actual size (100%)" = "Werkelijke grootte (100%)" +"Add a category" = "Categorie toevoegen" +"Add a comment" = "Reactie toevoegen" +"Add a description" = "Beschrijving toevoegen" +"Add a frame (F) or a shape (R) to get started" = "Voeg een frame (F) of een vorm (R) toe om te beginnen" +"Add a layer or component to tile first" = "Voeg eerst een laag of component toe om als tegel te gebruiken" +"Add a note for developers" = "Notitie voor ontwikkelaars toevoegen" +"Add action" = "Actie toevoegen" +"Add actions for when no condition holds" = "Voeg acties toe voor het geval geen enkele voorwaarde geldt" +"Add an annotation for developers" = "Annotatie voor developers toevoegen" +"Add another condition" = "Nog een voorwaarde toevoegen" +"Add auto layout" = "Auto layout toevoegen" +"Add background behind overlay" = "Achtergrond achter overlay toevoegen" +"Add content to this slot" = "Voeg inhoud toe aan dit slot" +"Add dev resource" = "Dev-resource toevoegen" +"Add effect" = "Effect toevoegen" +"Add export setting" = "Exportinstelling toevoegen" +"Add fill" = "Vulling toevoegen" +"Add interaction" = "Interactie toevoegen" +"Add layout grid" = "Layoutraster toevoegen" +"Add library" = "Bibliotheek toevoegen" +"Add library file…" = "Bibliotheekbestand toevoegen…" +"Add max height" = "Maximale hoogte toevoegen" +"Add max width" = "Maximale breedte toevoegen" +"Add min height" = "Minimale hoogte toevoegen" +"Add min width" = "Minimale breedte toevoegen" +"Add mode" = "Modus toevoegen" +"Add new page" = "Nieuwe pagina toevoegen" +"Add or update collections from a variables JSON" = "Voeg verzamelingen toe of werk ze bij vanuit een variables-JSON" +"Add properties to make instances configurable" = "Voeg eigenschappen toe om instances configureerbaar te maken" +"Add reaction" = "Reactie toevoegen" +"Add starting point" = "Startpunt toevoegen" +"Add stroke" = "Lijn toevoegen" +"Add variant" = "Variant toevoegen" +"Add variant with a new value" = "Variant met een nieuwe waarde toevoegen" +"Added" = "Toegevoegd" +"Added the library \"{name}\"" = "Bibliotheek \"{name}\" toegevoegd" +"Adding to {name}" = "Toevoegen aan {name}" +"Adjust" = "Aanpassen" +"Adjustment presets" = "Aanpassingspresets" +"Advanced" = "Geavanceerd" +"Advanced stroke" = "Geavanceerde lijn" +"After delay" = "Na vertraging" +"After delay {ms}ms" = "Na {ms}ms vertraging" +"Align" = "Uitlijnen" +"Align bottom" = "Onderaan uitlijnen" +"Align center" = "Midden uitlijnen" +"Align horizontal centers" = "Horizontale middens uitlijnen" +"Align left" = "Links uitlijnen" +"Align middle" = "Midden uitlijnen" +"Align right" = "Rechts uitlijnen" +"Align text baselines" = "Tekstbasislijnen uitlijnen" +"Align top" = "Bovenaan uitlijnen" +"Align vertical centers" = "Verticale middens uitlijnen" +"Alignment" = "Uitlijning" +"All" = "Alle" +"All categories" = "Alle categorieën" +"All comments" = "Alle reacties" +"All components" = "Alle components" +"All fonts" = "Alle lettertypen" +"All supported properties" = "Alle ondersteunde eigenschappen" +"All variables" = "Alle variabelen" +"Alpha" = "Alpha" +"Alphabetical" = "Alfabetisch" +"Alt+drag" = "Alt+drag" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Een analysescherm op een raster: cijfers, diagrammen getekend met vectoren en een tabel" +"An invite link that opens FigCraft and joins" = "Een uitnodigingslink die FigCraft opent en waarmee je deelneemt" +"Anchor" = "Anker" +"Android" = "Android" +"Angular" = "Angular" +"Animation" = "Animatie" +"Annotations" = "Annotaties" +"Annotations shown on the canvas" = "Annotaties die op het canvas worden weergegeven" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Een andere server, of een server waarvoor je een sleutel nodig hebt. De gebruikelijke server stel je in bij Voorkeuren." +"Any status" = "Alle statussen" +"Anyone on your network with this code can edit the file, while this app stays open." = "Iedereen in je netwerk met deze code kan het bestand bewerken, zolang deze app open blijft." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Iedereen met de bewerklink kan het bestand wijzigen; de weergavelink laat mensen alleen kijken. De links bevatten geheimen: deel ze alsof het wachtwoorden zijn. Met `figcraft-server links` maak je er meer en trek je ze in." +"Anyone with this code can edit the file." = "Iedereen met deze code kan het bestand bewerken." +"Anyone with this code can look at the file, not change it." = "Iedereen met deze code kan het bestand bekijken, maar niet wijzigen." +"Appearance" = "Weergave" +"Apply a component property" = "Een componenteigenschap toepassen" +"Apply variable" = "Variabele toepassen" +"Apply variable to text" = "Variabele toepassen op tekst" +"Apply variable to visibility" = "Variabele toepassen op zichtbaarheid" +"Apps" = "Apps" +"Apr" = "apr." +"Arc" = "Boog" +"Arc ratio" = "Boogverhouding" +"Arc start" = "Start van de boog" +"Arc sweep" = "Boogdoorloop" +"Archive" = "Archiveren" +"Archived" = "Gearchiveerd" +"Arrange" = "Schikken" +"Arrow" = "Pijl" +"As typed" = "Zoals getypt" +"Asking the server…" = "Bezig met vragen aan de server…" +"Assets" = "Assets" +"Aug" = "aug." +"Auto" = "Auto" +"Auto (space around)" = "Auto (ruimte eromheen)" +"Auto (space between)" = "Auto (afstand ertussen)" +"Auto (space evenly)" = "Auto (gelijkmatig verdelen)" +"Auto ({mode})" = "Auto ({mode})" +"Auto columns" = "Automatische kolommen" +"Auto gap" = "Automatische afstand" +"Auto height" = "Automatische hoogte" +"Auto layout" = "Auto layout" +"Auto rows" = "Automatische rijen" +"Auto width" = "Automatische breedte" +"Back" = "Terug" +"Back to Ready for dev" = "Terug naar Ready for dev" +"Back to Ready for dev: the next change compares with how it is now" = "Terug naar Gereed voor dev: de volgende wijziging wordt vergeleken met hoe het nu is" +"Back to assets" = "Terug naar assets" +"Back to current" = "Terug naar huidige" +"Back to styles" = "Terug naar stijlen" +"Back to the FigCraft server" = "Terug naar de FigCraft-server" +"Back to the canvas from a focused region" = "Terug naar het canvas vanuit een focusgebied" +"Back to the current version" = "Terug naar de huidige versie" +"Back to the font's value" = "Terug naar de waarde van het lettertype" +"Back to the values the font style implies" = "Terug naar de waarden die bij de lettertypestijl horen" +"Background" = "Achtergrond" +"Background blur" = "Achtergrondvervaging" +"Basics" = "Basis" +"Before" = "Voor" +"Bend" = "Buiging" +"Bevel" = "Afgeschuind" +"Binary assets added" = "Binaire assets toegevoegd" +"Binary assets removed" = "Verwijderde binaire assets" +"Black & white" = "Black & white" +"Bleed" = "Afloop" +"Bleed marks" = "Aansnijmarkeringen" +"Bleed marks need a bleed" = "Aansnijtekens vereisen een aansnijrand" +"Blend" = "Mengmodus" +"Blend mode" = "Blendmodus" +"Blue" = "Blauw" +"Blur" = "Vervaging" +"Boolean" = "Boolean" +"Boolean groups" = "Booleaanse groepen" +"Both directions" = "Beide richtingen" +"Bottom" = "Onder" +"Bottom left radius" = "Radius linksonder" +"Bottom right radius" = "Radius rechtsonder" +"Bound to {name}. Click to detach the variable." = "Gekoppeld aan {name}. Klik om de variabele los te koppelen." +"Branch" = "Branch" +"Branch archived" = "Branch gearchiveerd" +"Branch merged" = "Branch samengevoegd" +"Branch name" = "Branchnaam" +"Branch of {file}" = "Branch van {file}" +"Branch of {file} · merged or archived" = "Branch van {file} · samengevoegd of gearchiveerd" +"Branch updated from main" = "Branch bijgewerkt vanaf main" +"Branch {name}" = "Branch {name}" +"Branches" = "Branches" +"Bring forward" = "Naar voren brengen" +"Bring the main file's changes into the branch" = "De wijzigingen uit het hoofdbestand in de branch brengen" +"Bring to front" = "Op de voorgrond plaatsen" +"Bristle" = "Bristle" +"Browse" = "Bladeren" +"Brush" = "Penseel" +"Brush rotation jitter" = "Penseelrotatiejitter" +"Brush scatter" = "Penseelverstrooiing" +"Brush size jitter" = "Penseelgrootte-jitter" +"Brush spacing" = "Penseelafstand" +"Bulleted list" = "Lijst met opsommingstekens" +"C or Shift+C" = "C of Shift+C" +"C or ⇧C" = "C of ⇧C" +"CMYK colour" = "CMYK-kleur" +"CSS custom properties…" = "CSS-variabelen…" +"Can edit" = "Kan bewerken" +"Can view" = "Kan bekijken" +"Can't read this library: {e}" = "Kan deze bibliotheek niet lezen: {e}" +"Cancel" = "Annuleren" +"Canvas" = "Canvas" +"Canvas stacking" = "Stapeling op canvas" +"Cap height to baseline" = "Hoofdletterhoogte tot basislijn" +"Case" = "Hoofdlettergebruik" +"Case-sensitive forms" = "Hoofdlettergevoelige vormen" +"Categories" = "Categorieën" +"Center" = "Midden" +"Change alias" = "Alias wijzigen" +"Change resolved" = "Wijziging opgelost" +"Change style" = "Stijl wijzigen" +"Change to" = "Wijzigen naar" +"Change to needs a layer inside a variant" = "Wijzigen naar vereist een laag binnen een variant" +"Changed" = "Gewijzigd" +"Changed on both sides: keep one" = "Aan beide kanten gewijzigd: één behouden" +"Changes on main" = "Wijzigingen op main" +"Changes on the branch" = "Wijzigingen op de branch" +"Changes unknown" = "Onbekende wijzigingen" +"Checking the server…" = "Server controleren…" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI of Compose om dit component aan zijn code te koppelen." +"Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen." +"Choose collection" = "Kies een verzameling" +"Choose image" = "Afbeelding kiezen" +"Choose mode" = "Modus kiezen" +"Choose variable" = "Variabele kiezen" +"Circle" = "Cirkel" +"Clear from this list" = "Verwijderen uit deze lijst" +"Clear recent" = "Recenten wissen" +"Click + to replace mixed content" = "Klik op + om gemengde inhoud te vervangen" +"Click anywhere on the canvas to leave one." = "Klik ergens op het canvas om er een achter te laten." +"Click or drag to merge · {alt} removes" = "Klik of sleep om samen te voegen · {alt} verwijdert" +"Click or drag to remove · {alt} merges" = "Klik of sleep om te verwijderen · {alt} voegt samen" +"Click to record a key" = "Klik om een toets op te nemen" +"Clip content" = "Inhoud bijsnijden" +"Clip to shape" = "Bijsnijden op vorm" +"Close" = "Sluiten" +"Close overlay" = "Overlay sluiten" +"Close preview" = "Voorvertoning sluiten" +"Close tab" = "Tabblad sluiten" +"Close version history" = "Versiegeschiedenis sluiten" +"Close when clicking outside" = "Sluiten bij klikken erbuiten" +"Close without saving" = "Sluiten zonder opslaan" +"Code" = "Code" +"Code or invite link" = "Code of uitnodigingslink" +"Code syntax" = "Code-syntaxis" +"Collapse" = "Inklappen" +"Collapse panel" = "Paneel samenvouwen" +"Collection" = "Verzameling" +"Collections" = "Verzamelingen" +"Color" = "Kleur" +"Color burn" = "Color burn" +"Color dodge" = "Color dodge" +"Color format" = "Kleurnotatie" +"Color profile" = "Kleurprofiel" +"Color profile: {name}" = "Kleurprofiel: {name}" +"Color styles" = "Kleurstijlen" +"Color values in this file are in this color space." = "Kleurwaarden in dit bestand gebruiken deze kleurruimte." +"Color variables" = "Kleurvariabelen" +"Colour" = "Kleur" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Kleur- en tekststijlen, lichte en donkere variabelen, knoppen, invoervelden en kaarten" +"Column" = "Kolom" +"Column gap" = "Kolomafstand" +"Column sizes" = "Kolomgroottes" +"Column span" = "Kolomspan" +"Columns" = "Kolommen" +"Columns {count}" = "Kolommen {count}" +"Comment" = "Reactie" +"Comments" = "Reacties" +"Commits" = "Commits" +"Company" = "Bedrijf" +"Compare changes" = "Wijzigingen vergelijken" +"Compatibility notes" = "Compatibiliteitsopmerkingen" +"Completed" = "Voltooid" +"Component" = "Component" +"Component name" = "Componentnaam" +"Component property" = "Componenteigenschap" +"Component set" = "Componentenset" +"Component sets" = "Componentensets" +"Components" = "Components" +"Conditional" = "Voorwaardelijk" +"Confetti" = "Confetti" +"Conflicts" = "Conflicten" +"Connect to code" = "Verbinden met code" +"Connected" = "Verbonden" +"Connected to code: {from}" = "Verbonden met code: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Verbinding verbroken, opnieuw verbinden (poging {attempt})…" +"Constrain proportions" = "Verhoudingen vergrendelen" +"Constraints" = "Constraints" +"Content" = "Inhoud" +"Contents hidden" = "Inhoud verborgen" +"Contents only" = "Alleen de inhoud" +"Contextual alternates" = "Contextuele alternatieven" +"Contrast" = "Contrast" +"Contributors" = "Bijdragers" +"Contributors: {people} · Commits: {commits}" = "Bijdragers: {people} · Commits: {commits}" +"Controller" = "Controller" +"Convert to frame" = "Converteren naar frame" +"Convert to section" = "Converteren naar sectie" +"Cool" = "Cool" +"Copied as CSS" = "Gekopieerd als CSS" +"Copied as PNG" = "Gekopieerd als PNG" +"Copied as SVG" = "Gekopieerd als SVG" +"Copied as text" = "Gekopieerd als tekst" +"Copied {value}" = "{value} gekopieerd" +"Copy" = "Kopiëren" +"Copy as" = "Kopiëren als" +"Copy as CSS" = "Kopiëren als CSS" +"Copy as PNG" = "Kopiëren als PNG" +"Copy as SVG" = "Kopiëren als SVG" +"Copy code" = "Code kopiëren" +"Copy link" = "Link kopiëren" +"Copy the {label}" = "Kopieer de {label}" +"Copy {value}" = "{value} kopiëren" +"Corner radius" = "Hoekradius" +"Corner smoothing" = "Hoekafvlakking" +"Couldn't download the variables" = "De variabelen konden niet worden gedownload" +"Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt." +"Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}" +"Count" = "Aantal" +"Create" = "Maken" +"Create a variable collection first" = "Maak eerst een variabeleverzameling" +"Create a variable first" = "Maak eerst een variabele" +"Create alias" = "Alias maken" +"Create branch" = "Branch aanmaken" +"Create collection" = "Verzameling maken" +"Create component" = "Component maken" +"Create component property" = "Componenteigenschap maken" +"Create components, styles or variables to publish them as a library." = "Maak componenten, stijlen of variabelen om ze als bibliotheek te publiceren." +"Create property…" = "Eigenschap maken…" +"Create style" = "Stijl maken" +"Create style from the selected text" = "Stijl maken van de geselecteerde tekst" +"Created" = "Gemaakt" +"Crop" = "Bijsnijden" +"Crop marks" = "Snijtekens" +"Ctrl+arrows" = "Ctrl+pijltjes" +"Ctrl+click" = "Ctrl+click" +"Ctrl+scroll" = "Ctrl+scroll" +"Current name" = "Huidige naam" +"Current version" = "Huidige versie" +"Cursor chat" = "Cursorchat" +"Custom" = "Aangepast" +"Custom size" = "Aangepaste grootte" +"Cut" = "Knippen" +"Dark" = "Donker" +"Darken" = "Darken" +"Dash" = "Streep" +"Dashboard" = "Dashboard" +"Date created" = "Aanmaakdatum" +"Dec" = "dec." +"Decoration" = "Decoratie" +"Decoration style" = "Decoratiestijl" +"Deep select" = "Diep selecteren" +"Default" = "Standaard" +"Default content" = "Standaardinhoud" +"Default value (the top-left variant)" = "Standaardwaarde (de variant linksboven)" +"Delay" = "Vertraging" +"Delete" = "Verwijderen" +"Delete category (its annotations stay)" = "Categorie verwijderen (de annotaties blijven staan)" +"Delete collection" = "Verzameling verwijderen" +"Delete group" = "Groep verwijderen" +"Delete mode" = "Modus verwijderen" +"Delete page" = "Pagina verwijderen" +"Delete preset" = "Preset verwijderen" +"Delete property" = "Eigenschap verwijderen" +"Delete the style (layers keep their values)" = "Verwijdert de stijl (lagen behouden hun waarden)" +"Delete thread" = "Gesprek verwijderen" +"Delete variable" = "Variabele verwijderen" +"Delete “{name}”? This can't be undone." = "“{name}” verwijderen? Dit kan niet ongedaan worden gemaakt." +"Density" = "Dichtheid" +"Depth" = "Diepte" +"Describe how and when to use this component" = "Beschrijf hoe en wanneer je dit component gebruikt" +"Describe what changed" = "Beschrijf wat er is gewijzigd" +"Description" = "Beschrijving" +"Description (optional)" = "Beschrijving (optioneel)" +"Design" = "Design" +"Design system starter" = "Designsysteem-starter" +"Desktop" = "Desktop" +"Destination" = "Bestemming" +"Detach alias" = "Alias loskoppelen" +"Detach instance" = "Instance loskoppelen" +"Detach property" = "Eigenschap loskoppelen" +"Detach style" = "Stijl loskoppelen" +"Detach styles" = "Stijlen loskoppelen" +"Detach variable" = "Variabele loskoppelen" +"Details" = "Details" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Dev-bronnen" +"Development" = "Ontwikkeling" +"Device" = "Apparaat" +"Device at 100%" = "Apparaat op 100%" +"Diamond" = "Ruit" +"Difference" = "Difference" +"Dimensions" = "Afmetingen" +"Direction" = "Richting" +"Disconnect from code" = "Loskoppelen van code" +"Dispersion" = "Dispersie" +"Display name" = "Weergavenaam" +"Dissolve" = "Dissolve" +"Distribute and tidy" = "Verdelen en opruimen" +"Distribute horizontal spacing" = "Horizontale afstand verdelen" +"Distribute vertical spacing" = "Verticale afstand verdelen" +"Divider" = "Scheidslijn" +"Don't truncate" = "Niet afkappen" +"Done" = "Klaar" +"Done editing" = "Klaar met bewerken" +"Dots" = "Dots" +"Dotted" = "Gestippeld" +"Down" = "Onder" +"Drafts" = "Concepten" +"Drag the stroke to add a width point" = "Sleep de lijn om een breedtepunt toe te voegen" +"Draw" = "Tekenen" +"Draw a frame" = "Teken een frame" +"Drawing tools" = "Tekengereedschap" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .json-, .svg- of afbeeldingsbestand los." +"Drop shadow" = "Slagschaduw" +"Drop to import" = "Loslaten om te importeren" +"Drop to open" = "Loslaten om te openen" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." +"Duo" = "Duo" +"Duplicate" = "Dupliceren" +"Duplicate by dragging" = "Dupliceren door te slepen" +"Duplicate mode" = "Modus dupliceren" +"Duplicate page" = "Pagina dupliceren" +"Edit" = "Bewerken" +"Edit as an expression" = "Bewerken als expressie" +"Edit colour" = "Kleur bewerken" +"Edit comment" = "Reactie bewerken" +"Edit dev resource" = "Dev-resource bewerken" +"Edit link" = "Link bewerken" +"Edit link copied" = "Link om te bewerken gekopieerd" +"Edit style" = "Stijl bewerken" +"Edit variable" = "Variabele bewerken" +"Edit with the builder" = "Bewerken met de builder" +"Edited {when}" = "Bewerkt {when}" +"Effect settings" = "Effectinstellingen" +"Effect styles" = "Effectstijlen" +"Effects" = "Effecten" +"Ellipse" = "Ellips" +"Ellipses" = "Ellipsen" +"Else" = "Anders" +"Else if" = "Anders als" +"Embedded profile" = "Ingebed profiel" +"Empty" = "Leeg" +"End" = "End" +"End point" = "Eindpunt" +"Ends the session; everyone keeps their copy" = "Beëindigt de sessie; iedereen houdt zijn eigen kopie" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Voer het adres van een figcraft-server in en druk op Bladeren om te zien welke plugins het aanbiedt." +"Enter to save · Esc to cancel" = "Enter om op te slaan · Esc om te annuleren" +"Eraser" = "Gum" +"Esc" = "Esc" +"Essential" = "Essentieel" +"Every conflict has a choice." = "Elk conflict heeft een keuze." +"Every top-level frame already starts a flow" = "Elk frame op het hoogste niveau begint al een flow" +"Exact opacity (4 then 5: 45%)" = "Exacte dekking (4 dan 5: 45%)" +"Exclude" = "Exclude" +"Excluded" = "Uitgesloten" +"Excluded from layout" = "Uitgesloten van de layout" +"Excludes" = "Sluit uit" +"Exclusion" = "Exclusion" +"Exit focus view (Esc)" = "Focusweergave afsluiten (Esc)" +"Expand" = "Uitvouwen" +"Export" = "Exporteren" +"Export all" = "Alles exporteren" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Frames en lagen exporteren als PNG, JPG, SVG of PDF" +"Export page as PNG" = "Pagina exporteren als PNG" +"Export settings" = "Exportinstellingen" +"Export variables" = "Variabelen exporteren" +"Export {name}" = "{name} exporteren" +"Export {n} layers" = { one = "{n} laag exporteren", other = "{n} lagen exporteren" } +"Exported {file}" = "{file} geëxporteerd" +"Exported {name}" = "{name} geëxporteerd" +"Exported {name}; {n} kinds of content were left out" = { one = "{name} geëxporteerd; {n} soort content is niet meegenomen", other = "{name} geëxporteerd; {n} soorten content zijn niet meegenomen" } +"Exported {n} files" = { one = "{n} bestand geëxporteerd", other = "{n} bestanden geëxporteerd" } +"Export…" = "Exporteren…" +"Expose properties to instances" = "Eigenschappen beschikbaar maken voor instances" +"Exposure" = "Belichting" +"Expression" = "Expressie" +"Extend collection" = "Verzameling uitbreiden" +"Extends \"{name}\": overridden values are marked" = "Erft van “{name}”: overschreven waarden zijn gemarkeerd" +"Eye" = "Oog" +"F" = "F" +"Fade" = "Fade" +"False" = "Onwaar" +"Feb" = "feb." +"Fetching from the server…" = "Bezig met ophalen van de server…" +"FigCraft couldn't open its window: {error}" = "FigCraft kon zijn venster niet openen: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ondersteunt nog niet elke functie van deze bestanden. De notities vermelden wat er in de geïmporteerde kopie is weggelaten of gewijzigd; het originele bestand is ongewijzigd." +"File" = "Bestand" +"File name" = "Bestandsnaam" +"File not found" = "Bestand niet gevonden" +"File published" = "Bestand gepubliceerd" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Bestanden zijn niet end-to-end versleuteld: de beheerder van de server kan ze lezen." +"Files nobody opens for {n} days are deleted." = { one = "Na {n} dag zonder openen wordt een bestand verwijderd.", other = "Na {n} dagen zonder openen wordt een bestand verwijderd." } +"Fill" = "Vulling" +"Fill (fr)" = "Vullen (fr)" +"Fill container" = "Container vullen" +"Fill window" = "Venster vullen" +"Fills" = "Vullingen" +"Fill|sizing" = "Vullen" +"Filter and sort" = "Filteren en sorteren" +"Find" = "Zoeken" +"Find and replace" = "Zoeken en vervangen" +"Find layers" = "Lagen zoeken" +"First" = "Eerste" +"First commit" = "Eerste commit" +"First on top" = "Eerste bovenaan" +"Fit" = "Inpassen" +"Fit device to window" = "Apparaat inpassen in het venster" +"Fit to contents" = "Aanpassen aan inhoud" +"Fit to window" = "Inpassen in venster" +"Fit width" = "Breedte inpassen" +"Fixed" = "Vast" +"Fixed gap" = "Vaste afstand" +"Fixed size" = "Vaste grootte" +"Flatten" = "Samenvoegen tot vector" +"Flip" = "Spiegelen" +"Flip horizontal" = "Horizontaal spiegelen" +"Flip text to the other side" = "Tekst naar de andere kant spiegelen" +"Flip vertical" = "Verticaal spiegelen" +"Flip width profile" = "Breedteprofiel spiegelen" +"Flow starting point" = "Flow-startpunt" +"Flow starting points" = "Flow-startpunten" +"Flows" = "Flows" +"Focus view" = "Focusweergave" +"Fold autosaves" = "Automatisch opgeslagen versies inklappen" +"Folder: click to collapse" = "Map: klik om samen te vouwen" +"Folder: click to expand" = "Map: klik om uit te klappen" +"Follow" = "Volgen" +"Follow {name}" = "{name} volgen" +"Following" = "Volgend" +"Following {name} · {key} to stop" = "Volgt {name} · {key} om te stoppen" +"Font" = "Lettertype" +"Font not installed" = "Lettertype niet geïnstalleerd" +"Font size" = "Lettertypegrootte" +"Font style" = "Lettertypestijl" +"Forget on this computer" = "Vergeten op deze computer" +"Four screens of a trail app with components and a prototype flow" = "Vier schermen van een wandelroute-app met componenten en een prototype-flow" +"Fractions" = "Breuken" +"Frame" = "Frame" +"Frame selection" = "Selectie in frame plaatsen" +"Frame tools" = "Frame-hulpmiddelen" +"Frames" = "Frames" +"From the library \"{name}\": read-only" = "Uit de bibliotheek \"{name}\": alleen-lezen" +"From {name}" = "Van {name}" +"From {server}" = "Van {server}" +"Frost" = "Frost" +"Gap" = "Afstand" +"Gap between columns" = "Afstand tussen kolommen" +"Gap between items" = "Afstand tussen items" +"Gap between rows" = "Afstand tussen rijen" +"Getting started" = "Aan de slag" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Geef het adres van een server die openbaar bereikbaar is, of het adres van een server met bijbehorende key (`figcraft-server key` toont die)." +"Glass" = "Glass" +"Go to main component" = "Naar hoofdcomponent gaan" +"Go to {name}" = "Ga naar {name}" +"Grab bag" = "Grab bag" +"Gradient" = "Verloop" +"Gray" = "Grijs" +"Green" = "Groen" +"Greyscale components and two example screens to sketch a flow with" = "Grijswaardencomponenten en twee voorbeeldschermen om een flow mee te schetsen" +"Grid" = "Raster" +"Grid ({size})" = "Raster ({size})" +"Grid styles" = "Rasterstijlen" +"Grid {size}" = "Raster {size}" +"Group" = "Groep" +"Group selection" = "Selectie groeperen" +"Groups" = "Groepen" +"Gutter" = "Tussenruimte" +"Hand tool" = "Handgereedschap" +"Hanging list" = "Lijst met hangende inspringing" +"Hanging punctuation" = "Uithangende leestekens" +"Hard light" = "Hard light" +"Height" = "Hoogte" +"Help" = "Help" +"Help and resources" = "Help en bronnen" +"Help page" = "Helppagina" +"Here: {names}" = "Hier: {names}" +"Hidden" = "Verborgen" +"Hide contents" = "Inhoud verbergen" +"Hide from publishing" = "Verbergen bij publiceren" +"Hide section contents" = "Sectie-inhoud verbergen" +"Hide these tips (Getting started in the Help menu shows them again)" = "Deze tips verbergen (Aan de slag in het menu Help toont ze opnieuw)" +"Hide when publishing" = "Verbergen bij publiceren" +"Highlight layers on hover" = "Lagen uitlichten bij aanwijzen" +"Highlights" = "Hooglichten" +"Hold Alt" = "Houd Alt ingedrukt" +"Hold Alt+Z" = "Houd Alt+Z ingedrukt" +"Hold Ctrl" = "Ctrl ingedrukt houden" +"Hold Z" = "Houd Z ingedrukt" +"Hold ⌃ or ⌘" = "Houd ⌃ of ⌘ ingedrukt" +"Hold ⌥" = "Houd ⌥ ingedrukt" +"Hold ⌥Z" = "Houd ⌥Z ingedrukt" +"Home" = "Home" +"Horizontal" = "Horizontaal" +"Horizontal align" = "Horizontaal uitlijnen" +"Horizontal hex" = "Horizontaal hex" +"Horizontal padding" = "Horizontale padding" +"Horizontal spacing" = "Horizontale afstand" +"Hosted by {operator}" = "Gehost door {operator}" +"Hover a layer to see its properties" = "Houd de muis boven een laag om de eigenschappen te zien" +"Hover other layers to measure the distance" = "Beweeg over andere lagen om de afstand te meten" +"Hue" = "Hue" +"Hug" = "Hug" +"Hug contents" = "Aangepast aan inhoud" +"Icon components on a 24 px grid with keylines and a size preview" = "Iconcomponents op een raster van 24 px met keylines en een voorvertoning van de grootte" +"Icon set starter" = "Iconenset-starter" +"If" = "Als" +"If it keeps happening, please report it at {link} with this message." = "Als dit blijft gebeuren, meld het dan op {link} met dit bericht." +"If {condition}" = "Als {condition}" +"Ignore auto layout (absolute position)" = "Auto layout negeren (absolute positie)" +"Image" = "Afbeelding" +"Images" = "Afbeeldingen" +"Import" = "Importeren" +"Import path" = "Importpad" +"Import plugin from manifest" = "Plugin importeren vanuit een manifest" +"Import plugin from manifest…" = "Plugin importeren vanuit manifest…" +"Import report" = "Importrapport" +"Import variables" = "Variabelen importeren" +"Imported document" = "Geïmporteerd document" +"Imported {name}" = "{name} geïmporteerd" +"Imported {n} new variables, updated {updated}" = { one = "{n} nieuwe variabele geïmporteerd, {updated} bijgewerkt", other = "{n} nieuwe variabelen geïmporteerd, {updated} bijgewerkt" } +"Import…" = "Importeren…" +"In an instance, this comes from the main component" = "In een instance komt dit uit de hoofdcomponent" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "In de browser kun je deelnemen aan sessies die de desktop-app host en aan bestanden die op een FigCraft-server staan. Een pagina die via https is geopend, bereikt andere computers alleen via wss://-links (een server achter TLS)." +"Include bounding box" = "Bounding box meenemen" +"Include id attribute" = "id-attribuut opnemen" +"Included" = "Inbegrepen" +"Included in layout" = "Inbegrepen in de layout" +"Individual corners" = "Individuele hoeken" +"Individual strokes" = "Afzonderlijke lijnen" +"Ink" = "Ink" +"Inner shadow" = "Binnenschaduw" +"Inside" = "Binnen" +"Inspect" = "Inspecteren" +"Inspect the layer under the pointer: size, colours, typography" = "Inspecteer de laag onder de muisaanwijzer: grootte, kleuren, typografie" +"Install" = "Installeren" +"Install plugin" = "Plugin installeren" +"Installed" = "Geïnstalleerd" +"Installed {name}" = "{name} geïnstalleerd" +"Installing…" = "Installeren…" +"Instance" = "Instance" +"Instance properties" = "Instance-eigenschappen" +"Instance swap" = "Instance verwisselen" +"Instance swap (no other component)" = "Instance verwisselen (geen andere component)" +"Instances" = "Instances" +"Instant" = "Direct" +"Interaction" = "Interactie" +"Interactions" = "Interacties" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-ontwerp en prototyping. Een gratis, open-source, clean-room ontwerptool, helemaal van nul geschreven in Rust, onderdeel van de ArtCraft Crafting Apps." +"Intersect" = "Intersect" +"Intersects" = "Overlapt" +"Invalid color" = "Ongeldige kleur" +"Invite link" = "Uitnodigingslink" +"Invite link copied" = "Uitnodigingslink gekopieerd" +"It asks permission to:" = "Het vraagt toestemming om:" +"Jan" = "jan." +"Join" = "Deelnemen" +"Join code copied" = "Toegangscode gekopieerd" +"Join code or invite link" = "Deelnamecode of uitnodigingslink" +"Join live session" = "Deelnemen aan live-sessie" +"Join us on Discord" = "Chat met ons op Discord" +"Join with a code" = "Deelnemen met een code" +"Joined" = "Erbij gekomen" +"Joined {file}" = "Deelgenomen aan {file}" +"Joining…" = "Bezig met deelnemen…" +"Join|stroke" = "Hoek" +"Jul" = "jul." +"Jun" = "jun." +"Just now" = "Zojuist" +"Justify" = "Uitvullen" +"Keep appearance" = "Uiterlijk behouden" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." +"Keep tool selected after use" = "Tool na gebruik geselecteerd houden" +"Keep values" = "Waarden behouden" +"Key/Gamepad" = "Toets/Gamepad" +"Key/Gamepad {key}" = "Toets/Gamepad {key}" +"Keyboard shortcuts" = "Sneltoetsen" +"Kits" = "Kits" +"Landing page" = "Landingspagina" +"Language" = "Taal" +"Lasso" = "Lasso" +"Last" = "Laatst" +"Last change: {note}" = "Laatste wijziging: {note}" +"Last commit" = "Laatste commit" +"Last modified" = "Laatst gewijzigd" +"Last on top" = "Laatste bovenaan" +"Layer blur" = "Laagvervaging" +"Layer name" = "Laagnaam" +"Layers" = "Lagen" +"Layers in an instance take their position from the main component" = "Lagen in een instance nemen hun positie over van de hoofdcomponent" +"Layout" = "Layout" +"Layout grid" = "Layoutraster" +"Layout grid settings" = "Instellingen voor layoutraster" +"Layout grids" = "Layout-rasters" +"Leave session" = "Sessie verlaten" +"Leaves" = "Leaves" +"Left" = "Links" +"Left & right" = "Links en rechts" +"Left side" = "Linkerkant" +"Left side width" = "Breedte linkerkant" +"Letter case" = "Letterkast" +"Letter spacing" = "Letterafstand" +"Libraries" = "Bibliotheken" +"Libraries in this file" = "Bibliotheken in dit bestand" +"Library updates available" = "Bibliotheekupdates beschikbaar" +"Library: click to collapse" = "Bibliotheek: klik om samen te vouwen" +"Library: click to expand" = "Bibliotheek: klik om uit te klappen" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Bibliotheek: de variabelen zijn hier alleen-lezen; bewerk ze in het bibliotheekbestand en publiceer dat" +"Light" = "Licht" +"Lighten" = "Lighten" +"Light|glass" = "Light" +"Line" = "Lijn" +"Line arrow" = "Lijnpijl" +"Line delta" = "Regeldelta" +"Line height" = "Regelafstand" +"Linear" = "Lineair" +"Lines" = "Lijnen" +"Lines +/−" = "Regels +/−" +"Lines added" = "Toegevoegde regels" +"Lines deleted" = "Regels verwijderd" +"Link" = "Koppeling" +"Link copied" = "Link gekopieerd" +"Link docs, code or tickets for developers." = "Koppel documentatie, code of tickets voor ontwikkelaars." +"Link frames on the Prototype tab, then present the flow" = "Koppel frames op het Prototype-tabblad en presenteer daarna de flow" +"Link this component to its code component: instances then show your real snippet with their values." = "Koppel deze component aan zijn codecomponent: instances laten dan je echte snippet zien met hun waarden." +"List spacing" = "Lijstafstand" +"List style" = "Lijststijl" +"Live session" = "Live sessie" +"Live · {people}" = "Live · {people}" +"Local components" = "Lokale componenten" +"Local network" = "Lokaal netwerk" +"Local styles" = "Lokale stijlen" +"Local variables" = "Lokale variabelen" +"Lock/Unlock" = "Vergrendelen/ontgrendelen" +"Locked" = "Vergrendeld" +"Lowercase" = "Kleine letters" +"Luminance" = "Luminantie" +"Luminosity" = "Luminosity" +"Main" = "Main" +"Main component deleted" = "Hoofdcomponent verwijderd" +"Main menu" = "Hoofdmenu" +"Make a component" = "Maak een component" +"Make slot" = "Slot maken" +"Mar" = "mrt." +"Margin" = "Marge" +"Mark all as read" = "Markeer alles als gelezen" +"Mark as resolved" = "Markeren als opgelost" +"Mark as unread" = "Markeren als ongelezen" +"Mark as unresolved" = "Markeren als onopgelost" +"Mask" = "Masker" +"Mass · Stiffness · Damping" = "Massa · Stijfheid · Demping" +"Match (empty: the whole name)" = "Overeenkomst (leeg: de hele naam)" +"Max H" = "Max H" +"Max W" = "Max B" +"Max lines" = "Max. regels" +"Maximize" = "Maximaliseren" +"May" = "mei" +"Measure to the selection" = "Meten tot de selectie" +"Merge into main" = "Samenvoegen in main" +"Merge regions" = "Gebieden samenvoegen" +"Merge the branch into the main file (one undo step) and archive it" = "Voeg de branch samen met het hoofdbestand (één keer ongedaan maken) en archiveer hem" +"Merged" = "Samengevoegd" +"Merged PRs" = "Samengevoegde PRs" +"Min H" = "Min H" +"Min W" = "Min. B" +"Minimize" = "Minimaliseren" +"Mirror angle" = "Hoek spiegelen" +"Mirror angle and length" = "Spiegel hoek en lengte" +"Mirroring" = "Spiegeling" +"Missing" = "Ontbreekt" +"Missing layer" = "Ontbrekende laag" +"Missing style" = "Ontbrekende stijl" +"Missing variable" = "Ontbrekende variabele" +"Miter" = "Punt" +"Miter angle" = "Verstekhoek" +"Mixed" = "Gemengd" +"Mixed fonts" = "Gemengde lettertypen" +"Mixed interactions: click + to replace them" = "Gemengde interacties: klik op + om ze te vervangen" +"Mobile app" = "Mobiele app" +"Mode" = "Modus" +"Model" = "Model" +"Models" = "Modellen" +"Mono" = "Mono" +"More" = "Meer" +"More instance actions" = "Meer instance-acties" +"Mouse down" = "Muisknop ingedrukt" +"Mouse enter" = "Muis erover" +"Mouse leave" = "Muis verlaat" +"Mouse up" = "Muis loslaten" +"Move" = "Verplaatsen" +"Move along the toolbar or the layer rows" = "Beweeg langs de werkbalk of de rijen met lagen" +"Move column left" = "Kolom naar links verplaatsen" +"Move column right" = "Kolom naar rechts verplaatsen" +"Move down" = "Omlaag verplaatsen" +"Move in" = "Naar binnen bewegen" +"Move out" = "Naar buiten verplaatsen" +"Move tools" = "Verplaatsgereedschap" +"Move up" = "Omhoog verplaatsen" +"Moved to {name}" = "Verplaatst naar {name}" +"Multi" = "Multi" +"Multiply" = "Multiply" +"Muted" = "Muted" +"Name" = "Naam" +"Name (A–Z)" = "Naam (A–Z)" +"Named instance" = "Benoemde instance" +"Navigate to" = "Ga naar" +"Nested instance" = "Geneste instance" +"New design file" = "Nieuw ontwerpbestand" +"Newest first" = "Nieuwste eerst" +"Next frame" = "Volgend frame" +"Next or previous control in the focused region" = "Volgende of vorige bedieningselement in het gebied met focus" +"Next ready for dev" = "Volgende klaar voor dev" +"Next size option" = "Volgende grootteoptie" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Geen assets: geen exportinstellingen, pictogrammen of afbeeldingen. Voeg exportinstellingen toe op het Design-tabblad." +"No boolean variables fit here." = "Er passen hier geen booleaanse variabelen." +"No boolean variables yet. Create them in Local variables." = "Nog geen booleaanse variabelen. Maak ze in Lokale variabelen." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Geen branches. Een branch is een kopie van het bestand om wijzigingen in uit te proberen, daarna te beoordelen en samen te voegen." +"No category" = "Geen categorie" +"No changes" = "Geen wijzigingen" +"No color styles" = "Geen kleurstijlen" +"No colour variables fit here." = "Hier passen geen kleurvariabelen." +"No colour variables yet. Create them in Local variables." = "Nog geen kleurvariabelen. Maak ze in Lokale variabelen." +"No comments yet" = "Nog geen reacties" +"No compatibility warnings reported" = "Geen compatibiliteitswaarschuwingen gerapporteerd" +"No components yet. Select layers and press {keys}." = "Nog geen components. Selecteer lagen en druk op {keys}." +"No conflicts." = "Geen conflicten." +"No contributor data was built into this copy." = "In deze kopie zijn geen bijdragersgegevens ingebouwd." +"No device" = "Geen apparaat" +"No fonts match" = "Geen lettertypen gevonden" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier is geen toegangscode (de server is ouder dan toegangscodes, of dit adres past niet in een code): deel in plaats daarvan de link." +"No layer changes to show." = "Geen lagenwijzigingen om te tonen." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Nog geen bibliotheken. Voeg een .figcraft-bestand toe dat als bibliotheek is gepubliceerd, of plak de link van een teambibliotheek." +"No list" = "Geen lijst" +"No matching comments" = "Geen overeenkomende reacties" +"No matching components" = "Geen overeenkomende components" +"No matching layers" = "Geen bijpassende lagen" +"No matching styles" = "Geen bijpassende stijlen" +"No matching variables" = "Geen passende variabelen" +"No mirroring" = "Geen spiegeling" +"No model credits were built into this copy." = "Er zijn geen modelcredits in deze kopie ingebouwd." +"No number variables fit here." = "Hier passen geen getalvariabelen." +"No number variables yet. Create them in Local variables." = "Nog geen getalvariabelen. Maak ze in Lokale variabelen." +"No other pages" = "Geen andere pagina's" +"No paints" = "Geen vullingen" +"No plugins on {server}." = "Geen plugins op {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Nog geen plugins. Importeer een development-plugin via zijn figcraft-plugin.json." +"No preferred components" = "Geen voorkeurscomponenten" +"No preset" = "Geen preset" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Nog geen opgeslagen versies. Sla een versie op om een moment in je werk vast te leggen waar je later naartoe kunt terugkeren." +"No scrolling" = "Geen scrollen" +"No shortcuts match" = "Er komen geen sneltoetsen overeen" +"No status" = "Geen status" +"No string variables fit here." = "Er passen hier geen string-variabelen." +"No string variables yet. Create them in Local variables." = "Nog geen tekenreeksvariabelen. Maak ze aan in Lokale variabelen." +"No styles yet." = "Nog geen stijlen." +"No styles yet. Click + to create one from the selection." = "Nog geen stijlen. Klik op + om er een te maken vanuit de selectie." +"No templates match your search" = "Geen sjablonen gevonden voor je zoekopdracht" +"No text styles yet. Click + to create one from the selected text." = "Nog geen tekststijlen. Klik op + om er een te maken vanuit de geselecteerde tekst." +"No variables fit here." = "Er passen hier geen variabelen." +"No variables yet. Create them in Local variables." = "Nog geen variabelen. Maak ze in Lokale variabelen." +"Nobody else is here yet" = "Er is nog niemand anders hier" +"Noise" = "Ruis" +"None" = "Geen" +"None." = "Geen." +"Normal" = "Normal" +"Not bound" = "Niet gekoppeld" +"Not in a live session" = "Niet in een live sessie" +"Not in this font" = "Niet in dit lettertype" +"Not properties, kept as written: {names}" = "Geen eigenschappen, ongewijzigd overgenomen: {names}" +"Not read yet" = "Nog niet gelezen" +"Not shared" = "Niet gedeeld" +"Note {n}" = "Notitie {n}" +"Nothing changed since this was marked ready for dev." = "Er is niets gewijzigd sinds dit was gemarkeerd als ready for dev." +"Nothing here yet. Set a frame's status to list it." = "Nog niets hier. Stel de status van een frame in om het hier te tonen." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Er staat nog niets online. Gebruik Delen bij een bestand om het online te zetten, dan wordt het hier vermeld." +"Nothing to export" = "Niets te exporteren" +"Nov" = "nov." +"Now" = "Nu" +"Nudge (Shift: big nudge)" = "Verschuiven (Shift: grote stap)" +"Nudge amount" = "Stapafstand" +"Number" = "Getal" +"Number ↑" = "Getal ↑" +"Number ↓" = "Nummer ↓" +"Numbered list" = "Genummerde lijst" +"Object" = "Object" +"Oct" = "okt." +"Off" = "Uit" +"Offered first when filling the slot (any content is allowed)" = "Wordt het eerst aangeboden bij het vullen van de slot (elke inhoud is toegestaan)" +"Offered first when swapping the instance (any component is allowed)" = "Wordt als eerste aangeboden bij het verwisselen van de instance (elk component is toegestaan)" +"Offset" = "Verschuiving" +"Oldest first" = "Oudste eerst" +"On click" = "Bij klik" +"On drag" = "Bij slepen" +"On this page" = "Op deze pagina" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Eén aankondiging in vijf gangbare formaten: vierkant, staand, story, link en banner" +"Online" = "Online" +"Online files" = "Online bestanden" +"Online sharing is off: files are shared on the local network only." = "Online delen staat uit: bestanden worden alleen op het lokale netwerk gedeeld." +"Only the first changed layers are listed." = "Alleen de eerste gewijzigde lagen worden weergegeven." +"Only the first {n} changed layers are listed." = "Er worden alleen de eerste {n} gewijzigde lagen weergegeven." +"Only your threads" = "Alleen jouw gesprekken" +"Opacity" = "Dekking" +"Opacity & blend" = "Dekking & blend" +"Opacity 10%…90%, 0: 100%" = "Dekking 10%…90%, 0: 100%" +"Open" = "Openen" +"Open .figcraft, .svg or images" = "Open .figcraft, .svg of afbeeldingen" +"Open branch" = "Branch openen" +"Open file" = "Bestand openen" +"Open files" = "Geopende bestanden" +"Open in focus view" = "Openen in focusweergave" +"Open in new window" = "Openen in een nieuw venster" +"Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft" +"Open link" = "Link openen" +"Open link {url}" = "Link {url} openen" +"Open overlay" = "Overlay openen" +"Open prototype preview" = "Voorvertoning van prototype openen" +"Open variables ({count})" = "Variabelen openen ({count})" +"Opened as a new document" = "Geopend als nieuw document" +"Opened “{name}” in a new tab" = "“{name}” geopend in een nieuw tabblad" +"Opening the file…" = "Bestand openen…" +"Opening {n} files as new files" = { one = "Openen als nieuw bestand", other = "{n} bestanden openen als nieuwe bestanden" } +"Open|comments" = "Open" +"Options" = "Opties" +"Orange" = "Oranje" +"Order" = "Volgorde" +"Other" = "Overige" +"Other apps may not open every .fig file exported here." = "Het kan zijn dat andere apps niet elk .fig-bestand openen dat hier wordt geëxporteerd." +"Others lose access and keep their copy; the file stays on the server for you" = "Anderen verliezen de toegang en behouden hun kopie; het bestand blijft voor jou op de server" +"Others type this under Share ▸ Join with a code" = "Anderen typen dit onder Delen ▸ Deelnemen met een code" +"Outline stroke" = "Lijn omzetten naar contour" +"Outline text" = "Tekst omzetten in outlines" +"Outlines" = "Contouren" +"Outside" = "Buiten" +"Overflow" = "Overloop" +"Overlay" = "Overlay" +"Overlay position" = "Overlaypositie" +"Overridden here (the collection it extends has another value)" = "Hier overschreven (de verzameling die het uitbreidt heeft een andere waarde)" +"Overrides" = "Overschrijvingen" +"Owner token" = "Eigenaartoken" +"Owner token copied" = "Eigenaartoken gekopieerd" +"PDF print options" = "PDF-afdrukopties" +"PDF print settings" = "PDF-afdrukinstellingen" +"Padding" = "Padding" +"Page" = "Pagina" +"Pages" = "Pagina's" +"Pages added to the document" = "Pagina's toegevoegd aan het document" +"Pages: {pages}" = "Pagina's: {pages}" +"Paint bucket" = "Vulemmertje" +"Pan the canvas" = "Verschuif het canvas" +"Pan with nothing selected (Shift: farther)" = "Verschuiven zonder selectie (Shift: verder)" +"Panels" = "Panelen" +"Paper" = "Paper" +"Paragraph indent" = "Alinea-inspringen" +"Paragraph indent applies to left-aligned text" = "Alineainspringing geldt voor links uitgelijnde tekst" +"Paragraph spacing" = "Alinea-afstand" +"Pass through" = "Pass through" +"Paste a link (https://…)" = "Plak een link (https://…)" +"Paste a team library link (a view link)" = "Plak een link naar een teambibliotheek (een weergavelink)" +"Paste here" = "Hier plakken" +"Paste to replace" = "Plakken om te vervangen" +"Pasted design layers; {n} kinds of content were left out" = { one = "Ontwerplagen geplakt; {n} soort inhoud is weggelaten", other = "Ontwerplagen geplakt; {n} soorten inhoud zijn weggelaten" } +"Pattern" = "Patroon" +"Pattern alignment" = "Patroonuitlijning" +"Pattern scale" = "Patroonschaal" +"Pattern source" = "Patroonbron" +"Pen" = "Pen" +"Pencil" = "Potlood" +"People in this file" = "Personen in dit bestand" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Mensen kunnen met een code van overal deelnemen. Het bestand blijft op {server} staan zolang je het deelt." +"People on your network join with a code. Nothing goes through a server." = "Mensen op je netwerk nemen deel met een code. Er gaat niets via een server." +"Phone" = "Telefoon" +"Pick color" = "Kleur opnemen" +"Pick color (I)" = "Kleur kiezen (I)" +"Pick one font family first" = "Kies eerst één lettertypefamilie" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Kies de Frame-tool en sleep daarna op het canvas of kies een apparaatgrootte" +"Pin a property" = "Eigenschap vastspelden" +"Pinch" = "Pinch" +"Pixel grid" = "Pixelraster" +"Pixel preview 1x" = "Pixelvoorvertoning 1x" +"Pixel preview 2x" = "Pixelvoorvertoning 2x" +"Place a shape or frame (its tool chosen)" = "Plaats een vorm of frame (het bijbehorende gereedschap is gekozen)" +"Place automatically" = "Automatisch plaatsen" +"Place image" = "Afbeelding plaatsen" +"Placed on the current page" = "Geplaatst op de huidige pagina" +"Playground" = "Playground" +"Plugin" = "Plugin" +"Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins draaien in de sandbox van FigCraft, zonder bestanden, netwerk of klok. Installeer alleen plugins die je vertrouwt." +"Plus darker" = "Plus darker" +"Plus lighter" = "Plus lighter" +"Point" = "Punt" +"Points" = "Punten" +"Polygon" = "Veelhoek" +"Polygons" = "Veelhoeken" +"Port" = "Poort" +"Position" = "Positie" +"Post" = "Plaatsen" +"Preferences" = "Voorkeuren" +"Preferred" = "Voorkeur" +"Present" = "Presenteren" +"Present or preview" = "Presenteren of voorvertonen" +"Present this flow" = "Deze flow presenteren" +"Presentation" = "Presentatie" +"Presentation slides" = "Presentatiedia's" +"Presenting: comments" = "Presenteren: reacties" +"Presenting: cycle the size options" = "Presenteren: door de grootteopties bladeren" +"Presenting: full screen" = "Presenteren: volledig scherm" +"Presenting: next frame" = "Presenteren: volgende frame" +"Presenting: previous frame" = "Presenteren: vorig frame" +"Presenting: restart" = "Presenteren: opnieuw starten" +"Preserve scroll position" = "Scrollpositie behouden" +"Presets" = "Presets" +"Press a key…" = "Druk op een toets…" +"Preview" = "Voorvertoning" +"Preview this flow" = "Voorvertoning van deze flow" +"Preview this frame" = "Dit frame voorvertonen" +"Previous frame" = "Vorige frame" +"Previous ready for dev" = "Vorige klaar voor developers" +"Progressive" = "Progressive" +"Properties" = "Eigenschappen" +"Prototype" = "Prototype" +"Prototype preview" = "Prototypevoorvertoning" +"Prototype settings" = "Prototype-instellingen" +"Publish" = "Publiceren" +"Publish changes" = "Wijzigingen publiceren" +"Publish to another server…" = "Publiceren op een andere server…" +"Publish to server" = "Publiceren naar de server" +"Published version {v}" = "Gepubliceerde versie {v}" +"Published version {v} to the server" = "Versie {v} naar de server gepubliceerd" +"Published version {v}: save the file so other files can use it" = "Gepubliceerde versie {v}: sla het bestand op zodat andere bestanden het kunnen gebruiken" +"Published · {people}" = "Gepubliceerd · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publiceren naar een server vraagt om de desktop-app; in de browser kun je deelnemen aan de bestanden van een server" +"Publishing with the server's key." = "Publiceren met de sleutel van de server." +"Publishing…" = "Bezig met publiceren…" +"Push" = "Pushen" +"Push changes to main component" = "Wijzigingen naar hoofdcomponent doorvoeren" +"Put text on path" = "Tekst op pad plaatsen" +"R" = "R" +"Radial" = "Radiaal" +"Radius" = "Radius" +"Ratio" = "Verhouding" +"Reading the library…" = "Bibliotheek wordt gelezen…" +"Ready for dev" = "Klaar voor dev" +"Real name" = "Echte naam" +"Recent" = "Onlangs geopend" +"Recent files" = "Onlangs geopende bestanden" +"Recents" = "Onlangs geopend" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Na een onverwachte afsluiting van de app is één niet-opgeslagen bestand hersteld: sla het op om het te bewaren", other = "Na een onverwachte afsluiting van de app zijn {n} niet-opgeslagen bestanden hersteld: sla ze op om ze te bewaren" } +"Rectangle" = "Rechthoek" +"Rectangles" = "Rechthoeken" +"Red" = "Rood" +"Reduce motion" = "Beweging verminderen" +"Refraction" = "Breking" +"Regular expression" = "Reguliere expressie" +"Remove" = "Verwijderen" +"Remove all" = "Alles verwijderen" +"Remove annotation" = "Annotatie verwijderen" +"Remove auto layout" = "Auto layout verwijderen" +"Remove dev resource" = "Dev-resource verwijderen" +"Remove export setting" = "Exportinstelling verwijderen" +"Remove from recents" = "Verwijderen uit onlangs geopend" +"Remove from server" = "Van de server verwijderen" +"Remove from server…" = "Van server verwijderen…" +"Remove guide" = "Hulplijn verwijderen" +"Remove interaction" = "Interactie verwijderen" +"Remove library from this file" = "Bibliotheek uit dit bestand verwijderen" +"Remove min and max" = "Min en max verwijderen" +"Remove min and max height" = "Minimale en maximale hoogte verwijderen" +"Remove min and max width" = "Minimale en maximale breedte verwijderen" +"Remove regions" = "Gebieden verwijderen" +"Remove slot" = "Vak verwijderen" +"Remove starting point" = "Startpunt verwijderen" +"Remove stop" = "Stop verwijderen" +"Remove text from path" = "Tekst losmaken van pad" +"Remove the else and its actions" = "De else en de bijbehorende acties verwijderen" +"Remove this condition and its actions" = "Deze voorwaarde en de bijbehorende acties verwijderen" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Dit bestand van de server verwijderen? Het kan daarna niet meer worden gedeeld of geopend vanaf de server. Jouw kopie blijft hier open." +"Remove {name} from session" = "{name} verwijderen uit de sessie" +"Remove {name} from {server}?" = "{name} van {server} verwijderen?" +"Removed" = "Verwijderd" +"Removed from the server" = "Van de server verwijderd" +"Removed since it was marked" = "Verwijderd sinds het was gemarkeerd" +"Rename" = "Hernoemen" +"Rename duplicated layers" = "Gedupliceerde lagen hernoemen" +"Rename folder" = "Map hernoemen" +"Rename group" = "Groep hernoemen" +"Rename layers" = "Lagen hernoemen" +"Rename mode" = "Modus hernoemen" +"Rename on the server" = "Hernoemen op de server" +"Rename on the server…" = "Hernoemen op de server…" +"Rename page" = "Pagina hernoemen" +"Rename to" = "Hernoemen naar" +"Replace all" = "Alles vervangen" +"Replace mixed export settings" = "Gemengde exportinstellingen vervangen" +"Replace with" = "Vervangen door" +"Replace with a new interaction" = "Vervangen door een nieuwe interactie" +"Reply" = "Antwoorden" +"Report" = "Melden" +"Report this file…" = "Dit bestand melden…" +"Reset" = "Herstellen" +"Reset adjustments" = "Aanpassingen herstellen" +"Reset all changes" = "Alle wijzigingen terugdraaien" +"Reset all overrides on this layer" = "Alle overschrijvingen op deze laag herstellen" +"Reset overrides" = "Overschrijvingen terugzetten" +"Reset slot" = "Slot terugzetten" +"Reset this override" = "Deze overschrijving terugzetten" +"Reset to style" = "Terugzetten naar stijl" +"Reset to the inherited value" = "Terugzetten naar de overgeërfde waarde" +"Resize by the nudge amount" = "Formaat wijzigen met de stapafstand" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Pas het formaat van frames aan naar het scherm van het apparaat (of het venster zonder apparaat): hun constraints en auto layout reageren" +"Resize to fit" = "Formaat aanpassen aan inhoud" +"Resizing" = "Formaat wijzigen" +"Resolve" = "Oplossen" +"Resolve change" = "Wijziging oplossen" +"Resolved" = "Opgelost" +"Resources" = "Bronnen" +"Responsive" = "Responsive" +"Restart" = "Opnieuw starten" +"Restore" = "Terugzetten" +"Restore main component" = "Hoofdcomponent terugzetten" +"Restore this version" = "Deze versie terugzetten" +"Restored “{name}”" = "“{name}” teruggezet" +"Reverse the order" = "Volgorde omdraaien" +"Reversed triangle" = "Omgekeerde driehoek" +"Review and merge" = "Controleren en samenvoegen" +"Ribbon" = "Ribbon" +"Right" = "Rechts" +"Right side" = "Rechterkant" +"Right side width" = "Breedte rechterkant" +"Rotate 90°" = "90° draaien" +"Rotate 90° right" = "90° naar rechts draaien" +"Rotate to landscape" = "Draai naar liggend" +"Rotate to portrait" = "Draaien naar staand" +"Rotation" = "Rotatie" +"Rotation jitter" = "Verspreide rotatie" +"Round" = "Rond" +"Rounded" = "Rounded" +"Row" = "Rij" +"Row gap" = "Rijafstand" +"Row sizes" = "Rijgroottes" +"Row span" = "Rijbereik" +"Rows" = "Rijen" +"Rows {count}" = "Rijen {count}" +"Rulers" = "Linialen" +"Run" = "Uitvoeren" +"Run {label}" = "{label} uitvoeren" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Zoals de laag" +"Saturation" = "Verzadiging" +"Save" = "Opslaan" +"Save as" = "Opslaan als" +"Save as preset…" = "Opslaan als preset…" +"Save files automatically" = "Bestanden automatisch opslaan" +"Save version {key}" = "Versie opslaan {key}" +"Saved" = "Opgeslagen" +"Saved {name}" = "{name} opgeslagen" +"Say something" = "Zeg iets" +"Scale" = "Schalen" +"Scatter" = "Scatter" +"Scoping: where pickers offer it" = "Bereik: waar keuzeschermen hem aanbieden" +"Screen" = "Screen" +"Scroll behavior" = "Scrollgedrag" +"Scroll to" = "Scrollen naar" +"Scroll to layer" = "Naar laag scrollen" +"Scroll with parent" = "Scrollen met bovenliggend" +"Search" = "Zoeken" +"Search actions, layers and tools" = "Zoek naar acties, lagen en tools" +"Search assets" = "Zoek assets" +"Search comments" = "Zoeken in reacties" +"Search fonts" = "Lettertypen zoeken" +"Search layers" = "Lagen doorzoeken" +"Search plugins" = "Zoeken naar plugins" +"Search shortcuts" = "Sneltoetsen zoeken" +"Search templates" = "Sjablonen zoeken" +"Section" = "Sectie" +"Section color" = "Sectiekleur" +"Sections" = "Secties" +"See all templates" = "Alle sjablonen bekijken" +"Select a frame and add a starting point to present it as a flow." = "Selecteer een frame en voeg een startpunt toe om het als flow te presenteren." +"Select a frame to preview it" = "Kies een frame voor een voorvertoning" +"Select a layer to see its properties and code" = "Selecteer een laag om de eigenschappen en de code te zien" +"Select all" = "Alles selecteren" +"Select all with same" = "Alles met hetzelfde selecteren" +"Select layer" = "Laag selecteren" +"Select layers and they space themselves as their content changes" = "Selecteer lagen en ze passen hun afstand automatisch aan als de inhoud verandert" +"Select layers to apply the style to" = "Selecteer lagen om de stijl op toe te passen" +"Select layers with this color" = "Lagen met deze kleur selecteren" +"Select overlapping shapes" = "Overlappende vormen selecteren" +"Select points to edit their position, radius and mirroring" = "Selecteer punten om hun positie, radius en spiegeling te bewerken" +"Select slot" = "Kies een slot" +"Selection" = "Selectie" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selectiekader, {w} bij {h} op {x}, {y}: pijlen verplaatsen het, Shift+pijlen of {cmd}+pijlen wijzigen het formaat, Enter selecteert, Escape annuleert" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selectiekader: pijltjes verplaatsen het, Shift+pijltjes of {cmd}+pijltjes vergroten of verkleinen het, Enter selecteert, Escape annuleert" +"Selection colors" = "Kleuren in selectie" +"Send backward" = "Naar achteren sturen" +"Send report" = "Rapport verzenden" +"Send to back" = "Naar achterste brengen" +"Sep" = "sep." +"Server address" = "Serveradres" +"Server address (ws://host:47900/)" = "Serveradres (ws://host:47900/)" +"Server for online sharing" = "Server voor online delen" +"Set as default" = "Als standaard instellen" +"Set variable" = "Variabele instellen" +"Set variable mode" = "Variabelemodus instellen" +"Set variable {name}" = "Variabele {name} instellen" +"Set {name} to {value}" = "{name} instellen op {value}" +"Shadows" = "Schaduwen" +"Shape" = "Vorm" +"Shape builder" = "Shape builder" +"Shape tools" = "Vormgereedschap" +"Share" = "Delen" +"Share this file" = "Dit bestand delen" +"Share with a code" = "Delen met een code" +"Share: live session, export" = "Delen: live-sessie, exporteren" +"Shared" = "Gedeeld" +"Shared: copy its code from the file's menu" = "Gedeeld: kopieer de code uit het menu van het bestand" +"Sharing a file needs the desktop app; here you can join one." = "Een bestand delen vereist de desktop-app; hier kun je aan één deelnemen." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Delen op het lokale netwerk (online delen staat uit: er is geen server ingesteld in Voorkeuren)." +"Sharing on your network · {people}" = "Delen via je netwerk · {people}" +"Sharing online · {people}" = "Delen online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Delen gestopt: het bestand blijft op de server staan, alleen voor jou toegankelijk" +"Show" = "Weergeven" +"Show all templates" = "Alle sjablonen tonen" +"Show as grid" = "Als raster weergeven" +"Show as list" = "Weergeven als lijst" +"Show autosaves" = "Automatisch opgeslagen versies tonen" +"Show behind transparent areas" = "Weergeven achter transparante gebieden" +"Show comments (Shift+C)" = "Reacties tonen (Shift+C)" +"Show device frame" = "Apparaatframe weergeven" +"Show dimensions on objects" = "Toon afmetingen op objecten" +"Show hotspot hints" = "Hints voor hotspots tonen" +"Show in Explorer" = "Weergeven in Verkenner" +"Show in Finder" = "In Finder weergeven" +"Show in folder" = "In map tonen" +"Show only this on the canvas" = "Alleen dit op het canvas weergeven" +"Show open" = "Openstaande tonen" +"Show section contents" = "Sectie-inhoud weergeven" +"Show this layer" = "Deze laag tonen" +"Show usage" = "Gebruik weergeven" +"Show/Hide" = "Tonen/Verbergen" +"Show/Hide UI" = "UI tonen/verbergen" +"Side by side" = "Naast elkaar" +"Sidebar" = "Zijbalk" +"Simplify stroke" = "Lijn vereenvoudigen" +"Size" = "Grootte" +"Size jitter" = "Groottevariatie" +"Skip ink" = "Skip ink" +"Slashed zero" = "Nul met schuine streep" +"Slice" = "Slice" +"Slices" = "Slices" +"Slide in" = "Inschuiven" +"Slide out" = "Uitschuiven" +"Slot" = "Slot" +"Slot content" = "Slotinhoud" +"Small capitals" = "Kapitelen" +"Small caps" = "Kleine kapitalen" +"Smart animate" = "Smart animate" +"Snap to pixel grid" = "Uitlijnen op pixelraster" +"Social and slides" = "Sociale media en dia's" +"Social media" = "Sociale media" +"Social post sizes" = "Maten voor sociale posts" +"Soft light" = "Soft light" +"Solid" = "Effen" +"Some variants have the same property values: give each one a unique combination" = "Sommige varianten hebben dezelfde eigenschapswaarden: geef elke variant een unieke combinatie" +"Sort" = "Sorteren" +"Sort by" = "Sorteren op" +"Sort by date" = "Op datum sorteren" +"Sort by page" = "Sorteren op pagina" +"Sort by {column}" = "Sorteren op {column}" +"Space around" = "Ruimte rondom" +"Space between" = "Ruimte ertussen" +"Space drag" = "Slepen met de spatiebalk" +"Space evenly" = "Gelijkmatig verdelen" +"Space+drag" = "Space+drag" +"Spacing" = "Afstand" +"Spam, stolen work, abuse…" = "Spam, gestolen werk, misbruik…" +"Splay" = "Splay" +"Spotlight me" = "Spotlight mij" +"Spread" = "Spread" +"Square" = "Vierkant" +"Standard" = "Standaard" +"Standard ligatures" = "Standaardligaturen" +"Star" = "Ster" +"Stars" = "Stars" +"Start" = "Start" +"Start ascending from / stop descending at" = "Begin oplopend vanaf / stop aflopend bij" +"Start from a blank canvas" = "Begin vanaf een leeg canvas" +"Start from a template" = "Begin met een sjabloon" +"Start on path" = "Begin op het pad" +"Start point" = "Startpunt" +"Start sharing" = "Delen starten" +"Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt" +"Starting to share…" = "Bezig met delen…" +"Status" = "Status" +"Sticky" = "Sticky" +"Stop" = "Stoppen" +"Stop following {name}" = "Stop met {name} te volgen" +"Stop session" = "Sessie stoppen" +"Stop sharing" = "Stoppen met delen" +"Stop spotlighting" = "Stop met uitlichten" +"Stop {n} {hex}" = "Stop {n} {hex}" +"Stretch" = "Uitrekken" +"Strikethrough" = "Doorstrepen" +"String" = "Tekenreeks" +"Stroke" = "Lijn" +"Stroke style" = "Lijnstijl" +"Stroke weight" = "Lijndikte" +"Strokes" = "Lijnen" +"Style" = "Stijl" +"Styles" = "Stijlen" +"Stylistic set 1" = "Stilistische set 1" +"Stylistic set 2" = "Stylistische set 2" +"Stylistic set 3" = "Stylistische set 3" +"Stylistic set 4" = "Stilistische set 4" +"Stylistic set 5" = "Stilistische set 5" +"Subtract" = "Subtract" +"Subtracts" = "Trekt af" +"Suffix" = "Achtervoegsel" +"Suspend snapping while dragging" = "Uitlijning opschorten tijdens het slepen" +"Swap instance" = "Instance verwisselen" +"Swap library: use another library's assets of the same names" = "Bibliotheek verwisselen: assets met dezelfde namen uit een andere bibliotheek gebruiken" +"Swap orientation" = "Oriëntatie verwisselen" +"Swap overlay" = "Overlay verwisselen" +"Swap to {name}" = "Wisselen naar {name}" +"Swap width and height" = "Breedte en hoogte omwisselen" +"Swapped instance" = "Verwisselde instance" +"Swapped {n} assets" = { one = "{n} asset verwisseld", other = "{n} assets verwisseld" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "{n} asset verwisseld; {missed} had geen overeenkomst en gebruikt nog de oude bibliotheek", other = "{n} assets verwisseld; {missed} hadden geen overeenkomst en gebruiken nog de oude bibliotheek" } +"Sweep" = "Sweep" +"System" = "Systeem" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabel" +"Tablet" = "Tablet" +"Tabular numbers" = "Tabulaire cijfers" +"Take the values from the first selected layer" = "Waarden van de eerste geselecteerde laag overnemen" +"Taper" = "Taper" +"Taper both ends" = "Beide uiteinden versmallen" +"Tell the server's operator what is wrong with it" = "Vertel de beheerder van de server wat er mis mee is" +"Temperature" = "Temperatuur" +"Template" = "Sjabloon" +"Templates" = "Sjablonen" +"Terms" = "Voorwaarden" +"Text" = "Tekst" +"Text align" = "Tekstuitlijning" +"Text content" = "Tekstinhoud" +"Text style" = "Tekststijl" +"Text styles" = "Tekststijlen" +"Texts" = "Teksten" +"Texture" = "Texture" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Het canvas toont de kleuren als voorvertoning in sRGB; geëxporteerde PNG-, JPG- en WebP-bestanden worden gelabeld als Display P3." +"The destination document is unavailable. Nothing was imported." = "Het doeldocument is niet beschikbaar. Er is niets geïmporteerd." +"The destination document was closed. Nothing was imported." = "Het doeldocument is gesloten. Er is niets geïmporteerd." +"The destination page was removed. Nothing was imported." = "De doelpagina is verwijderd. Er is niets geïmporteerd." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Het bestand wordt van de server verwijderd: niemand kan het daar nog openen of eraan deelnemen, en dit kan niet ongedaan worden gemaakt. Kopieën die open zijn, blijven bij wie ze heeft." +"The file now holds another library" = "Het bestand bevat nu een andere bibliotheek" +"The file now holds another library (\"{name}\")" = "Het bestand bevat nu een andere bibliotheek (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "De bestanden die je vanaf deze computer online hebt gedeeld of gepubliceerd. Ze blijven op hun server totdat je ze verwijdert." +"The link carries the session's secret: share it only with people you invite." = "De link bevat het geheim van de sessie: deel hem alleen met mensen die je uitnodigt." +"The name in your online files and on the server. The document inside keeps its own name." = "De naam in je online bestanden en op de server. Het document erin behoudt zijn eigen naam." +"The selection uses several text styles: click to apply one" = "De selectie gebruikt meerdere tekststijlen: klik om er een toe te passen" +"The server deletes this file if nobody opens it before {date}." = "De server verwijdert dit bestand als niemand het vóór {date} opent." +"The whole file" = "Het hele bestand" +"Theme" = "Thema" +"There's no destination for that action" = "Er is geen doel voor die actie" +"Thickness" = "Dikte" +"This branch can't be reviewed: {e}" = "Deze branch kan niet worden beoordeeld: {e}" +"This branch was merged or archived." = "Deze branch is samengevoegd of gearchiveerd." +"This computer only" = "Alleen op deze computer" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Deze computer onthoudt het eigenaartoken, zodat het bestand beheerd kan worden vanuit de Online-lijst op het beginscherm. Kopieer het om het bestand vanaf een andere plek te beheren: het is het wachtwoord voor dit bestand en wordt niet opnieuw weergegeven." +"This condition is edited as an expression" = "Deze voorwaarde wordt als expressie bewerkt" +"This file" = "Dit bestand" +"This file has unsaved changes" = "Dit bestand heeft niet-opgeslagen wijzigingen" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Dit bestand staat op {server}, alleen toegankelijk voor jou. Deel het om anderen binnen te laten met een nieuwe code." +"This frame is new on the branch." = "Dit frame is nieuw op de branch." +"This frame isn't on the branch." = "Dit frame staat niet op de branch." +"This server is open to the public: no key needed." = "Deze server is voor iedereen toegankelijk: geen sleutel nodig." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Deze server heeft een sleutel nodig: voeg ?key=… toe aan het adres (`figcraft-server key` toont die op de computer van de server)." +"This server takes files only with its key: see Advanced." = "Deze server accepteert bestanden alleen met de bijbehorende sleutel: zie Geavanceerd." +"Tidy up" = "Opruimen" +"Tile" = "Tegelen" +"Tile type" = "Tegeltype" +"Tint" = "Tint" +"Title" = "Titel" +"Title case" = "Hoofdletter per woord" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om deze bibliotheek met je team te delen, publiceer het bestand naar een server (Delen ▸ Publiceren naar server…), publiceer daarna de bibliotheek." +"Toggle" = "Omschakelen" +"Toggle light/dark theme" = "Wisselen tussen licht/donker thema" +"Toggle visibility" = "Zichtbaarheid omschakelen" +"Tools" = "Gereedschappen" +"Top" = "Boven" +"Top & bottom" = "Boven & onder" +"Top left radius" = "Radius linksboven" +"Top right radius" = "Hoekradius rechtsboven" +"Transform" = "Transformeren" +"Triangle arrow" = "Driehoekspijl" +"Trigger" = "Trigger" +"True" = "Waar" +"Truncate text" = "Tekst afkappen" +"Truncate with an ellipsis" = "Afkappen met een beletselteken" +"Try again" = "Probeer het opnieuw" +"Try another search or filter." = "Probeer een andere zoekopdracht of filter." +"Try another word, or look at every kind." = "Probeer een ander woord, of bekijk alle soorten." +"Turn a layer into a component, reuse it, and change every copy at once" = "Maak van een laag een component, hergebruik die en verander alle kopieën tegelijk" +"Two digits" = "Twee cijfers" +"Type" = "Type" +"Type settings" = "Tekstinstellingen" +"Type the join code you were given, or paste an invite link." = "Typ de toegangscode die je hebt gekregen, of plak een uitnodigingslink." +"Typography" = "Typografie" +"Underline" = "Onderstrepen" +"Undo takes back only your own changes; what others changed since stays." = "Ongedaan maken zet alleen je eigen wijzigingen terug; wat anderen sindsdien hebben gewijzigd, blijft staan." +"Ungroup" = "Groep opheffen" +"Uniform" = "Gelijkmatig" +"Union" = "Union" +"Unions" = "Unies" +"Unknown — treated as sRGB" = "Onbekend — behandeld als sRGB" +"Unread" = "Ongelezen" +"Unresolve" = "Oplossing ongedaan maken" +"Unsaved changes" = "Niet-opgeslagen wijzigingen" +"Unsaved changes in {names}. Close the window and lose them?" = "Niet-opgeslagen wijzigingen in {names}. Het venster sluiten en ze verliezen?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niet-opgeslagen wijzigingen: sla eerst op ({save}), of sluit het tabblad met {alt}-klik om ze te verwerpen" +"Unsaved changes: save first, or close again with {key} to discard" = "Niet-opgeslagen wijzigingen: sla eerst op, of sluit nogmaals met {key} om te verwerpen" +"Up" = "Omhoog" +"Update" = "Bijwerken" +"Update from main" = "Bijwerken vanaf main" +"Update from selection" = "Bijwerken vanuit de selectie" +"Update plugin" = "Plugin bijwerken" +"Updated {components} and {styles}" = "{components} en {styles} bijgewerkt" +"Updated {name}" = "{name} bijgewerkt" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Het bijwerken van de grafische driver helpt vaak. Om alleen OpenGL te gebruiken, start je FigCraft vanaf de Opdrachtprompt met:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Het bijwerken van de grafische driver helpt vaak. WGPU_BACKEND (vulkan, gl, metal) noemt een andere grafische API om te proberen." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploadt dit bestand naar een FigCraft-server en houdt dit tabblad daarmee gesynchroniseerd. Je krijgt een link om te bewerken en een link om te bekijken." +"Uppercase" = "Hoofdletters" +"Use a fixed value" = "Gebruik een vaste waarde" +"Use an expression, e.g. count + 1" = "Gebruik een expressie, bijv. count + 1" +"Use as mask" = "Gebruiken als masker" +"Use auto layout" = "Auto layout gebruiken" +"Use number keys for opacity" = "Gebruik cijfertoetsen voor dekking" +"Use the .figcraft or .fig extension." = "Gebruik de extensie .figcraft of .fig." +"Use the layer's fill" = "De vulling van de laag gebruiken" +"Used in {files} · {instances}" = "Gebruikt in {files} · {instances}" +"Username" = "Gebruikersnaam" +"Uses variables: each layer shows them in its own mode" = "Gebruikt variabelen: elke laag toont ze in zijn eigen modus" +"Value" = "Waarde" +"Variable" = "Variabele" +"Variable collections" = "Verzamelingen van variabelen" +"Variable modes" = "Variabelemodi" +"Variable width" = "Variabele breedte" +"Variables" = "Variabelen" +"Variables JSON…" = "Variabelen-JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Variabelen slaan herbruikbare waarden (kleuren, getallen, tekenreeksen, booleans) op met modi zoals Light en Dark." +"Variant" = "Variant" +"Vector" = "Vector" +"Vectors" = "Vectoren" +"Version" = "Versie" +"Version history" = "Versiegeschiedenis" +"Version {version}" = "Versie {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versie {v} · {components} · {styles} · {variables}" +"Vertical" = "Verticaal" +"Vertical align" = "Verticaal uitlijnen" +"Vertical hex" = "Verticale hex" +"Vertical padding" = "Verticale padding" +"Vertical spacing" = "Verticale afstand" +"Vertical trim" = "Verticaal bijsnijden" +"View" = "Weergave" +"View link" = "Weergavelink" +"View link copied" = "Weergavelink gekopieerd" +"View only" = "Alleen weergeven" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Alleen weergeven: je ziet elke wijziging live terwijl die plaatsvindt, maar je kunt niet bewerken. Verlaat de sessie om een kopie te bewaren die je kunt bewerken." +"Viewing “{name}”" = "“{name}” bekijken" +"Violet" = "Violet" +"Visibility" = "Zichtbaarheid" +"Visible" = "Zichtbaar" +"Vivid" = "Vivid" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is ingesteld en geeft de te gebruiken grafische API aan; als je het uitzet, kiest FigCraft zelf." +"Warm" = "Warm" +"Watch" = "Watch" +"Wavy" = "Golvend" +"Web" = "Web" +"WebSocket URL" = "WebSocket-URL" +"WebSocket URL copied" = "WebSocket-URL gekopieerd" +"Websites" = "Websites" +"Wedge" = "Wedge" +"Weight" = "Dikte" +"What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor Dev Mode" +"What is wrong with this file? The server's operator gets your report." = "Wat klopt er niet aan dit bestand? Je melding gaat naar de beheerder van de server." +"What it's for" = "Waarvoor het dient" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Vanwaar dit is veranderd, is niet bekend: het veranderde voordat FigCraft een kopie bewaarde van dev-klare frames. Los de wijziging op (of markeer het weer als te vergelijken) om vanaf nu te kunnen vergelijken." +"What you try here changes this preview and the code below, not the file" = "Wat je hier probeert, wijzigt deze voorvertoning en de code hieronder, niet het bestand" +"While hovering" = "Tijdens het aanwijzen" +"While pressing" = "Tijdens het indrukken" +"White" = "Wit" +"Who runs it, its terms and how long it keeps files" = "Wie het beheert, de voorwaarden en hoelang het bestanden bewaart" +"Why it changed" = "Waarom het is veranderd" +"Widget" = "Widget" +"Widgets" = "Widgets" +"Width" = "Breedte" +"Width point" = "Breedtepunt" +"Width profile" = "Breedteprofiel" +"Wireframe kit" = "Wireframekit" +"With the layers' export settings" = "Met de exportinstellingen van de lagen" +"Wrap" = "Omloop" +"Wrap in new section" = "Inpakken in een nieuwe sectie" +"Yellow" = "Geel" +"Yesterday" = "Gisteren" +"You" = "Jij" +"You'll get the shared file as a new tab and edit it together." = "Je krijgt het gedeelde bestand als nieuw tabblad en bewerkt het samen." +"You're spotlighting: everyone follows your view" = "Je bent in de spotlight: iedereen volgt jouw weergave" +"Your copy of the file stays open" = "Je kopie van het bestand blijft open" +"Your copy stays open; the file stays on the server" = "Jouw kopie blijft open; het bestand blijft op de server" +"Your name" = "Jouw naam" +"Your name in comments" = "Je naam in reacties" +"Your system asks for reduced motion: animations stay off." = "Jouw systeem vraagt om minder beweging: animaties blijven uit." +"Z" = "Z" +"Z to A" = "Z tot A" +"Zoom" = "Zoom" +"Zoom around the pointer" = "Inzoomen rond de aanwijzer" +"Zoom device to fill window" = "Apparaat inzoomen om het venster te vullen" +"Zoom in" = "Inzoomen" +"Zoom out" = "Uitzoomen" +"Zoom to 100%" = "Zoomen naar 100%" +"Zoom to 200%" = "Inzoomen op 200%" +"Zoom to 50%" = "Zoomen naar 50%" +"Zoom to fit" = "Inpassen" +"Zoom to selection" = "Inzoomen op selectie" +"Zoom tool: click to zoom in, drag an area" = "Zoomgereedschap: klik om in te zoomen, sleep om een gebied te selecteren" +"Zoom tool: zoom out" = "Zoomgereedschap: uitzoomen" +"and {n} more" = { one = "en {n} meer", other = "en {n} meer" } +"bleed marks" = "afloopmarkeringen" +"by {author}" = "door {author}" +"call any command its access allows" = "elke opdracht aanroepen die diens toegang toestaat" +"call only: {commands}" = "alleen aanroepbaar: {commands}" +"can edit the document" = "kan het document bewerken" +"conditional prototype actions nested too deeply were skipped" = "Voorwaardelijke prototype-acties die te diep genest zijn, zijn overgeslagen" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "voorwaardelijke prototypeacties met voorwaarden die niet konden worden geëvalueerd zijn overgeslagen" +"connector label background was skipped: style data is missing" = "achtergrond van connectorlabel overgeslagen: stijldata ontbreekt" +"connector label was skipped: background bounds are invalid" = "connectorlabel is overgeslagen: achtergrondgrenzen zijn ongeldig" +"connector label was skipped: background transform is invalid" = "connector labels overgeslagen: achtergrondtransformatie is ongeldig" +"connector label was skipped: composed text transform is invalid" = "connectorlabel is overgeslagen: samengestelde teksttransformatie is ongeldig" +"connector label was skipped: derived background bounds are missing" = "connectorlabel is overgeslagen: afgeleide achtergrondgrenzen ontbreken" +"connector label was skipped: derived text bounds are missing" = "connectorlabel is overgeslagen: afgeleide tekstgrenzen ontbreken" +"connector label was skipped: text bounds are invalid" = "connectorlabel overgeslagen: de tekstafmetingen zijn ongeldig" +"connector label was skipped: text style data is missing" = "connectorlabel is overgeslagen: tekststijlgegevens ontbreken" +"connector label was skipped: text transform is invalid" = "Het label van de verbinding is overgeslagen: de teksttransformatie is ongeldig" +"connector labels were skipped" = "labels van connectoren zijn overgeslagen" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "connectorlagen worden geïmporteerd als statische frame-/vectormomentopnames met labels indien aanwezig; live eindpuntkoppelingen worden niet behouden" +"connector snapshot vector data is missing; connector was left empty" = "vectordata in de connector-snapshot ontbreekt; connector is leeggelaten" +"connectors whose ends can't be placed are empty" = "connectors waarvan de uiteinden niet geplaatst kunnen worden zijn leeg" +"crop marks" = "snijmarkeringen" +"damaged vector geometry was skipped" = "beschadigde vectorgeometrie is overgeslagen" +"damaged vector networks import as outlines" = "beschadigde vectornetwerken worden geïmporteerd als outlines" +"development: {path}" = "ontwikkeling: {path}" +"document children other than pages were skipped" = "documentkinderen anders dan pagina's zijn overgeslagen" +"duplicate grid track sizing entries use equal tracks" = "Dubbele vermeldingen voor trackgroottes gebruiken gelijke tracks" +"e.g. count + 1" = "bijv. count + 1" +"e.g. count >= 3 and not done" = "bijv. aantal >= 3 en niet klaar" +"elbowed and curved connectors without a saved path import as straight lines" = "haakse en gebogen connectors zonder opgeslagen pad worden geïmporteerd als rechte lijnen" +"entries without a node type were skipped" = "items zonder node-type zijn overgeslagen" +"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-links uit een browser, chat of e-mail openen deze app en laten je deelnemen aan de sessie." +"filled areas of a vector with their own fill use the layer's fill" = "gevulde gebieden van een vector met een eigen vulling gebruiken de vulling van de laag" +"from {server}" = "van {server}" +"gamepad triggers without exactly one known controller button were skipped" = "gamepad-triggers zonder precies één bekende controllerknop zijn overgeslagen" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met rastertracks overschrijdt 1000 items; de tracks gebruiken de standaard gelijke grootte" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "Trackgrootte wordt niet ondersteund of is ongeldig opgemaakt; de betreffende tracks gebruiken gelijke groottes" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met maten voor rastertracks bevat meer dan 1000 items; de tracks gebruiken de standaard gelijke maat" +"hidden" = "verborgen" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "afbeeldingsfilters zijn dubbelzinnig omdat beide filtercontainers aanwezig zijn; filterconversie is overgeslagen" +"image filters use FigCraft's approximate renderer" = "afbeeldingsfilters gebruiken de benaderende renderer van FigCraft" +"instance swap properties whose default component isn't in the file were skipped" = "instance-swap-eigenschappen waarvan de standaardcomponent niet in het bestand zit, zijn overgeslagen" +"instances inside their own main component were imported as frames" = "instances in hun eigen hoofdcomponent zijn geïmporteerd als frames" +"instances of components that aren't in the file show no layers" = "instances van components die niet in het bestand staan, tonen geen lagen" +"instances of components that aren't in the file were imported as frames" = "instances van componenten die het bestand niet bevat zijn als frames geïmporteerd" +"instances that would nest a component inside itself were imported as frames" = "Instanties die een component in zichzelf zouden nesten, zijn als frames geïmporteerd" +"instances without a main component were imported as empty frames" = "instances zonder hoofdcomponent zijn geïmporteerd als lege frames" +"invalid internal .fig text path data was ignored" = "ongeldige padgegevens van interne .fig-tekst zijn genegeerd" +"invalid or oversized vector networks import as outlines" = "ongeldige of te grote vectornetwerken worden geïmporteerd als contouren" +"invalid or oversized vector path data was skipped" = "ongeldige of te grote vectorpadgegevens zijn overgeslagen" +"just now" = "zojuist" +"key triggers without a supported key were skipped" = "key-triggers zonder een ondersteunde toets zijn overgeslagen" +"key up triggers import as key presses" = "key-up-triggers worden geïmporteerd als toetsaanslagen" +"layers without a position were placed at their parent's origin" = "lagen zonder positie zijn op de oorsprong van het bovenliggende element geplaatst" +"layers without a type were skipped" = "lagen zonder type zijn overgeslagen" +"legacy image filter container is unsupported; filter conversion was skipped" = "Verouderde container voor beeldfilters wordt niet ondersteund; filterconversie is overgeslagen" +"locked" = "vergrendeld" +"malformed image filters container was skipped" = "misvormde container met afbeeldingsfilters is overgeslagen" +"malformed image paintFilter container was skipped" = "onjuist gevormde image paintFilter-container is overgeslagen" +"malformed slot content assignment was skipped" = "onjuiste toewijzing van slot-inhoud is overgeslagen" +"no bleed" = "geen afloop" +"nobody else here yet" = "nog niemand anders hier" +"non-default slot settings are not represented and were skipped" = "niet-standaard slot-instellingen zijn niet overgenomen en zijn overgeslagen" +"now" = "nu" +"or" = "of" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "Lijnverbindingen op punten zijn behouden; gestreepte lijnen of lijnen met variabele breedte worden weergegeven met de verbinding van de laag" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "voorkeurssleutels van slotcomponenten konden niet naar bestandsknooppunten worden herleid en zijn overgeslagen" +"prototype actions of unknown types were skipped" = "prototypeacties van onbekende typen zijn overgeslagen" +"prototype actions without a destination in the file were skipped" = "prototype-acties zonder bestemming in het bestand zijn overgeslagen" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype-expressies over variabelen die niet in de variables-JSON staan zijn overgeslagen" +"prototype expressions with unsupported functions were skipped" = "prototype-expressies met niet-ondersteunde functies zijn overgeslagen" +"prototype interactions without supported actions were skipped" = "prototype-interacties zonder ondersteunde acties zijn overgeslagen" +"prototype navigations of unknown types were skipped" = "prototype-navigaties van onbekende typen zijn overgeslagen" +"prototype transitions of unknown types play instantly" = "prototype-overgangen van onbekende typen worden direct afgespeeld" +"prototype triggers of unknown types were skipped" = "prototype-triggers van onbekende typen zijn overgeslagen" +"put a widget on the canvas" = "een widget op het canvas zetten" +"read and edit the document" = "het document lezen en bewerken" +"read the document" = "lees het document" +"reads the document" = "leest het document" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable-acties op variabelen die niet in de variables-JSON staan zijn overgeslagen" +"set-variable actions with values that can't be evaluated were skipped" = "set-variable-acties met waarden die niet kunnen worden geëvalueerd zijn overgeslagen" +"set-variable actions with values that don't fit the variable were skipped" = "set-variable-acties met waarden die niet in de variabele passen, zijn overgeslagen" +"set-variable actions without a value were skipped" = "set-variable-acties zonder waarde zijn overgeslagen" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode-acties op verzamelingen of modi die niet in de variables JSON staan, zijn overgeslagen" +"slot content assignment target is missing or is not slot content" = "het toewijzingsdoel van slotcontent ontbreekt of is geen slotcontent" +"slot content assignment uses the empty GUID sentinel and was skipped" = "Toewijzing van slot-inhoud gebruikt de lege GUID-sentinelwaarde en is overgeslagen" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "sommige vectorpunten verwijzen naar puntinstellingen die ontbreken in het bestand; ze gebruiken de verbindingen van de laag en gewone hoeken" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "sommige vectorsegmenten verwijzen naar stijlinstellingen die in het bestand ontbreken; ze gebruiken de lijn van de laag" +"stale or invalid grid track sizing entries were ignored" = "verouderde of ongeldige items voor de trackgroottes van het raster zijn genegeerd" +"stars and polygons without point counts or geometry use the default point count" = "sterren en veelhoeken zonder puntaantallen of geometrie gebruiken het standaardpuntaantal" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "stijlinstellingen van sommige vectorsegmenten worden niet ondersteund; die segmenten gebruiken de lijn van de laag" +"text path underline or strikethrough decoration is not rendered" = "onderstreep- of doorhaaldecoratie op een tekstpad wordt niet weergegeven" +"text style overrides refer to missing table entries" = "tekststijl-overschrijvingen verwijzen naar ontbrekende tabelitems" +"the file has no pages" = "het bestand heeft geen pagina's" +"the plugin" = "de plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "De variabelen-JSON bevat geen `variableCollections` (de respons van lokale variabelen was verwacht) en is genegeerd" +"this session only" = "alleen voor deze sessie" +"unknown component property types were skipped" = "componenteigenschappen van onbekende typen zijn overgeslagen" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "variabele-aliassen naar variabelen die niet in de variables JSON zitten (of van een ander type zijn) zijn overgeslagen" +"variable bindings to properties the layer doesn't have were skipped" = "variabelekoppelingen naar eigenschappen die de laag niet heeft zijn overgeslagen" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "variabelekoppelingen naar variabelen die niet in de variabelen-JSON zitten (bibliotheekvariabelen) zijn overgeslagen" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "variabelekoppelingen zijn overgeslagen: de bestands-JSON bevat geen variabelendefinities (geef de JSON van lokale variabelen mee als `variables`, of voeg de `meta` ervan naast de bestands-JSON in)" +"variable bindings whose variable type doesn't fit the property were skipped" = "variabelekoppelingen waarvan het variabeletype niet bij de eigenschap past zijn overgeslagen" +"variable collections without modes were skipped" = "variabeleverzamelingen zonder modi zijn overgeslagen" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "opzoekingen in prototype-expressies naar andere combinaties dan een variabele en een van zijn modi zijn overgeslagen" +"variable modes of collections that aren't in the file were skipped" = "Variabelemodi van verzamelingen die niet in het bestand staan, zijn overgeslagen" +"variable modes of collections that aren't in the variables JSON were skipped" = "variabelemodi van verzamelingen die niet in de variables-JSON staan zijn overgeslagen" +"variable values that don't match their type (or composed colours) were skipped" = "waarden van variabelen die niet bij hun type passen (of samengestelde kleuren) zijn overgeslagen" +"variables of unknown types were skipped" = "variabelen van onbekende typen zijn overgeslagen" +"variables whose collection isn't in the variables JSON were skipped" = "variabelen waarvan de verzameling niet in de variables-JSON zit zijn overgeslagen" +"variables without readable values were skipped" = "variabelen zonder leesbare waarde zijn overgeslagen" +"vector points with unknown stroke joins use the layer join" = "vectorpunten met onbekende lijnverbindingen gebruiken de verbinding van de laag" +"vectors without geometry are empty" = "vectoren zonder geometrie zijn leeg" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectoren zonder geometrie zijn leeg (exporteer de bestands-JSON met geometry=paths)" +"very long text was cut to 1,048,576 characters" = "zeer lange tekst is ingekort tot 1.048.576 tekens" +"very long vector paths were skipped" = "Zeer lange vectorpaden zijn overgeslagen" +"video actions are not supported and were skipped" = "videoacties worden niet ondersteund en zijn overgeslagen" +"video triggers are not supported and were skipped" = "videotriggers worden niet ondersteund en zijn overgeslagen" +"yesterday" = "gisteren" +"{instances} in {files}" = "{instances} in {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: voorlopige vertaling, wacht op controle door een moedertaalspreker." +"{length} bleed" = "{length} afloop" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} (from {library})" = "{name} (uit {library})" +"{name} (you)" = "{name} (jij)" +"{name} ({layers} layers)" = "{name} ({layers} lagen)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, standaard {default}. Dubbelklik op de schuifregelaar om terug te gaan naar de waarde van de stijl; Escape tijdens het slepen annuleert." +"{name} cleared from the list" = "{name} uit de lijst verwijderd" +"{name} from {path}" = "{name} uit {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}" +"{name} is on the server." = "{name} staat op de server." +"{name} is spotlighting" = "{name} staat in de spotlight" +"{name} was removed from the server" = "{name} is van de server verwijderd" +"{name} · deleted on main" = "{name} · verwijderd op main" +"{name} · deleted on the branch" = "{name} · verwijderd op de branch" +"{name} · not published" = "{name} · niet gepubliceerd" +"{name} · on a server" = "{name} · op een server" +"{name} · published version {v}" = "{name} · gepubliceerde versie {v}" +"{name} · version {v} on the server" = "{name} · versie {v} op de server" +"{name}, in a live session" = "{name}, in een live sessie" +"{name}, online, open only to you" = "{name}, online, alleen voor jou toegankelijk" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} bij {h}, geselecteerd" +"{name}\nClick to apply to the selection" = "{name}\nKlik om op de selectie toe te passen" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSleep naar het canvas, of klik om in het midden van de weergave te plaatsen.\nHoud Alt (Option) ingedrukt terwijl je op een instance loslaat om die te verwisselen." +"{n} added" = { one = "{n} toegevoegd", other = "{n} toegevoegd" } +"{n} autosave versions" = { one = "{n} automatisch opgeslagen versie", other = "{n} automatisch opgeslagen versies" } +"{n} changed" = { one = "{n} gewijzigd", other = "{n} gewijzigd" } +"{n} columns" = { one = "{n} kolom", other = "{n} kolommen" } +"{n} components" = { one = "{n} component", other = "{n} componenten" } +"{n} components and styles have newer versions" = { one = "{n} component of stijl heeft een nieuwere versie", other = "{n} components en stijlen hebben nieuwere versies" } +"{n} conflicts to resolve" = { one = "{n} conflict om op te lossen", other = "{n} conflicten om op te lossen" } +"{n} days ago" = { one = "{n} dag geleden", other = "{n} dagen geleden" } +"{n} files" = { one = "{n} bestand", other = "{n} bestanden" } +"{n} hours ago" = { one = "{n} uur geleden", other = "{n} uur geleden" } +"{n} instances" = { one = "{n} instance", other = "{n} instances" } +"{n} layers" = { one = "{n} laag", other = "{n} lagen" } +"{n} layers as one file" = { one = "{n} laag als één bestand", other = "{n} lagen als één bestand" } +"{n} layers selected" = { one = "{n} laag geselecteerd", other = "{n} lagen geselecteerd" } +"{n} layers · {parts}" = { one = "{n} laag · {parts}", other = "{n} lagen · {parts}" } +"{n} matches" = { one = "{n} overeenkomst", other = "{n} overeenkomsten" } +"{n} minutes ago" = { one = "{n} minuut geleden", other = "{n} minuten geleden" } +"{n} months ago" = { one = "{n} maand geleden", other = "{n} maanden geleden" } +"{n} more notes omitted from this report" = { one = "{n} extra notitie weggelaten uit dit rapport", other = "{n} extra notities weggelaten uit dit rapport" } +"{n} of {total}" = "{n} van {total}" +"{n} people joined" = { one = "{n} persoon is erbij gekomen", other = "{n} personen zijn erbij gekomen" } +"{n} preferred components" = { one = "{n} voorkeurscomponent", other = "{n} voorkeurscomponenten" } +"{n} ready" = { one = "{n} klaar", other = "{n} klaar" } +"{n} removed" = { one = "{n} verwijderd", other = "{n} verwijderd" } +"{n} replies" = { one = "{n} antwoord", other = "{n} antwoorden" } +"{n} rows" = { one = "{n} rij", other = "{n} rijen" } +"{n} styles" = { one = "{n} stijl", other = "{n} stijlen" } +"{n} variables" = { one = "{n} variabele", other = "{n} variabelen" } +"{n} weeks ago" = { one = "{n} week geleden", other = "{n} weken geleden" } +"{n} years ago" = { one = "{n} jaar geleden", other = "{n} jaar geleden" } +"{n}d" = "{n}d" +"{n}h" = "{n}h" +"{n}m" = "{n}m" +"{n}w" = "{n}w" +"{page}, nothing selected" = "{page}, niets geselecteerd" +"{profile} (current)" = "{profile} (huidig)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is de waarde ervan; {property ? \"a\" : \"b\"} kiest een string. Andere {…} blijven letterlijk staan." +"{server} can't say what became of your files (it is older than this app)." = "{server} kan niet zeggen wat er met je bestanden is gebeurd (deze is ouder dan deze app)." +"{server} · No longer on the server" = "{server} · Niet meer op de server" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Geopend op {date}" +"{tool} hold {key}" = "{tool} {key} ingedrukt houden" +"{value} isn't a colour" = "{value} is geen kleur" +"{when} · edited" = "{when} · bewerkt" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→, Spatie of N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ arrows" +"⌘ click" = "⌘ click" +"⌘ scroll" = "⌘ scroll" +"⌥ drag" = "⌥ drag" + +[commands] +"align.bottom" = "Onder uitlijnen" +"align.hcenter" = "Horizontale middens uitlijnen" +"align.left" = "Links uitlijnen" +"align.right" = "Rechts uitlijnen" +"align.top" = "Boven uitlijnen" +"align.vcenter" = "Verticale middens uitlijnen" +"annotation.add" = "Annotatie toevoegen" +"annotation.addCategory" = "Annotatiecategorie toevoegen" +"annotation.categories" = "Annotatiecategorieën" +"annotation.edit" = "Annotatie bewerken" +"annotation.editCategory" = "Annotatiecategorie bewerken" +"annotation.list" = "Annotaties weergeven" +"annotation.remove" = "Annotatie verwijderen" +"annotation.removeCategory" = "Annotatiecategorie verwijderen" +"app.actions" = "Acties" +"app.preferences" = "Voorkeuren" +"arrange.back" = "Achteraan plaatsen" +"arrange.backward" = "Naar achteren sturen" +"arrange.forward" = "Naar voren brengen" +"arrange.front" = "Vooraan plaatsen" +"arrange.packH" = "Horizontaal samenpakken" +"arrange.packV" = "Verticaal stapelen" +"arrange.roundToPixels" = "Afronden op pixels" +"arrange.spacing" = "Afstand" +"arrange.tidy" = "Opruimen" +"bool.exclude" = "Exclude toepassen op selectie" +"bool.flatten" = "Boolean afvlakken" +"bool.intersect" = "Intersect toepassen op selectie" +"bool.subtract" = "Selectie aftrekken" +"bool.union" = "Union toepassen op selectie" +"branch.archive" = "Branch archiveren" +"branch.create" = "Branch maken" +"branch.list" = "Branches weergeven" +"branch.merge" = "Branch samenvoegen" +"branch.open" = "Branch openen" +"branch.review" = "Branch beoordelen" +"branch.updateFromMain" = "Bijwerken vanaf main" +"codeConnect.export" = "Codeverbindingen exporteren" +"codeConnect.import" = "Codekoppelingen importeren" +"codeConnect.remove" = "Koppeling met code verbreken" +"codeConnect.set" = "Aan code koppelen" +"collab.cursor" = "Cursor weergeven op" +"collab.cursorChat" = "Cursorchat" +"collab.diff" = "Syncwijzigingen weergeven" +"collab.files" = "Jouw online bestanden" +"collab.follow" = "Volgen" +"collab.forgetFile" = "Online bestand vergeten" +"collab.host" = "Live sessie starten" +"collab.join" = "Deelnemen aan live sessie" +"collab.leave" = "Live sessie verlaten" +"collab.manageFile" = "Gepubliceerd bestand beheren" +"collab.openFile" = "Online bestand openen" +"collab.peers" = "Mensen in dit bestand" +"collab.publish" = "Publiceren op server" +"collab.recoverFile" = "Online bestand herstellen" +"collab.registerLinks" = "Uitnodigingslinks openen in FigCraft" +"collab.removePerson" = "Uit sessie verwijderen" +"collab.report" = "Bestand melden bij serverbeheerder" +"collab.serverInfo" = "Serverinformatie" +"collab.share" = "Delen met een deelnamecode" +"collab.spotlight" = "Mij in de spotlight zetten" +"collab.stop" = "Livesessie stoppen" +"command.batch" = "Meerdere opdrachten uitvoeren" +"comment.add" = "Reactie toevoegen" +"comment.delete" = "Reactie verwijderen" +"comment.edit" = "Reactie bewerken" +"comment.link" = "Link naar reactie kopiëren" +"comment.list" = "Reacties weergeven" +"comment.markRead" = "Reactie als gelezen markeren" +"comment.move" = "Reactie verplaatsen" +"comment.next" = "Volgende reactie" +"comment.open" = "Reactie openen" +"comment.people" = "Personen in reacties" +"comment.react" = "Op reactie reageren" +"comment.reply" = "Op reactie antwoorden" +"comment.resolve" = "Reactie oplossen" +"comment.zoom" = "Inzoomen op reacties" +"component.addProperty" = "Componenteigenschap toevoegen" +"component.addVariant" = "Variant toevoegen" +"component.bindProperty" = "Laag aan eigenschap koppelen" +"component.combineVariants" = "Combineren als varianten" +"component.create" = "Component maken" +"component.createMultiple" = "Meerdere componenten maken" +"component.deleteProperty" = "Componenteigenschap verwijderen" +"component.detach" = "Instance loskoppelen" +"component.editProperty" = "Componenteigenschap bewerken" +"component.exposeInstance" = "Geneste instance zichtbaar maken" +"component.goToMain" = "Naar hoofdcomponent gaan" +"component.hideWhenPublishing" = "Verbergen bij publiceren" +"component.list" = "Componenten weergeven" +"component.makeSlot" = "Slot maken" +"component.playground" = "Componentplayground" +"component.properties" = "Componenteigenschappen" +"component.pushChanges" = "Wijzigingen naar hoofdcomponent pushen" +"component.removeSlot" = "Slot verwijderen" +"component.resetInstance" = "Alle wijzigingen herstellen" +"component.resetOverride" = "Overschrijving resetten" +"component.restoreMain" = "Hoofdcomponent terugzetten" +"dev.compare" = "Wijzigingen vergelijken" +"dev.focus" = "Focusweergave" +"dev.resolveChange" = "Wijziging oplossen" +"dev.unfocus" = "Focusweergave verlaten" +"devResource.add" = "Dev-resource toevoegen" +"devResource.edit" = "Dev-resource bewerken" +"devResource.list" = "Dev-resources weergeven" +"devResource.remove" = "Dev-resource verwijderen" +"distribute.horizontal" = "Horizontale afstand verdelen" +"distribute.vertical" = "Verticale afstand verdelen" +"doc.home" = "Home" +"doc.list" = "Documenten weergeven" +"doc.nextTab" = "Volgend tabblad" +"doc.previousTab" = "Vorig tabblad" +"doc.switch" = "Document wisselen" +"edit.copy" = "Kopiëren" +"edit.copyAsCss" = "Kopiëren als CSS" +"edit.copyAsPng" = "Kopiëren als PNG" +"edit.copyAsSvg" = "Kopiëren als SVG" +"edit.copyAsText" = "Kopiëren als tekst" +"edit.copyLink" = "Link naar selectie kopiëren" +"edit.copyProperties" = "Eigenschappen kopiëren" +"edit.cut" = "Knippen" +"edit.delete" = "Verwijderen" +"edit.duplicate" = "Dupliceren" +"edit.findReplace" = "Zoeken en vervangen" +"edit.paste" = "Plakken" +"edit.pasteDesignLayers" = "Ontwerplagen plakken" +"edit.pasteOver" = "Plakken over selectie" +"edit.pasteProperties" = "Eigenschappen plakken" +"edit.pasteReplace" = "Plakken ter vervanging" +"edit.pickColor" = "Kleur oppikken" +"edit.redo" = "Opnieuw" +"edit.repeat" = "Laatste opdracht herhalen" +"edit.setDefaultProperties" = "Standaardeigenschappen instellen" +"edit.undo" = "Ongedaan maken" +"effect.add" = "Effect toevoegen" +"effect.move" = "Effect verplaatsen" +"effect.remove" = "Effect verwijderen" +"effect.set" = "Effect instellen" +"export.add" = "Exportinstelling toevoegen" +"export.remove" = "Exportinstelling verwijderen" +"export.update" = "Exportinstelling bewerken" +"file.close" = "Tabblad sluiten" +"file.colorProfile" = "Kleurprofiel" +"file.duplicate" = "Bestand dupliceren" +"file.export" = "Exporteren" +"file.exportFramesPdf" = "Frames exporteren naar PDF" +"file.exportLayers" = "Lagen exporteren" +"file.exportSvg" = "Selectie exporteren als SVG" +"file.import" = "Importeren" +"file.new" = "Nieuw ontwerpbestand" +"file.newFromTemplate" = "Nieuw vanuit sjabloon" +"file.open" = "Openen" +"file.openRecent" = "Onlangs geopende bestanden" +"file.placeImage" = "Afbeelding plaatsen" +"file.recover" = "Bestand herstellen" +"file.rename" = "Bestand hernoemen" +"file.revert" = "Terugdraaien naar opgeslagen versie" +"file.save" = "Opslaan" +"file.saveAs" = "Opslaan als" +"file.saveVersion" = "Opslaan in versiegeschiedenis" +"file.versionHistory" = "Versiegeschiedenis weergeven" +"fill.add" = "Vulling toevoegen" +"fill.move" = "Vulling verplaatsen" +"fill.pattern" = "Patroonvulling" +"fill.remove" = "Vulling verwijderen" +"fill.set" = "Vulling instellen" +"frame.create" = "Frame maken" +"frame.presets" = "Frame-voorinstellingen weergeven" +"frame.swapOrientation" = "Oriëntatie omwisselen" +"grid.add" = "Layoutraster toevoegen" +"grid.move" = "Layoutraster verplaatsen" +"grid.remove" = "Layoutraster verwijderen" +"guide.add" = "Hulplijn toevoegen" +"guide.clear" = "Alle hulplijnen verwijderen" +"guide.move" = "Hulplijn verplaatsen" +"guide.remove" = "Hulplijn verwijderen" +"guide.select" = "Hulplijn selecteren" +"help.about" = "Over FigCraft" +"help.gettingStarted" = "Aan de slag" +"help.page" = "Helppagina" +"help.shortcuts" = "Sneltoetsen" +"image.adjust" = "Afbeelding aanpassen" +"image.deletePreset" = "Afbeeldingspreset verwijderen" +"image.place" = "Afbeelding plaatsen" +"image.presets" = "Afbeeldingsvoorinstellingen" +"image.savePreset" = "Voorinstelling voor afbeelding opslaan" +"inspect.assets" = "Assets inspecteren" +"inspect.code" = "Code voor laag" +"inspect.componentCode" = "Componentcode" +"inspect.properties" = "Eigenschappen inspecteren" +"inspect.readyForDev" = "Ready for dev" +"instance.create" = "Instance maken" +"instance.exposed" = "Zichtbare geneste instances" +"instance.place" = "Instance plaatsen" +"instance.resetSlot" = "Slot herstellen" +"instance.setProperties" = "Eigenschappen van instance instellen" +"instance.slotInsert" = "In slot invoegen" +"instance.swap" = "Instance verwisselen" +"instance.swapAt" = "Instance op punt verwisselen" +"layers.collapseAll" = "Lagen inklappen" +"layout.add" = "Auto layout toevoegen" +"layout.remove" = "Auto layout verwijderen" +"layout.setTrack" = "Grootte van rastertrack instellen" +"layout.suggest" = "Auto layout voorstellen" +"libraries.open" = "Bibliotheken" +"libraries.publish" = "Bibliotheek publiceren…" +"library.acceptUpdates" = "Bibliotheekupdates accepteren" +"library.add" = "Bibliotheek toevoegen" +"library.aliasVariable" = "Alias van bibliotheekvariabele maken" +"library.applyStyle" = "Bibliotheekstijl toepassen" +"library.bindVariable" = "Bibliotheekvariabele koppelen" +"library.extendCollection" = "Bibliotheekverzameling uitbreiden" +"library.import" = "Bibliotheekcomponent importeren" +"library.importVariable" = "Bibliotheekvariabele importeren" +"library.list" = "Bibliotheken weergeven" +"library.place" = "Bibliotheekcomponent plaatsen" +"library.publish" = "Bibliotheek publiceren" +"library.publishToServer" = "Bibliotheek naar server publiceren" +"library.remove" = "Bibliotheek verwijderen" +"library.setVariableMode" = "Modus van bibliotheekvariabele instellen" +"library.swap" = "Bibliotheek verwisselen" +"library.swapAt" = "Instance verwisselen voor bibliotheekcomponent" +"library.updates" = "Bibliotheekupdates" +"library.usage" = "Bibliotheekgebruik" +"measure.distances" = "Afstanden meten" +"measure.padding" = "Padding van auto layout meten" +"node.create" = "Laag maken" +"node.get" = "Laag ophalen" +"node.reorder" = "Laag herordenen" +"node.reparent" = "Laag verplaatsen naar" +"node.set" = "Eigenschappen instellen" +"object.batchRename" = "Batch hernoemen" +"object.convertToFrame" = "Converteren naar frame" +"object.convertToGroup" = "Converteren naar groep" +"object.convertToSection" = "Converteren naar sectie" +"object.flipH" = "Horizontaal spiegelen" +"object.flipV" = "Verticaal spiegelen" +"object.frameSelection" = "Selectie framen" +"object.group" = "Selectie groeperen" +"object.hideOthers" = "Overige lagen verbergen" +"object.mask" = "Als masker gebruiken" +"object.moveToPage" = "Verplaatsen naar pagina" +"object.opacityKeys" = "Dekking via cijfertoetsen" +"object.rasterize" = "Selectie rasteriseren" +"object.releaseMask" = "Masker opheffen" +"object.removeFill" = "Vulling verwijderen" +"object.removeFrame" = "Frame verwijderen" +"object.removeStroke" = "Lijn verwijderen" +"object.rename" = "Hernoemen" +"object.resizeToFit" = "Formaat aanpassen aan inhoud" +"object.rotate180" = "180° draaien" +"object.rotateLeft" = "90° naar links draaien" +"object.rotateRight" = "90° naar rechts draaien" +"object.setThumbnail" = "Instellen als miniatuur" +"object.swapFillStroke" = "Vulling en lijn omwisselen" +"object.toggleLock" = "Selectie vergrendelen/ontgrendelen" +"object.toggleVisible" = "Selectie tonen/verbergen" +"object.ungroup" = "Selectie ontgroeperen" +"object.wrapInSection" = "In nieuwe sectie plaatsen" +"page.add" = "Pagina toevoegen" +"page.delete" = "Pagina verwijderen" +"page.duplicate" = "Pagina dupliceren" +"page.list" = "Pagina's weergeven" +"page.move" = "Pagina verplaatsen" +"page.rename" = "Pagina hernoemen" +"page.select" = "Naar pagina gaan" +"page.setBackground" = "Pagina-achtergrond" +"paint.documentColors" = "Documentkleuren" +"plugin.close" = "Pluginpaneel sluiten" +"plugin.directory.install" = "Plugin installeren vanuit map" +"plugin.directory.list" = "Plugincatalogus doorbladeren" +"plugin.directory.publish" = "Plugin naar catalogus publiceren" +"plugin.directory.unpublish" = "Publicatie van plugin in catalogus ongedaan maken" +"plugin.event" = "Bedieningselement in pluginpaneel gebruiken" +"plugin.import" = "Plugin importeren vanuit manifest…" +"plugin.list" = "Plugins weergeven" +"plugin.remove" = "Plugin verwijderen" +"plugin.run" = "Plugin uitvoeren" +"plugin.ui" = "Pluginpaneel" +"plugins.manage" = "Plugins beheren…" +"plugins.runLast" = "Laatste plugin uitvoeren" +"pref.autoSave" = "Bestanden automatisch opslaan" +"pref.highlightHover" = "Lagen markeren bij aanwijzen" +"pref.keepTool" = "Gereedschap geselecteerd houden na gebruik" +"pref.language" = "Interfacetaal" +"pref.nudge" = "Stapafstand" +"pref.numberKeysOpacity" = "Cijfertoetsen gebruiken voor dekking" +"pref.publishServer" = "Server voor online delen" +"pref.reduceMotion" = "Beweging verminderen" +"pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" +"pref.showDimensions" = "Afmetingen op objecten tonen" +"pref.userName" = "Je naam in reacties" +"proto.action.add" = "Prototypeactie toevoegen" +"proto.action.move" = "Volgorde van prototypeactie wijzigen" +"proto.action.remove" = "Prototype-actie verwijderen" +"proto.action.set" = "Prototype-actie bewerken" +"proto.closePreview" = "Prototype-voorvertoning sluiten" +"proto.conditional.addBlock" = "Else if / else toevoegen" +"proto.conditional.removeBlock" = "if / else if / else verwijderen" +"proto.conditional.setCondition" = "Voorwaarde bewerken" +"proto.evaluate" = "Prototype-expressie evalueren" +"proto.flow.add" = "Startpunt van flow toevoegen" +"proto.flow.remove" = "Flow verwijderen" +"proto.flow.rename" = "Flow hernoemen" +"proto.interaction.add" = "Interactie toevoegen" +"proto.interaction.clear" = "Alle interacties verwijderen" +"proto.interaction.remove" = "Interactie verwijderen" +"proto.interaction.set" = "Interactie bewerken" +"proto.present" = "Presenteren" +"proto.presentOptions" = "Presentatieopties" +"proto.preview" = "Prototypevoorvertoning openen" +"proto.settings" = "Prototype-instellingen" +"section.toggleContents" = "Sectie-inhoud verbergen/weergeven" +"select.add" = "Toevoegen aan selectie" +"select.all" = "Alles selecteren" +"select.area" = "Gebied selecteren" +"select.byName" = "Lagen op naam selecteren" +"select.children" = "Onderliggende items selecteren" +"select.inverse" = "Selectie omkeren" +"select.layersAt" = "Lagen op een punt" +"select.matching" = "Overeenkomende lagen selecteren" +"select.next" = "Volgende laag op hetzelfde niveau selecteren" +"select.none" = "Selectie opheffen" +"select.parent" = "Bovenliggende selecteren" +"select.previous" = "Vorige laag op hetzelfde niveau selecteren" +"select.remove" = "Uit selectie verwijderen" +"select.sameEffect" = "Zelfde effect selecteren" +"select.sameFill" = "Dezelfde vulling selecteren" +"select.sameFont" = "Lagen met hetzelfde lettertype selecteren" +"select.sameInstance" = "Zelfde instance selecteren" +"select.sameProperties" = "Dezelfde eigenschappen selecteren" +"select.sameStroke" = "Lagen met dezelfde lijn selecteren" +"select.sameText" = "Dezelfde teksteigenschappen selecteren" +"select.set" = "Selecteren" +"selection.applyColorStyle" = "Kleurstijl op selectie toepassen" +"selection.colors" = "Selectiekleuren" +"selection.selectColor" = "Lagen op kleur selecteren" +"selection.setPaint" = "Selectiekleur wijzigen" +"shape.arrow" = "Pijl" +"shape.ellipse" = "Ellips" +"shape.line" = "Lijn" +"shape.polygon" = "Veelhoek" +"shape.rectangle" = "Rechthoek" +"shape.star" = "Ster" +"shapeBuilder.merge" = "Gebieden samenvoegen" +"shapeBuilder.mode" = "Shape builder-modus" +"shapeBuilder.regions" = "Shape builder-gebieden" +"shapeBuilder.remove" = "Gebieden verwijderen" +"stroke.add" = "Lijn toevoegen" +"stroke.brush" = "Penseel" +"stroke.move" = "Lijn verplaatsen" +"stroke.remove" = "Lijn verwijderen" +"stroke.set" = "Lijn instellen" +"stroke.widthProfile" = "Breedteprofiel" +"style.apply" = "Stijl toepassen" +"style.bind" = "Variabele aan stijl koppelen" +"style.create" = "Stijl maken" +"style.delete" = "Stijl verwijderen" +"style.detach" = "Stijl loskoppelen" +"style.duplicate" = "Stijl dupliceren" +"style.list" = "Stijlen weergeven" +"style.move" = "Stijlen verplaatsen" +"style.renameFolder" = "Stijlmap hernoemen" +"style.update" = "Stijl bewerken" +"template.list" = "Sjablonen weergeven" +"text.alignCenter" = "Midden uitlijnen" +"text.alignJustify" = "Uitvullen" +"text.alignLeft" = "Links uitlijnen" +"text.alignRight" = "Rechts uitlijnen" +"text.bold" = "Vet" +"text.bulletList" = "Lijst met opsommingstekens" +"text.case" = "Hoofdlettergebruik" +"text.caseLower" = "Kleine letters" +"text.caseOriginal" = "Oorspronkelijk hoofdlettergebruik" +"text.caseSmallCaps" = "Kleine hoofdletters" +"text.caseTitle" = "Hoofdletters per woord" +"text.caseUpper" = "Hoofdletters" +"text.create" = "Tekst" +"text.decoration" = "Decoratie" +"text.edit.begin" = "Tekst bewerken" +"text.edit.caret" = "Invoegpunt instellen" +"text.edit.end" = "Tekst bewerken stoppen" +"text.edit.insert" = "Tekst typen" +"text.edit.key" = "Tekstoets" +"text.edit.preedit" = "Tekst samenstellen (IME)" +"text.edit.selectLine" = "Regel selecteren" +"text.edit.selectWord" = "Woord selecteren" +"text.edit.selection" = "Tekstselectie" +"text.endPreview" = "Tekstvoorvertoning beëindigen" +"text.fonts" = "Lettertypen weergeven" +"text.italic" = "Cursief" +"text.lineHeightDown" = "Regelafstand verkleinen" +"text.lineHeightUp" = "Regelafstand vergroten" +"text.link" = "Link maken" +"text.listIndent" = "Lijst inspringen" +"text.listOutdent" = "Inspringing lijst verkleinen" +"text.listStyle" = "Lijststijl" +"text.maxLines" = "Maximaal aantal regels" +"text.numberList" = "Genummerde lijst" +"text.onPath" = "Tekst op pad" +"text.paragraphIndent" = "Alinea-inspringing" +"text.paragraphSpacing" = "Alinea-afstand" +"text.pathOptions" = "Opties voor tekstpad" +"text.preview" = "Tekstwijziging voorvertonen" +"text.removeFromPath" = "Tekst van pad verwijderen" +"text.set" = "Tekst instellen" +"text.setFeatures" = "OpenType-functies" +"text.setVariations" = "Assen van variabele lettertypen" +"text.sizeDown" = "Lettergrootte verkleinen" +"text.sizeUp" = "Lettertypegrootte vergroten" +"text.strikethrough" = "Doorstrepen" +"text.style" = "Tekstbereik opmaken" +"text.trackingDown" = "Letterafstand verkleinen" +"text.trackingUp" = "Letterafstand vergroten" +"text.underline" = "Onderstrepen" +"text.weightDown" = "Lettertypedikte verkleinen" +"text.weightUp" = "Letterdikte vergroten" +"tool.arrow" = "Pijl" +"tool.comment" = "Reactie" +"tool.ellipse" = "Ellips" +"tool.frame" = "Frame" +"tool.hand" = "Hand" +"tool.line" = "Lijn" +"tool.move" = "Verplaatsen" +"tool.pen" = "Pen" +"tool.pencil" = "Potlood" +"tool.pencilBrush" = "Potloodpenseel" +"tool.place" = "Plaatsen met de tool" +"tool.polygon" = "Veelhoek" +"tool.rectangle" = "Rechthoek" +"tool.scale" = "Schalen" +"tool.scaleAnchor" = "Schaalanker" +"tool.section" = "Sectie" +"tool.set" = "Gereedschap kiezen" +"tool.shapeBuilder" = "Shape builder" +"tool.slice" = "Slice" +"tool.star" = "Ster" +"tool.text" = "Tekst" +"transform.move" = "Verplaatsen" +"transform.moveTo" = "Verplaatsen naar" +"transform.nudge" = "Verschuiven" +"transform.nudgeSize" = "Formaat wijzigen in stappen" +"transform.resize" = "Formaat wijzigen" +"transform.rotate" = "Draaien" +"transform.rotationOrigin" = "Rotatiepunt verplaatsen" +"transform.scale" = "Schalen" +"transform.setRotationOrigin" = "Rotatiepunt instellen" +"variable.addMode" = "Modus toevoegen" +"variable.bind" = "Variabele koppelen" +"variable.create" = "Variabele maken" +"variable.createCollection" = "Variabeleverzameling maken" +"variable.delete" = "Variabele verwijderen" +"variable.deleteCollection" = "Verzameling verwijderen" +"variable.duplicate" = "Variabele dupliceren" +"variable.export" = "Variabelen exporteren" +"variable.import" = "Variabelen importeren" +"variable.list" = "Variabelen weergeven" +"variable.moveMode" = "Modus verplaatsen" +"variable.removeMode" = "Modus verwijderen" +"variable.renameCollection" = "Verzameling hernoemen" +"variable.renameMode" = "Modus hernoemen" +"variable.resetOverride" = "Overschrijving van variabele resetten" +"variable.set" = "Variabelewaarde instellen" +"variable.setMode" = "Variabelemodus instellen" +"variable.update" = "Variabele bewerken" +"vector.create" = "Vector" +"vector.deleteHeal" = "Selectie verwijderen en herstellen" +"vector.edit.addVertex" = "Punt toevoegen" +"vector.edit.begin" = "Vector bewerken" +"vector.edit.bend" = "Segment buigen" +"vector.edit.bounds" = "Grenzen van geselecteerde punten" +"vector.edit.connect" = "Punten verbinden" +"vector.edit.cornerRadius" = "Hoekradius van punt" +"vector.edit.cut" = "Pad knippen" +"vector.edit.delete" = "Punten verwijderen" +"vector.edit.end" = "Klaar met bewerken" +"vector.edit.erase" = "Wissen" +"vector.edit.lasso" = "Met lasso selecteren" +"vector.edit.mirroring" = "Handgrepen spiegelen" +"vector.edit.moveHandles" = "Handgrepen verplaatsen" +"vector.edit.moveVertices" = "Punten verplaatsen" +"vector.edit.nudge" = "Punten verschuiven" +"vector.edit.paint" = "Verfemmer" +"vector.edit.pen" = "Penpunt" +"vector.edit.penEnd" = "Penpad beëindigen" +"vector.edit.position" = "Puntpositie" +"vector.edit.resize" = "Formaat van punten wijzigen" +"vector.edit.rotate" = "Punten draaien" +"vector.edit.select" = "Punten selecteren" +"vector.edit.selectAll" = "Alle punten selecteren" +"vector.edit.setHandle" = "Handgreep instellen" +"vector.edit.smooth" = "Punten glad of scherp maken" +"vector.edit.state" = "Status van vectorbewerking" +"vector.edit.strokeCap" = "Lijneinde van punt" +"vector.edit.strokeJoin" = "Lijnaansluiting van punt" +"vector.edit.tool" = "Tool voor vectorbewerking" +"vector.edit.transform" = "Punten transformeren" +"vector.edit.widthPoint" = "Breedtepunt" +"vector.flatten" = "Afvlakken" +"vector.join" = "Selectie samenvoegen" +"vector.offset" = "Pad offsetten" +"vector.outlineStroke" = "Lijn omzetten naar contouren" +"vector.reverse" = "Padrichting omkeren" +"vector.simplify" = "Vector vereenvoudigen" +"vector.smoothJoin" = "Selectie vloeiend samenvoegen" +"version.autosave" = "Versie automatisch opslaan" +"version.delete" = "Versie verwijderen" +"version.duplicate" = "Versie dupliceren" +"version.exitPreview" = "Terug naar huidige versie" +"version.list" = "Versies weergeven" +"version.preview" = "Versie bekijken" +"version.rename" = "Versie hernoemen" +"version.restore" = "Versie terugzetten" +"view.assets" = "Assetpaneel" +"view.comments" = "Reacties" +"view.contextMenu" = "Contextmenu tonen" +"view.design" = "Ontwerppaneel" +"view.designPrototype" = "Wisselen tussen Design en Prototype" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Framecontouren" +"view.fullscreen" = "Volledig scherm" +"view.get" = "Weergave ophalen" +"view.layers" = "Lagenpaneel" +"view.layoutGrids" = "Layoutrasters" +"view.leftSidebar" = "Linkerzijbalk tonen/verbergen" +"view.maskOutlines" = "Maskercontouren" +"view.multiplayerCursors" = "Multiplayer-cursors" +"view.nextFrame" = "Inzoomen op volgend frame" +"view.nextPage" = "Volgende pagina" +"view.nextRegion" = "Volgend gebied focussen" +"view.outlines" = "Contouren" +"view.outlinesHiddenLayers" = "Verborgen lagen opnemen in contouren" +"view.outlinesObjectBounds" = "Objectgrenzen opnemen in contouren" +"view.outlinesSelection" = "Contouren voor selectie" +"view.pan" = "Weergave verschuiven" +"view.panelWidths" = "Paneelbreedtes herstellen" +"view.pixelGrid" = "Pixelraster" +"view.pixelPreview" = "Pixelvoorvertoning" +"view.prevFrame" = "Inzoomen op vorig frame" +"view.prevPage" = "Vorige pagina" +"view.previousRegion" = "Vorige regio focussen" +"view.proofSrgb" = "sRGB-proefweergave" +"view.prototype" = "Prototypepaneel" +"view.rulers" = "Linialen" +"view.set" = "Weergave instellen" +"view.slices" = "Slices" +"view.snapGeometry" = "Uitlijnen op geometrie" +"view.snapObjects" = "Uitlijnen op objecten" +"view.snapPixel" = "Uitlijnen op pixelraster" +"view.themeDark" = "Donker thema" +"view.themeLight" = "Licht thema" +"view.themeSystem" = "Systeemthema volgen" +"view.toggleUi" = "UI tonen/verbergen" +"view.zoom100" = "Inzoomen op 100%" +"view.zoom200" = "Zoomen naar 200%" +"view.zoom50" = "Zoom instellen op 50%" +"view.zoomAt" = "Zoomgereedschap" +"view.zoomFit" = "Inpassen" +"view.zoomIn" = "Inzoomen" +"view.zoomOut" = "Uitzoomen" +"view.zoomSelection" = "Inzoomen op selectie" +"widget.click" = "Op widget klikken" +"widget.insert" = "Widget invoegen" +"widget.list" = "Widgets weergeven" +"window.focus" = "Venster naar voren brengen" +"window.new" = "Nieuw venster" + +[menu] +"bool.exclude" = "Exclude" +"bool.flatten" = "Flatten" +"bool.intersect" = "Intersect" +"bool.subtract" = "Subtract" +"bool.union" = "Union" +"file.duplicate" = "Dupliceren" +"file.import" = "Importeren uit bestand…" +"file.rename" = "Hernoemen" +"libraries.open" = "Bibliotheken openen" +"select.sameEffect" = "Effect" +"select.sameFill" = "Vulling" +"select.sameFont" = "Lettertype" +"select.sameInstance" = "Instance" +"select.sameProperties" = "Eigenschappen" +"select.sameStroke" = "Lijn" +"select.sameText" = "Teksteigenschappen" +"text.alignCenter" = "Tekst centreren" +"text.alignJustify" = "Tekst uitvullen" +"text.alignLeft" = "Tekst links uitlijnen" +"text.alignRight" = "Tekst rechts uitlijnen" +"view.assets" = "Assets" +"view.design" = "Ontwerp" +"view.layers" = "Lagen" +"view.outlines" = "Omtrekken weergeven" +"view.outlinesHiddenLayers" = "Verborgen lagen meenemen" +"view.outlinesObjectBounds" = "Objectgrenzen meenemen" +"view.outlinesSelection" = "Omtrekken van selectie weergeven" +"view.prototype" = "Prototype" +"view.themeDark" = "Donker" +"view.themeLight" = "Licht" +"view.themeSystem" = "Systeem volgen" diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 5bdf2094..d4d81d7b 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -343,11 +343,12 @@ fn system_languages_pick_the_first_one_with_a_catalog() { // Portugal's Portuguese and a bare "pt" read the Brazilian catalog. assert_eq!(code("system", Some("pt-PT")), "pt-BR"); assert_eq!(code("pt", None), "pt-BR"); - // An OS list: the first language there is a catalog for (English counts). - assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de"); - assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en"); - assert_eq!(code("system", Some("nl,da")), "en"); - assert_eq!(code("system", Some("nl,sv")), "sv"); + // An OS list: the first language there is a catalog for (English counts). Hindi has no + // catalog (Dutch gained one; `hi` keeps exercising the no-catalog path). + assert_eq!(code("system", Some("hi-IN,de-DE,en-US")), "de"); + assert_eq!(code("system", Some("hi-IN, en-US,fr-FR")), "en"); + assert_eq!(code("system", Some("hi,da")), "en"); + assert_eq!(code("system", Some("hi,sv")), "sv"); assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's"); } @@ -472,7 +473,7 @@ fn russian_language_tags_and_integer_plurals() { assert_eq!(resolve(tag, None), ru, "{tag}"); assert_eq!(resolve("system", Some(tag)), ru, "{tag}"); } - assert_eq!(resolve("system", Some("nl,ru-RU,en-US")), ru); + assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru); for n in [1, 21, 31, 101, 1_001] { assert_eq!(plural("ru", n), Plural::One, "{n}"); assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой")); diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..68ee6d10 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a Dutch draft: `nl.toml` at 100% of the strings, machine-drafted, awaiting native review; the catalogs re-measured over 2,315 entries; preceding: the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Dutch complete as an unreviewed draft; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -179,26 +179,27 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). ### 5. Localization: English, reviewed Russian, plus unreviewed drafts -- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, - Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish +- **Missing:** native reviews of the Spanish, French, German, Dutch, Brazilian Portuguese, + Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), Hindi, Arabic and Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the craft-fonts CJK build. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in - `crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31 - menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, - Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese, - Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they - were drafted show English), with `es-419.toml` a Latin American overlay listing only what + `crates/ui-egui/locales/` (measured 2026-10-18: 1,718 UI keys plus 566 command labels and 31 + menu wordings, 2,315 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, + Spanish is held to completeness by the check, and Dutch (2026-10-18) covers every string as a + draft; the German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese machine drafts + cover 95–99% (strings added since they were drafted show English), with `es-419.toml` a Latin + American overlay listing only what differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals) covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker - reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)). + reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.8–99.6% ([localization-parity](localization-parity.md#languages)). CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. - **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English errors. - **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers; - 29–51 h for the key languages left and the drafts' follow-ups (kept on 2026-10-17: the + 29–51 h for the key languages left and the drafts' follow-ups (kept on 2026-10-18: the Indonesian draft takes 2–3 h off, about what bringing the drafts that slipped to 95% back to every string adds). Detail in [`localization-parity.md`](localization-parity.md); how to add one in @@ -444,6 +445,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Dutch draft (gap 5): `nl.toml` at 100% of the strings (1,718 UI keys, 566 command labels, 31 menu wordings — 2,315 entries), machine-drafted, a draft awaiting native review; the catalog counts re-measured over all languages (the other drafts read 95–99% on the new basis), the language table rows and evidence refreshed. Ranking and hours unchanged | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 50fcd4ec..7df91731 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,12 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a Dutch draft: `nl.toml` at 100% of the strings, machine-drafted, awaiting native review; the language table re-measured over 2,315 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted, -waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key -languages of the craftrules standard, then the other languages Figma ships. How to add a +waiting for native speakers' review), plus Dutch, Swedish and Turkish catalogs. This page lists the +twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). ## What Figma ships @@ -18,21 +18,21 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-17, from source) +## What FigCraft has (measured 2026-10-18, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels - and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete +- **Strings:** 1,718 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 566 command labels + and 31 menu wordings by command id: 2,315 entries (counted against `en.toml` and the complete Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, - 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a + 한국어, Nederlands, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional @@ -85,23 +85,24 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,315 / 2,315 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,209 / 2,315 (95.4%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,315 / 2,315 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| French | fr | 2,210 / 2,315 (95.5%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,210 / 2,315 (95.5%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Indonesian | id | 2,287 / 2,315 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,297 / 2,315 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,219 / 2,315 (95.9%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,209 / 2,315 (95.4%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved -by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu -wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header -says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft +Other languages (measured 2026-10-18): Russian (`ru`) 2,287 / 2,315 (98.8%), reviewed and approved +by VanTan, one/few/many integer plurals; Dutch (`nl`) 2,315 / 2,315 (100%), machine-drafted, one/other +plurals; Swedish (`sv`) 2,307 (99.6%), its command labels and menu +wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,286 (98.8%), whose header +says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,287 (98.8%), a draft with one/few/other plurals. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries @@ -109,17 +110,19 @@ The layer is done. Per-language hours above are agent hours for a catalog of ~1, it isn't one/other; every language also needs a native speaker's review (human). Left before full parity besides the languages: localizing engine messages and number formatting (4–6 h); right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** (kept on -2026-10-17: Indonesian's draft takes 2–3 h off, about what bringing the drafts back from 95% to -every string adds). +2026-10-18: bringing the drafts that slipped behind the newest strings back again balances +against the finished drafts like Indonesian's). Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, Indonesian and Czech, which Figma doesn't ship), then -Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). +and variants has one, plus Simplified Chinese, Indonesian, Czech and Dutch, which Figma doesn't +ship), then Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users +see English). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | A Dutch draft (gap 5): `nl.toml` translates every UI string (1,718 keys; 41 with plural forms, one/other), every command label (566) and every menu wording (31) — 2,315 entries — machine-drafted in one style pass (informal je register, infinitive menu labels, sentence case, months abbreviated the Dutch way: jan., feb., mrt., apr., mei…; established tool terms such as frame, auto layout, component, variant, prototype, overlay, widget, plugin, branch and constraints, and blend-mode, brush-preset and boolean names stay English) and marked as a draft awaiting native review; Preferences ▸ Language lists Nederlands, System maps any Dutch tag (nl-NL, nl…) to it, Dutch's default plural rule is the one/other default. The language-tag tests that used Dutch as a language without a catalog switched to Hindi, and the language table is re-measured over 2,315 entries (the drafts read 95–99%) | | 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | diff --git a/docs/translating.md b/docs/translating.md index 0e94d20d..1ea1b94f 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -15,14 +15,12 @@ Simplified. `pref.language {language}` does the same from the CLI, MCP and the control channel. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay -`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), -Simplified Chinese (`zh-Hans`), Czech (`cs`) and Indonesian (`id`), machine-drafted and waiting for -native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog -keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) -was machine-drafted and reviewed by a native speaker. -`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`) -and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and -Swedish (`sv`), which translates every UI string, command label and menu wording. +`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), +Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`) and Indonesian (`id`), machine-drafted +and waiting for native speakers' review. Russian (`ru`, Русский) was reviewed and approved by +VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » +quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, and Swedish (`sv`) +translates every UI string, command label and menu wording. ## The catalogs From a819e0a18847a865f6535712a580228f7ae7bde8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 12:28:27 +0700 Subject: [PATCH 218/245] =?UTF-8?q?#776:=20Edit=20=E2=96=B8=20Delete=20fro?= =?UTF-8?q?m=20the=20menu=20bar=20removes=20the=20open=20interaction=20too?= =?UTF-8?q?=20(the=20guard=20moves=20to=20menus::invoke,=20which=20the=20D?= =?UTF-8?q?elete=20binding=20also=20reaches);=20has=5Fselected=5Finteracti?= =?UTF-8?q?on=20goes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/menus.rs | 3 +++ crates/ui-egui/src/proto_editor_tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/proto_panel.rs | 7 ------- crates/ui-egui/src/shortcuts.rs | 8 -------- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c917bd23..e32f50c8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -114,6 +114,9 @@ pub fn record_recent(app: &mut FigApp, key: &str) { pub fn invoke(app: &mut FigApp, id: &str) { match id { + // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab + // removes that interaction, not the layer (#767). + "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index 31988a35..c63e62c6 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -262,6 +262,23 @@ fn deleting_a_prototype_interaction_removes_the_interaction_not_the_component() assert!(u.app.session.doc().unwrap().node(btn).is_none(), "when no interaction is selected, Delete removes the layer"); } +/// Edit ▸ Delete from the menu bar does the same as the Delete key (#767). +#[test] +fn edit_delete_from_the_menu_removes_the_open_interaction_not_the_layer() { + let (s, _a, _b, btn) = setup(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + crate::menus::invoke(&mut u.app, "edit.delete"); + let doc = u.app.session.doc().unwrap(); + assert!(doc.node(btn).is_some_and(|n| n.interactions.is_empty()), "the interaction goes, the layer stays"); + assert_eq!(crate::proto_panel::editor(&u.ctx), None); + crate::menus::invoke(&mut u.app, "edit.delete"); + assert!(u.app.session.doc().unwrap().node(btn).is_none(), "with no interaction open, Edit ▸ Delete deletes the layer"); +} + #[test] fn escape_closes_prototype_editor_without_deselecting_layer() { let (s, a, _b, btn) = setup(); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 20546008..85af66c5 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -40,13 +40,6 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> { ctx.data(|d| d.get_temp(editor_id())) } -/// Whether an interaction is selected (its editor is open). -pub fn has_selected_interaction(app: &FigApp, ctx: &egui::Context) -> bool { - let Some((node, index)) = editor(ctx) else { return false }; - let Ok(ds) = app.session.ds() else { return false }; - ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_some() -} - /// Delete the selected interaction (when one is active in the Prototype tab). /// Returns true if an interaction was deleted. pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> bool { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 695457af..f3197960 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -225,14 +225,6 @@ pub fn run_binding(app: &mut FigApp, id: &str) { "edit.delete" if app.session.tool.pen.is_some() => { let _ = app.session.pen_finish(); } - "edit.delete" - if app.ui.right_tab == crate::RightTab::Prototype - && app.ui_context.as_ref().is_some_and(|ctx| crate::proto_panel::has_selected_interaction(app, ctx)) => - { - if let Some(ctx) = app.ui_context.clone() { - let _ = crate::proto_panel::delete_selected_interaction(app, &ctx); - } - } // Enter with a shape or frame tool chosen places one in the middle of the view. "select.children" if app.session.tool.tool.draws_shape() => { app.run("tool.place", json!({})); From 633455c380aeda72fb8d198805ef3f9007045c3c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 13:25:02 +0700 Subject: [PATCH 219/245] #787: leave the progress docs to the maintainer --- ROADMAP.md | 17 ++++-------- docs/gaps.md | 24 ++++++++--------- docs/localization-parity.md | 53 +++++++++++++++++-------------------- 3 files changed, 41 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a8af7b49..8a587286 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-18 · **Change:** minor (Dutch draft catalog complete at 100% of the strings, machine-drafted and awaiting native review; language table re-measured over 2,315 entries; preceding: 2026-10-17 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-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) 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 @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Dutch, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -103,7 +103,6 @@ native speakers' reviews of the six language drafts need people. | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| Dutch | nl | 100% | draft (needs native review) | | French | fr | 95% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | @@ -112,14 +111,9 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Percentages measured 2026-10-18 over 2,315 entries (1,718 UI keys, 566 command labels and 31 menu -wordings; strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by -VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish -(`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, -Spanish and Portuguese +Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Dutch, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian -drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -132,7 +126,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, nl, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log @@ -322,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Dutch draft catalog (gap 5): `nl.toml` translates every UI string (1,718 keys; 41 with plural forms, one/other), every command label (566) and every menu wording (31) — 2,315 entries, machine-drafted in one style pass and marked as a draft awaiting native review; established tool terms (frame, auto layout, component, variant, prototype, overlay, widget, plugin, branch, constraints) and blend-mode, brush-preset and boolean names stay English, informal je register, infinitive menu labels, months abbreviated the Dutch way (jan., feb., mrt., apr., mei, …), `%` without a space and ordinary quotes. Preferences ▸ Language lists Nederlands; System maps any Dutch tag (nl-NL, nl…) to it, and the language-tag tests that used Dutch as a no-catalog example switched to Hindi. String counts re-measured from all catalogs: 1,718 UI keys, 566 command labels and 31 menu wordings (2,315 entries); the other drafts read 95–99% on the new basis. 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/docs/gaps.md b/docs/gaps.md index 68ee6d10..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a Dutch draft: `nl.toml` at 100% of the strings, machine-drafted, awaiting native review; the catalogs re-measured over 2,315 entries; preceding: the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; 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 @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Dutch complete as an unreviewed draft; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -179,27 +179,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). ### 5. Localization: English, reviewed Russian, plus unreviewed drafts -- **Missing:** native reviews of the Spanish, French, German, Dutch, Brazilian Portuguese, - Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish +- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, + Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), Hindi, Arabic and Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the craft-fonts CJK build. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in - `crates/ui-egui/locales/` (measured 2026-10-18: 1,718 UI keys plus 566 command labels and 31 - menu wordings, 2,315 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, - Spanish is held to completeness by the check, and Dutch (2026-10-18) covers every string as a - draft; the German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese machine drafts - cover 95–99% (strings added since they were drafted show English), with `es-419.toml` a Latin - American overlay listing only what + `crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31 + menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, + Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese, + Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they + were drafted show English), with `es-419.toml` a Latin American overlay listing only what differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals) covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker - reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.8–99.6% ([localization-parity](localization-parity.md#languages)). + reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)). CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. - **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English errors. - **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers; - 29–51 h for the key languages left and the drafts' follow-ups (kept on 2026-10-18: the + 29–51 h for the key languages left and the drafts' follow-ups (kept on 2026-10-17: the Indonesian draft takes 2–3 h off, about what bringing the drafts that slipped to 95% back to every string adds). Detail in [`localization-parity.md`](localization-parity.md); how to add one in @@ -445,7 +444,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Dutch draft (gap 5): `nl.toml` at 100% of the strings (1,718 UI keys, 566 command labels, 31 menu wordings — 2,315 entries), machine-drafted, a draft awaiting native review; the catalog counts re-measured over all languages (the other drafts read 95–99% on the new basis), the language table rows and evidence refreshed. Ranking and hours unchanged | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7df91731..50fcd4ec 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,12 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a Dutch draft: `nl.toml` at 100% of the strings, machine-drafted, awaiting native review; the language table re-measured over 2,315 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted, -waiting for native speakers' review), plus Dutch, Swedish and Turkish catalogs. This page lists the -twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a +waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key +languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). ## What Figma ships @@ -18,21 +18,21 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-18, from source) +## What FigCraft has (measured 2026-10-17, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,718 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 566 command labels - and 31 menu wordings by command id: 2,315 entries (counted against `en.toml` and the complete +- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels + and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, - 한국어, Nederlands, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a + 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional @@ -85,24 +85,23 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,315 / 2,315 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,209 / 2,315 (95.4%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,315 / 2,315 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,210 / 2,315 (95.5%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,210 / 2,315 (95.5%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Indonesian | id | 2,287 / 2,315 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Japanese | ja | 2,297 / 2,315 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,219 / 2,315 (95.9%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,209 / 2,315 (95.4%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages (measured 2026-10-18): Russian (`ru`) 2,287 / 2,315 (98.8%), reviewed and approved -by VanTan, one/few/many integer plurals; Dutch (`nl`) 2,315 / 2,315 (100%), machine-drafted, one/other -plurals; Swedish (`sv`) 2,307 (99.6%), its command labels and menu -wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,286 (98.8%), whose header -says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,287 (98.8%), a draft +Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved +by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu +wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header +says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft with one/few/other plurals. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries @@ -110,19 +109,17 @@ The layer is done. Per-language hours above are agent hours for a catalog of ~1, it isn't one/other; every language also needs a native speaker's review (human). Left before full parity besides the languages: localizing engine messages and number formatting (4–6 h); right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** (kept on -2026-10-18: bringing the drafts that slipped behind the newest strings back again balances -against the finished drafts like Indonesian's). +2026-10-17: Indonesian's draft takes 2–3 h off, about what bringing the drafts back from 95% to +every string adds). Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, Indonesian, Czech and Dutch, which Figma doesn't -ship), then Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users -see English). +and variants has one, plus Simplified Chinese, Indonesian and Czech, which Figma doesn't ship), then +Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | A Dutch draft (gap 5): `nl.toml` translates every UI string (1,718 keys; 41 with plural forms, one/other), every command label (566) and every menu wording (31) — 2,315 entries — machine-drafted in one style pass (informal je register, infinitive menu labels, sentence case, months abbreviated the Dutch way: jan., feb., mrt., apr., mei…; established tool terms such as frame, auto layout, component, variant, prototype, overlay, widget, plugin, branch and constraints, and blend-mode, brush-preset and boolean names stay English) and marked as a draft awaiting native review; Preferences ▸ Language lists Nederlands, System maps any Dutch tag (nl-NL, nl…) to it, Dutch's default plural rule is the one/other default. The language-tag tests that used Dutch as a language without a catalog switched to Hindi, and the language table is re-measured over 2,315 entries (the drafts read 95–99%) | | 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | From ae89635e009aa54face30ac23ff0d6be68c4108f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 13:25:03 +0700 Subject: [PATCH 220/245] #787: translate the 11 strings main gained since the catalog was drafted --- crates/ui-egui/locales/nl.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index c8eb0697..8d8cac68 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -93,6 +93,7 @@ status = "draft — needs native review" "All components" = "Alle components" "All fonts" = "Alle lettertypen" "All supported properties" = "Alle ondersteunde eigenschappen" +"All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" "All variables" = "Alle variabelen" "Alpha" = "Alpha" "Alphabetical" = "Alfabetisch" @@ -220,6 +221,7 @@ status = "draft — needs native review" "Changes on main" = "Wijzigingen op main" "Changes on the branch" = "Wijzigingen op de branch" "Changes unknown" = "Onbekende wijzigingen" +"Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…" "Checking the server…" = "Server controleren…" "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI of Compose om dit component aan zijn code te koppelen." "Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen." @@ -649,6 +651,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Instance verwisselen (geen andere component)" "Instances" = "Instances" "Instant" = "Direct" +"Intensity" = "Intensiteit" "Interaction" = "Interactie" "Interactions" = "Interacties" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-ontwerp en prototyping. Een gratis, open-source, clean-room ontwerptool, helemaal van nul geschreven in Rust, onderdeel van de ArtCraft Crafting Apps." @@ -773,6 +776,7 @@ status = "draft — needs native review" "Mirror angle and length" = "Spiegel hoek en lengte" "Mirroring" = "Spiegeling" "Missing" = "Ontbreekt" +"Missing fonts" = "Ontbrekende lettertypen" "Missing layer" = "Ontbrekende laag" "Missing style" = "Ontbrekende stijl" "Missing variable" = "Ontbrekende variabele" @@ -831,6 +835,7 @@ status = "draft — needs native review" "No conflicts." = "Geen conflicten." "No contributor data was built into this copy." = "In deze kopie zijn geen bijdragersgegevens ingebouwd." "No device" = "Geen apparaat" +"No files or templates match your search" = "Geen bestanden of sjablonen komen overeen met je zoekopdracht" "No fonts match" = "Geen lettertypen gevonden" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier is geen toegangscode (de server is ouder dan toegangscodes, of dit adres past niet in een code): deel in plaats daarvan de link." "No layer changes to show." = "Geen lagenwijzigingen om te tonen." @@ -1173,9 +1178,11 @@ status = "draft — needs native review" "Search actions, layers and tools" = "Zoek naar acties, lagen en tools" "Search assets" = "Zoek assets" "Search comments" = "Zoeken in reacties" +"Search files and templates" = "Bestanden en sjablonen zoeken" "Search fonts" = "Lettertypen zoeken" "Search layers" = "Lagen doorzoeken" "Search plugins" = "Zoeken naar plugins" +"Search results" = "Zoekresultaten" "Search shortcuts" = "Sneltoetsen zoeken" "Search templates" = "Sjablonen zoeken" "Section" = "Sectie" @@ -1377,6 +1384,7 @@ status = "draft — needs native review" "This file" = "Dit bestand" "This file has unsaved changes" = "Dit bestand heeft niet-opgeslagen wijzigingen" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Dit bestand staat op {server}, alleen toegankelijk voor jou. Deel het om anderen binnen te laten met een nieuwe code." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Dit bestand gebruikt lettertypen die niet op deze computer zijn geïnstalleerd. Hun tekst wordt in een vervangend lettertype getoond tot je ze installeert." "This frame is new on the branch." = "Dit frame is nieuw op de branch." "This frame isn't on the branch." = "Dit frame staat niet op de branch." "This server is open to the public: no key needed." = "Deze server is voor iedereen toegankelijk: geen sleutel nodig." @@ -1405,6 +1413,7 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "Afkappen met een beletselteken" "Try again" = "Probeer het opnieuw" "Try another search or filter." = "Probeer een andere zoekopdracht of filter." +"Try another search, or browse templates." = "Probeer een andere zoekopdracht of bekijk de sjablonen." "Try another word, or look at every kind." = "Probeer een ander woord, of bekijk alle soorten." "Turn a layer into a component, reuse it, and change every copy at once" = "Maak van een laag een component, hergebruik die en verander alle kopieën tegelijk" "Two digits" = "Twee cijfers" @@ -1595,6 +1604,7 @@ status = "draft — needs native review" "nobody else here yet" = "nog niemand anders hier" "non-default slot settings are not represented and were skipped" = "niet-standaard slot-instellingen zijn niet overgenomen en zijn overgeslagen" "now" = "nu" +"only in a text style or variable" = "alleen in een tekststijl of variabele" "or" = "of" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "Lijnverbindingen op punten zijn behouden; gestreepte lijnen of lijnen met variabele breedte worden weergegeven met de verbinding van de laag" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "voorkeurssleutels van slotcomponenten konden niet naar bestandsknooppunten worden herleid en zijn overgeslagen" @@ -1702,6 +1712,7 @@ status = "draft — needs native review" "{n} replies" = { one = "{n} antwoord", other = "{n} antwoorden" } "{n} rows" = { one = "{n} rij", other = "{n} rijen" } "{n} styles" = { one = "{n} stijl", other = "{n} stijlen" } +"{n} text layers" = { one = "{n} tekstlaag", other = "{n} tekstlagen" } "{n} variables" = { one = "{n} variabele", other = "{n} variabelen" } "{n} weeks ago" = { one = "{n} week geleden", other = "{n} weken geleden" } "{n} years ago" = { one = "{n} jaar geleden", other = "{n} jaar geleden" } From 99e5db399ba3141a4b06c4a4e61c5fe2128e3ebf Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 18 Oct 2026 14:09:51 +0700 Subject: [PATCH 221/245] M7: quitting on macOS ends the process after saving, avoiding the Touch Bar teardown crash (#781) (#791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Quit (macOS):** `on_exit` now calls `services::shutdown` and then, on macOS only, `std::process::exit(0)`. This happens before eframe tears the windows down, which is where the crash in #781 is thrown (same approach as the sibling apps, tracked in craft-libs#3). - **`services::shutdown(app, dir, recovery)`** replaces `save_state`. It runs on every platform and everything in it is finished before it returns: 1. the preferences (`state.json`) are written atomically, as before; 2. this run's recovery copies are settled by the new `Recovery::quit(session, asked)`: - if the unsaved-changes question was asked (`ui.quit`: last window closed through the question, or `app.quit`), or nothing is unsaved, it works as before: `finish()` removes them; - otherwise the unsaved documents are copied (waiting for any copy still being written) and the copies stay for the next start, as after a crash. This is the macOS Quit menu (Cmd+Q): winit's menu calls `terminate:`, which reaches `on_exit` without asking. Until now that path removed the recovery copies of files that had unsaved changes; 3. live sessions end explicitly: a session this app hosts tells everyone and stops; a joined session is left. Before, this only happened in the app's thread-local destructor at process exit, if at all. - If the app can't be reached in `on_exit` (it's busy), the recovery copies are left on disk for the next start rather than removed. Quit paths checked: - **Cmd+Q / the menu bar's Quit (macOS):** `terminate:` → winit `applicationWillTerminate` → eframe `exiting` → `on_exit`. Covered. - **Last window closed** (close button, title bar Close, Dock/Finder quit Apple event → `Close`): `on_close_request` → `begin` sets `ui.quit` → root `Close` → `on_exit`. Covered. - **`app.quit` (control channel / MCP):** sets `ui.quit` and closes the root window → `on_exit`. Covered. Its reply goes to the connection thread before the window closes. The process now ends sooner after that, so a client that reads very slowly could in theory see EOF instead of the reply (not seen). - **The frame guard (panic):** isn't a quit. It writes recovery copies and the app keeps running, so it doesn't need this. A panic outside the guard aborts as before. - **eframe error mid-run:** eframe also calls `on_exit` before `run_native` returns `Err`. On macOS that error now only reaches the log (eframe logs it), not the "window couldn't open" box. Startup failures still show the box: `on_exit` only runs once the app exists. Part of #781. --- apps/figcraft/src/main.rs | 11 +++++---- apps/figcraft/src/recovery.rs | 11 +++++++++ apps/figcraft/src/recovery_tests.rs | 17 ++++++++++++++ apps/figcraft/src/services.rs | 14 +++++++++--- apps/figcraft/src/services_tests.rs | 35 +++++++++++++++++++++++++++++ docs/hardware-parity.md | 5 +++-- 6 files changed, 84 insertions(+), 9 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index fe89d0a7..ddb70204 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -107,10 +107,13 @@ impl eframe::App for App { } fn on_exit(&mut self) { - with_app(|fig| services::save_state(fig)); - if let Some(r) = recovery::lock(&self.recovery).take() { - r.finish(); - } + // With the app out of reach (busy), its recovery copies stay for the next start. + let copies = recovery::lock(&self.recovery).take(); + with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies)); + // Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support + // throws on the content view AccessKit restores (craft-libs#3, #781). + #[cfg(target_os = "macos")] + std::process::exit(0); } } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index b2614598..62243bc7 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -250,6 +250,17 @@ impl Recovery { self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) }); } + /// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was + /// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the + /// unsaved documents are copied and the copies stay for the next start, as after a crash. + pub fn quit(mut self, s: &Session, asked: bool) { + if asked || !s.docs.iter().any(|d| needs_copy(s, d)) { + return self.finish(); + } + self.write(s, true); + self.settle(s); + } + /// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier /// runs' copies still waiting for their outcome stay for the next start.) pub fn finish(self) { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index a3d608f9..8f384b87 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() { + let root = root("quitunasked"); + // macOS's Quit menu: nothing asked, so the unsaved document comes back next time. + let s = edited(); + Recovery::start(&root).unwrap().quit(&s, false); + let mut next = Session::empty(); + assert_eq!(recover(&root, &mut next), 1); + let _ = std::fs::remove_dir_all(&root); + // Asked (saved or discarded on purpose), or nothing unsaved: nothing is left. + for (s, asked) in [(edited(), true), (Session::new(), false)] { + Recovery::start(&root).unwrap().quit(&s, asked); + assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}"); + } + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn a_run_whose_folder_name_is_still_held_takes_another() { let root = root("pidreuse"); diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 6930f44d..7ee41ada 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> { /// The preferences' file in the config folder. const STATE: &str = "state.json"; -pub fn save_state(app: &FigApp) { - if let Some(dir) = config_dir() { - write_prefs(&dir, &app.session.prefs); +/// Everything quitting keeps, all done when it returns (macOS then ends the process): the +/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit)) +/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves). +pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option) { + if let Some(dir) = dir { + write_prefs(dir, &app.session.prefs); } + // Before the sessions end: a recovered online file's copy waits for its session's outcome. + if let Some(r) = recovery { + r.quit(&app.session, app.ui.quit); + } + drop(std::mem::take(&mut app.session.live)); } /// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 2390b92f..2abec5bb 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() { assert_eq!(mode(&dir.join("state.json")), 0o600); let _ = std::fs::remove_dir_all(&dir); } + +/// A live link that only records the sessions it was told to leave. +struct Leaves(Arc>>); + +impl figcraft_engine::live::LiveLink for Leaves { + fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result { + Err("not in this test".into()) + } + fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { + Ok(()) + } + fn leave(&mut self, session: u64) { + self.0.lock().unwrap().push(session); + } + fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {} + fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {} + fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> { + Vec::new() + } +} + +#[test] +fn quitting_keeps_the_preferences_and_ends_the_live_sessions() { + let dir = root("shutdown"); + let mut fig = start(&dir); + let left = Arc::new(Mutex::new(Vec::new())); + fig.session.live.install(Box::new(Leaves(left.clone()))); + let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap(); + fig.session.prefs.user_name = "Ada".into(); + crate::services::shutdown(&mut fig, Some(&dir), None); + assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left"); + assert!(!fig.session.live.available(), "nothing is left to send on"); + assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index bf945c12..df822981 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -39,7 +39,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Platform | Figma | FigCraft | Notes | |---|---|---|---| -| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens | +| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); Quit from the menu bar, which asks nothing, keeps unsaved files' recovery copies for the next start | | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged | From 8629a6f586f7637fb0148c75c585a8e7fbc21e3d Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 18 Oct 2026 14:59:14 +0700 Subject: [PATCH 222/245] M3: variable.import reads variables outside `meta` and never imports nothing silently (#790) (#793) Fixes #790. Fixes #790. --- crates/engine/src/cmd/variables_io.rs | 2 +- crates/engine/src/lib.rs | 2 + .../src/variables_import_shapes_tests.rs | 60 +++++++++++++++++++ crates/format/src/rest/mod.rs | 23 +++++-- crates/format/src/rest/variables.rs | 20 ++++++- docs/components-parity.md | 5 +- docs/mcp.md | 7 ++- 7 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/variables_import_shapes_tests.rs diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 99a78640..f3b9a820 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { spec!( "variable.import", "Import variables", - "{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}", + "{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}", true, import ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index af0f555e..fedcffc9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1816,6 +1816,8 @@ mod variables_behaviour_tests; #[cfg(test)] mod variables_editor_tests; #[cfg(test)] +mod variables_import_shapes_tests; +#[cfg(test)] mod variables_io_tests; #[cfg(test)] mod vector_behaviour_tests; diff --git a/crates/engine/src/variables_import_shapes_tests.rs b/crates/engine/src/variables_import_shapes_tests.rs new file mode 100644 index 00000000..067ccab2 --- /dev/null +++ b/crates/engine/src/variables_import_shapes_tests.rs @@ -0,0 +1,60 @@ +//! `variable.import` reads `variables` next to `variableCollections`, under `meta` or at the top +//! level, and never imports nothing without saying so (#790). + +use serde_json::{Value, json}; + +use crate::Session; + +fn collections() -> Value { + json!({"VC:1": {"id": "VC:1", "name": "Theme", "modes": [{"modeId": "1:0", "name": "Light"}], "defaultModeId": "1:0", + "variableIds": ["VariableID:1"]}}) +} + +fn variables() -> Value { + json!({"VariableID:1": {"id": "VariableID:1", "name": "bg", "variableCollectionId": "VC:1", "resolvedType": "COLOR", + "valuesByMode": {"1:0": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}}}}) +} + +fn import(json: Value) -> Value { + let mut s = Session::new(); + match s.execute("variable.import", &json!({"variables": json})) { + Ok(r) => r, + Err(e) => panic!("{e}"), + } +} + +#[test] +fn variables_import_reads_every_shape() { + for shape in [ + json!({"meta": {"variableCollections": collections(), "variables": variables()}}), + json!({"variableCollections": collections(), "variables": variables()}), + json!({"meta": {"variableCollections": collections()}, "variables": variables()}), + json!({"variableCollections": collections(), "meta": {"variables": variables()}}), + ] { + let r = import(shape.clone()); + assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)), "{shape}: {r}"); + assert_eq!(r["warnings"], json!([]), "{shape}: {r}"); + } +} + +#[test] +fn variables_import_never_imports_nothing_silently() { + // Collections without variables: they are added, with a warning saying where variables go. + let r = import(json!({"meta": {"variableCollections": collections()}})); + assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(0)), "{r}"); + assert!(r["warnings"].to_string().contains("no variables found: expected `meta.variables`"), "{r}"); + // An empty file's export reads back with the same warning, not an error. + let mut s = Session::new(); + let text = s.execute("variable.export", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + let r = s.execute("variable.import", &json!({"variables": text})).unwrap(); + assert!(r["warnings"].to_string().contains("no variables found"), "{r}"); + // Collections listing variables that aren't there. + let mut colls = collections(); + colls["VC:1"]["variableIds"] = json!(["VariableID:1", "VariableID:2"]); + let r = import(json!({"meta": {"variableCollections": colls, "variables": variables()}})); + assert_eq!(r["variables"].as_u64(), Some(1), "{r}"); + assert!(r["warnings"].to_string().contains("1 of the 2 variables the collections list in `variableIds` weren't imported"), "{r}"); + // No collections anywhere: an error naming the expected shape. + let e = Session::new().execute("variable.import", &json!({"variables": {"variables": variables()}})).unwrap_err().to_string(); + assert!(e.contains("(`meta.variableCollections` and `meta.variables`)"), "{e}"); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index be5c7157..232afc25 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -106,12 +106,25 @@ pub fn is_variables_json(v: &Value) -> bool { } /// The collections and variables of a local variables response (or its `meta`), checked like a -/// file's, with the warnings about what was skipped; None when `v` isn't one. +/// file's, with the warnings about what was skipped (and when it holds no variables at all, or +/// fewer than its collections list); None when `v` isn't one. pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec)> { - let src = variables::source(v)?; + variables::source(v)?; let mut cx = Ctx::new(Document::new("Variables"), &no_images); - cx.variables(src); - Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish())) + cx.variables(v); + let vars = std::mem::take(&mut cx.doc.variables); + let mut warnings = cx.warnings.finish(); + if vars.variables.is_empty() { + warnings.push("no variables found: expected `meta.variables` (an object of variables by id, next to `meta.variableCollections`)".into()); + } else { + let listed = variables::listed_ids(v); + let read: BTreeSet<&str> = vars.variables.iter().map(|x| x.id.as_str()).collect(); + let missing = listed.difference(&read).count(); + if missing > 0 { + warnings.push(format!("{missing} of the {} variables the collections list in `variableIds` weren't imported", listed.len())); + } + } + Some((vars, warnings)) } /// Import `bytes` when they are REST API file JSON; `None` when they are something else (the @@ -191,7 +204,7 @@ fn import_parts( cx.collect_meta(e); } // Variables first: layers link to them as they are built. - for src in [variables::source(v), variables.and_then(variables::source)].into_iter().flatten() { + for src in [Some(v), variables].into_iter().flatten() { cx.variables(src); } if variables.is_some_and(|x| variables::source(x).is_none()) { diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index 91d14001..d58da409 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -27,6 +27,16 @@ pub fn source(v: &Value) -> Option<&Value> { [v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object)) } +/// The variable ids the collections of a local variables response list in `variableIds`. +pub(super) fn listed_ids(v: &Value) -> BTreeSet<&str> { + source(v) + .into_iter() + .flat_map(|src| objects(src, "variableCollections", MAX_COLLECTIONS)) + .flat_map(|(_, c)| list(c, "variableIds").iter().filter_map(Value::as_str).map(clip_str)) + .take(MAX_VARIABLES) + .collect() +} + pub(super) fn kind_of(t: &str) -> Option { Some(match t { "COLOR" => VariableType::Color, @@ -118,14 +128,18 @@ fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator { - /// Add the collections and variables of a local variables response (or its `meta`). - pub(super) fn variables(&mut self, src: &Value) { + /// Add the collections and variables of a local variables response (or its `meta`); nothing + /// when `v` isn't one. The `variables` map is read next to `variableCollections`, or from + /// `meta` or the top level when it sits on the other side. + pub(super) fn variables(&mut self, v: &Value) { + let Some(src) = source(v) else { return }; for (key, c) in objects(src, "variableCollections", MAX_COLLECTIONS) { self.collection(key, c); } + let holder = [Some(src), v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variables").is_some_and(Value::is_object)); let mut found: Vec = Vec::new(); let mut seen: BTreeSet = BTreeSet::new(); - for (key, v) in objects(src, "variables", MAX_VARIABLES) { + for (key, v) in holder.into_iter().flat_map(|h| objects(h, "variables", MAX_VARIABLES)) { if let Some(var) = self.variable(key, v) && seen.insert(var.id.clone()) { diff --git a/docs/components-parity.md b/docs/components-parity.md index 3b9793b0..2640ed1c 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | -| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | | 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index a84bf05b..786fde0a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -883,10 +883,13 @@ path?}` writes this file's collections (default: all but library copies) as a lo response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`), and returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, -JSON text or a path) reads one back: a collection joins this file's collection of the same name +JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level) reads +one back: a collection joins this file's collection of the same name (values matched by mode name, new modes and variables added, a same-named variable of another type skipped with a warning; library copies and extended collections are never targets), others are -added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The +added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`, +with a warning when the JSON holds no variables (`no variables found: expected meta.variables …`) +or fewer than its collections list in `variableIds`. The Variables editor's Import… and Export… buttons do the same through file dialogs. Strokes take colour styles and variables like fills: `style.apply {style, target: "stroke"}` links From 55fbcd0d7ea6a1349002dc775cd361924796e96b Mon Sep 17 00:00:00 2001 From: Esa Komulainen Date: Sun, 18 Oct 2026 11:03:36 +0300 Subject: [PATCH 223/245] M10: add Finnish (fi) interface catalog --- crates/ui-egui/locales/fi.toml | 2343 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n_tests.rs | 40 + docs/translating.md | 12 + 3 files changed, 2395 insertions(+) create mode 100644 crates/ui-egui/locales/fi.toml diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml new file mode 100644 index 00000000..eb3ce94c --- /dev/null +++ b/crates/ui-egui/locales/fi.toml @@ -0,0 +1,2343 @@ +# Finnish interface catalog. Original translations; MIT OR Apache-2.0. +# AI-assisted draft; not reviewed by a human Finnish/native speaker. +# Sentence case, imperative commands. Integer plurals: 1 taso, 0/2/21 tasoa. +# Product names, shortcut notation, numeric glyphs and code examples retain their identity. +# Command ids and context suffixes are immutable keys; translated text is display-only. +# Completeness measured against the English UI and live command/menu source sets; see tests. + +[meta] +name = "Suomi" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% kaikista muutoksista" +"(no longer published)" = "(ei enää julkaistu)" +"({n} warnings: some entries were skipped)" = { one = "({n} varoitus: joitakin kohteita ohitettiin)", other = "({n} varoitusta: joitakin kohteita ohitettiin)" } +"+ Create variable" = "+ Luo muuttuja" +".fig (experimental)" = ".fig (kokeellinen)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR:t {prs}, muutokset {commits}, rivit +{added} / −{deleted} (Δ {delta}), binääriresurssit +{bin_added} / −{bin_deleted}, {first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Tämänniminen .figcraft-tiedosto on jo olemassa. Vahvista korvaaminen valitsemalla sen koko nimi." +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Tämän tiedoston kokoelma, joka sisältää nämä tilat ja muuttujat. Voit ohittaa niiden arvot tilakohtaisesti ja lisätä omia muuttujia." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Yksittäinen taso tasataan kehykseensä. Valitse useampia tasoja tai yksi kehyksen sisällä oleva taso." +"A responsive product page: hero, features, figures, a quote and a footer" = "Mukautuva tuotesivu: pääosio, ominaisuudet, tunnusluvut, lainaus ja alatunniste" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Jousi liikkuu, kunnes se asettuu. Sen massa, jäykkyys ja vaimennus määräävät keston." +"A title, an agenda, a big figure, a chart and a closing slide" = "Otsikko, esityslista, suuri tunnusluku, kaavio ja päätösdia" +"A to Z" = "A:sta Z:aan" +"About" = "Tietoja" +"About FigCraft" = "Tietoja FigCraftista" +"About this server" = "Tietoja tästä palvelimesta" +"Action" = "Toiminto" +"Actions" = "Toiminnot" +"Actions for {name}" = "Kohteen {name} toiminnot" +"Actions…" = "Toiminnot…" +"Active" = "Aktiivinen" +"Actual size (100%)" = "Todellinen koko (100 %)" +"Add a category" = "Lisää luokka" +"Add a comment" = "Lisää kommentti" +"Add a description" = "Lisää kuvaus" +"Add a frame (F) or a shape (R) to get started" = "Aloita lisäämällä kehys (F) tai muoto (R)" +"Add a layer or component to tile first" = "Lisää ensin laatoitettava taso tai komponentti" +"Add a note for developers" = "Lisää huomautus kehittäjille" +"Add action" = "Lisää toiminto" +"Add actions for when no condition holds" = "Lisää toimintoja tilanteeseen, jossa mikään ehto ei täyty" +"Add an annotation for developers" = "Lisää huomautus kehittäjille" +"Add another condition" = "Lisää toinen ehto" +"Add auto layout" = "Lisää automaattinen asettelu" +"Add background behind overlay" = "Lisää tausta peittokuvan taakse" +"Add content to this slot" = "Lisää sisältöä tähän sisältöpaikkaan" +"Add dev resource" = "Lisää kehittäjäresurssi" +"Add effect" = "Lisää tehoste" +"Add export setting" = "Lisää vientiasetus" +"Add fill" = "Lisää täyttö" +"Add interaction" = "Lisää vuorovaikutus" +"Add layout grid" = "Lisää asetteluruudukko" +"Add library" = "Lisää kirjasto" +"Add library file…" = "Lisää kirjastotiedosto…" +"Add max height" = "Lisää enimmäiskorkeus" +"Add max width" = "Lisää enimmäisleveys" +"Add min height" = "Lisää vähimmäiskorkeus" +"Add min width" = "Lisää vähimmäisleveys" +"Add mode" = "Lisää tila" +"Add new page" = "Lisää uusi sivu" +"Add or update collections from a variables JSON" = "Lisää tai päivitä kokoelmia muuttujien JSON-tiedostosta" +"Add properties to make instances configurable" = "Lisää ominaisuuksia, jotta ilmentymiä voi mukauttaa" +"Add reaction" = "Lisää reaktio" +"Add starting point" = "Lisää aloituspiste" +"Add stroke" = "Lisää reunaviiva" +"Add variant" = "Lisää variantti" +"Add variant with a new value" = "Lisää variantti uudella arvolla" +"Added" = "Lisätty" +"Added the library \"{name}\"" = "Kirjasto ”{name}” lisätty" +"Adding to {name}" = "Lisätään kohteeseen {name}" +"Adjust" = "Säädä" +"Adjustment presets" = "Säädön esiasetukset" +"Advanced" = "Lisäasetukset" +"Advanced stroke" = "Reunaviivan lisäasetukset" +"After delay" = "Viiveen jälkeen" +"After delay {ms}ms" = "Viiveen {ms}ms jälkeen" +"Align" = "Tasaa" +"Align bottom" = "Tasaa alas" +"Align center" = "Keskitä" +"Align horizontal centers" = "Keskitä vaakasuunnassa" +"Align left" = "Tasaa vasemmalle" +"Align middle" = "Tasaa keskelle" +"Align right" = "Tasaa oikealle" +"Align text baselines" = "Tasaa tekstin peruslinjat" +"Align top" = "Tasaa ylös" +"Align vertical centers" = "Keskitä pystysuunnassa" +"Alignment" = "Tasaus" +"All" = "Kaikki" +"All categories" = "Kaikki luokat" +"All comments" = "Kaikki kommentit" +"All components" = "Kaikki komponentit" +"All fonts" = "Kaikki fontit" +"All supported properties" = "Kaikki tuetut ominaisuudet" +"All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" +"All variables" = "Kaikki muuttujat" +"Alpha" = "Alfa" +"Alphabetical" = "Aakkosjärjestys" +"Alt+drag" = "Alt+drag" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytiikkanäkymä ruudukossa: tunnuslukuja, vektoreista piirrettyjä kaavioita ja taulukko" +"An invite link that opens FigCraft and joins" = "Kutsulinkki, joka avaa FigCraftin ja liittyy istuntoon" +"Anchor" = "Ankkuri" +"Android" = "Android" +"Angular" = "Kulmaliukuväri" +"Animation" = "Animaatio" +"Annotations" = "Huomautukset" +"Annotations shown on the canvas" = "Piirtoalueella näytettävät huomautukset" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Toinen palvelin tai avainta tarvitseva palvelin. Tavallinen palvelin asetetaan asetuksissa." +"Any status" = "Kaikki tilat" +"Anyone on your network with this code can edit the file, while this app stays open." = "Kuka tahansa lähiverkossasi voi muokata tiedostoa tällä koodilla niin kauan kuin sovellus on auki." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Kuka tahansa muokkauslinkin saanut voi muuttaa tiedostoa. Katselulinkillä voi vain katsella. Linkit sisältävät salaiset tunnisteet: jaa niitä kuin salasanoja. `figcraft-server links` luo lisää linkkejä ja peruu niitä." +"Anyone with this code can edit the file." = "Kuka tahansa tämän koodin saanut voi muokata tiedostoa." +"Anyone with this code can look at the file, not change it." = "Kuka tahansa tämän koodin saanut voi katsella tiedostoa, mutta ei muuttaa sitä." +"Appearance" = "Ulkoasu" +"Apply a component property" = "Käytä komponentin ominaisuutta" +"Apply variable" = "Käytä muuttujaa" +"Apply variable to text" = "Käytä muuttujaa tekstiin" +"Apply variable to visibility" = "Käytä muuttujaa näkyvyyteen" +"Apps" = "Sovellukset" +"Apr" = "huhtik." +"Arc" = "Kaari" +"Arc ratio" = "Kaaren suhde" +"Arc start" = "Kaaren alku" +"Arc sweep" = "Kaaren pyyhkäisy" +"Archive" = "Arkistoi" +"Archived" = "Arkistoitu" +"Arrange" = "Järjestä" +"Arrow" = "Nuoli" +"As typed" = "Kirjoitetussa muodossa" +"Asking the server…" = "Kysytään palvelimelta…" +"Assets" = "Resurssit" +"Aug" = "elok." +"Auto" = "Automaattinen" +"Auto (space around)" = "Automaattinen (tila ympärillä)" +"Auto (space between)" = "Automaattinen (tila välissä)" +"Auto (space evenly)" = "Automaattinen (tasavälein)" +"Auto ({mode})" = "Automaattinen ({mode})" +"Auto columns" = "Automaattiset sarakkeet" +"Auto gap" = "Automaattinen väli" +"Auto height" = "Automaattinen korkeus" +"Auto layout" = "Automaattinen asettelu" +"Auto rows" = "Automaattiset rivit" +"Auto width" = "Automaattinen leveys" +"Back" = "Takaisin" +"Back to Ready for dev" = "Palauta kehitysvalmiiksi" +"Back to Ready for dev: the next change compares with how it is now" = "Palauta kehitysvalmiiksi: seuraavaa muutosta verrataan nykyiseen tilaan" +"Back to assets" = "Takaisin resursseihin" +"Back to current" = "Takaisin nykyiseen" +"Back to styles" = "Takaisin tyyleihin" +"Back to the FigCraft server" = "Takaisin FigCraft-palvelimelle" +"Back to the canvas from a focused region" = "Palaa kohdistetulta alueelta piirtoalueelle" +"Back to the current version" = "Takaisin nykyiseen versioon" +"Back to the font's value" = "Palauta fontin arvo" +"Back to the values the font style implies" = "Palauta fonttityylin mukaiset arvot" +"Background" = "Tausta" +"Background blur" = "Taustan sumennus" +"Basics" = "Perusteet" +"Before" = "Ennen" +"Bend" = "Taivutus" +"Bevel" = "Viiste" +"Binary assets added" = "Lisätyt binääriresurssit" +"Binary assets removed" = "Poistetut binääriresurssit" +"Black & white" = "Mustavalkoinen" +"Bleed" = "Leikkuuvara" +"Bleed marks" = "Leikkuuvaramerkit" +"Bleed marks need a bleed" = "Leikkuuvaramerkit edellyttävät leikkuuvaraa" +"Blend" = "Sekoitus" +"Blend mode" = "Sekoitustila" +"Blue" = "Sininen" +"Blur" = "Sumennus" +"Boolean" = "Totuusarvo" +"Boolean groups" = "Boolen ryhmät" +"Both directions" = "Molemmat suunnat" +"Bottom" = "Alhaalta" +"Bottom left radius" = "Vasemman alakulman säde" +"Bottom right radius" = "Oikean alakulman säde" +"Bound to {name}. Click to detach the variable." = "Sidottu muuttujaan {name}. Irrota muuttuja napsauttamalla." +"Branch" = "Haara" +"Branch archived" = "Haara arkistoitu" +"Branch merged" = "Haara yhdistetty" +"Branch name" = "Haaran nimi" +"Branch of {file}" = "Tiedoston {file} haara" +"Branch of {file} · merged or archived" = "Tiedoston {file} haara · yhdistetty tai arkistoitu" +"Branch updated from main" = "Haara päivitetty päähaarasta" +"Branch {name}" = "Haara {name}" +"Branches" = "Haarat" +"Bring forward" = "Tuo eteenpäin" +"Bring the main file's changes into the branch" = "Tuo päätiedoston muutokset haaraan" +"Bring to front" = "Tuo etummaiseksi" +"Bristle" = "Harjas" +"Browse" = "Selaa" +"Brush" = "Sivellin" +"Brush rotation jitter" = "Siveltimen kierron vaihtelu" +"Brush scatter" = "Siveltimen hajonta" +"Brush size jitter" = "Siveltimen koon vaihtelu" +"Brush spacing" = "Siveltimen välistys" +"Bulleted list" = "Luettelomerkitty luettelo" +"C or Shift+C" = "C or Shift+C" +"C or ⇧C" = "C or ⇧C" +"CMYK colour" = "CMYK-väri" +"CSS custom properties…" = "CSS:n mukautetut ominaisuudet…" +"Can edit" = "Voi muokata" +"Can view" = "Voi katsella" +"Can't read this library: {e}" = "Kirjastoa ei voi lukea: {e}" +"Cancel" = "Peruuta" +"Canvas" = "Piirtoalue" +"Canvas stacking" = "Piirtoalueen pinoamisjärjestys" +"Cap height to baseline" = "Versaalikorkeudesta peruslinjaan" +"Case" = "Kirjainkoko" +"Case-sensitive forms" = "Kirjainkokoon mukautuvat muodot" +"Categories" = "Luokat" +"Center" = "Keskelle" +"Change alias" = "Vaihda alias" +"Change resolved" = "Muutos ratkaistu" +"Change style" = "Vaihda tyyli" +"Change to" = "Vaihda kohteeksi" +"Change to needs a layer inside a variant" = "Vaihto edellyttää variantin sisällä olevaa tasoa" +"Changed" = "Muutettu" +"Changed on both sides: keep one" = "Muutettu molemmilla puolilla: säilytä toinen" +"Changes on main" = "Päähaaran muutokset" +"Changes on the branch" = "Haaran muutokset" +"Changes unknown" = "Muutokset tuntemattomia" +"Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…" +"Checking the server…" = "Tarkistetaan palvelinta…" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI tai Compose yhdistääksesi tämän komponentin sen koodiin." +"Choose another profile to change it." = "Vaihda valitsemalla toinen profiili." +"Choose collection" = "Valitse kokoelma" +"Choose image" = "Valitse kuva" +"Choose mode" = "Valitse tila" +"Choose variable" = "Valitse muuttuja" +"Circle" = "Ympyrä" +"Clear from this list" = "Poista tästä luettelosta" +"Clear recent" = "Tyhjennä viimeisimmät" +"Click + to replace mixed content" = "Korvaa vaihteleva sisältö napsauttamalla +" +"Click anywhere on the canvas to leave one." = "Lisää huomautus napsauttamalla mitä tahansa piirtoalueen kohtaa." +"Click or drag to merge · {alt} removes" = "Yhdistä napsauttamalla tai vetämällä · {alt} poistaa" +"Click or drag to remove · {alt} merges" = "Poista napsauttamalla tai vetämällä · {alt} yhdistää" +"Click to record a key" = "Tallenna näppäin napsauttamalla" +"Clip content" = "Leikkaa sisältö rajoihin" +"Clip to shape" = "Leikkaa muotoon" +"Close" = "Sulje" +"Close overlay" = "Sulje peittokuva" +"Close preview" = "Sulje esikatselu" +"Close tab" = "Sulje välilehti" +"Close version history" = "Sulje versiohistoria" +"Close when clicking outside" = "Sulje napsautettaessa ulkopuolelle" +"Close without saving" = "Sulje tallentamatta" +"Code" = "Koodi" +"Code or invite link" = "Koodi tai kutsulinkki" +"Code syntax" = "Koodin syntaksi" +"Collapse" = "Tiivistä muunnokset" +"Collapse panel" = "Supista paneeli" +"Collection" = "Kokoelma" +"Collections" = "Kokoelmat" +"Color" = "Väri" +"Color burn" = "Värivarjostus" +"Color dodge" = "Värilisävalotus" +"Color format" = "Värimuoto" +"Color profile" = "Väriprofiili" +"Color profile: {name}" = "Väriprofiili: {name}" +"Color styles" = "Värityylit" +"Color values in this file are in this color space." = "Tämän tiedoston väriarvot ovat tässä väriavaruudessa." +"Color variables" = "Värimuuttujat" +"Colour" = "Väri" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Väri- ja tekstityylejä, vaalean ja tumman tilan muuttujia, painikkeita, syötekenttiä ja kortteja" +"Column" = "Sarake" +"Column gap" = "Sarakkeiden väli" +"Column sizes" = "Sarakkeiden koot" +"Column span" = "Sarakeulottuvuus" +"Columns" = "Sarakkeet" +"Columns {count}" = "Sarakkeita: {count}" +"Comment" = "Kommentti" +"Comments" = "Kommentit" +"Commits" = "Muutokset" +"Company" = "Yritys" +"Compare changes" = "Vertaa muutoksia" +"Compatibility notes" = "Yhteensopivuushuomautukset" +"Completed" = "Valmis" +"Component" = "Komponentti" +"Component name" = "Komponentin nimi" +"Component property" = "Komponentin ominaisuus" +"Component set" = "Komponenttijoukko" +"Component sets" = "Komponenttijoukot" +"Components" = "Komponentit" +"Conditional" = "Ehdollinen" +"Confetti" = "Konfetti" +"Conflicts" = "Ristiriidat" +"Connect to code" = "Yhdistä koodiin" +"Connected" = "Yhdistetty" +"Connected to code: {from}" = "Yhdistetty koodiin: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Yhteys katkesi, yhdistetään uudelleen (yritys {attempt})…" +"Constrain proportions" = "Säilytä mittasuhteet" +"Constraints" = "Rajoitteet" +"Content" = "Sisältö" +"Contents hidden" = "Sisältö kätketty" +"Contents only" = "Vain sisältö" +"Contextual alternates" = "Kontekstuaaliset vaihtoehdot" +"Contrast" = "Kontrasti" +"Contributors" = "Tekijät" +"Contributors: {people} · Commits: {commits}" = "Tekijät: {people} · Muutokset: {commits}" +"Controller" = "Ohjain" +"Convert to frame" = "Muunna kehykseksi" +"Convert to section" = "Muunna osioksi" +"Cool" = "Viileä" +"Copied as CSS" = "Kopioitu CSS-muodossa" +"Copied as PNG" = "Kopioitu PNG-muodossa" +"Copied as SVG" = "Kopioitu SVG-muodossa" +"Copied as text" = "Kopioitu tekstinä" +"Copied {value}" = "Kopioitu: {value}" +"Copy" = "Kopioi" +"Copy as" = "Kopioi muodossa" +"Copy as CSS" = "Kopioi CSS-muodossa" +"Copy as PNG" = "Kopioi PNG-muodossa" +"Copy as SVG" = "Kopioi SVG-muodossa" +"Copy code" = "Kopioi koodi" +"Copy link" = "Kopioi linkki" +"Copy the {label}" = "Kopioi: {label}" +"Copy {value}" = "Kopioi: {value}" +"Corner radius" = "Kulman säde" +"Corner smoothing" = "Kulman pehmennys" +"Couldn't download the variables" = "Muuttujia ei voitu ladata" +"Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." +"Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}" +"Count" = "Määrä" +"Create" = "Luo" +"Create a variable collection first" = "Luo ensin muuttujakokoelma" +"Create a variable first" = "Luo ensin muuttuja" +"Create alias" = "Luo alias" +"Create branch" = "Luo haara" +"Create collection" = "Luo kokoelma" +"Create component" = "Luo komponentti" +"Create component property" = "Luo komponentin ominaisuus" +"Create components, styles or variables to publish them as a library." = "Luo komponentteja, tyylejä tai muuttujia, jotta voit julkaista ne kirjastona." +"Create property…" = "Luo ominaisuus…" +"Create style" = "Luo tyyli" +"Create style from the selected text" = "Luo tyyli valitusta tekstistä" +"Created" = "Luotu" +"Crop" = "Rajaa" +"Crop marks" = "Leikkausmerkit" +"Ctrl+arrows" = "Ctrl+arrows" +"Ctrl+click" = "Ctrl+click" +"Ctrl+scroll" = "Ctrl+scroll" +"Current name" = "Nykyinen nimi" +"Current version" = "Nykyinen versio" +"Cursor chat" = "Kohdistinkeskustelu" +"Custom" = "Mukautettu" +"Custom size" = "Mukautettu koko" +"Cut" = "Leikkaa" +"Dark" = "Tumma" +"Darken" = "Tummenna" +"Dash" = "Katkoviiva" +"Dashboard" = "Koontinäkymä" +"Date created" = "Luontipäivä" +"Dec" = "jouluk." +"Decoration" = "Koristelu" +"Decoration style" = "Koristelun tyyli" +"Deep select" = "Valitse sisäkkäinen kohde" +"Default" = "Oletus" +"Default content" = "Oletussisältö" +"Default value (the top-left variant)" = "Oletusarvo (vasemman yläkulman variantti)" +"Delay" = "Viive" +"Delete" = "Poista" +"Delete category (its annotations stay)" = "Poista luokka (sen huomautukset säilyvät)" +"Delete collection" = "Poista kokoelma" +"Delete group" = "Poista ryhmä" +"Delete mode" = "Poista tila" +"Delete page" = "Poista sivu" +"Delete preset" = "Poista esiasetus" +"Delete property" = "Poista ominaisuus" +"Delete the style (layers keep their values)" = "Poista tyyli (tasojen arvot säilyvät)" +"Delete thread" = "Poista keskustelu" +"Delete variable" = "Poista muuttuja" +"Delete “{name}”? This can't be undone." = "Poistetaanko ”{name}”? Tätä ei voi kumota." +"Density" = "Tiheys" +"Depth" = "Syvyys" +"Describe how and when to use this component" = "Kuvaile, miten ja milloin tätä komponenttia käytetään" +"Describe what changed" = "Kuvaile muutokset" +"Description" = "Kuvaus" +"Description (optional)" = "Kuvaus (valinnainen)" +"Design" = "Suunnittelu" +"Design system starter" = "Muotoilujärjestelmän aloituspohja" +"Desktop" = "Työpöytä" +"Destination" = "Kohde" +"Detach alias" = "Irrota alias" +"Detach instance" = "Irrota ilmentymä" +"Detach property" = "Irrota ominaisuus" +"Detach style" = "Irrota tyyli" +"Detach styles" = "Irrota tyylit" +"Detach variable" = "Irrota muuttuja" +"Details" = "Tiedot" +"Dev Mode" = "Kehittäjätila" +"Dev resources" = "Kehittäjäresurssit" +"Development" = "Kehitys" +"Device" = "Laite" +"Device at 100%" = "Laite koossa 100 %" +"Diamond" = "Vinoneliö" +"Difference" = "Erotus" +"Dimensions" = "Mitat" +"Direction" = "Suunta" +"Disconnect from code" = "Katkaise yhteys koodiin" +"Dispersion" = "Hajonta" +"Display name" = "Näyttönimi" +"Dissolve" = "Häivytys" +"Distribute and tidy" = "Jaa ja järjestä" +"Distribute horizontal spacing" = "Jaa vaakavälit tasan" +"Distribute vertical spacing" = "Jaa pystyvälit tasan" +"Divider" = "Erotin" +"Don't truncate" = "Älä lyhennä" +"Done" = "Valmis" +"Done editing" = "Lopeta muokkaus" +"Dots" = "Pisteet" +"Dotted" = "Pisteviiva" +"Down" = "Alas" +"Drafts" = "Luonnokset" +"Drag the stroke to add a width point" = "Lisää leveyspiste vetämällä reunaviivaa" +"Draw" = "Piirrä" +"Draw a frame" = "Piirrä kehys" +"Drawing tools" = "Piirtotyökalut" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .json-, .svg- tai kuvatiedosto." +"Drop shadow" = "Heittovarjo" +"Drop to import" = "Tuo pudottamalla" +"Drop to open" = "Avaa pudottamalla" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Kaksoislisensoitu: MIT OR Apache-2.0. ArtCraft-tiimin ja yhteisön tekemä." +"Duo" = "Kaksivärinen" +"Duplicate" = "Monista" +"Duplicate by dragging" = "Monista vetämällä" +"Duplicate mode" = "Monista tila" +"Duplicate page" = "Monista sivu" +"Edit" = "Muokkaa" +"Edit as an expression" = "Muokkaa lausekkeena" +"Edit colour" = "Muokkaa väriä" +"Edit comment" = "Muokkaa kommenttia" +"Edit dev resource" = "Muokkaa kehittäjäresurssia" +"Edit link" = "Muokkaa linkkiä" +"Edit link copied" = "Muokkauslinkki kopioitu" +"Edit style" = "Muokkaa tyyliä" +"Edit variable" = "Muokkaa muuttujaa" +"Edit with the builder" = "Muokkaa rakentimella" +"Edited {when}" = "Muokattu {when}" +"Effect settings" = "Tehosteen asetukset" +"Effect styles" = "Tehostetyylit" +"Effects" = "Tehosteet" +"Ellipse" = "Ellipsi" +"Ellipses" = "Ellipsit" +"Else" = "Muuten" +"Else if" = "Muuten jos" +"Embedded profile" = "Upotettu profiili" +"Empty" = "Tyhjä" +"End" = "Loppu" +"End point" = "Loppupiste" +"Ends the session; everyone keeps their copy" = "Lopettaa istunnon. Kaikki säilyttävät oman kopionsa." +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Anna figcraft-server-palvelimen osoite ja näytä sen tarjoamat liitännäiset painamalla Selaa." +"Enter to save · Esc to cancel" = "Tallenna Enterillä · peruuta Escillä" +"Eraser" = "Pyyhekumi" +"Esc" = "Esc" +"Essential" = "Olennainen" +"Every conflict has a choice." = "Jokaiselle ristiriidalle on valittu ratkaisu." +"Every top-level frame already starts a flow" = "Jokainen ylimmän tason kehys aloittaa jo kulun" +"Exact opacity (4 then 5: 45%)" = "Tarkka peittävyys (4 ja sitten 5: 45 %)" +"Exclude" = "Jätä pois" +"Excluded" = "Jätetty pois" +"Excluded from layout" = "Jätetty pois asettelusta" +"Excludes" = "Poissulkee" +"Exclusion" = "Poissulku" +"Exit focus view (Esc)" = "Poistu kohdistusnäkymästä (Esc)" +"Expand" = "Laajenna" +"Export" = "Vie" +"Export all" = "Vie kaikki" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Vie kehyksiä ja tasoja PNG-, JPG-, SVG- tai PDF-muodossa" +"Export page as PNG" = "Vie sivu PNG-muodossa" +"Export settings" = "Vientiasetukset" +"Export variables" = "Vie muuttujat" +"Export {name}" = "Vie {name}" +"Export {n} layers" = { one = "Vie {n} taso", other = "Vie {n} tasoa" } +"Exported {file}" = "Viety: {file}" +"Exported {name}" = "Viety: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Viety {name}; {n} sisältötyyppi jätettiin pois", other = "Viety {name}; {n} sisältötyyppiä jätettiin pois" } +"Exported {n} files" = { one = "Viety {n} tiedosto", other = "Viety {n} tiedostoa" } +"Export…" = "Vie…" +"Expose properties to instances" = "Tuo ominaisuudet ilmentymien käyttöön" +"Exposure" = "Valotus" +"Expression" = "Lauseke" +"Extend collection" = "Laajenna kokoelmaa" +"Extends \"{name}\": overridden values are marked" = "Laajentaa kokoelmaa ”{name}”: ohitetut arvot on merkitty" +"Eye" = "Silmä" +"F" = "F" +"Fade" = "Häivytys" +"False" = "Epätosi" +"Feb" = "helmik." +"Fetching from the server…" = "Haetaan palvelimelta…" +"FigCraft couldn't open its window: {error}" = "FigCraft ei voinut avata ikkunaansa: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ei vielä tue näiden tiedostojen kaikkia ominaisuuksia. Huomautukset kertovat, mitä tuodusta kopiosta jätettiin pois tai muutettiin. Alkuperäinen tiedosto säilyy ennallaan." +"File" = "Tiedosto" +"File name" = "Tiedostonimi" +"File not found" = "Tiedostoa ei löydy" +"File published" = "Tiedosto julkaistu" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Tiedostoja ei salata päästä päähän. Palvelimen ylläpitäjä voi lukea ne." +"Files nobody opens for {n} days are deleted." = { one = "Tiedostot, joita kukaan ei avaa {n} päivään, poistetaan.", other = "Tiedostot, joita kukaan ei avaa {n} päivään, poistetaan." } +"Fill" = "Täyttö" +"Fill (fr)" = "Täytä (fr)" +"Fill container" = "Täytä säilö" +"Fill window" = "Täytä ikkuna" +"Fills" = "Täytöt" +"Fill|sizing" = "Täytä" +"Filter and sort" = "Suodata ja lajittele" +"Find" = "Etsi" +"Find and replace" = "Etsi ja korvaa" +"Find layers" = "Etsi tasoja" +"First" = "Ensimmäinen" +"First commit" = "Ensimmäinen muutos" +"First on top" = "Ensimmäinen päällimmäisenä" +"Fit" = "Sovita" +"Fit device to window" = "Sovita laite ikkunaan" +"Fit to contents" = "Sovita sisältöön" +"Fit to window" = "Sovita ikkunaan" +"Fit width" = "Sovita leveyteen" +"Fixed" = "Kiinteä" +"Fixed gap" = "Kiinteä väli" +"Fixed size" = "Kiinteä koko" +"Flatten" = "Yhdistä vektorimuodoksi" +"Flip" = "Peilaa" +"Flip horizontal" = "Peilaa vaakasuunnassa" +"Flip text to the other side" = "Siirrä teksti toiselle puolelle" +"Flip vertical" = "Peilaa pystysuunnassa" +"Flip width profile" = "Peilaa leveysprofiili" +"Flow starting point" = "Kulun aloituspiste" +"Flow starting points" = "Kulkujen aloituspisteet" +"Flows" = "Kulut" +"Focus view" = "Kohdistusnäkymä" +"Fold autosaves" = "Tiivistä automaattiset tallennukset" +"Folder: click to collapse" = "Kansio: supista napsauttamalla" +"Folder: click to expand" = "Kansio: laajenna napsauttamalla" +"Follow" = "Seuraa" +"Follow {name}" = "Seuraa käyttäjää {name}" +"Following" = "Seurataan" +"Following {name} · {key} to stop" = "Seurataan käyttäjää {name} · lopeta painamalla {key}" +"Font" = "Fontti" +"Font not installed" = "Fonttia ei ole asennettu" +"Font size" = "Fonttikoko" +"Font style" = "Fonttityyli" +"Forget on this computer" = "Unohda tällä tietokoneella" +"Four screens of a trail app with components and a prototype flow" = "Retkeilysovelluksen neljä näkymää, komponentit ja prototyypin kulku" +"Fractions" = "Murtoluvut" +"Frame" = "Kehys" +"Frame selection" = "Kehystä valinta" +"Frame tools" = "Kehystyökalut" +"Frames" = "Kehykset" +"From the library \"{name}\": read-only" = "Kirjastosta ”{name}”: vain luku" +"From {name}" = "Lähteestä {name}" +"From {server}" = "Palvelimelta {server}" +"Frost" = "Huurre" +"Gap" = "Väli" +"Gap between columns" = "Sarakkeiden väli" +"Gap between items" = "Kohteiden väli" +"Gap between rows" = "Rivien väli" +"Getting started" = "Aloittaminen" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Anna julkisen palvelimen osoite tai palvelimen osoite avaimineen (`figcraft-server key` tulostaa avaimen)." +"Glass" = "Lasi" +"Go to main component" = "Siirry pääkomponenttiin" +"Go to {name}" = "Siirry kohteeseen {name}" +"Grab bag" = "Sekalaiset" +"Gradient" = "Liukuväri" +"Gray" = "Harmaa" +"Green" = "Vihreä" +"Greyscale components and two example screens to sketch a flow with" = "Harmaasävykomponentit ja kaksi esimerkkinäkymää kulun luonnosteluun" +"Grid" = "Ruudukko" +"Grid ({size})" = "Ruudukko ({size})" +"Grid styles" = "Ruudukkotyylit" +"Grid {size}" = "Ruudukko {size}" +"Group" = "Ryhmä" +"Group selection" = "Ryhmittele valinta" +"Groups" = "Ryhmät" +"Gutter" = "Palstaväli" +"Hand tool" = "Käsityökalu" +"Hanging list" = "Riippuva luettelo" +"Hanging punctuation" = "Välimerkkien ulonnus" +"Hard light" = "Kova valo" +"Height" = "Korkeus" +"Help" = "Ohje" +"Help and resources" = "Ohje ja resurssit" +"Help page" = "Ohjesivu" +"Here: {names}" = "Paikalla: {names}" +"Hidden" = "Kätketty" +"Hide contents" = "Kätke sisältö" +"Hide from publishing" = "Jätä julkaisematta" +"Hide section contents" = "Kätke osion sisältö" +"Hide these tips (Getting started in the Help menu shows them again)" = "Kätke nämä vinkit (Ohje-valikon Aloittaminen näyttää ne uudelleen)" +"Hide when publishing" = "Kätke julkaistaessa" +"Highlight layers on hover" = "Korosta tasot osoitettaessa" +"Highlights" = "Huippuvalot" +"Hold Alt" = "Pidä Alt painettuna" +"Hold Alt+Z" = "Pidä Alt+Z painettuna" +"Hold Ctrl" = "Pidä Ctrl painettuna" +"Hold Z" = "Pidä Z painettuna" +"Hold ⌃ or ⌘" = "Pidä ⌃ tai ⌘ painettuna" +"Hold ⌥" = "Pidä ⌥ painettuna" +"Hold ⌥Z" = "Pidä ⌥Z painettuna" +"Home" = "Aloitus" +"Horizontal" = "Vaakasuunta" +"Horizontal align" = "Vaakatasaus" +"Horizontal hex" = "Vaakasuuntainen kuusikulmio" +"Horizontal padding" = "Vaakasuuntainen sisämarginaali" +"Horizontal spacing" = "Vaakavälistys" +"Hosted by {operator}" = "Ylläpitäjä: {operator}" +"Hover a layer to see its properties" = "Näytä tason ominaisuudet viemällä osoitin sen päälle" +"Hover other layers to measure the distance" = "Mittaa etäisyys viemällä osoitin muiden tasojen päälle" +"Hue" = "Sävy" +"Hug" = "Sovita sisältöön" +"Hug contents" = "Sovita sisältöön" +"Icon components on a 24 px grid with keylines and a size preview" = "Kuvakekomponentteja 24 px:n ruudukossa, apulinjat ja koon esikatselu" +"Icon set starter" = "Kuvakejoukon aloituspohja" +"If" = "Jos" +"If it keeps happening, please report it at {link} with this message." = "Jos ongelma jatkuu, ilmoita siitä osoitteessa {link} ja liitä tämä viesti mukaan." +"If {condition}" = "Jos {condition}" +"Ignore auto layout (absolute position)" = "Ohita automaattinen asettelu (absoluuttinen sijainti)" +"Image" = "Kuva" +"Images" = "Kuvat" +"Import" = "Tuo" +"Import path" = "Tuontipolku" +"Import plugin from manifest" = "Tuo liitännäinen manifestista" +"Import plugin from manifest…" = "Tuo liitännäinen manifestista…" +"Import report" = "Tuontiraportti" +"Import variables" = "Tuo muuttujat" +"Imported document" = "Tuotu asiakirja" +"Imported {name}" = "Tuotu: {name}" +"Imported {n} new variables, updated {updated}" = { one = "Tuotu {n} uusi muuttuja, päivitetty {updated}", other = "Tuotu {n} uutta muuttujaa, päivitetty {updated}" } +"Import…" = "Tuo…" +"In an instance, this comes from the main component" = "Ilmentymässä tämä arvo tulee pääkomponentista" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Selaimessa voit liittyä työpöytäsovelluksen isännöimiin istuntoihin ja FigCraft-palvelimen tiedostoihin. https-sivu tavoittaa muut tietokoneet vain wss://-linkeillä (TLS:n takana oleva palvelin)." +"Include bounding box" = "Sisällytä rajauslaatikko" +"Include id attribute" = "Sisällytä id-määrite" +"Included" = "Sisällytetty" +"Included in layout" = "Sisällytetty asetteluun" +"Individual corners" = "Kulmat erikseen" +"Individual strokes" = "Reunaviivat erikseen" +"Ink" = "Muste" +"Inner shadow" = "Sisävarjo" +"Inside" = "Sisäpuolella" +"Inspect" = "Tarkastele" +"Inspect the layer under the pointer: size, colours, typography" = "Tarkastele osoittimen alla olevaa tasoa: koko, värit ja typografia" +"Install" = "Asenna" +"Install plugin" = "Asenna liitännäinen" +"Installed" = "Asennettu" +"Installed {name}" = "Asennettu: {name}" +"Installing…" = "Asennetaan…" +"Instance" = "Ilmentymä" +"Instance properties" = "Ilmentymän ominaisuudet" +"Instance swap" = "Ilmentymän vaihto" +"Instance swap (no other component)" = "Ilmentymän vaihto (ei muuta komponenttia)" +"Instances" = "Ilmentymät" +"Instant" = "Välitön" +"Intensity" = "Vahvuus" +"Interaction" = "Vuorovaikutus" +"Interactions" = "Vuorovaikutukset" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Käyttöliittymien suunnittelu ja prototyypit. Ilmainen avoimen lähdekoodin suunnittelutyökalu, joka on toteutettu riippumattomasti alusta alkaen Rustilla. Osa ArtCraft-sovellusperhettä." +"Intersect" = "Leikkaus" +"Intersects" = "Leikkaa" +"Invalid color" = "Virheellinen väri" +"Invite link" = "Kutsulinkki" +"Invite link copied" = "Kutsulinkki kopioitu" +"It asks permission to:" = "Se pyytää oikeutta:" +"Jan" = "tammik." +"Join" = "Liity" +"Join code copied" = "Liittymiskoodi kopioitu" +"Join code or invite link" = "Liittymiskoodi tai kutsulinkki" +"Join live session" = "Liity reaaliaikaiseen istuntoon" +"Join us on Discord" = "Liity yhteisöömme Discordissa" +"Join with a code" = "Liity koodilla" +"Joined" = "Liitytty" +"Joined {file}" = "Liitytty tiedostoon {file}" +"Joining…" = "Liitytään…" +"Join|stroke" = "Liitos" +"Jul" = "heinäk." +"Jun" = "kesäk." +"Just now" = "Juuri nyt" +"Justify" = "Tasaa molemmat reunat" +"Keep appearance" = "Säilytä ulkoasu" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." +"Keep tool selected after use" = "Pidä työkalu valittuna käytön jälkeen" +"Keep values" = "Säilytä arvot" +"Key/Gamepad" = "Näppäin/peliohjain" +"Key/Gamepad {key}" = "Näppäin/peliohjain {key}" +"Keyboard shortcuts" = "Pikanäppäimet" +"Kits" = "Paketit" +"Landing page" = "Laskeutumissivu" +"Language" = "Kieli" +"Lasso" = "Lasso" +"Last" = "Viimeinen" +"Last change: {note}" = "Viimeisin muutos: {note}" +"Last commit" = "Viimeisin muutos" +"Last modified" = "Viimeksi muokattu" +"Last on top" = "Viimeinen päällimmäisenä" +"Layer blur" = "Tason sumennus" +"Layer name" = "Tason nimi" +"Layers" = "Tasot" +"Layers in an instance take their position from the main component" = "Ilmentymän tasot saavat sijaintinsa pääkomponentista" +"Layout" = "Asettelu" +"Layout grid" = "Asetteluruudukko" +"Layout grid settings" = "Asetteluruudukon asetukset" +"Layout grids" = "Asetteluruudukot" +"Leave session" = "Poistu istunnosta" +"Leaves" = "Lehdet" +"Left" = "Vasen" +"Left & right" = "Vasen ja oikea" +"Left side" = "Vasen puoli" +"Left side width" = "Vasemman puolen leveys" +"Letter case" = "Kirjainkoko" +"Letter spacing" = "Merkkiväli" +"Libraries" = "Kirjastot" +"Libraries in this file" = "Tämän tiedoston kirjastot" +"Library updates available" = "Kirjastopäivityksiä saatavilla" +"Library: click to collapse" = "Kirjasto: supista napsauttamalla" +"Library: click to expand" = "Kirjasto: laajenna napsauttamalla" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Kirjasto: sen muuttujat ovat tässä vain luettavissa. Muokkaa niitä kirjastotiedostossa ja julkaise se." +"Light" = "Vaalea" +"Lighten" = "Vaalenna" +"Light|glass" = "Valo" +"Line" = "Viiva" +"Line arrow" = "Viivanuoli" +"Line delta" = "Rivimuutos" +"Line height" = "Rivikorkeus" +"Linear" = "Lineaarinen" +"Lines" = "Viivat" +"Lines +/−" = "Rivit +/−" +"Lines added" = "Lisätyt rivit" +"Lines deleted" = "Poistetut rivit" +"Link" = "Linkki" +"Link copied" = "Linkki kopioitu" +"Link docs, code or tickets for developers." = "Linkitä dokumentaatiota, koodia tai tehtäviä kehittäjille." +"Link frames on the Prototype tab, then present the flow" = "Linkitä kehykset Prototyyppi-välilehdellä ja esitä sitten kulku" +"Link this component to its code component: instances then show your real snippet with their values." = "Yhdistä tämä komponentti sen koodikomponenttiin. Ilmentymät näyttävät sitten todellisen koodikatkelmasi omilla arvoillaan." +"List spacing" = "Luettelon välistys" +"List style" = "Luettelotyyli" +"Live session" = "Reaaliaikainen istunto" +"Live · {people}" = "Reaaliaikainen · {people}" +"Local components" = "Paikalliset komponentit" +"Local network" = "Paikallisverkko" +"Local styles" = "Paikalliset tyylit" +"Local variables" = "Paikalliset muuttujat" +"Lock/Unlock" = "Lukitse/avaa lukitus" +"Locked" = "Lukittu" +"Lowercase" = "Pienet kirjaimet" +"Luminance" = "Luminanssi" +"Luminosity" = "Valoisuus" +"Main" = "Päähaara" +"Main component deleted" = "Pääkomponentti poistettu" +"Main menu" = "Päävalikko" +"Make a component" = "Luo komponentti" +"Make slot" = "Luo sisältöpaikka" +"Mar" = "maalisk." +"Margin" = "Marginaali" +"Mark all as read" = "Merkitse kaikki luetuiksi" +"Mark as resolved" = "Merkitse ratkaistuksi" +"Mark as unread" = "Merkitse lukemattomaksi" +"Mark as unresolved" = "Merkitse ratkaisemattomaksi" +"Mask" = "Maski" +"Mass · Stiffness · Damping" = "Massa · jäykkyys · vaimennus" +"Match (empty: the whole name)" = "Etsittävä (tyhjä: koko nimi)" +"Max H" = "Enimmäiskorkeus" +"Max W" = "Enimmäisleveys" +"Max lines" = "Rivien enimmäismäärä" +"Maximize" = "Suurenna" +"May" = "toukok." +"Measure to the selection" = "Mittaa valintaan" +"Merge into main" = "Yhdistä päähaaraan" +"Merge regions" = "Yhdistä alueet" +"Merge the branch into the main file (one undo step) and archive it" = "Yhdistä haara päätiedostoon (yksi kumottava toiminto) ja arkistoi se" +"Merged" = "Yhdistetty" +"Merged PRs" = "Yhdistetyt PR:t" +"Min H" = "Vähimmäiskorkeus" +"Min W" = "Vähimmäisleveys" +"Minimize" = "Pienennä" +"Mirror angle" = "Peilaa kulma" +"Mirror angle and length" = "Peilaa kulma ja pituus" +"Mirroring" = "Peilaus" +"Missing" = "Puuttuu" +"Missing fonts" = "Puuttuvat fontit" +"Missing layer" = "Puuttuva taso" +"Missing style" = "Puuttuva tyyli" +"Missing variable" = "Puuttuva muuttuja" +"Miter" = "Jiiri" +"Miter angle" = "Jiirikulma" +"Mixed" = "Useita arvoja" +"Mixed fonts" = "Useita fontteja" +"Mixed interactions: click + to replace them" = "Vaihtelevia vuorovaikutuksia: korvaa napsauttamalla +" +"Mobile app" = "Mobiilisovellus" +"Mode" = "Tila" +"Model" = "Malli" +"Models" = "Mallit" +"Mono" = "Yksivärinen" +"More" = "Lisää" +"More instance actions" = "Lisää ilmentymän toimintoja" +"Mouse down" = "Hiiren painallus" +"Mouse enter" = "Osoitin saapuu" +"Mouse leave" = "Osoitin poistuu" +"Mouse up" = "Hiiren vapautus" +"Move" = "Siirrä" +"Move along the toolbar or the layer rows" = "Siirry työkalurivillä tai tasoriveillä" +"Move column left" = "Siirrä sarake vasemmalle" +"Move column right" = "Siirrä sarake oikealle" +"Move down" = "Siirrä alas" +"Move in" = "Siirry sisään" +"Move out" = "Siirry ulos" +"Move tools" = "Siirtotyökalut" +"Move up" = "Siirrä ylös" +"Moved to {name}" = "Siirretty kohteeseen {name}" +"Multi" = "Monivärinen" +"Multiply" = "Kerro" +"Muted" = "Hillitty" +"Name" = "Nimi" +"Name (A–Z)" = "Nimi (A–Z)" +"Named instance" = "Nimetty ilmentymä" +"Navigate to" = "Siirry kohteeseen" +"Nested instance" = "Sisäkkäinen ilmentymä" +"New design file" = "Uusi suunnittelutiedosto" +"Newest first" = "Uusimmat ensin" +"Next frame" = "Seuraava kehys" +"Next or previous control in the focused region" = "Seuraava tai edellinen säädin kohdistetulla alueella" +"Next ready for dev" = "Seuraava kehitysvalmis" +"Next size option" = "Seuraava kokovaihtoehto" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Ei resursseja: ei vientiasetuksia, kuvakkeita tai kuvia. Lisää vientiasetuksia Suunnittelu-välilehdellä." +"No boolean variables fit here." = "Tähän ei sovi totuusarvomuuttujia." +"No boolean variables yet. Create them in Local variables." = "Ei vielä totuusarvomuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Ei haaroja. Haara on tiedoston kopio, jossa voi kokeilla muutoksia ja sitten tarkistaa ja yhdistää ne." +"No category" = "Ei luokkaa" +"No changes" = "Ei muutoksia" +"No color styles" = "Ei värityylejä" +"No colour variables fit here." = "Tähän ei sovi värimuuttujia." +"No colour variables yet. Create them in Local variables." = "Ei vielä värimuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No comments yet" = "Ei vielä kommentteja" +"No compatibility warnings reported" = "Yhteensopivuusvaroituksia ei ilmoitettu" +"No components yet. Select layers and press {keys}." = "Ei vielä komponentteja. Valitse tasoja ja paina {keys}." +"No conflicts." = "Ei ristiriitoja." +"No contributor data was built into this copy." = "Tähän versioon ei ole sisällytetty tekijätietoja." +"No device" = "Ei laitetta" +"No files or templates match your search" = "Hakua vastaavia tiedostoja tai malleja ei löydy" +"No fonts match" = "Vastaavia fontteja ei löydy" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Liittymiskoodia ei ole (palvelin on liittymiskoodeja vanhempi tai tätä osoitetta ei voi sisällyttää koodiin). Jaa linkki sen sijaan." +"No layer changes to show." = "Ei näytettäviä tasomuutoksia." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Ei vielä kirjastoja. Lisää kirjastona julkaistu .figcraft-tiedosto tai liitä tiimikirjaston linkki." +"No list" = "Ei luetteloa" +"No matching comments" = "Vastaavia kommentteja ei löydy" +"No matching components" = "Vastaavia komponentteja ei löydy" +"No matching layers" = "Vastaavia tasoja ei löydy" +"No matching styles" = "Vastaavia tyylejä ei löydy" +"No matching variables" = "Vastaavia muuttujia ei löydy" +"No mirroring" = "Ei peilausta" +"No model credits were built into this copy." = "Tähän versioon ei ole sisällytetty mallien tekijätietoja." +"No number variables fit here." = "Tähän ei sovi lukumuuttujia." +"No number variables yet. Create them in Local variables." = "Ei vielä lukumuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No other pages" = "Ei muita sivuja" +"No paints" = "Ei täyttöjä" +"No plugins on {server}." = "Palvelimella {server} ei ole liitännäisiä." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Ei vielä liitännäisiä. Tuo kehitysliitännäinen sen figcraft-plugin.json-tiedostosta." +"No preferred components" = "Ei ensisijaisia komponentteja" +"No preset" = "Ei esiasetusta" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Ei vielä tallennettuja versioita. Tallenna versio, jotta voit palata tähän työvaiheeseen myöhemmin." +"No scrolling" = "Ei vieritystä" +"No shortcuts match" = "Vastaavia pikanäppäimiä ei löydy" +"No status" = "Ei tilaa" +"No string variables fit here." = "Tähän ei sovi merkkijonomuuttujia." +"No string variables yet. Create them in Local variables." = "Ei vielä merkkijonomuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No styles yet." = "Ei vielä tyylejä." +"No styles yet. Click + to create one from the selection." = "Ei vielä tyylejä. Luo tyyli valinnasta napsauttamalla +." +"No templates match your search" = "Hakua vastaavia malleja ei löydy" +"No text styles yet. Click + to create one from the selected text." = "Ei vielä tekstityylejä. Luo tyyli valitusta tekstistä napsauttamalla +." +"No variables fit here." = "Tähän ei sovi muuttujia." +"No variables yet. Create them in Local variables." = "Ei vielä muuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"Nobody else is here yet" = "Kukaan muu ei ole vielä paikalla" +"Noise" = "Kohina" +"None" = "Ei mitään" +"None." = "Ei mitään." +"Normal" = "Normaali" +"Not bound" = "Ei sidottu" +"Not in a live session" = "Ei reaaliaikaisessa istunnossa" +"Not in this font" = "Ei tässä fontissa" +"Not properties, kept as written: {names}" = "Eivät ole ominaisuuksia, säilytetään sellaisinaan: {names}" +"Not read yet" = "Ei vielä luettu" +"Not shared" = "Ei jaettu" +"Note {n}" = "Huomautus {n}" +"Nothing changed since this was marked ready for dev." = "Mikään ei ole muuttunut kehitysvalmiiksi merkitsemisen jälkeen." +"Nothing here yet. Set a frame's status to list it." = "Ei vielä sisältöä. Lisää kehys luetteloon asettamalla sen tila." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Ei vielä verkkotiedostoja. Julkaise tiedosto verkkoon Jaa-toiminnolla, niin se näkyy tässä." +"Nothing to export" = "Ei vietävää" +"Nov" = "marrask." +"Now" = "Nyt" +"Nudge (Shift: big nudge)" = "Siirrä askel (Shift: suuri askel)" +"Nudge amount" = "Siirtoaskeleen koko" +"Number" = "Luku" +"Number ↑" = "Luku ↑" +"Number ↓" = "Luku ↓" +"Numbered list" = "Numeroitu luettelo" +"Object" = "Objekti" +"Oct" = "lokak." +"Off" = "Ei käytössä" +"Offered first when filling the slot (any content is allowed)" = "Ehdotetaan ensin sisältöpaikkaa täytettäessä (kaikki sisältö sallitaan)" +"Offered first when swapping the instance (any component is allowed)" = "Ehdotetaan ensin ilmentymää vaihdettaessa (kaikki komponentit sallitaan)" +"Offset" = "Siirtymä" +"Oldest first" = "Vanhimmat ensin" +"On click" = "Napsautettaessa" +"On drag" = "Vedettäessä" +"On this page" = "Tällä sivulla" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Yksi ilmoitus viidessä tavallisessa koossa: neliö, pysty, tarina, linkki ja banneri" +"Online" = "Verkossa" +"Online files" = "Verkkotiedostot" +"Online sharing is off: files are shared on the local network only." = "Verkkojakaminen on pois käytöstä. Tiedostoja jaetaan vain lähiverkossa." +"Only the first changed layers are listed." = "Vain ensimmäiset muuttuneet tasot näytetään." +"Only the first {n} changed layers are listed." = "Vain ensimmäiset {n} muuttunutta tasoa näytetään." +"Only your threads" = "Vain omat keskustelut" +"Opacity" = "Peittävyys" +"Opacity & blend" = "Peittävyys ja sekoitus" +"Opacity 10%…90%, 0: 100%" = "Peittävyys 10 %…90 %, 0: 100 %" +"Open" = "Avaa" +"Open .figcraft, .svg or images" = "Avaa .figcraft-, .svg- tai kuvatiedostoja" +"Open branch" = "Avaa haara" +"Open file" = "Avaa tiedosto" +"Open files" = "Avaa tiedostoja" +"Open in focus view" = "Avaa kohdistusnäkymässä" +"Open in new window" = "Avaa uudessa ikkunassa" +"Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa" +"Open link" = "Avaa linkki" +"Open link {url}" = "Avaa linkki {url}" +"Open overlay" = "Avaa peittokuva" +"Open prototype preview" = "Avaa prototyypin esikatselu" +"Open variables ({count})" = "Avaa muuttujat ({count})" +"Opened as a new document" = "Avattu uutena asiakirjana" +"Opened “{name}” in a new tab" = "”{name}” avattu uudessa välilehdessä" +"Opening the file…" = "Avataan tiedostoa…" +"Opening {n} files as new files" = { one = "Avataan uutena tiedostona", other = "Avataan uusia tiedostoja: {n}" } +"Open|comments" = "Avoin" +"Options" = "Asetukset" +"Orange" = "Oranssi" +"Order" = "Järjestys" +"Other" = "Muu" +"Other apps may not open every .fig file exported here." = "Muut sovellukset eivät välttämättä avaa kaikkia tässä vietyjä .fig-tiedostoja." +"Others lose access and keep their copy; the file stays on the server for you" = "Muiden käyttöoikeus päättyy, ja he säilyttävät kopionsa. Tiedosto jää palvelimelle käyttöösi." +"Others type this under Share ▸ Join with a code" = "Muut kirjoittavat tämän kohtaan Jaa ▸ Liity koodilla" +"Outline stroke" = "Muunna reunaviiva poluksi" +"Outline text" = "Muunna teksti poluiksi" +"Outlines" = "Ääriviivat" +"Outside" = "Ulkopuolella" +"Overflow" = "Ylivuoto" +"Overlay" = "Peittokuva" +"Overlay position" = "Peittokuvan sijainti" +"Overridden here (the collection it extends has another value)" = "Ohitettu tässä (laajennetussa kokoelmassa on toinen arvo)" +"Overrides" = "Ohitukset" +"Owner token" = "Omistajan tunniste" +"Owner token copied" = "Omistajan tunniste kopioitu" +"PDF print options" = "PDF-tulostusasetukset" +"PDF print settings" = "PDF-tulostusasetukset" +"Padding" = "Sisämarginaali" +"Page" = "Sivu" +"Pages" = "Sivut" +"Pages added to the document" = "Sivut lisätty asiakirjaan" +"Pages: {pages}" = "Sivut: {pages}" +"Paint bucket" = "Maalipurkki" +"Pan the canvas" = "Siirrä piirtoaluetta" +"Pan with nothing selected (Shift: farther)" = "Siirrä näkymää ilman valintaa (Shift: pidemmälle)" +"Panels" = "Paneelit" +"Paper" = "Paperi" +"Paragraph indent" = "Kappaleen sisennys" +"Paragraph indent applies to left-aligned text" = "Kappaleen sisennys koskee vasemmalle tasattua tekstiä" +"Paragraph spacing" = "Kappaleväli" +"Pass through" = "Läpivienti" +"Paste a link (https://…)" = "Liitä linkki (https://…)" +"Paste a team library link (a view link)" = "Liitä tiimikirjaston linkki (katselulinkki)" +"Paste here" = "Liitä tähän" +"Paste to replace" = "Liitä korvaten" +"Pasted design layers; {n} kinds of content were left out" = { one = "Suunnittelutasot liitetty; {n} sisältötyyppi jätettiin pois", other = "Suunnittelutasot liitetty; {n} sisältötyyppiä jätettiin pois" } +"Pattern" = "Kuvio" +"Pattern alignment" = "Kuvion tasaus" +"Pattern scale" = "Kuvion skaalaus" +"Pattern source" = "Kuvion lähde" +"Pen" = "Kynä" +"Pencil" = "Lyijykynä" +"People in this file" = "Tämän tiedoston käyttäjät" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Käyttäjät voivat liittyä koodilla mistä tahansa. Tiedosto säilytetään palvelimella {server} jakamisen ajan." +"People on your network join with a code. Nothing goes through a server." = "Lähiverkkosi käyttäjät liittyvät koodilla. Mitään ei kulje palvelimen kautta." +"Phone" = "Puhelin" +"Pick color" = "Poimi väri" +"Pick color (I)" = "Poimi väri (I)" +"Pick one font family first" = "Valitse ensin yksi fonttiperhe" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Valitse Kehys-työkalu ja vedä piirtoalueella tai valitse laitteen koko" +"Pin a property" = "Kiinnitä ominaisuus" +"Pinch" = "Nipistys" +"Pixel grid" = "Pikseliruudukko" +"Pixel preview 1x" = "Pikseliesikatselu 1x" +"Pixel preview 2x" = "Pikseliesikatselu 2x" +"Place a shape or frame (its tool chosen)" = "Sijoita muoto tai kehys (sen työkalu valittuna)" +"Place automatically" = "Sijoita automaattisesti" +"Place image" = "Sijoita kuva" +"Placed on the current page" = "Sijoitettu nykyiselle sivulle" +"Playground" = "Kokeilualue" +"Plugin" = "Liitännäinen" +"Plugins" = "Liitännäiset" +"Plugins & widgets" = "Liitännäiset ja pienoisohjelmat" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Liitännäiset toimivat FigCraftin eristetyssä ympäristössä ilman tiedostoja, verkkoa tai kelloa. Asenna vain luottamiasi liitännäisiä." +"Plus darker" = "Tummempi summa" +"Plus lighter" = "Vaaleampi summa" +"Point" = "Piste" +"Points" = "Pisteet" +"Polygon" = "Monikulmio" +"Polygons" = "Monikulmiot" +"Port" = "Portti" +"Position" = "Sijainti" +"Post" = "Lähetä" +"Preferences" = "Asetukset" +"Preferred" = "Ensisijainen" +"Present" = "Esitä" +"Present or preview" = "Esitä tai esikatsele" +"Present this flow" = "Esitä tämä kulku" +"Presentation" = "Esitys" +"Presentation slides" = "Esitysdiat" +"Presenting: comments" = "Esitys: kommentit" +"Presenting: cycle the size options" = "Esitys: kierrä kokovaihtoehtoja" +"Presenting: full screen" = "Esitys: koko näyttö" +"Presenting: next frame" = "Esitys: seuraava kehys" +"Presenting: previous frame" = "Esitys: edellinen kehys" +"Presenting: restart" = "Esitys: aloita uudelleen" +"Preserve scroll position" = "Säilytä vierityksen sijainti" +"Presets" = "Esiasetukset" +"Press a key…" = "Paina näppäintä…" +"Preview" = "Esikatselu" +"Preview this flow" = "Esikatsele tätä kulkua" +"Preview this frame" = "Esikatsele tätä kehystä" +"Previous frame" = "Edellinen kehys" +"Previous ready for dev" = "Edellinen kehitysvalmis" +"Progressive" = "Asteittainen" +"Properties" = "Ominaisuudet" +"Prototype" = "Prototyyppi" +"Prototype preview" = "Prototyypin esikatselu" +"Prototype settings" = "Prototyypin asetukset" +"Publish" = "Julkaise" +"Publish changes" = "Julkaise muutokset" +"Publish to another server…" = "Julkaise toiselle palvelimelle…" +"Publish to server" = "Julkaise palvelimelle" +"Published version {v}" = "Julkaistu versio {v}" +"Published version {v} to the server" = "Versio {v} julkaistu palvelimelle" +"Published version {v}: save the file so other files can use it" = "Versio {v} julkaistu: tallenna tiedosto, jotta muut tiedostot voivat käyttää sitä" +"Published · {people}" = "Julkaistu · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Palvelimelle julkaiseminen vaatii työpöytäsovelluksen. Selaimessa voit liittyä palvelimen tiedostoihin." +"Publishing with the server's key." = "Julkaistaan palvelimen avaimella." +"Publishing…" = "Julkaistaan…" +"Push" = "Työntö" +"Push changes to main component" = "Siirrä muutokset pääkomponenttiin" +"Put text on path" = "Sijoita teksti polulle" +"R" = "R" +"Radial" = "Säteittäinen" +"Radius" = "Säde" +"Ratio" = "Suhde" +"Reading the library…" = "Luetaan kirjastoa…" +"Ready for dev" = "Kehitysvalmis" +"Real name" = "Oikea nimi" +"Recent" = "Viimeisimmät" +"Recent files" = "Viimeksi avatut tiedostot" +"Recents" = "Viimeisimmät" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Tallentamaton tiedosto palautettiin sovelluksen sulkeuduttua odottamatta. Tallenna se säilyttääksesi sen.", other = "Palautettu {n} tallentamatonta tiedostoa sovelluksen sulkeuduttua odottamatta. Tallenna ne säilyttääksesi ne." } +"Rectangle" = "Suorakulmio" +"Rectangles" = "Suorakulmiot" +"Red" = "Punainen" +"Reduce motion" = "Vähennä liikettä" +"Refraction" = "Taittuminen" +"Regular expression" = "Säännöllinen lauseke" +"Remove" = "Poista" +"Remove all" = "Poista kaikki" +"Remove annotation" = "Poista huomautus" +"Remove auto layout" = "Poista automaattinen asettelu" +"Remove dev resource" = "Poista kehittäjäresurssi" +"Remove export setting" = "Poista vientiasetus" +"Remove from recents" = "Poista viimeisimmistä" +"Remove from server" = "Poista palvelimelta" +"Remove from server…" = "Poista palvelimelta…" +"Remove guide" = "Poista apuviiva" +"Remove interaction" = "Poista vuorovaikutus" +"Remove library from this file" = "Poista kirjasto tästä tiedostosta" +"Remove min and max" = "Poista vähimmäis- ja enimmäisrajat" +"Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" +"Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" +"Remove regions" = "Poista alueet" +"Remove slot" = "Poista sisältöpaikka" +"Remove starting point" = "Poista aloituspiste" +"Remove stop" = "Poista liukuvärin piste" +"Remove text from path" = "Poista teksti polulta" +"Remove the else and its actions" = "Poista muuten-haara ja sen toiminnot" +"Remove this condition and its actions" = "Poista tämä ehto ja sen toiminnot" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Poistetaanko tämä tiedosto palvelimelta? Sitä ei voi enää jakaa tai avata sieltä. Oma kopiosi pysyy auki tässä." +"Remove {name} from session" = "Poista {name} istunnosta" +"Remove {name} from {server}?" = "Poistetaanko {name} palvelimelta {server}?" +"Removed" = "Poistettu" +"Removed from the server" = "Poistettu palvelimelta" +"Removed since it was marked" = "Poistettu merkitsemisen jälkeen" +"Rename" = "Nimeä uudelleen" +"Rename duplicated layers" = "Nimeä monistetut tasot uudelleen" +"Rename folder" = "Nimeä kansio uudelleen" +"Rename group" = "Nimeä ryhmä uudelleen" +"Rename layers" = "Nimeä tasot uudelleen" +"Rename mode" = "Nimeä tila uudelleen" +"Rename on the server" = "Nimeä uudelleen palvelimella" +"Rename on the server…" = "Nimeä uudelleen palvelimella…" +"Rename page" = "Nimeä sivu uudelleen" +"Rename to" = "Uusi nimi" +"Replace all" = "Korvaa kaikki" +"Replace mixed export settings" = "Korvaa vaihtelevat vientiasetukset" +"Replace with" = "Korvaa kohteella" +"Replace with a new interaction" = "Korvaa uudella vuorovaikutuksella" +"Reply" = "Vastaa" +"Report" = "Ilmoita" +"Report this file…" = "Ilmoita tästä tiedostosta…" +"Reset" = "Palauta" +"Reset adjustments" = "Palauta säädöt" +"Reset all changes" = "Palauta kaikki muutokset" +"Reset all overrides on this layer" = "Palauta kaikki tämän tason ohitukset" +"Reset overrides" = "Palauta ohitukset" +"Reset slot" = "Palauta sisältöpaikka" +"Reset this override" = "Palauta tämä ohitus" +"Reset to style" = "Palauta tyylin arvo" +"Reset to the inherited value" = "Palauta peritty arvo" +"Resize by the nudge amount" = "Muuta kokoa siirtoaskeleen verran" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Sovita kehysten koko laitteen näyttöön (tai ikkunaan ilman laitetta). Rajoitteet ja automaattinen asettelu mukautuvat." +"Resize to fit" = "Sovita koko" +"Resizing" = "Koon muuttaminen" +"Resolve" = "Ratkaise" +"Resolve change" = "Ratkaise muutos" +"Resolved" = "Ratkaistu" +"Resources" = "Resurssit" +"Responsive" = "Mukautuva" +"Restart" = "Aloita uudelleen" +"Restore" = "Palauta" +"Restore main component" = "Palauta pääkomponentti" +"Restore this version" = "Palauta tämä versio" +"Restored “{name}”" = "”{name}” palautettu" +"Reverse the order" = "Käännä järjestys" +"Reversed triangle" = "Käänteinen kolmio" +"Review and merge" = "Tarkista ja yhdistä" +"Ribbon" = "Valintanauha" +"Right" = "Oikea" +"Right side" = "Oikea puoli" +"Right side width" = "Oikean puolen leveys" +"Rotate 90°" = "Kierrä 90°" +"Rotate 90° right" = "Kierrä 90° oikealle" +"Rotate to landscape" = "Käännä vaakasuuntaan" +"Rotate to portrait" = "Käännä pystysuuntaan" +"Rotation" = "Kierto" +"Rotation jitter" = "Kierron vaihtelu" +"Round" = "Pyöreä" +"Rounded" = "Pyöristetty" +"Row" = "Rivi" +"Row gap" = "Rivien väli" +"Row sizes" = "Rivien koot" +"Row span" = "Riviulottuvuus" +"Rows" = "Rivit" +"Rows {count}" = "Rivejä: {count}" +"Rulers" = "Viivaimet" +"Run" = "Suorita" +"Run {label}" = "Suorita {label}" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Sama kuin tasolla" +"Saturation" = "Kylläisyys" +"Save" = "Tallenna" +"Save as" = "Tallenna nimellä" +"Save as preset…" = "Tallenna esiasetuksena…" +"Save files automatically" = "Tallenna tiedostot automaattisesti" +"Save version {key}" = "Tallenna versio {key}" +"Saved" = "Tallennettu" +"Saved {name}" = "Tallennettu: {name}" +"Say something" = "Kirjoita viesti" +"Scale" = "Skaalaus" +"Scatter" = "Hajonta" +"Scoping: where pickers offer it" = "Käyttöalue: missä valitsimet tarjoavat tätä" +"Screen" = "Rasteri" +"Scroll behavior" = "Vierityksen toiminta" +"Scroll to" = "Vieritä kohteeseen" +"Scroll to layer" = "Vieritä tasoon" +"Scroll with parent" = "Vieritä emotason mukana" +"Search" = "Etsi" +"Search actions, layers and tools" = "Etsi toimintoja, tasoja ja työkaluja" +"Search assets" = "Etsi resursseja" +"Search comments" = "Etsi kommentteja" +"Search files and templates" = "Etsi tiedostoja ja malleja" +"Search fonts" = "Etsi fontteja" +"Search layers" = "Etsi tasoja" +"Search plugins" = "Etsi liitännäisiä" +"Search results" = "Hakutulokset" +"Search shortcuts" = "Etsi pikanäppäimiä" +"Search templates" = "Etsi malleja" +"Section" = "Osio" +"Section color" = "Osion väri" +"Sections" = "Osiot" +"See all templates" = "Näytä kaikki mallit" +"Select a frame and add a starting point to present it as a flow." = "Valitse kehys ja lisää aloituspiste, jotta voit esittää sen kulkuna." +"Select a frame to preview it" = "Valitse kehys esikatseltavaksi" +"Select a layer to see its properties and code" = "Valitse taso, jotta näet sen ominaisuudet ja koodin" +"Select all" = "Valitse kaikki" +"Select all with same" = "Valitse kaikki samalla ominaisuudella" +"Select layer" = "Valitse taso" +"Select layers and they space themselves as their content changes" = "Valitse tasoja, niin niiden välit mukautuvat sisällön muuttuessa" +"Select layers to apply the style to" = "Valitse tasot, joihin tyyliä käytetään" +"Select layers with this color" = "Valitse tämänväriset tasot" +"Select overlapping shapes" = "Valitse päällekkäiset muodot" +"Select points to edit their position, radius and mirroring" = "Valitse pisteitä muokataksesi niiden sijaintia, sädettä ja peilausta" +"Select slot" = "Valitse sisältöpaikka" +"Selection" = "Valinta" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Valintalaatikko, {w} × {h} kohdassa {x}, {y}: nuolet siirtävät, Shift+nuolet tai {cmd}+nuolet muuttavat kokoa, Enter valitsee, Escape peruuttaa" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Valintalaatikko: nuolet siirtävät, Shift+nuolet tai {cmd}+nuolet muuttavat kokoa, Enter valitsee, Escape peruuttaa" +"Selection colors" = "Valinnan värit" +"Send backward" = "Vie taaksepäin" +"Send report" = "Lähetä ilmoitus" +"Send to back" = "Vie takimmaiseksi" +"Sep" = "syysk." +"Server address" = "Palvelimen osoite" +"Server address (ws://host:47900/)" = "Palvelimen osoite (ws://host:47900/)" +"Server for online sharing" = "Verkkojakamisen palvelin" +"Set as default" = "Aseta oletukseksi" +"Set variable" = "Aseta muuttuja" +"Set variable mode" = "Aseta muuttujan tila" +"Set variable {name}" = "Aseta muuttuja {name}" +"Set {name} to {value}" = "Aseta {name} arvoon {value}" +"Shadows" = "Varjot" +"Shape" = "Muoto" +"Shape builder" = "Muodonrakennin" +"Shape tools" = "Muototyökalut" +"Share" = "Jaa" +"Share this file" = "Jaa tämä tiedosto" +"Share with a code" = "Jaa koodilla" +"Share: live session, export" = "Jaa: reaaliaikainen istunto, vienti" +"Shared" = "Jaettu" +"Shared: copy its code from the file's menu" = "Jaettu: kopioi koodi tiedoston valikosta" +"Sharing a file needs the desktop app; here you can join one." = "Tiedoston jakaminen vaatii työpöytäsovelluksen. Tässä voit liittyä jaettuun tiedostoon." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Jaetaan lähiverkossa (verkkojakaminen on pois käytöstä: asetuksissa ei ole palvelinta)." +"Sharing on your network · {people}" = "Jaetaan lähiverkossasi · {people}" +"Sharing online · {people}" = "Jaetaan verkossa · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Jakaminen lopetettu: tiedosto jää palvelimelle vain sinun käyttöösi" +"Show" = "Näytä" +"Show all templates" = "Näytä kaikki mallit" +"Show as grid" = "Näytä ruudukkona" +"Show as list" = "Näytä luettelona" +"Show autosaves" = "Näytä automaattiset tallennukset" +"Show behind transparent areas" = "Näytä läpinäkyvien alueiden takana" +"Show comments (Shift+C)" = "Näytä kommentit (Shift+C)" +"Show device frame" = "Näytä laitteen kehys" +"Show dimensions on objects" = "Näytä objektien mitat" +"Show hotspot hints" = "Näytä aktiivisten kohtien vihjeet" +"Show in Explorer" = "Näytä Resurssienhallinnassa" +"Show in Finder" = "Näytä Finderissa" +"Show in folder" = "Näytä kansiossa" +"Show only this on the canvas" = "Näytä piirtoalueella vain tämä" +"Show open" = "Näytä avoimet" +"Show section contents" = "Näytä osion sisältö" +"Show this layer" = "Näytä tämä taso" +"Show usage" = "Näytä käyttö" +"Show/Hide" = "Näytä/kätke" +"Show/Hide UI" = "Näytä/kätke käyttöliittymä" +"Side by side" = "Vierekkäin" +"Sidebar" = "Sivupalkki" +"Simplify stroke" = "Yksinkertaista reunaviiva" +"Size" = "Koko" +"Size jitter" = "Koon vaihtelu" +"Skip ink" = "Väistä merkkien viivoja" +"Slashed zero" = "Viivallinen nolla" +"Slice" = "Viipale" +"Slices" = "Viipaleet" +"Slide in" = "Liu'u sisään" +"Slide out" = "Liu'u ulos" +"Slot" = "Sisältöpaikka" +"Slot content" = "Sisältöpaikan sisältö" +"Small capitals" = "Kapiteelit" +"Small caps" = "Kapiteelit" +"Smart animate" = "Älykäs animointi" +"Snap to pixel grid" = "Tartu pikseliruudukkoon" +"Social and slides" = "Some ja diat" +"Social media" = "Sosiaalinen media" +"Social post sizes" = "Somejulkaisujen koot" +"Soft light" = "Pehmeä valo" +"Solid" = "Yksivärinen" +"Some variants have the same property values: give each one a unique combination" = "Joillakin varianteilla on samat ominaisuusarvot. Anna kullekin yksilöllinen yhdistelmä." +"Sort" = "Lajittele" +"Sort by" = "Lajitteluperuste" +"Sort by date" = "Lajittele päivämäärän mukaan" +"Sort by page" = "Lajittele sivun mukaan" +"Sort by {column}" = "Lajittele sarakkeen {column} mukaan" +"Space around" = "Tila ympärillä" +"Space between" = "Tila välissä" +"Space drag" = "Space drag" +"Space evenly" = "Tasavälein" +"Space+drag" = "Space+drag" +"Spacing" = "Välistys" +"Spam, stolen work, abuse…" = "Roskaposti, varastettu työ, väärinkäyttö…" +"Splay" = "Harotus" +"Spotlight me" = "Nosta minut esiin" +"Spread" = "Leviäminen" +"Square" = "Neliö" +"Standard" = "Vakio" +"Standard ligatures" = "Tavalliset ligatuurit" +"Star" = "Tähti" +"Stars" = "Tähdet" +"Start" = "Alku" +"Start ascending from / stop descending at" = "Nousevan osan alku / laskevan osan loppu" +"Start from a blank canvas" = "Aloita tyhjältä piirtoalueelta" +"Start from a template" = "Aloita mallista" +"Start on path" = "Aloituskohta polulla" +"Start point" = "Alkupiste" +"Start sharing" = "Aloita jakaminen" +"Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi" +"Starting to share…" = "Aloitetaan jakamista…" +"Status" = "Tila" +"Sticky" = "Kiinnittyvä" +"Stop" = "Pysäytä" +"Stop following {name}" = "Lopeta käyttäjän {name} seuraaminen" +"Stop session" = "Lopeta istunto" +"Stop sharing" = "Lopeta jakaminen" +"Stop spotlighting" = "Lopeta esiin nostaminen" +"Stop {n} {hex}" = "Piste {n} {hex}" +"Stretch" = "Venytys" +"Strikethrough" = "Yliviivaus" +"String" = "Merkkijono" +"Stroke" = "Reunaviiva" +"Stroke style" = "Reunaviivan tyyli" +"Stroke weight" = "Reunaviivan paksuus" +"Strokes" = "Reunaviivat" +"Style" = "Tyyli" +"Styles" = "Tyylit" +"Stylistic set 1" = "Tyylijoukko 1" +"Stylistic set 2" = "Tyylijoukko 2" +"Stylistic set 3" = "Tyylijoukko 3" +"Stylistic set 4" = "Tyylijoukko 4" +"Stylistic set 5" = "Tyylijoukko 5" +"Subtract" = "Vähennä" +"Subtracts" = "Vähentää" +"Suffix" = "Jälkiliite" +"Suspend snapping while dragging" = "Keskeytä tarttuminen vetämisen ajaksi" +"Swap instance" = "Vaihda ilmentymä" +"Swap library: use another library's assets of the same names" = "Vaihda kirjasto: käytä toisen kirjaston samannimisiä resursseja" +"Swap orientation" = "Vaihda suunta" +"Swap overlay" = "Vaihda peittokuva" +"Swap to {name}" = "Vaihda kohteeseen {name}" +"Swap width and height" = "Vaihda leveys ja korkeus keskenään" +"Swapped instance" = "Ilmentymä vaihdettu" +"Swapped {n} assets" = { one = "Vaihdettu {n} resurssi", other = "Vaihdettu {n} resurssia" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vaihdettu {n} resurssi; ilman vastinetta jäi {missed}, ja ne käyttävät yhä vanhaa kirjastoa", other = "Vaihdettu {n} resurssia; ilman vastinetta jäi {missed}, ja ne käyttävät yhä vanhaa kirjastoa" } +"Sweep" = "Pyyhkäisy" +"System" = "Järjestelmä" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Taulukko" +"Tablet" = "Tabletti" +"Tabular numbers" = "Tasalevyiset numerot" +"Take the values from the first selected layer" = "Ota arvot ensimmäisestä valitusta tasosta" +"Taper" = "Kapeneminen" +"Taper both ends" = "Kavenna molemmat päät" +"Tell the server's operator what is wrong with it" = "Kerro palvelimen ylläpitäjälle, mikä siinä on vikana" +"Temperature" = "Lämpötila" +"Template" = "Malli" +"Templates" = "Mallit" +"Terms" = "Ehdot" +"Text" = "Teksti" +"Text align" = "Tekstin tasaus" +"Text content" = "Tekstisisältö" +"Text style" = "Tekstityyli" +"Text styles" = "Tekstityylit" +"Texts" = "Tekstit" +"Texture" = "Tekstuuri" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Piirtoalue esikatselee värit sRGB-muodossa. Vietäviin PNG-, JPG- ja WebP-tiedostoihin merkitään Display P3 -profiili." +"The destination document is unavailable. Nothing was imported." = "Kohdeasiakirja ei ole käytettävissä. Mitään ei tuotu." +"The destination document was closed. Nothing was imported." = "Kohdeasiakirja suljettiin. Mitään ei tuotu." +"The destination page was removed. Nothing was imported." = "Kohdesivu poistettiin. Mitään ei tuotu." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Tiedosto poistetaan palvelimelta. Kukaan ei voi enää avata sitä tai liittyä siihen sieltä, eikä tätä voi kumota. Avoimet kopiot säilyvät käyttäjillään." +"The file now holds another library" = "Tiedosto sisältää nyt toisen kirjaston" +"The file now holds another library (\"{name}\")" = "Tiedosto sisältää nyt toisen kirjaston (”{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Tältä tietokoneelta verkossa jakamasi tai julkaisemasi tiedostot. Ne säilyvät palvelimellaan, kunnes poistat ne." +"The link carries the session's secret: share it only with people you invite." = "Linkki sisältää istunnon salaisen tunnisteen. Jaa se vain kutsumillesi henkilöille." +"The name in your online files and on the server. The document inside keeps its own name." = "Nimi verkkotiedostoissasi ja palvelimella. Sisällä oleva asiakirja säilyttää oman nimensä." +"The selection uses several text styles: click to apply one" = "Valinta käyttää useita tekstityylejä. Käytä yhtä niistä napsauttamalla." +"The server deletes this file if nobody opens it before {date}." = "Palvelin poistaa tämän tiedoston, ellei kukaan avaa sitä ennen päivää {date}." +"The whole file" = "Koko tiedosto" +"Theme" = "Teema" +"There's no destination for that action" = "Toiminnolla ei ole kohdetta" +"Thickness" = "Paksuus" +"This branch can't be reviewed: {e}" = "Tätä haaraa ei voi tarkistaa: {e}" +"This branch was merged or archived." = "Tämä haara on yhdistetty tai arkistoitu." +"This computer only" = "Vain tämä tietokone" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Tämä tietokone muistaa omistajan tunnisteen, joten tiedostoa voi hallita aloitusnäkymän Verkossa-luettelosta. Kopioi tunniste hallitaksesi tiedostoa muualla. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen." +"This condition is edited as an expression" = "Tätä ehtoa muokataan lausekkeena" +"This file" = "Tämä tiedosto" +"This file has unsaved changes" = "Tässä tiedostossa on tallentamattomia muutoksia" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tämä tiedosto on palvelimella {server} vain sinun käytössäsi. Jaa se, jotta muut voivat liittyä uudella koodilla." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tämä tiedosto käyttää fontteja, joita ei ole asennettu tälle tietokoneelle. Teksti piirretään korvaavalla fontilla, kunnes asennat puuttuvat fontit." +"This frame is new on the branch." = "Tämä kehys on uusi haarassa." +"This frame isn't on the branch." = "Tätä kehystä ei ole haarassa." +"This server is open to the public: no key needed." = "Tämä palvelin on julkinen: avainta ei tarvita." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tämä palvelin tarvitsee avaimen. Lisää osoitteeseen ?key=… (`figcraft-server key` tulostaa avaimen palvelimen koneella)." +"This server takes files only with its key: see Advanced." = "Tämä palvelin ottaa tiedostoja vastaan vain avaimellaan. Katso Lisäasetukset." +"Tidy up" = "Järjestä tasavälein" +"Tile" = "Laatoitus" +"Tile type" = "Laatan tyyppi" +"Tint" = "Sävytys" +"Title" = "Otsikko" +"Title case" = "Sanojen isot alkukirjaimet" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Jaa tämä kirjasto tiimillesi julkaisemalla tiedosto palvelimelle (Jaa ▸ Julkaise palvelimelle…) ja julkaisemalla kirjasto siellä." +"Toggle" = "Vaihda tila" +"Toggle light/dark theme" = "Vaihda vaalea/tumma teema" +"Toggle visibility" = "Vaihda näkyvyys" +"Tools" = "Työkalut" +"Top" = "Ylhäältä" +"Top & bottom" = "Ylä- ja alareuna" +"Top left radius" = "Vasemman yläkulman säde" +"Top right radius" = "Oikean yläkulman säde" +"Transform" = "Muunna" +"Triangle arrow" = "Kolmionuoli" +"Trigger" = "Laukaisin" +"True" = "Tosi" +"Truncate text" = "Lyhennä tekstiä" +"Truncate with an ellipsis" = "Lyhennä kolmella pisteellä" +"Try again" = "Yritä uudelleen" +"Try another search or filter." = "Kokeile toista hakua tai suodatinta." +"Try another search, or browse templates." = "Kokeile toista hakua tai selaa malleja." +"Try another word, or look at every kind." = "Kokeile toista sanaa tai katso kaikki tyypit." +"Turn a layer into a component, reuse it, and change every copy at once" = "Muunna taso komponentiksi, käytä sitä uudelleen ja muuta kaikkia kopioita kerralla" +"Two digits" = "Kaksi numeroa" +"Type" = "Teksti" +"Type settings" = "Tekstiasetukset" +"Type the join code you were given, or paste an invite link." = "Kirjoita saamasi liittymiskoodi tai liitä kutsulinkki." +"Typography" = "Typografia" +"Underline" = "Alleviivaus" +"Undo takes back only your own changes; what others changed since stays." = "Kumoaminen peruu vain omat muutoksesi. Muiden sen jälkeen tekemät muutokset säilyvät." +"Ungroup" = "Pura ryhmittely" +"Uniform" = "Tasainen" +"Union" = "Yhdiste" +"Unions" = "Yhdistää" +"Unknown — treated as sRGB" = "Tuntematon — käsitellään sRGB:nä" +"Unread" = "Lukematon" +"Unresolve" = "Merkitse ratkaisemattomaksi" +"Unsaved changes" = "Tallentamattomat muutokset" +"Unsaved changes in {names}. Close the window and lose them?" = "Tallentamattomia muutoksia kohteissa {names}. Suljetaanko ikkuna ja hylätään ne?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Tallentamattomia muutoksia: tallenna ensin ({save}) tai hylkää sulkemalla välilehti {alt}-napsautuksella" +"Unsaved changes: save first, or close again with {key} to discard" = "Tallentamattomia muutoksia: tallenna ensin tai hylkää sulkemalla uudelleen näppäimellä {key}" +"Up" = "Ylös" +"Update" = "Päivitä" +"Update from main" = "Päivitä päähaarasta" +"Update from selection" = "Päivitä valinnasta" +"Update plugin" = "Päivitä liitännäinen" +"Updated {components} and {styles}" = "Päivitetty {components} ja {styles}" +"Updated {name}" = "Päivitetty: {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Näytönohjainajurin päivittäminen auttaa usein. Käytä vain OpenGL:ää käynnistämällä FigCraft komentokehotteesta näin:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Näytönohjainajurin päivittäminen auttaa usein. WGPU_BACKEND (vulkan, gl, metal) määrittää toisen kokeiltavan grafiikkarajapinnan." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lataa tämän tiedoston FigCraft-palvelimelle ja pitää tämän välilehden synkronoituna sen kanssa. Saat muokkauslinkin ja katselulinkin." +"Uppercase" = "Isot kirjaimet" +"Use a fixed value" = "Käytä kiinteää arvoa" +"Use an expression, e.g. count + 1" = "Käytä lauseketta, esim. count + 1" +"Use as mask" = "Käytä maskina" +"Use auto layout" = "Käytä automaattista asettelua" +"Use number keys for opacity" = "Säädä peittävyyttä numeronäppäimillä" +"Use the .figcraft or .fig extension." = "Käytä .figcraft- tai .fig-tiedostopäätettä." +"Use the layer's fill" = "Käytä tason täyttöä" +"Used in {files} · {instances}" = "Käytössä tiedostoissa {files} · {instances}" +"Username" = "Käyttäjänimi" +"Uses variables: each layer shows them in its own mode" = "Käyttää muuttujia: kukin taso näyttää ne omassa tilassaan" +"Value" = "Arvo" +"Variable" = "Muuttuja" +"Variable collections" = "Muuttujakokoelmat" +"Variable modes" = "Muuttujatilat" +"Variable width" = "Vaihteleva leveys" +"Variables" = "Muuttujat" +"Variables JSON…" = "Muuttujat JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Muuttujat tallentavat uudelleenkäytettäviä arvoja (värejä, lukuja, merkkijonoja ja totuusarvoja). Niillä voi olla tiloja, kuten vaalea ja tumma." +"Variant" = "Variantti" +"Vector" = "Vektori" +"Vectors" = "Vektorit" +"Version" = "Versio" +"Version history" = "Versiohistoria" +"Version {version}" = "Versio {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versio {v} · {components} · {styles} · {variables}" +"Vertical" = "Pystysuunta" +"Vertical align" = "Pystytasaus" +"Vertical hex" = "Pystysuuntainen kuusikulmio" +"Vertical padding" = "Pystysuuntainen sisämarginaali" +"Vertical spacing" = "Pystyvälistys" +"Vertical trim" = "Pystysuuntainen rajaus" +"View" = "Näytä" +"View link" = "Katselulinkki" +"View link copied" = "Katselulinkki kopioitu" +"View only" = "Vain katselu" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Vain katselu: näet kaikki muutokset reaaliajassa, mutta et voi muokata. Poistu säilyttääksesi muokattavan kopion." +"Viewing “{name}”" = "Katsellaan tiedostoa ”{name}”" +"Violet" = "Violetti" +"Visibility" = "Näkyvyys" +"Visible" = "Näkyvä" +"Vivid" = "Kirkas" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND on asetettu ja määrittää käytettävän grafiikkarajapinnan. Poista se käytöstä, jotta FigCraft voi valita." +"Warm" = "Lämmin" +"Watch" = "Seuraa" +"Wavy" = "Aaltoileva" +"Web" = "Verkko" +"WebSocket URL" = "WebSocket-URL" +"WebSocket URL copied" = "WebSocket-URL kopioitu" +"Websites" = "Verkkosivustot" +"Wedge" = "Kiila" +"Weight" = "Paksuus" +"What changed since it was marked ready for dev" = "Muutokset kehitysvalmiiksi merkitsemisen jälkeen" +"What is wrong with this file? The server's operator gets your report." = "Mikä tässä tiedostossa on vikana? Palvelimen ylläpitäjä saa ilmoituksesi." +"What it's for" = "Käyttötarkoitus" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Aiempi tila ei ole tiedossa: muutos tehtiin ennen kuin FigCraft tallensi kopioita kehitysvalmiista kehyksistä. Ratkaise muutos tai merkitse kehys uudelleen valmiiksi, jotta jatkossa vertailu onnistuu." +"What you try here changes this preview and the code below, not the file" = "Tässä kokeilemasi asiat muuttavat esikatselua ja alla olevaa koodia, eivät tiedostoa" +"While hovering" = "Osoitettaessa" +"While pressing" = "Painettaessa" +"White" = "Valkoinen" +"Who runs it, its terms and how long it keeps files" = "Ylläpitäjä, käyttöehdot ja tiedostojen säilytysaika" +"Why it changed" = "Muutoksen syy" +"Widget" = "Pienoisohjelma" +"Widgets" = "Pienoisohjelmat" +"Width" = "Leveys" +"Width point" = "Leveyspiste" +"Width profile" = "Leveysprofiili" +"Wireframe kit" = "Rautalankapaketti" +"With the layers' export settings" = "Tasojen vientiasetuksilla" +"Wrap" = "Rivitys" +"Wrap in new section" = "Ympäröi uudella osiolla" +"Yellow" = "Keltainen" +"Yesterday" = "Eilen" +"You" = "Sinä" +"You'll get the shared file as a new tab and edit it together." = "Jaettu tiedosto avautuu uuteen välilehteen, ja voitte muokata sitä yhdessä." +"You're spotlighting: everyone follows your view" = "Olet esillä: kaikki seuraavat näkymääsi" +"Your copy of the file stays open" = "Oma kopiosi tiedostosta pysyy auki" +"Your copy stays open; the file stays on the server" = "Oma kopiosi pysyy auki, ja tiedosto jää palvelimelle" +"Your name" = "Nimesi" +"Your name in comments" = "Nimesi kommenteissa" +"Your system asks for reduced motion: animations stay off." = "Järjestelmäsi pyytää vähennettyä liikettä. Animaatiot pidetään pois käytöstä." +"Z" = "Z" +"Z to A" = "Z:sta A:han" +"Zoom" = "Zoomaus" +"Zoom around the pointer" = "Zoomaa osoittimen ympärillä" +"Zoom device to fill window" = "Zoomaa laite ikkunan kokoiseksi" +"Zoom in" = "Lähennä" +"Zoom out" = "Loitonna" +"Zoom to 100%" = "Zoomaa kokoon 100 %" +"Zoom to 200%" = "Zoomaa kokoon 200 %" +"Zoom to 50%" = "Zoomaa kokoon 50 %" +"Zoom to fit" = "Sovita näkymään" +"Zoom to selection" = "Zoomaa valintaan" +"Zoom tool: click to zoom in, drag an area" = "Zoomaustyökalu: lähennä napsauttamalla tai vedä alue" +"Zoom tool: zoom out" = "Zoomaustyökalu: loitonna" +"and {n} more" = { one = "ja vielä {n}", other = "ja vielä {n}" } +"bleed marks" = "leikkuuvaramerkit" +"by {author}" = "tekijä: {author}" +"call any command its access allows" = "kutsua kaikkia käyttöoikeuksiensa sallimia komentoja" +"call only: {commands}" = "kutsua vain: {commands}" +"can edit the document" = "voi muokata asiakirjaa" +"conditional prototype actions nested too deeply were skipped" = "liian syvälle sisäkkäiset ehdolliset prototyyppitoiminnot ohitettiin" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "ehdolliset prototyyppitoiminnot ohitettiin, koska niiden ehtoja ei voi laskea" +"connector label background was skipped: style data is missing" = "yhdistimen tekstin tausta ohitettiin: tyylitiedot puuttuvat" +"connector label was skipped: background bounds are invalid" = "yhdistimen teksti ohitettiin: taustan rajat ovat virheelliset" +"connector label was skipped: background transform is invalid" = "yhdistimen teksti ohitettiin: taustan muunnos on virheellinen" +"connector label was skipped: composed text transform is invalid" = "yhdistimen teksti ohitettiin: yhdistetty tekstimuunnos on virheellinen" +"connector label was skipped: derived background bounds are missing" = "yhdistimen teksti ohitettiin: johdetut taustan rajat puuttuvat" +"connector label was skipped: derived text bounds are missing" = "yhdistimen teksti ohitettiin: johdetut tekstin rajat puuttuvat" +"connector label was skipped: text bounds are invalid" = "yhdistimen teksti ohitettiin: tekstin rajat ovat virheelliset" +"connector label was skipped: text style data is missing" = "yhdistimen teksti ohitettiin: tekstin tyylitiedot puuttuvat" +"connector label was skipped: text transform is invalid" = "yhdistimen teksti ohitettiin: tekstin muunnos on virheellinen" +"connector labels were skipped" = "yhdistimien tekstit ohitettiin" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "yhdistintasot tuodaan staattisina kehys- ja vektorikuvina mahdollisine teksteineen; päiden reaaliaikaisia sidoksia ei säilytetä" +"connector snapshot vector data is missing; connector was left empty" = "yhdistimen vektorikuvatiedot puuttuvat; yhdistin jätettiin tyhjäksi" +"connectors whose ends can't be placed are empty" = "yhdistimet, joiden päitä ei voi sijoittaa, ovat tyhjiä" +"crop marks" = "leikkausmerkit" +"damaged vector geometry was skipped" = "vaurioitunut vektorigeometria ohitettiin" +"damaged vector networks import as outlines" = "vaurioituneet vektoriverkot tuodaan ääriviivoina" +"development: {path}" = "kehitys: {path}" +"document children other than pages were skipped" = "asiakirjan muut kuin sivuina olevat alikohteet ohitettiin" +"duplicate grid track sizing entries use equal tracks" = "päällekkäiset ruudukon kokomääritykset käyttävät tasakokoisia osia" +"e.g. count + 1" = "esim. count + 1" +"e.g. count >= 3 and not done" = "esim. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "mutkaiset ja kaarevat yhdistimet, joilla ei ole tallennettua polkua, tuodaan suorina viivoina" +"entries without a node type were skipped" = "kohteet, joilta puuttuu solmutyyppi, ohitettiin" +"figcraft:// links from a browser, chat or email open this app and join the session." = "selaimen, keskustelun tai sähköpostin figcraft://-linkit avaavat tämän sovelluksen ja liittyvät istuntoon." +"filled areas of a vector with their own fill use the layer's fill" = "vektorin omalla täytöllä määritetyt alueet käyttävät tason täyttöä" +"from {server}" = "palvelimelta {server}" +"gamepad triggers without exactly one known controller button were skipped" = "peliohjainlaukaisimet, joilla ei ole täsmälleen yhtä tunnettua ohjainpainiketta, ohitettiin" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon osien luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ruudukon kokomääritys on virheellinen tai sitä ei tueta; kyseiset osat käyttävät tasakokoa" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon kokomääritysten luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa" +"hidden" = "kätketty" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "kuvasuotimet ovat monitulkintaisia, koska molemmat suodinsäilöt ovat läsnä; suotimien muunnos ohitettiin" +"image filters use FigCraft's approximate renderer" = "kuvasuotimet käyttävät FigCraftin likimääräistä renderöijää" +"instance swap properties whose default component isn't in the file were skipped" = "ilmentymän vaihto-ominaisuudet ohitettiin, koska niiden oletuskomponenttia ei ole tiedostossa" +"instances inside their own main component were imported as frames" = "oman pääkomponenttinsa sisällä olevat ilmentymät tuotiin kehyksinä" +"instances of components that aren't in the file show no layers" = "tiedostosta puuttuvien komponenttien ilmentymät eivät näytä tasoja" +"instances of components that aren't in the file were imported as frames" = "tiedostosta puuttuvien komponenttien ilmentymät tuotiin kehyksinä" +"instances that would nest a component inside itself were imported as frames" = "ilmentymät, jotka sijoittaisivat komponentin itsensä sisään, tuotiin kehyksinä" +"instances without a main component were imported as empty frames" = "ilmentymät, joilta puuttuu pääkomponentti, tuotiin tyhjinä kehyksinä" +"invalid internal .fig text path data was ignored" = "virheelliset sisäiset .fig-tekstipolun tiedot ohitettiin" +"invalid or oversized vector networks import as outlines" = "virheelliset tai liian suuret vektoriverkot tuodaan ääriviivoina" +"invalid or oversized vector path data was skipped" = "virheelliset tai liian suuret vektoripolun tiedot ohitettiin" +"just now" = "juuri nyt" +"key triggers without a supported key were skipped" = "näppäinlaukaisimet, joilla ei ole tuettua näppäintä, ohitettiin" +"key up triggers import as key presses" = "näppäimen vapautuslaukaisimet tuodaan näppäimen painalluksina" +"layers without a position were placed at their parent's origin" = "tasot, joilta puuttuu sijainti, sijoitettiin emotasonsa origoon" +"layers without a type were skipped" = "tasot, joilta puuttuu tyyppi, ohitettiin" +"legacy image filter container is unsupported; filter conversion was skipped" = "vanhaa kuvasuodinsäilöä ei tueta; suotimien muunnos ohitettiin" +"locked" = "lukittu" +"malformed image filters container was skipped" = "virheellinen image filters -säilö ohitettiin" +"malformed image paintFilter container was skipped" = "virheellinen image paintFilter -säilö ohitettiin" +"malformed slot content assignment was skipped" = "virheellinen sisältöpaikan sisällön määritys ohitettiin" +"no bleed" = "ei leikkuuvaraa" +"nobody else here yet" = "kukaan muu ei ole vielä paikalla" +"non-default slot settings are not represented and were skipped" = "oletuksesta poikkeavia sisältöpaikan asetuksia ei esitetä, ja ne ohitettiin" +"now" = "nyt" +"only in a text style or variable" = "vain tekstityylissä tai muuttujassa" +"or" = "tai" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "pisteiden viivaliitokset säilytettiin; katko- ja vaihtelevanlevyiset viivat käyttävät tason liitostyyliä" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "sisältöpaikan ensisijaisten komponenttien avaimia ei voitu yhdistää tiedoston solmuihin, ja ne ohitettiin" +"prototype actions of unknown types were skipped" = "tuntemattoman tyyppiset prototyyppitoiminnot ohitettiin" +"prototype actions without a destination in the file were skipped" = "prototyyppitoiminnot, joiden kohdetta ei ole tiedostossa, ohitettiin" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "muuttujiin viittaavat prototyyppilausekkeet ohitettiin, koska muuttujia ei ole muuttujien JSON-tiedostossa" +"prototype expressions with unsupported functions were skipped" = "prototyyppilausekkeet, joissa on tukemattomia funktioita, ohitettiin" +"prototype interactions without supported actions were skipped" = "prototyypin vuorovaikutukset, joissa ei ole tuettuja toimintoja, ohitettiin" +"prototype navigations of unknown types were skipped" = "tuntemattoman tyyppiset prototyypin siirtymät kohteeseen ohitettiin" +"prototype transitions of unknown types play instantly" = "tuntemattoman tyyppiset prototyypin siirtymäanimaatiot toistuvat välittömästi" +"prototype triggers of unknown types were skipped" = "tuntemattoman tyyppiset prototyypin laukaisimet ohitettiin" +"put a widget on the canvas" = "lisätä pienoisohjelman piirtoalueelle" +"read and edit the document" = "lukea ja muokata asiakirjaa" +"read the document" = "lukea asiakirjan" +"reads the document" = "lukee asiakirjan" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "muuttujan asetustoiminnot ohitettiin, koska muuttujia ei ole muuttujien JSON-tiedostossa" +"set-variable actions with values that can't be evaluated were skipped" = "muuttujan asetustoiminnot ohitettiin, koska arvoja ei voi laskea" +"set-variable actions with values that don't fit the variable were skipped" = "muuttujan asetustoiminnot ohitettiin, koska arvot eivät sovi muuttujaan" +"set-variable actions without a value were skipped" = "muuttujan asetustoiminnot, joilta puuttuu arvo, ohitettiin" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "muuttujatilan asetustoiminnot ohitettiin, koska kokoelmia tai tiloja ei ole muuttujien JSON-tiedostossa" +"slot content assignment target is missing or is not slot content" = "sisältöpaikan määrityksen kohde puuttuu tai ei ole sisältöpaikan sisältöä" +"slot content assignment uses the empty GUID sentinel and was skipped" = "sisältöpaikan määritys käyttää tyhjää GUID-erikoisarvoa, ja se ohitettiin" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "jotkin vektoripisteet viittaavat tiedostosta puuttuviin pisteasetuksiin; ne käyttävät tason liitostyyliä ja tavallisia kulmia" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "jotkin vektorisegmentit viittaavat tiedostosta puuttuviin tyyliasetuksiin; ne käyttävät tason reunaviivaa" +"stale or invalid grid track sizing entries were ignored" = "vanhentuneet tai virheelliset ruudukon kokomääritykset ohitettiin" +"stars and polygons without point counts or geometry use the default point count" = "tähdet ja monikulmiot, joilta puuttuu pistemäärä tai geometria, käyttävät oletuspistemäärää" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "joidenkin vektorisegmenttien tyyliasetuksia ei tueta; nämä segmentit käyttävät tason reunaviivaa" +"text path underline or strikethrough decoration is not rendered" = "tekstipolun alleviivausta tai yliviivausta ei renderöidä" +"text style overrides refer to missing table entries" = "tekstityylin ohitukset viittaavat puuttuviin taulukkokohteisiin" +"the file has no pages" = "tiedostossa ei ole sivuja" +"the plugin" = "liitännäinen" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "muuttujien JSON-tiedostosta puuttuu `variableCollections` (odotettiin paikallisten muuttujien vastausta), ja tiedosto ohitettiin" +"this session only" = "vain tämä istunto" +"unknown component property types were skipped" = "tuntemattoman tyyppiset komponenttiominaisuudet ohitettiin" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "muuttuja-aliakset ohitettiin, koska kohdemuuttujia ei ole muuttujien JSON-tiedostossa tai ne ovat eri tyyppiä" +"variable bindings to properties the layer doesn't have were skipped" = "muuttujasidokset ominaisuuksiin, joita tasolla ei ole, ohitettiin" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "muuttujasidokset muuttujiin, joita ei ole muuttujien JSON-tiedostossa (kirjastomuuttujat), ohitettiin" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "muuttujasidokset ohitettiin: tiedoston JSON ei määritä muuttujia (anna paikallisten muuttujien JSON arvona `variables` tai sisällytä sen `meta` tiedoston JSONin viereen)" +"variable bindings whose variable type doesn't fit the property were skipped" = "muuttujasidokset, joiden muuttujatyyppi ei sovi ominaisuuteen, ohitettiin" +"variable collections without modes were skipped" = "muuttujakokoelmat, joilla ei ole tiloja, ohitettiin" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "prototyyppilausekkeiden muuttujatilahaut, jotka eivät ole muuttuja ja jokin sen tiloista, ohitettiin" +"variable modes of collections that aren't in the file were skipped" = "tiedostosta puuttuvien kokoelmien muuttujatilat ohitettiin" +"variable modes of collections that aren't in the variables JSON were skipped" = "muuttujien JSON-tiedostosta puuttuvien kokoelmien muuttujatilat ohitettiin" +"variable values that don't match their type (or composed colours) were skipped" = "muuttujien arvot, jotka eivät vastaa tyyppiään (tai yhdistettyjä värejä), ohitettiin" +"variables of unknown types were skipped" = "tuntemattoman tyyppiset muuttujat ohitettiin" +"variables whose collection isn't in the variables JSON were skipped" = "muuttujat, joiden kokoelmaa ei ole muuttujien JSON-tiedostossa, ohitettiin" +"variables without readable values were skipped" = "muuttujat, joiden arvoja ei voi lukea, ohitettiin" +"vector points with unknown stroke joins use the layer join" = "vektoripisteet, joilla on tuntematon liitostyyli, käyttävät tason liitostyyliä" +"vectors without geometry are empty" = "vektorit ilman geometriaa ovat tyhjiä" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorit ilman geometriaa ovat tyhjiä (vie tiedoston JSON asetuksella geometry=paths)" +"very long text was cut to 1,048,576 characters" = "erittäin pitkä teksti katkaistiin 1 048 576 merkkiin" +"very long vector paths were skipped" = "erittäin pitkät vektoripolut ohitettiin" +"video actions are not supported and were skipped" = "videotoimintoja ei tueta, ja ne ohitettiin" +"video triggers are not supported and were skipped" = "videolaukaisimia ei tueta, ja ne ohitettiin" +"yesterday" = "eilen" +"{instances} in {files}" = "{instances} tiedostoissa {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: käännösluonnos, joka odottaa äidinkielisen puhujan tarkistusta." +"{length} bleed" = "Leikkuuvara {length}" +"{month} {day}, {year}" = "{day}. {month} {year}" +"{name}\nClick to apply to the selection" = "{name}\nKäytä valintaan napsauttamalla" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nVedä piirtoalueelle tai sijoita näkymän keskelle napsauttamalla.\nVaihda ilmentymä pudottamalla sen päälle Alt (Option) painettuna." +"{name} (from {library})" = "{name} (kirjastosta {library})" +"{name} (you)" = "{name} (sinä)" +"{name} ({layers} layers)" = "{name} (tasoja: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, oletus {default}. Palauta tyylin arvo kaksoisnapsauttamalla liukusäädintä. Escape peruuttaa vetämisen." +"{name} cleared from the list" = "{name} poistettu luettelosta" +"{name} from {path}" = "{name} polusta {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}." +"{name} is on the server." = "{name} on palvelimella." +"{name} is spotlighting" = "{name} nostaa näkymäänsä esiin" +"{name} was removed from the server" = "{name} poistettiin palvelimelta" +"{name} · deleted on main" = "{name} · poistettu päähaarassa" +"{name} · deleted on the branch" = "{name} · poistettu haarassa" +"{name} · not published" = "{name} · ei julkaistu" +"{name} · on a server" = "{name} · palvelimella" +"{name} · published version {v}" = "{name} · julkaistu versio {v}" +"{name} · version {v} on the server" = "{name} · versio {v} palvelimella" +"{name}, in a live session" = "{name}, reaaliaikaisessa istunnossa" +"{name}, online, open only to you" = "{name}, verkossa, vain sinun käytössäsi" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, valittu" +"{n} added" = { one = "Lisätty: {n}", other = "Lisätty: {n}" } +"{n} autosave versions" = { one = "{n} automaattisesti tallennettu versio", other = "{n} automaattisesti tallennettua versiota" } +"{n} changed" = { one = "Muutettu: {n}", other = "Muutettu: {n}" } +"{n} columns" = { one = "{n} sarake", other = "{n} saraketta" } +"{n} components" = { one = "{n} komponentti", other = "{n} komponenttia" } +"{n} components and styles have newer versions" = { one = "Uudempi versio saatavilla: {n} komponentti tai tyyli", other = "Uudempia versioita saatavilla: {n} komponenttia ja tyyliä" } +"{n} conflicts to resolve" = { one = "{n} ratkaistava ristiriita", other = "{n} ratkaistavaa ristiriitaa" } +"{n} days ago" = { one = "{n} päivä sitten", other = "{n} päivää sitten" } +"{n} files" = { one = "{n} tiedosto", other = "{n} tiedostoa" } +"{n} hours ago" = { one = "{n} tunti sitten", other = "{n} tuntia sitten" } +"{n} instances" = { one = "{n} ilmentymä", other = "{n} ilmentymää" } +"{n} layers" = { one = "{n} taso", other = "{n} tasoa" } +"{n} layers as one file" = { one = "{n} taso yhtenä tiedostona", other = "{n} tasoa yhtenä tiedostona" } +"{n} layers selected" = { one = "{n} taso valittu", other = "{n} tasoa valittu" } +"{n} layers · {parts}" = { one = "{n} taso · {parts}", other = "{n} tasoa · {parts}" } +"{n} matches" = { one = "{n} osuma", other = "{n} osumaa" } +"{n} minutes ago" = { one = "{n} minuutti sitten", other = "{n} minuuttia sitten" } +"{n} months ago" = { one = "{n} kuukausi sitten", other = "{n} kuukautta sitten" } +"{n} more notes omitted from this report" = { one = "Tästä raportista on jätetty pois vielä {n} huomautus", other = "Tästä raportista on jätetty pois vielä {n} huomautusta" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { one = "{n} henkilö liittyi", other = "{n} henkilöä liittyi" } +"{n} preferred components" = { one = "{n} ensisijainen komponentti", other = "{n} ensisijaista komponenttia" } +"{n} ready" = { one = "{n} valmis", other = "{n} valmista" } +"{n} removed" = { one = "Poistettu: {n}", other = "Poistettu: {n}" } +"{n} replies" = { one = "{n} vastaus", other = "{n} vastausta" } +"{n} rows" = { one = "{n} rivi", other = "{n} riviä" } +"{n} styles" = { one = "{n} tyyli", other = "{n} tyyliä" } +"{n} text layers" = { one = "{n} tekstitaso", other = "{n} tekstitasoa" } +"{n} variables" = { one = "{n} muuttuja", other = "{n} muuttujaa" } +"{n} weeks ago" = { one = "{n} viikko sitten", other = "{n} viikkoa sitten" } +"{n} years ago" = { one = "{n} vuosi sitten", other = "{n} vuotta sitten" } +"{n}d" = "{n}pv" +"{n}h" = "{n}t" +"{n}m" = "{n}min" +"{n}w" = "{n}vk" +"{page}, nothing selected" = "{page}, ei valintaa" +"{profile} (current)" = "{profile} (nykyinen)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} on sen arvo. {property ? \"a\" : \"b\"} valitsee merkkijonon. Muut {…}-ilmaukset säilyvät sellaisinaan." +"{server} can't say what became of your files (it is older than this app)." = "{server} ei pysty kertomaan tiedostojesi tilaa (se on tätä sovellusta vanhempi)." +"{server} · No longer on the server" = "{server} · Ei enää palvelimella" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Avattu {date}" +"{tool} hold {key}" = "{tool} pidä {key} painettuna" +"{value} isn't a colour" = "{value} ei ole väri" +"{when} · edited" = "{when} · muokattu" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→, Space or N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ arrows" +"⌘ click" = "⌘ click" +"⌘ scroll" = "⌘ scroll" +"⌥ drag" = "⌥ drag" + +[commands] +"align.bottom" = "Tasaa alas" +"align.hcenter" = "Keskitä vaakasuunnassa" +"align.left" = "Tasaa vasemmalle" +"align.right" = "Tasaa oikealle" +"align.top" = "Tasaa ylös" +"align.vcenter" = "Keskitä pystysuunnassa" +"annotation.add" = "Lisää huomautus" +"annotation.addCategory" = "Lisää huomautusluokka" +"annotation.categories" = "Huomautusten luokat" +"annotation.edit" = "Muokkaa huomautusta" +"annotation.editCategory" = "Muokkaa huomautusluokkaa" +"annotation.list" = "Luettele huomautukset" +"annotation.remove" = "Poista huomautus" +"annotation.removeCategory" = "Poista huomautusluokka" +"app.actions" = "Toiminnot" +"app.preferences" = "Asetukset" +"arrange.back" = "Vie takimmaiseksi" +"arrange.backward" = "Vie taaksepäin" +"arrange.forward" = "Tuo eteenpäin" +"arrange.front" = "Tuo etummaiseksi" +"arrange.packH" = "Pakkaa vaakasuunnassa" +"arrange.packV" = "Pakkaa pystysuunnassa" +"arrange.roundToPixels" = "Pyöristä pikseleihin" +"arrange.spacing" = "Välistys" +"arrange.tidy" = "Järjestä tasavälein" +"bool.exclude" = "Sulje valinnan päällekkäinen alue pois" +"bool.flatten" = "Yhdistä Boolen muoto vektoriksi" +"bool.intersect" = "Leikkaa valinnan yhteinen alue" +"bool.subtract" = "Vähennä valinta" +"bool.union" = "Yhdistä valinta" +"branch.archive" = "Arkistoi haara" +"branch.create" = "Luo haara" +"branch.list" = "Luettele haarat" +"branch.merge" = "Yhdistä haara" +"branch.open" = "Avaa haara" +"branch.review" = "Tarkista haara" +"branch.updateFromMain" = "Päivitä päähaarasta" +"codeConnect.export" = "Vie koodiyhteydet" +"codeConnect.import" = "Tuo koodiyhteydet" +"codeConnect.remove" = "Katkaise yhteys koodiin" +"codeConnect.set" = "Yhdistä koodiin" +"collab.cursor" = "Näytä kohdistin kohdassa" +"collab.cursorChat" = "Kohdistinkeskustelu" +"collab.diff" = "Näytä synkronoinnin muutokset" +"collab.files" = "Omat verkkotiedostot" +"collab.follow" = "Seuraa" +"collab.forgetFile" = "Unohda verkkotiedosto" +"collab.host" = "Aloita reaaliaikainen istunto" +"collab.join" = "Liity reaaliaikaiseen istuntoon" +"collab.leave" = "Poistu reaaliaikaisesta istunnosta" +"collab.manageFile" = "Hallitse julkaistua tiedostoa" +"collab.openFile" = "Avaa verkkotiedosto" +"collab.peers" = "Tämän tiedoston käyttäjät" +"collab.publish" = "Julkaise palvelimelle" +"collab.recoverFile" = "Palauta verkkotiedosto" +"collab.registerLinks" = "Avaa kutsulinkit FigCraftissa" +"collab.removePerson" = "Poista istunnosta" +"collab.report" = "Ilmoita tiedostosta palvelimen ylläpitäjälle" +"collab.serverInfo" = "Palvelimen tiedot" +"collab.share" = "Jaa liittymiskoodilla" +"collab.spotlight" = "Nosta minut esiin" +"collab.stop" = "Lopeta reaaliaikainen istunto" +"command.batch" = "Suorita useita komentoja" +"comment.add" = "Lisää kommentti" +"comment.delete" = "Poista kommentti" +"comment.edit" = "Muokkaa kommenttia" +"comment.link" = "Kopioi linkki kommenttiin" +"comment.list" = "Luettele kommentit" +"comment.markRead" = "Merkitse kommentti luetuksi" +"comment.move" = "Siirrä kommentti" +"comment.next" = "Seuraava kommentti" +"comment.open" = "Avaa kommentti" +"comment.people" = "Kommenttien osallistujat" +"comment.react" = "Reagoi kommenttiin" +"comment.reply" = "Vastaa kommenttiin" +"comment.resolve" = "Ratkaise kommentti" +"comment.zoom" = "Zoomaa kommentteihin" +"component.addProperty" = "Lisää komponentin ominaisuus" +"component.addVariant" = "Lisää variantti" +"component.bindProperty" = "Sido taso ominaisuuteen" +"component.combineVariants" = "Yhdistä varianteiksi" +"component.create" = "Luo komponentti" +"component.createMultiple" = "Luo useita komponentteja" +"component.deleteProperty" = "Poista komponentin ominaisuus" +"component.detach" = "Irrota ilmentymä" +"component.editProperty" = "Muokkaa komponentin ominaisuutta" +"component.exposeInstance" = "Tuo sisäkkäinen ilmentymä käyttöön" +"component.goToMain" = "Siirry pääkomponenttiin" +"component.hideWhenPublishing" = "Kätke julkaistaessa" +"component.list" = "Luettele komponentit" +"component.makeSlot" = "Luo sisältöpaikka" +"component.playground" = "Komponenttien kokeilualue" +"component.properties" = "Komponentin ominaisuudet" +"component.pushChanges" = "Siirrä muutokset pääkomponenttiin" +"component.removeSlot" = "Poista sisältöpaikka" +"component.resetInstance" = "Palauta kaikki muutokset" +"component.resetOverride" = "Palauta ohitus" +"component.restoreMain" = "Palauta pääkomponentti" +"dev.compare" = "Vertaa muutoksia" +"dev.focus" = "Kohdistusnäkymä" +"dev.resolveChange" = "Ratkaise muutos" +"dev.unfocus" = "Poistu kohdistusnäkymästä" +"devResource.add" = "Lisää kehittäjäresurssi" +"devResource.edit" = "Muokkaa kehittäjäresurssia" +"devResource.list" = "Luettele kehittäjäresurssit" +"devResource.remove" = "Poista kehittäjäresurssi" +"distribute.horizontal" = "Jaa vaakavälit tasan" +"distribute.vertical" = "Jaa pystyvälit tasan" +"doc.home" = "Aloitus" +"doc.list" = "Luettele asiakirjat" +"doc.nextTab" = "Seuraava välilehti" +"doc.previousTab" = "Edellinen välilehti" +"doc.switch" = "Vaihda asiakirjaa" +"edit.copy" = "Kopioi" +"edit.copyAsCss" = "Kopioi CSS-muodossa" +"edit.copyAsPng" = "Kopioi PNG-muodossa" +"edit.copyAsSvg" = "Kopioi SVG-muodossa" +"edit.copyAsText" = "Kopioi tekstinä" +"edit.copyLink" = "Kopioi linkki valintaan" +"edit.copyProperties" = "Kopioi ominaisuudet" +"edit.cut" = "Leikkaa" +"edit.delete" = "Poista" +"edit.duplicate" = "Monista" +"edit.findReplace" = "Etsi ja korvaa" +"edit.paste" = "Liitä" +"edit.pasteDesignLayers" = "Liitä suunnittelutasoja" +"edit.pasteOver" = "Liitä valinnan päälle" +"edit.pasteProperties" = "Liitä ominaisuudet" +"edit.pasteReplace" = "Liitä korvaten" +"edit.pickColor" = "Poimi väri" +"edit.redo" = "Tee uudelleen" +"edit.repeat" = "Toista viimeisin komento" +"edit.setDefaultProperties" = "Aseta oletusominaisuudet" +"edit.undo" = "Kumoa" +"effect.add" = "Lisää tehoste" +"effect.move" = "Siirrä tehoste" +"effect.remove" = "Poista tehoste" +"effect.set" = "Aseta tehoste" +"export.add" = "Lisää vientiasetus" +"export.remove" = "Poista vientiasetus" +"export.update" = "Muokkaa vientiasetusta" +"file.close" = "Sulje välilehti" +"file.colorProfile" = "Väriprofiili" +"file.duplicate" = "Monista tiedosto" +"file.export" = "Vie" +"file.exportFramesPdf" = "Vie kehykset PDF-muodossa" +"file.exportLayers" = "Vie tasot" +"file.exportSvg" = "Vie valinta SVG-muodossa" +"file.import" = "Tuo" +"file.new" = "Uusi suunnittelutiedosto" +"file.newFromTemplate" = "Uusi mallista" +"file.open" = "Avaa" +"file.openRecent" = "Avaa viimeksi avattu" +"file.placeImage" = "Sijoita kuva" +"file.recover" = "Palauta tiedosto" +"file.rename" = "Nimeä tiedosto uudelleen" +"file.revert" = "Palauta tallennettu" +"file.save" = "Tallenna" +"file.saveAs" = "Tallenna nimellä" +"file.saveVersion" = "Tallenna versiohistoriaan" +"file.versionHistory" = "Näytä versiohistoria" +"fill.add" = "Lisää täyttö" +"fill.move" = "Siirrä täyttö" +"fill.pattern" = "Kuviotäyttö" +"fill.remove" = "Poista täyttö" +"fill.set" = "Aseta täyttö" +"frame.create" = "Luo kehys" +"frame.presets" = "Luettele kehysten esiasetukset" +"frame.swapOrientation" = "Vaihda suunta" +"grid.add" = "Lisää asetteluruudukko" +"grid.move" = "Siirrä asetteluruudukko" +"grid.remove" = "Poista asetteluruudukko" +"guide.add" = "Lisää apuviiva" +"guide.clear" = "Poista kaikki apuviivat" +"guide.move" = "Siirrä apuviiva" +"guide.remove" = "Poista apuviiva" +"guide.select" = "Valitse apuviiva" +"help.about" = "Tietoja FigCraftista" +"help.gettingStarted" = "Aloittaminen" +"help.page" = "Ohjesivu" +"help.shortcuts" = "Pikanäppäimet" +"image.adjust" = "Säädä kuvaa" +"image.deletePreset" = "Poista kuvan esiasetus" +"image.place" = "Sijoita kuva" +"image.presets" = "Kuvan esiasetukset" +"image.savePreset" = "Tallenna kuvan esiasetus" +"inspect.assets" = "Tarkastele resursseja" +"inspect.code" = "Tason koodi" +"inspect.componentCode" = "Komponentin koodi" +"inspect.properties" = "Tarkastele ominaisuuksia" +"inspect.readyForDev" = "Kehitysvalmis" +"instance.create" = "Luo ilmentymä" +"instance.exposed" = "Käyttöön tuodut sisäkkäiset ilmentymät" +"instance.place" = "Sijoita ilmentymä" +"instance.resetSlot" = "Palauta sisältöpaikka" +"instance.setProperties" = "Aseta ilmentymän ominaisuudet" +"instance.slotInsert" = "Lisää sisältöpaikkaan" +"instance.swap" = "Vaihda ilmentymä" +"instance.swapAt" = "Vaihda ilmentymä pisteessä" +"layers.collapseAll" = "Supista tasot" +"layout.add" = "Lisää automaattinen asettelu" +"layout.remove" = "Poista automaattinen asettelu" +"layout.setTrack" = "Aseta ruudukon osan koko" +"layout.suggest" = "Ehdota automaattista asettelua" +"libraries.open" = "Kirjastot" +"libraries.publish" = "Julkaise kirjasto…" +"library.acceptUpdates" = "Hyväksy kirjaston päivitykset" +"library.add" = "Lisää kirjasto" +"library.aliasVariable" = "Luo alias kirjastomuuttujaan" +"library.applyStyle" = "Käytä kirjastotyyliä" +"library.bindVariable" = "Sido kirjastomuuttuja" +"library.extendCollection" = "Laajenna kirjastokokoelmaa" +"library.import" = "Tuo kirjastokomponentti" +"library.importVariable" = "Tuo kirjastomuuttuja" +"library.list" = "Luettele kirjastot" +"library.place" = "Sijoita kirjastokomponentti" +"library.publish" = "Julkaise kirjasto" +"library.publishToServer" = "Julkaise kirjasto palvelimelle" +"library.remove" = "Poista kirjasto" +"library.setVariableMode" = "Aseta kirjastomuuttujan tila" +"library.swap" = "Vaihda kirjasto" +"library.swapAt" = "Vaihda ilmentymä kirjastokomponenttiin" +"library.updates" = "Kirjaston päivitykset" +"library.usage" = "Kirjaston käyttö" +"measure.distances" = "Mittaa etäisyyksiä" +"measure.padding" = "Mittaa automaattisen asettelun sisämarginaali" +"node.create" = "Luo taso" +"node.get" = "Hae taso" +"node.reorder" = "Muuta tason järjestystä" +"node.reparent" = "Siirrä taso kohteeseen" +"node.set" = "Aseta ominaisuudet" +"object.batchRename" = "Nimeä joukko uudelleen" +"object.convertToFrame" = "Muunna kehykseksi" +"object.convertToGroup" = "Muunna ryhmäksi" +"object.convertToSection" = "Muunna osioksi" +"object.flipH" = "Peilaa vaakasuunnassa" +"object.flipV" = "Peilaa pystysuunnassa" +"object.frameSelection" = "Kehystä valinta" +"object.group" = "Ryhmittele valinta" +"object.hideOthers" = "Kätke muut tasot" +"object.mask" = "Käytä maskina" +"object.moveToPage" = "Siirrä sivulle" +"object.opacityKeys" = "Peittävyys numeronäppäimillä" +"object.rasterize" = "Rasteroi valinta" +"object.releaseMask" = "Vapauta maski" +"object.removeFill" = "Poista täyttö" +"object.removeFrame" = "Poista kehys" +"object.removeStroke" = "Poista reunaviiva" +"object.rename" = "Nimeä uudelleen" +"object.resizeToFit" = "Sovita koko" +"object.rotate180" = "Kierrä 180°" +"object.rotateLeft" = "Kierrä 90° vasemmalle" +"object.rotateRight" = "Kierrä 90° oikealle" +"object.setThumbnail" = "Aseta pikkukuvaksi" +"object.swapFillStroke" = "Vaihda täyttö ja reunaviiva keskenään" +"object.toggleLock" = "Lukitse/avaa valinnan lukitus" +"object.toggleVisible" = "Näytä/kätke valinta" +"object.ungroup" = "Pura valinnan ryhmittely" +"object.wrapInSection" = "Ympäröi uudella osiolla" +"page.add" = "Lisää sivu" +"page.delete" = "Poista sivu" +"page.duplicate" = "Monista sivu" +"page.list" = "Luettele sivut" +"page.move" = "Siirrä sivu" +"page.rename" = "Nimeä sivu uudelleen" +"page.select" = "Siirry sivulle" +"page.setBackground" = "Sivun tausta" +"paint.documentColors" = "Asiakirjan värit" +"plugin.close" = "Sulje liitännäispaneeli" +"plugin.directory.install" = "Asenna liitännäinen hakemistosta" +"plugin.directory.list" = "Selaa liitännäishakemistoa" +"plugin.directory.publish" = "Julkaise liitännäinen hakemistoon" +"plugin.directory.unpublish" = "Peru liitännäisen julkaisu hakemistosta" +"plugin.event" = "Käytä liitännäispaneelin säädintä" +"plugin.import" = "Tuo liitännäinen manifestista…" +"plugin.list" = "Luettele liitännäiset" +"plugin.remove" = "Poista liitännäinen" +"plugin.run" = "Suorita liitännäinen" +"plugin.ui" = "Liitännäispaneeli" +"plugins.manage" = "Hallitse liitännäisiä…" +"plugins.runLast" = "Suorita viimeisin liitännäinen" +"pref.autoSave" = "Tallenna tiedostot automaattisesti" +"pref.highlightHover" = "Korosta tasot osoitettaessa" +"pref.keepTool" = "Pidä työkalu valittuna käytön jälkeen" +"pref.language" = "Käyttöliittymän kieli" +"pref.nudge" = "Siirtoaskeleen koko" +"pref.numberKeysOpacity" = "Säädä peittävyyttä numeronäppäimillä" +"pref.publishServer" = "Verkkojakamisen palvelin" +"pref.reduceMotion" = "Vähennä liikettä" +"pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" +"pref.showDimensions" = "Näytä objektien mitat" +"pref.userName" = "Nimesi kommenteissa" +"proto.action.add" = "Lisää prototyyppitoiminto" +"proto.action.move" = "Muuta prototyyppitoimintojen järjestystä" +"proto.action.remove" = "Poista prototyyppitoiminto" +"proto.action.set" = "Muokkaa prototyyppitoimintoa" +"proto.closePreview" = "Sulje prototyypin esikatselu" +"proto.conditional.addBlock" = "Lisää muuten jos / muuten" +"proto.conditional.removeBlock" = "Poista jos / muuten jos / muuten" +"proto.conditional.setCondition" = "Muokkaa ehtoa" +"proto.evaluate" = "Laske prototyyppilauseke" +"proto.flow.add" = "Lisää kulun aloituspiste" +"proto.flow.remove" = "Poista kulku" +"proto.flow.rename" = "Nimeä kulku uudelleen" +"proto.interaction.add" = "Lisää vuorovaikutus" +"proto.interaction.clear" = "Poista kaikki vuorovaikutukset" +"proto.interaction.remove" = "Poista vuorovaikutus" +"proto.interaction.set" = "Muokkaa vuorovaikutusta" +"proto.present" = "Esitä" +"proto.presentOptions" = "Esityksen asetukset" +"proto.preview" = "Avaa prototyypin esikatselu" +"proto.settings" = "Prototyypin asetukset" +"section.toggleContents" = "Kätke/näytä osion sisältö" +"select.add" = "Lisää valintaan" +"select.all" = "Valitse kaikki" +"select.area" = "Valitse alue" +"select.byName" = "Valitse tasoja nimen perusteella" +"select.children" = "Valitse alikohteet" +"select.inverse" = "Käännä valinta" +"select.layersAt" = "Pisteessä olevat tasot" +"select.matching" = "Valitse vastaavat tasot" +"select.next" = "Valitse seuraava rinnakkaiskohde" +"select.none" = "Poista valinta" +"select.parent" = "Valitse emotaso" +"select.previous" = "Valitse edellinen rinnakkaiskohde" +"select.remove" = "Poista valinnasta" +"select.sameEffect" = "Valitse sama tehoste" +"select.sameFill" = "Valitse sama täyttö" +"select.sameFont" = "Valitse sama fontti" +"select.sameInstance" = "Valitse sama ilmentymä" +"select.sameProperties" = "Valitse samat ominaisuudet" +"select.sameStroke" = "Valitse sama reunaviiva" +"select.sameText" = "Valitse samat tekstiominaisuudet" +"select.set" = "Valitse" +"selection.applyColorStyle" = "Käytä valinnan värityyliä" +"selection.colors" = "Valinnan värit" +"selection.selectColor" = "Valitse tämänväriset tasot" +"selection.setPaint" = "Muuta valinnan väriä" +"shape.arrow" = "Nuoli" +"shape.ellipse" = "Ellipsi" +"shape.line" = "Viiva" +"shape.polygon" = "Monikulmio" +"shape.rectangle" = "Suorakulmio" +"shape.star" = "Tähti" +"shapeBuilder.merge" = "Yhdistä alueet" +"shapeBuilder.mode" = "Muodonrakentimen tila" +"shapeBuilder.regions" = "Muodonrakentimen alueet" +"shapeBuilder.remove" = "Poista alueet" +"stroke.add" = "Lisää reunaviiva" +"stroke.brush" = "Sivellin" +"stroke.move" = "Siirrä reunaviiva" +"stroke.remove" = "Poista reunaviiva" +"stroke.set" = "Aseta reunaviiva" +"stroke.widthProfile" = "Leveysprofiili" +"style.apply" = "Käytä tyyliä" +"style.bind" = "Sido muuttuja tyylissä" +"style.create" = "Luo tyyli" +"style.delete" = "Poista tyyli" +"style.detach" = "Irrota tyyli" +"style.duplicate" = "Monista tyyli" +"style.list" = "Luettele tyylit" +"style.move" = "Siirrä tyylejä" +"style.renameFolder" = "Nimeä tyylikansio uudelleen" +"style.update" = "Muokkaa tyyliä" +"template.list" = "Luettele mallit" +"text.alignCenter" = "Keskitä" +"text.alignJustify" = "Tasaa molemmat reunat" +"text.alignLeft" = "Tasaa vasemmalle" +"text.alignRight" = "Tasaa oikealle" +"text.bold" = "Lihavointi" +"text.bulletList" = "Luettelomerkitty luettelo" +"text.case" = "Kirjainkoko" +"text.caseLower" = "Pienet kirjaimet" +"text.caseOriginal" = "Alkuperäinen kirjainkoko" +"text.caseSmallCaps" = "Kapiteelit" +"text.caseTitle" = "Sanojen isot alkukirjaimet" +"text.caseUpper" = "Isot kirjaimet" +"text.create" = "Teksti" +"text.decoration" = "Koristelu" +"text.edit.begin" = "Muokkaa tekstiä" +"text.edit.caret" = "Aseta kohdistin" +"text.edit.end" = "Lopeta tekstin muokkaus" +"text.edit.insert" = "Kirjoita tekstiä" +"text.edit.key" = "Tekstinäppäin" +"text.edit.preedit" = "Koosta tekstiä (IME)" +"text.edit.selectLine" = "Valitse rivi" +"text.edit.selectWord" = "Valitse sana" +"text.edit.selection" = "Tekstivalinta" +"text.endPreview" = "Lopeta tekstin esikatselu" +"text.fonts" = "Luettele fontit" +"text.italic" = "Kursivointi" +"text.lineHeightDown" = "Pienennä rivikorkeutta" +"text.lineHeightUp" = "Suurenna rivikorkeutta" +"text.link" = "Luo linkki" +"text.listIndent" = "Sisennä luettelo" +"text.listOutdent" = "Vähennä luettelon sisennystä" +"text.listStyle" = "Luettelotyyli" +"text.maxLines" = "Rivien enimmäismäärä" +"text.missingFonts" = "Puuttuvat fontit" +"text.numberList" = "Numeroitu luettelo" +"text.onPath" = "Teksti polulla" +"text.paragraphIndent" = "Kappaleen sisennys" +"text.paragraphSpacing" = "Kappaleväli" +"text.pathOptions" = "Tekstipolun asetukset" +"text.preview" = "Esikatsele tekstimuutosta" +"text.removeFromPath" = "Poista teksti polulta" +"text.set" = "Aseta teksti" +"text.setFeatures" = "OpenType-ominaisuudet" +"text.setVariations" = "Muuttuvan fontin akselit" +"text.sizeDown" = "Pienennä fonttikokoa" +"text.sizeUp" = "Suurenna fonttikokoa" +"text.strikethrough" = "Yliviivaus" +"text.style" = "Muotoile tekstialue" +"text.trackingDown" = "Pienennä merkkiväliä" +"text.trackingUp" = "Suurenna merkkiväliä" +"text.underline" = "Alleviivaus" +"text.weightDown" = "Vähennä fontin paksuutta" +"text.weightUp" = "Lisää fontin paksuutta" +"tool.arrow" = "Nuoli" +"tool.comment" = "Kommentti" +"tool.ellipse" = "Ellipsi" +"tool.frame" = "Kehys" +"tool.hand" = "Käsi" +"tool.line" = "Viiva" +"tool.move" = "Siirrä" +"tool.pen" = "Kynä" +"tool.pencil" = "Lyijykynä" +"tool.pencilBrush" = "Lyijykynäsivellin" +"tool.place" = "Sijoita työkalulla" +"tool.polygon" = "Monikulmio" +"tool.rectangle" = "Suorakulmio" +"tool.scale" = "Skaalaus" +"tool.scaleAnchor" = "Skaalauksen ankkuri" +"tool.section" = "Osio" +"tool.set" = "Valitse työkalu" +"tool.shapeBuilder" = "Muodonrakennin" +"tool.slice" = "Viipale" +"tool.star" = "Tähti" +"tool.text" = "Teksti" +"transform.move" = "Siirrä" +"transform.moveTo" = "Siirrä kohteeseen" +"transform.nudge" = "Siirrä askelittain" +"transform.nudgeSize" = "Muuta kokoa askelittain" +"transform.resize" = "Muuta kokoa" +"transform.rotate" = "Kierrä" +"transform.rotationOrigin" = "Siirrä kiertokeskipistettä" +"transform.scale" = "Skaalaus" +"transform.setRotationOrigin" = "Aseta kiertokeskipiste" +"variable.addMode" = "Lisää tila" +"variable.bind" = "Sido muuttuja" +"variable.create" = "Luo muuttuja" +"variable.createCollection" = "Luo muuttujakokoelma" +"variable.delete" = "Poista muuttuja" +"variable.deleteCollection" = "Poista kokoelma" +"variable.duplicate" = "Monista muuttuja" +"variable.export" = "Vie muuttujat" +"variable.import" = "Tuo muuttujat" +"variable.list" = "Luettele muuttujat" +"variable.moveMode" = "Siirrä tila" +"variable.removeMode" = "Poista tila" +"variable.renameCollection" = "Nimeä kokoelma uudelleen" +"variable.renameMode" = "Nimeä tila uudelleen" +"variable.resetOverride" = "Palauta muuttujan ohitus" +"variable.set" = "Aseta muuttujan arvo" +"variable.setMode" = "Aseta muuttujan tila" +"variable.update" = "Muokkaa muuttujaa" +"vector.create" = "Vektori" +"vector.deleteHeal" = "Poista ja korjaa valinta" +"vector.edit.addVertex" = "Lisää piste" +"vector.edit.begin" = "Muokkaa vektoria" +"vector.edit.bend" = "Taivuta segmenttiä" +"vector.edit.bounds" = "Valittujen pisteiden rajat" +"vector.edit.connect" = "Yhdistä pisteet" +"vector.edit.cornerRadius" = "Pisteen kulmasäde" +"vector.edit.cut" = "Leikkaa polku" +"vector.edit.delete" = "Poista pisteet" +"vector.edit.end" = "Lopeta muokkaus" +"vector.edit.erase" = "Pyyhi" +"vector.edit.lasso" = "Valitse lassolla" +"vector.edit.mirroring" = "Kahvojen peilaus" +"vector.edit.moveHandles" = "Siirrä kahvoja" +"vector.edit.moveVertices" = "Siirrä pisteitä" +"vector.edit.nudge" = "Siirrä pisteitä askelittain" +"vector.edit.paint" = "Maalipurkki" +"vector.edit.pen" = "Kynän piste" +"vector.edit.penEnd" = "Päätä kynäpolku" +"vector.edit.position" = "Pisteen sijainti" +"vector.edit.resize" = "Muuta pisteiden kokoa" +"vector.edit.rotate" = "Kierrä pisteitä" +"vector.edit.select" = "Valitse pisteitä" +"vector.edit.selectAll" = "Valitse kaikki pisteet" +"vector.edit.setHandle" = "Aseta kahva" +"vector.edit.smooth" = "Pehmennä tai terävöitä pisteitä" +"vector.edit.state" = "Vektorimuokkauksen tila" +"vector.edit.strokeCap" = "Pisteen viivanpää" +"vector.edit.strokeJoin" = "Pisteen viivaliitos" +"vector.edit.tool" = "Vektorimuokkauksen työkalu" +"vector.edit.transform" = "Muunna pisteitä" +"vector.edit.widthPoint" = "Leveyspiste" +"vector.flatten" = "Yhdistä vektorimuodoksi" +"vector.join" = "Yhdistä valinta" +"vector.offset" = "Siirrä polun reunaa" +"vector.outlineStroke" = "Muunna reunaviiva poluksi" +"vector.reverse" = "Käännä polun suunta" +"vector.simplify" = "Yksinkertaista vektori" +"vector.smoothJoin" = "Yhdistä valinta pehmeästi" +"version.autosave" = "Tallenna versio automaattisesti" +"version.delete" = "Poista versio" +"version.duplicate" = "Monista versio" +"version.exitPreview" = "Takaisin nykyiseen versioon" +"version.list" = "Luettele versiot" +"version.preview" = "Katsele versiota" +"version.rename" = "Nimeä versio uudelleen" +"version.restore" = "Palauta versio" +"view.assets" = "Resurssipaneeli" +"view.comments" = "Kommentit" +"view.contextMenu" = "Näytä pikavalikko" +"view.design" = "Suunnittelupaneeli" +"view.designPrototype" = "Vaihda suunnittelun ja prototyypin välillä" +"view.devMode" = "Kehittäjätila" +"view.frameOutlines" = "Kehysten ääriviivat" +"view.fullscreen" = "Koko näyttö" +"view.get" = "Hae näkymä" +"view.layers" = "Tasopaneeli" +"view.layoutGrids" = "Asetteluruudukot" +"view.leftSidebar" = "Näytä/kätke vasen sivupalkki" +"view.maskOutlines" = "Maskien ääriviivat" +"view.multiplayerCursors" = "Yhteiskäytön kohdistimet" +"view.nextFrame" = "Zoomaa seuraavaan kehykseen" +"view.nextPage" = "Seuraava sivu" +"view.nextRegion" = "Kohdista seuraava alue" +"view.outlines" = "Ääriviivat" +"view.outlinesHiddenLayers" = "Sisällytä kätketyt tasot ääriviivoihin" +"view.outlinesObjectBounds" = "Sisällytä objektien rajat ääriviivoihin" +"view.outlinesSelection" = "Valinnan ääriviivat" +"view.pan" = "Siirrä näkymää" +"view.panelWidths" = "Palauta paneelien leveydet" +"view.pixelGrid" = "Pikseliruudukko" +"view.pixelPreview" = "Pikseliesikatselu" +"view.prevFrame" = "Zoomaa edelliseen kehykseen" +"view.prevPage" = "Edellinen sivu" +"view.previousRegion" = "Kohdista edellinen alue" +"view.proofSrgb" = "sRGB-vedossimulointi" +"view.prototype" = "Prototyyppipaneeli" +"view.rulers" = "Viivaimet" +"view.set" = "Aseta näkymä" +"view.slices" = "Viipaleet" +"view.snapGeometry" = "Tartu geometriaan" +"view.snapObjects" = "Tartu objekteihin" +"view.snapPixel" = "Tartu pikseliruudukkoon" +"view.themeDark" = "Tumma teema" +"view.themeLight" = "Vaalea teema" +"view.themeSystem" = "Seuraa järjestelmän teemaa" +"view.toggleUi" = "Näytä/kätke käyttöliittymä" +"view.zoom100" = "Zoomaa kokoon 100 %" +"view.zoom200" = "Zoomaa kokoon 200 %" +"view.zoom50" = "Zoomaa kokoon 50 %" +"view.zoomAt" = "Zoomaustyökalu" +"view.zoomFit" = "Sovita näkymään" +"view.zoomIn" = "Lähennä" +"view.zoomOut" = "Loitonna" +"view.zoomSelection" = "Zoomaa valintaan" +"widget.click" = "Napsauta pienoisohjelmaa" +"widget.insert" = "Lisää pienoisohjelma" +"widget.list" = "Luettele pienoisohjelmat" +"window.focus" = "Tuo ikkuna etummaiseksi" +"window.new" = "Uusi ikkuna" + +[menu] +"bool.exclude" = "Jätä pois" +"bool.flatten" = "Yhdistä vektorimuodoksi" +"bool.intersect" = "Leikkaus" +"bool.subtract" = "Vähennä" +"bool.union" = "Yhdiste" +"file.duplicate" = "Monista" +"file.import" = "Tuo tiedostosta…" +"file.rename" = "Nimeä uudelleen" +"libraries.open" = "Avaa kirjastot" +"select.sameEffect" = "Tehoste" +"select.sameFill" = "Täyttö" +"select.sameFont" = "Fontti" +"select.sameInstance" = "Ilmentymä" +"select.sameProperties" = "Ominaisuudet" +"select.sameStroke" = "Reunaviiva" +"select.sameText" = "Tekstiominaisuudet" +"text.alignCenter" = "Keskitä teksti" +"text.alignJustify" = "Tasaa tekstin molemmat reunat" +"text.alignLeft" = "Tasaa teksti vasemmalle" +"text.alignRight" = "Tasaa teksti oikealle" +"view.assets" = "Resurssit" +"view.design" = "Suunnittelu" +"view.layers" = "Tasot" +"view.outlines" = "Näytä ääriviivat" +"view.outlinesHiddenLayers" = "Sisällytä kätketyt tasot" +"view.outlinesObjectBounds" = "Sisällytä objektien rajat" +"view.outlinesSelection" = "Näytä valinnan ääriviivat" +"view.prototype" = "Prototyyppi" +"view.themeDark" = "Tumma" +"view.themeLight" = "Vaalea" +"view.themeSystem" = "Seuraa järjestelmää" diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index d4d81d7b..ea85ad32 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -8,6 +8,46 @@ use super::*; /// Languages that must translate every UI string and command label. const COMPLETE: &[&str] = &["es"]; +#[test] +fn finnish_locale_tags_and_integer_plurals() { + let fi = resolve("fi", None); + assert_eq!(LANGS[fi].code, "fi"); + assert_eq!(LANGS[fi].name, "Suomi"); + assert!(LANGS[fi].draft, "AI-assisted Finnish still needs human review"); + let mut session = figcraft_engine::Session::new(); + session.execute("pref.language", &serde_json::json!({"language":"fi"})).unwrap(); + let saved = serde_json::to_string(&session.prefs).unwrap(); + let restored: figcraft_engine::Prefs = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, "fi"); + for locale in ["fi-FI", "fi_FI.UTF-8", "FI-fi", "fi"] { + assert_eq!(resolve("system", Some(locale)), fi, "{locale}"); + } + for (count, expected) in [(0, "0 tasoa"), (1, "1 taso"), (2, "2 tasoa"), (21, "21 tasoa"), (101, "101 tasoa")] { + assert_eq!(fill(counted(fi, "{n} layers", count), &[("n", &count)]), expected); + } + assert_eq!(fill(counted(fi, "{n} minutes ago", 1), &[("n", &1)]), "1 minuutti sitten"); + assert_eq!(fill(counted(fi, "{n} minutes ago", 2), &[("n", &2)]), "2 minuuttia sitten"); + assert_eq!(fill(counted(fi, "{n} people joined", 2), &[("n", &2)]), "2 henkilöä liittyi"); +} + +#[test] +fn finnish_keeps_interpolated_names_and_context_specific_labels() { + let fi = resolve("fi", None); + let text = |key| match lookup(fi, key).unwrap() { + Text::Plain(s) => *s, + _ => panic!("expected a plain Finnish label: {key}"), + }; + assert_eq!(text("Cancel"), "Peruuta"); + assert_eq!(text("Fill"), "Täyttö"); + assert_eq!(text("Fill|sizing"), "Täytä"); + assert_eq!(text("Join|stroke"), "Liitos"); + assert_eq!(text("Open|comments"), "Avoin"); + assert_eq!(fill(text("Actions for {name}"), &[("name", &"File {1}.figcraft")]), "Kohteen File {1}.figcraft toiminnot"); + assert!(lookup(fi, "My layer {name}.svg").is_none()); + assert!(matches!(find(LANGS[fi].commands, "edit.undo"), Some(Text::Plain("Kumoa")))); + assert!(matches!(find(LANGS[fi].menu, "file.import"), Some(Text::Plain("Tuo tiedostosta…")))); +} + /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. fn dynamic_keys() -> BTreeSet { diff --git a/docs/translating.md b/docs/translating.md index 1ea1b94f..3700bab4 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -68,6 +68,18 @@ language also needs its script in `cjk::language`. ## Adding a language +Finnish (`fi`, Suomi) is an AI-assisted draft awaiting native-speaker review. Its initial catalog +covers the current 1,729 English UI keys, 567 command labels and 31 menu-specific wordings, including +42 plural entries. Counts use singular only for 1 (`1 taso`, `0/2/21 tasoa`); this already follows +the default integer rule. Context keys distinguish paint `Fill` (Täyttö), sizing `Fill|sizing` +(Täytä), stroke `Join|stroke` (Liitos) and comment status `Open|comments` (Avoin). +The build discovers `fi.toml` automatically; `fi-FI` and `fi_FI.UTF-8` resolve to it. Product names, +code identifiers and shortcut notation retain their identity. Like the other unreviewed catalogs, +Finnish remains a draft outside `COMPLETE`; future English additions may fall back until translated. +Noise modes Mono/Duo/Multi describe one, two or many grain colours and read Yksivärinen, +Kaksivärinen and Monivärinen, independently of their stable enum names. +The narrow glass-effect light-intensity field uses Vahvuus so its complete label fits beside the value. + 1. Copy `crates/ui-egui/locales/es.toml` to `.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and set `[meta] name` and `status = "draft — needs native review"`. 2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it, From e35f8eb1b78f7f30355509144e4e4f0d4a2f1905 Mon Sep 17 00:00:00 2001 From: Ashsajal Date: Sun, 18 Oct 2026 14:15:56 +0600 Subject: [PATCH 224/245] M8: wait for a recovery folder lock that frees itself (#334) A child process forked before its exec inherits the run's open descriptors, and a flock lasts while any descriptor for the file is open. A crashed run whose folder lock was still held by such a child read as a run that was still going, so recover_all skipped it and its unsaved documents were not opened at the next start. The desktop app's tests failed intermittently for the same reason whenever another test spawned a process in parallel (the reduced-motion read spawns gsettings on Linux and the BSDs): recovery_tests failed in 23 of 60 runs of the whole binary and never on its own. Taking a folder's lock now retries try_lock briefly, so a lock that frees itself is no longer read as a live run while a run that really is running still keeps its folder. Regression test: a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going holds the crashed run's lock on a second descriptor, the way a forked child does, and frees it shortly after; the copies come back. The recovery tests that collide with a spawning test went from 8 failures in 10 runs to 0 in 10. --- apps/figcraft/src/recovery.rs | 29 +++++++++++++++++++++++------ apps/figcraft/src/recovery_tests.rs | 22 ++++++++++++++++++++++ docs/hardware-parity.md | 1 + 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 62243bc7..0ad97f0d 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -120,8 +120,7 @@ impl Recovery { (0..10).find_map(|n| { let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") }); std::fs::create_dir_all(&dir).ok()?; - let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?; - lock.try_lock().ok()?; + let lock = lock_briefly(&dir.join(LOCK))?; Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) }) } @@ -293,6 +292,27 @@ fn remove_kept(w: Waiting) { /// The extension of the note beside the copy of a document that is one of your online files. const ONLINE: &str = "online"; +/// How long a folder's lock that frees itself is waited for, and between the tries. +const LOCK_WAIT: (usize, std::time::Duration) = (20, std::time::Duration::from_millis(5)); + +/// Lock the folder's `file`, waiting briefly for a lock that frees itself. A child between its +/// `fork` and its `exec` inherits the run's open descriptors, and a `flock` lasts while any +/// descriptor for the file is open, so a run that has ended can look like one that is still going +/// for the length of that window. A run that really is running holds the lock for as long as it +/// runs, and we give up on it after [`LOCK_WAIT`] (#334). +fn lock_briefly(file: &Path) -> Option { + let lock = File::options().write(true).create(true).truncate(false).open(file).ok()?; + let (tries, pause) = LOCK_WAIT; + for _ in 0..tries { + match lock.try_lock() { + Ok(()) => return Some(lock), + Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(pause), + Err(_) => return None, + } + } + None +} + /// The copy at `file` is of one of your online files: join that file again as its owner, keeping /// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its /// file isn't recorded any more, or is open already): then it opens as any other copy. @@ -369,10 +389,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { continue; } // Still running (another window of the app), or not ours to take. - let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue }; - if lock.try_lock().is_err() { - continue; - } + let Some(lock) = lock_briefly(&path.join(LOCK)) else { continue }; let lock = Arc::new(lock); let files: Vec = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect(); for f in &files { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 8f384b87..c05e86d0 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -203,6 +203,28 @@ fn a_run_whose_folder_name_is_still_held_takes_another() { let _ = std::fs::remove_dir_all(&root); } +/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and +/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the +/// file is open, so a crashed run's copies must still come back once that child has exec'd. +#[test] +fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() { + let root = root("fork-window"); + crashed(&edited(), &root); + // Hold the crashed run's folder lock on a second descriptor, the way a child forked before its + // exec holds it, and free it shortly after. + let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap(); + lock.try_lock().unwrap(); + let freed = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(40)); + drop(lock); + }); + // The next start waits for that lock rather than reading the run as still going. + let mut next = Session::empty(); + assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself"); + freed.join().unwrap(); + let _ = std::fs::remove_dir_all(&root); +} + /// The rectangles in `doc`. fn rectangles(doc: &figcraft_engine::doc::Document) -> usize { doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count() diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index df822981..4780a0a8 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Recovery copies and the fork/exec window (#334): a child process forked before its `exec` (the `gsettings` read of the reduced-motion setting, any spawn) inherits the running app's open recovery-folder lock, and a `flock` lasts while any descriptor for the file is open. A crashed run whose lock was still held by such a child therefore read as "still running" and its unsaved documents were not recovered at the next start, and the desktop app's tests failed intermittently whenever another test spawned a process in parallel. Taking a folder's lock now waits briefly for one that frees itself, so only a run that is really running keeps its folder. No numbers changed | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | From ef535e49b02f44735e0cf13ced886131b2aa21b8 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 18 Oct 2026 15:52:30 +0700 Subject: [PATCH 225/245] M1: REST import keeps slot layers and instances' slot content (#792) (#796) Fixes #792. Fixes #792. --- crates/engine/src/lib.rs | 2 + crates/engine/src/rest_slot_tests.rs | 91 +++++++++++ crates/format/src/rest/mod.rs | 112 ++++++++++++- crates/format/src/rest/slot_tests.rs | 229 +++++++++++++++++++++++++++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + docs/file-format-parity.md | 5 +- 7 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/rest_slot_tests.rs create mode 100644 crates/format/src/rest/slot_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fedcffc9..c32a99bb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1744,6 +1744,8 @@ mod proto_run_tests; #[cfg(test)] mod proto_tail_tests; #[cfg(test)] +mod rest_slot_tests; +#[cfg(test)] mod retained_walk_tests; #[cfg(test)] mod rotated_guides_tests; diff --git a/crates/engine/src/rest_slot_tests.rs b/crates/engine/src/rest_slot_tests.rs new file mode 100644 index 00000000..e272c3a7 --- /dev/null +++ b/crates/engine/src/rest_slot_tests.rs @@ -0,0 +1,91 @@ +//! Slots opened from REST file JSON: instances keep their own slot content through edits and +//! moves of the mains, and through saving. + +use crate::Session; +use crate::components::tests::{ex, healthy, idv}; +use crate::components::*; +use base64::Engine as _; +use figcraft_doc::*; +use serde_json::{Value, json}; + +fn at(x: f64, y: f64) -> Value { + json!([[1, 0, x], [0, 1, y]]) +} + +fn text(id: &str, s: &str) -> Value { + json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}}) +} + +/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot +/// holds a text and a chip. +fn file() -> Value { + let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids}); + let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}}); + let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, + "children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]}); + json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + {"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [ + {"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}} + ]}, + {"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160}, + "componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}}, + "children": [text("1:4", "Title"), slot("1:5", json!([]))]}, + {"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160}, + "componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}}, + "children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]} + ]}]}}) +} + +fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId { + doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}")) +} + +/// The instance's slot and the names of what it holds. +fn slot_content(s: &Session) -> (NodeId, Vec) { + let doc = s.doc().unwrap(); + let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container"); + let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot"); + let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect(); + (slot, names) +} + +#[test] +fn rest_slot_content_survives_main_edits_moves_and_saving() { + let mut s = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap()); + ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"})); + healthy(&s); + let want = vec!["Reports in progress".to_string(), "Chip".to_string()]; + let (slot, names) = slot_content(&s); + assert_eq!(names, want); + let doc = s.doc().unwrap(); + assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot")); + assert!(slot_edited(doc, slot), "the instance's own content"); + let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip"); + assert!(!inside_instance(doc, chip), "content stands alone"); + + // Edit and move both mains (the container's and the one used in the content). + let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container"); + let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip"); + let title = doc.node(main).unwrap().children[0]; + ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}})); + ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}})); + let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"}))); + ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder})); + healthy(&s); + assert_eq!(slot_content(&s).1, want, "kept across syncs"); + + // Saving and opening again keeps it. + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + let mut again = Session::new(); + ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"})); + healthy(&again); + let (slot, names) = slot_content(&again); + assert_eq!(names, want); + assert!(slot_edited(again.doc().unwrap(), slot)); + + // Resetting the slot shows the main's (empty) default content. + ex(&mut again, "instance.resetSlot", json!({"id": slot})); + assert!(slot_content(&again).1.is_empty()); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 232afc25..78279e91 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -6,8 +6,8 @@ //! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects, //! corners, constraints, auto layout, layout grids, export settings, text with styled runs, //! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and -//! component properties are imported. Image fills keep their `imageRef` as the image key and -//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched). +//! component properties (slots included) are imported. Image fills keep their `imageRef` as the +//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched). //! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows, //! scrolling and the prototype device. Variables come from the local variables response //! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately @@ -37,6 +37,8 @@ pub(crate) mod proto; #[cfg(test)] mod proto_variable_tests; #[cfg(test)] +pub(crate) mod slot_tests; +#[cfg(test)] mod tests; mod text; mod variables; @@ -381,6 +383,10 @@ pub(crate) struct Ctx<'a> { style_meta: BTreeMap<&'a str, &'a Value>, /// Instances (imported as frames until their main is resolved) with their JSON. instances: Vec<(NodeId, &'a Value)>, + /// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON. + slots: Vec<(NodeId, &'a Value)>, + /// Slot property defaults (the main's slot content id): (component or set, property) → value. + slot_defaults: BTreeMap<(NodeId, String), &'a Value>, /// Instance-swap property defaults: (component or set, property, REST id of the default). swap_defaults: Vec<(NodeId, String, String)>, /// Property definitions of component sets, shared by their variants. @@ -415,6 +421,8 @@ impl<'a> Ctx<'a> { set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), instances: Vec::new(), + slots: Vec::new(), + slot_defaults: BTreeMap::new(), swap_defaults: Vec::new(), set_defs: BTreeMap::new(), flows: Vec::new(), @@ -515,8 +523,10 @@ impl<'a> Ctx<'a> { sources.record(id, j, primary); } } - if ty == "INSTANCE" { - self.instances.push((id, j)); + match ty { + "INSTANCE" => self.instances.push((id, j)), + "SLOT" => self.slots.push((id, j)), + _ => {} } let kids = children(j); if kids.is_empty() { @@ -539,8 +549,8 @@ impl<'a> Ctx<'a> { let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32); let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty(); Some(match ty { - // Instances start as frames; `finish` links them to their main. - "FRAME" | "INSTANCE" => NodeKind::Frame, + // Instances and slots start as frames; `finish` links them to their main and property. + "FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame, "GROUP" => NodeKind::Group, "SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) }, "RECTANGLE" => NodeKind::Rectangle, @@ -751,7 +761,7 @@ impl<'a> Ctx<'a> { } /// Component property definitions (variant ones are implied by the variant names). - fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap { + fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap { let mut out = BTreeMap::new(); for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) { let default = d.get("defaultValue"); @@ -765,7 +775,13 @@ impl<'a> Ctx<'a> { continue; } Some("VARIANT") => continue, - Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() }, + Some("SLOT") => { + if let Some(d) = default { + self.slot_defaults.insert((owner, name.clone()), d); + } + self.slot_notes(d); + PropDef::Slot { preferred: Vec::new() } + } _ => { self.warn("unknown component property types were skipped"); continue; @@ -776,6 +792,20 @@ impl<'a> Ctx<'a> { out } + /// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys + /// of library components, not layers of the file) and limits on its content. + fn slot_notes(&mut self, d: &Value) { + if !list(d, "preferredValues").is_empty() { + self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped"); + } + // Insertion and empty-slot display only change editing aids, not what the file shows. + let s = d.get("slotSettings"); + let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0); + if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) { + self.warn("non-default slot settings are not represented and were skipped"); + } + } + /// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of /// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in /// the file's order. Layers then link to them; styles that no layer uses come across too. @@ -984,6 +1014,7 @@ impl<'a> Ctx<'a> { /// outside components, and instances that would nest a component inside itself (directly or /// through other components), stay frames. fn resolve_instances(&mut self) { + let frames: BTreeSet = self.instances.iter().map(|(i, _)| *i).collect(); let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new(); for (inst, j) in std::mem::take(&mut self.instances) { match text(j, "componentId").and_then(|r| self.component(r)) { @@ -1013,6 +1044,7 @@ impl<'a> Ctx<'a> { linked.push((inst, j)); } } + self.resolve_slots(&frames, &linked); // Overridden fields the file lists, on the instance and its layers. for (inst, j) in linked { for o in list(j, "overrides") { @@ -1028,6 +1060,70 @@ impl<'a> Ctx<'a> { } } + /// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer + /// references, else the one named like it, else the main's only slot property left. In an + /// instance it follows its main's slot, and holds the instance's own content (kept across syncs) + /// unless the instance's value of the property is the main's default content (without the + /// values to compare, a slot holding layers keeps them as its own). A slot without a property + /// stays a frame. `frames`: every instance + /// layer of the file, `linked`: those that became instances. + fn resolve_slots(&mut self, frames: &BTreeSet, linked: &[(NodeId, &'a Value)]) { + let linked: BTreeMap = linked.iter().copied().collect(); + let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new(); + for (id, j) in std::mem::take(&mut self.slots) { + // The nearest main component or instance above the slot owns it; in an instance whose + // main isn't in the file (a frame), the slot stays a frame too. + let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))), + Some(NodeKind::Component { .. }) => Some(Some((a, None))), + _ => frames.contains(&a).then_some(None), + }); + let Some(owner) = owner else { + self.warn("slot layers without a matching slot property were imported as frames"); + continue; + }; + let Some((main, inst)) = owner else { continue }; + let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue }; + let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect(); + let layer = text(j, "name").unwrap_or_default(); + let refs: Vec<&str> = + j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect(); + // An instance's slot is matched against all of its main's slot properties. + let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect(); + let name = defs + .iter() + .find(|d| refs.contains(&d.as_str())) + .or_else(|| defs.iter().find(|d| short_prop_name(d) == layer)) + .or_else(|| if free.len() == 1 { free.first().copied() } else { None }) + .map(|d| (*d).clone()); + let Some(n) = self.doc.node(id) else { continue }; + let has_content = !n.children.is_empty(); + let Some(inst) = inst else { + match name.filter(|p| bound.insert((main, p.clone()))) { + Some(p) => { + if let Some(n) = self.doc.node_mut(id) { + n.prop_refs.insert("slot".into(), p); + } + } + None => self.warn("slot layers without a matching slot property were imported as frames"), + } + continue; + }; + let set = self.doc.node(main).and_then(|n| n.parent); + let default = + name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied())); + let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value")); + let own = name.is_some() + && match (value, default) { + (Some(v), Some(d)) => v != d, + _ => has_content, + }; + if own && let Some(n) = self.doc.node_mut(id) { + n.overrides.insert("slot".into()); + } + } + } + /// An instance's property values (variant values are implied by the main it shows). fn instance_props(&self, j: &Value) -> BTreeMap { let mut out = BTreeMap::new(); diff --git a/crates/format/src/rest/slot_tests.rs b/crates/format/src/rest/slot_tests.rs new file mode 100644 index 00000000..d85d15c6 --- /dev/null +++ b/crates/format/src/rest/slot_tests.rs @@ -0,0 +1,229 @@ +//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and +//! holding each instance's own content (synthetic files). + +use super::*; +use serde_json::json; + +fn at(x: f64, y: f64) -> Value { + json!([[1, 0, x], [0, 1, y]]) +} + +pub(crate) fn file(children: Value) -> Value { + json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": children} + ]}}) +} + +fn content_id(local: u32) -> Value { + json!({"guid": {"sessionID": 5, "localID": local}}) +} + +fn slot(id: &str, name: &str, children: Value) -> Value { + json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children}) +} + +fn label(id: &str, text: &str) -> Value { + json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}}) +} + +/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty +/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them. +pub(crate) fn container_file(more: Vec) -> Value { + let mut layers = vec![ + json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [ + {"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}} + ]}), + json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160}, + "componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}}, + "children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}), + ]; + layers.extend(more); + file(Value::Array(layers)) +} + +/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`). +pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value { + json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160}, + "componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}}, + "children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]}) +} + +pub(crate) fn chip(id: &str) -> Value { + json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [ + {"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}} + ]}) +} + +fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node { + // Names repeat across copies: find by position in the file's tree instead. + fn path(v: &Value, rest: &str, out: &mut Vec) -> bool { + if v.get("id").and_then(Value::as_str) == Some(rest) { + return true; + } + for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() { + out.push(i); + if path(c, rest, out) { + return true; + } + out.pop(); + } + false + } + let mut p = Vec::new(); + assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file"); + let mut id = doc.pages[0]; + for i in p { + id = doc.node(id).unwrap().children[i]; + } + doc.node(id).unwrap() +} + +fn names(doc: &Document, n: &Node) -> Vec { + n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect() +} + +#[test] +fn slot_layers_bind_to_their_property_and_keep_instance_content() { + let v = container_file(vec![ + container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])), + container("3:1", 800.0, content_id(1), json!([])), + ]); + let (doc, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}"); + + let main = by_rest(&doc, &v, "1:2"); + let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) }; + assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix"); + let main_slot = by_rest(&doc, &v, "1:5"); + assert!(matches!(main_slot.kind, NodeKind::Frame)); + assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot")); + assert!(main_slot.children.is_empty(), "the main's slot stays empty"); + + let filled = by_rest(&doc, &v, "I2:1;1:5"); + assert!(filled.overrides.contains("slot"), "the instance's own content"); + assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's"); + assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]); + let chip = by_rest(&doc, &v, "2:3"); + assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main"); + + let default = by_rest(&doc, &v, "I3:1;1:5"); + assert!(!default.overrides.contains("slot"), "the main's default content follows the main"); +} + +#[test] +fn slot_ownership_without_values_follows_the_content() { + let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")])); + owned["componentProperties"] = json!({}); + let mut empty = container("3:1", 800.0, json!(null), json!([])); + empty["componentProperties"] = json!({}); + let v = container_file(vec![owned, empty]); + let (doc, _) = import(&v, &no_images).unwrap(); + assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot")); + assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot")); +} + +#[test] +fn slots_match_by_reference_then_name_then_the_one_left() { + let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300}, + "componentPropertyDefinitions": { + "Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)}, + "Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)}, + "Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)}, + "Visible#1:6": {"type": "BOOLEAN", "defaultValue": true} + }, + "children": [ + {"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"}, + "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}}, + {"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}}, + {"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}} + ]}); + let v = file(json!([comp])); + let (doc, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned(); + assert_eq!(bound("1:7").as_deref(), Some("Header")); + assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible")); + assert_eq!(bound("1:8").as_deref(), Some("Body")); + assert_eq!(bound("1:9").as_deref(), Some("Footer")); +} + +#[test] +fn variants_share_their_set_slot_property() { + let variant = |id: &str, name: &str, x: f64| { + json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100}, + "children": [slot(&format!("{id}s"), "Content", json!([]))]}) + }; + let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100}, + "componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}}, + "children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]}); + let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100}, + "componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}}, + "children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]}); + let v = file(json!([set, inst])); + let (doc, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + for rest in ["1:2s", "1:3s"] { + assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}"); + } + assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot")); +} + +#[test] +fn nested_instances_own_the_slots_of_their_main() { + // A "Screen" main places a Container whose slot holds a chip; an instance of Screen places + // other content in that nested slot. + let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160}, + "children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]}); + let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")])); + let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160}, + "children": [nested]}); + let v = container_file(vec![screen, outer]); + let (doc, _) = import(&v, &no_images).unwrap(); + let in_main = by_rest(&doc, &v, "I6:2;1:5"); + assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty()); + assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. })); + let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5"); + assert!(in_outer.overrides.contains("slot")); + assert_eq!(names(&doc, in_outer), ["Other"]); +} + +#[test] +fn slots_outside_components_or_without_a_property_stay_frames() { + let mut no_def = container_file(Vec::new()); + no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions"); + let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")])); + no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose); + let (doc, warnings) = import(&no_def, &no_images).unwrap(); + assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}"); + let kept = by_rest(&doc, &no_def, "8:1"); + assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty()); + assert_eq!(names(&doc, kept), ["Kept"]); + assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty()); +} + +#[test] +fn slot_settings_the_editor_cannot_keep_are_noted() { + let mut v = container_file(Vec::new()); + v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] = + json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3}); + v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] = + json!([{"type": "COMPONENT", "key": "abc"}]); + let (_, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}"); + assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}"); + let mut plain = container_file(Vec::new()); + plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] = + json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false}); + assert!(import(&plain, &no_images).unwrap().1.is_empty()); +} + +#[test] +fn junk_slot_json_never_panics() { + for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] { + let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]); + v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] = + json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()}); + v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk}); + assert!(import(&v, &no_images).is_ok()); + } +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c015e1b5..17f6b4c5 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1625,6 +1625,7 @@ name = "English" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" "slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content" "slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped" +"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" "stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 47917267..d054e222 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1626,6 +1626,7 @@ status = "draft — needs native review" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" "slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura" "slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió" +"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa" "stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas" diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b65f7729..adf0b0f2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -19,7 +19,7 @@ and prototype interactions are still unchecked there. |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | -| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | +| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | From 13fdae3d44ea27780c2e273d4aa65d9b3379b59c Mon Sep 17 00:00:00 2001 From: Ashsajal Date: Sun, 18 Oct 2026 14:55:12 +0600 Subject: [PATCH 226/245] M9: the other item lists merge one by item when a branch merges (gap 17) Fills, strokes and effects already merged item by item, so both sides changing different items of one of those lists was no conflict. Every other list conflicted as a whole: both sides editing different export settings, or different guides, or different prototype interactions, or different accessibility annotations or Dev Mode links, each had to be resolved by hand for the whole list, though the items stand on their own. Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources now merge item by item, with the same rule: the same item changed both ways, an item removed on one side, or different items added on both sides still conflict. Properties that are keyed maps rather than item lists (bound, varModes, overrides, propRefs, pluginData) are deliberately left out: their items line up by key, not by position. Text runs are left whole too: they live in the node's text data, so merging them item by item means splitting a property the change format keeps whole. Replaces the test that documented the gap (other_lists_still_conflict_as_a_whole) with one that checks each new list merges, and adds one for the same item changed both ways still conflicting. --- crates/collab/src/merge.rs | 27 +++++++++--- crates/collab/src/merge_list_tests.rs | 61 +++++++++++++++++++++++---- docs/collaboration-parity.md | 5 ++- docs/gaps.md | 11 ++++- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index bb18e661..381b4393 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -4,10 +4,12 @@ //! //! A conflict is the same property (or document-level entry) changed on both sides to different //! values, or a layer deleted on one side while the other side edited it or something inside it -//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects -//! merge item by item: both sides changing different items of the list (or one adding items at its -//! end) is no conflict, only the same item changed both ways or the list shortened on a side -//! while the other changed it. Nothing that conflicts is +//! (including adding layers to it or moving layers into or out of it). The layer lists whose items +//! stand on their own (fills, strokes, effects, export settings, guides, prototype interactions, +//! accessibility annotations and the Dev Mode links and resources) merge item by item: both sides +//! changing different items of the list (or one adding items at its end) is no conflict, only the +//! same item changed both ways or the list shortened on a side while the other changed it. Nothing +//! that conflicts is //! merged until it is resolved: each conflict is resolved for one side, and the other side's //! changes to it are left out. Everything else from both sides is kept. @@ -159,8 +161,21 @@ fn by_key(changes: &[Change]) -> BTreeMap> { out } -/// List properties whose items merge one by one. -const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects]; +/// List properties whose items merge one by item: the items stand on their own, so both sides +/// changing different ones (or one side adding at its end) is no conflict. Properties that are +/// keyed maps rather than item lists (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) +/// are not here: their items line up by key, not by position. +const PER_ITEM: &[Prop] = &[ + Prop::Fills, + Prop::Strokes, + Prop::Effects, + Prop::Exports, + Prop::Guides, + Prop::Interactions, + Prop::Annotations, + Prop::CodeLinks, + Prop::DevResources, +]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base /// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict. diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs index 436e6b6f..8b237145 100644 --- a/crates/collab/src/merge_list_tests.rs +++ b/crates/collab/src/merge_list_tests.rs @@ -1,5 +1,7 @@ -//! Fills, strokes and effects merge item by item: different items changed on each side merge, -//! the same item changed both ways conflicts. +//! The layer lists whose items stand on their own (fills, strokes, effects, export settings, +//! guides, prototype interactions, accessibility annotations, Dev Mode links and resources) merge +//! item by item: different items changed on each side merge, the same item changed both ways +//! conflicts. use figcraft_color::{Color, Paint}; use figcraft_doc::{Document, Effect, NodeId, NodeKind}; @@ -100,19 +102,60 @@ fn the_same_item_changed_both_ways_conflicts() { } #[test] -fn other_lists_still_conflict_as_a_whole() { - // Docs silent: lists other than fills, strokes and effects (export settings here) still - // conflict whole when both sides change them. +fn the_other_item_lists_merge_one_by_one_too() { + // Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode + // links and resources are item lists like fills: both sides changing different items merges. let (mut base, _, _, r) = files(); let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }; - base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")]; + let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset }; + let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None }; + let link = + |name: &str| figcraft_doc::CodeLink { language: "react".into(), import_path: String::new(), name: name.into(), template: String::new() }; + let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![export("@1"), export("@2")]; + n.guides = vec![guide(true, 10.0), guide(false, 20.0)]; + n.annotations = vec![annot("a1"), annot("a2")]; + n.code_links = vec![link("L1"), link("L2")]; + n.dev_resources = vec![res("https://a.test"), res("https://b.test")]; let (mut branch, mut main) = (base.clone(), base.clone()); branch.set_client_id(base.highest_id_space() + 1); - branch.get_mut(r).unwrap().exports[0] = export("@b"); - main.get_mut(r).unwrap().exports[1] = export("@m"); + // The branch changes the first item of each list; main the second. + let b = branch.get_mut(r).unwrap(); + (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.code_links[0].name, b.dev_resources[0].url) = + ("@b".into(), 11.0, "b1".into(), "LB".into(), "https://branch.test".into()); + let m = main.get_mut(r).unwrap(); + (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.code_links[1].name, m.dev_resources[1].url) = + ("@m".into(), 22.0, "m2".into(), "LM".into(), "https://main.test".into()); + both_ways(&base, &branch, &main, |doc| { + let n = doc.node(r).unwrap(); + let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect(); + assert_eq!(suffixes, ["@b", "@m"], "export settings"); + let offsets: Vec = n.guides.iter().map(|g| g.offset).collect(); + assert_eq!(offsets, [11.0, 22.0], "guides"); + let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect(); + assert_eq!(labels, ["b1", "m2"], "annotations"); + let names: Vec<&str> = n.code_links.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["LB", "LM"], "code links"); + let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect(); + assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources"); + }); +} + +#[test] +fn the_same_item_of_another_list_changed_both_ways_conflicts() { + let (mut base, _, _, r) = files(); + base.get_mut(r).unwrap().exports = vec![ + figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() }, + figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() }, + ]; + let (mut branch, mut main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + branch.get_mut(r).unwrap().exports[0].suffix = "@b".into(); + main.get_mut(r).unwrap().exports[0].suffix = "@m".into(); let p = plan(&base, &branch, &main).unwrap(); let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); - assert_eq!(keys, [format!("{r}/exports")]); + assert_eq!(keys, [format!("{r}/exports")], "the same item changed both ways still conflicts"); } #[test] diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index ae503633..920a9621 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | -| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | +| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes, effects, export settings, guides, prototype interactions, accessibility annotations and the Dev Mode links and resources merge item by item. Left: branches in live sessions; text runs conflict as a whole | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | | Notifications, activity | yes | no | with sharing | @@ -35,6 +35,9 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change (Enter sends, Escape takes it back, 100 characters, control characters dropped); your avatar's menu (or `collab.spotlight`) asks everyone to follow your view and page, latecomers included, until they move their own view. Both travel as signals: ephemeral presence the host validates, rate-limits (20 a second per person) and passes only to clients that announce they read them, so older clients keep working; never in the document or the log. Live cursors / follow / spotlight row partial → done, 8–15 h → 2–4 h; cursor chat row no → partial (no audio or reactions), 4–8 h → 2–5 h; multiplayer 45–90 h → 42–84 h (it counted these rows only in part); remaining 55–110 h (stale: team libraries had already gone to 4–8 h) → 46–92 h; collaboration ~13% unchanged at this rounding | +| Date | Change | Summary | +|---|---|---| +| 2026-10-18 | minor | Branch merge, gap 17: the layer lists whose items stand on their own merge item by item, not just fills, strokes and effects. Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources now merge item by item, each conflicting only when the same item changed on both sides, an item was removed on one side, or both sides added different items. Text runs are left whole (they live in the node's text data). Numbers unchanged | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..dd42a75b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -397,8 +397,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe different items changed on each side, or items added at the end on one side, merge without a conflict; only the same item changed both ways, an item removed on one side, or different items added on both sides conflict (as the whole list). -- **Missing:** branches of a file in a live session are refused; other lists (text runs, export - settings, guides…) still conflict as a whole; the branch history has no entries of its own for +- **Landed (2026-10-18):** the other layer lists whose items stand on their own merge item by item + too: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode + code links and resources, each with the same rule as fills (the same item changed both ways still + conflicts). Properties that are keyed maps rather than item lists (`bound`, `varModes`, + `overrides`, `propRefs`, `pluginData`) deliberately do not: their items line up by key. +- **Missing:** branches of a file in a live session are refused; text runs still conflict as a + whole (they live in the node's text data, so merging them means splitting a property the change + format keeps whole); the branch history has no entries of its own for its creation or updates from main (only saved versions, autosaves and the merge on main). - **Estimate:** 1–2 h. @@ -444,6 +450,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Gap 17: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources merge item by item like fills, strokes and effects (the same item changed both ways still conflicts). Text runs are left whole: they live in the node's text data, so merging them means splitting a property the change format keeps whole. Ranking and hours unchanged | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From d7a27c593ed15ad3ea8654bce15971fde7ac0c23 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 16:13:34 +0700 Subject: [PATCH 227/245] #795: wait once at startup for held recovery folders, only on Unix Collect the folders whose lock is held, wait 100 ms once and retry only those, instead of up to 100 ms per held folder. Recovery::start takes its folder without waiting again (a held name already falls through to another). Windows doesn't pass the lock to child processes, so the wait and its test are Unix-only; the test holds the lock 10 ms against the 100 ms wait. --- apps/figcraft/src/recovery.rs | 46 ++++++++++++++++------------- apps/figcraft/src/recovery_tests.rs | 3 +- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 0ad97f0d..06b0cbeb 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -120,7 +120,7 @@ impl Recovery { (0..10).find_map(|n| { let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") }); std::fs::create_dir_all(&dir).ok()?; - let lock = lock_briefly(&dir.join(LOCK))?; + let lock = lock_folder(&dir).ok()?; Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) }) } @@ -292,25 +292,15 @@ fn remove_kept(w: Waiting) { /// The extension of the note beside the copy of a document that is one of your online files. const ONLINE: &str = "online"; -/// How long a folder's lock that frees itself is waited for, and between the tries. -const LOCK_WAIT: (usize, std::time::Duration) = (20, std::time::Duration::from_millis(5)); +/// How long the startup waits, once, for folder locks that may free themselves (#334). +#[cfg(unix)] +const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100); -/// Lock the folder's `file`, waiting briefly for a lock that frees itself. A child between its -/// `fork` and its `exec` inherits the run's open descriptors, and a `flock` lasts while any -/// descriptor for the file is open, so a run that has ended can look like one that is still going -/// for the length of that window. A run that really is running holds the lock for as long as it -/// runs, and we give up on it after [`LOCK_WAIT`] (#334). -fn lock_briefly(file: &Path) -> Option { - let lock = File::options().write(true).create(true).truncate(false).open(file).ok()?; - let (tries, pause) = LOCK_WAIT; - for _ in 0..tries { - match lock.try_lock() { - Ok(()) => return Some(lock), - Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(pause), - Err(_) => return None, - } - } - None +/// Lock the folder `dir` (`WouldBlock` while another process holds it). +fn lock_folder(dir: &Path) -> Result { + let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?; + lock.try_lock()?; + Ok(lock) } /// The copy at `file` is of one of your online files: join that file again as its owner, keeping @@ -382,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { } } }; + let (mut free, mut held) = (Vec::new(), Vec::new()); for e in entries.flatten() { let path = e.path(); if !path.is_dir() { @@ -389,7 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { continue; } // Still running (another window of the app), or not ours to take. - let Some(lock) = lock_briefly(&path.join(LOCK)) else { continue }; + match lock_folder(&path) { + Ok(lock) => free.push((path, lock)), + Err(std::fs::TryLockError::WouldBlock) => held.push(path), + Err(_) => {} + } + } + // A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock` + // lasts while any of them is open, so an ended run's folder can look held for a moment. Wait + // once, however many folders are held, and try those again (#334). Windows doesn't pass the + // lock to child processes. + #[cfg(unix)] + if !held.is_empty() { + std::thread::sleep(LOCK_WAIT); + free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock)))); + } + for (path, lock) in free { let lock = Arc::new(lock); let files: Vec = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect(); for f in &files { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index c05e86d0..0322e237 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -206,6 +206,7 @@ fn a_run_whose_folder_name_is_still_held_takes_another() { /// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and /// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the /// file is open, so a crashed run's copies must still come back once that child has exec'd. +#[cfg(unix)] #[test] fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() { let root = root("fork-window"); @@ -215,7 +216,7 @@ fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() { let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap(); lock.try_lock().unwrap(); let freed = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(40)); + std::thread::sleep(std::time::Duration::from_millis(10)); drop(lock); }); // The next start waits for that lock rather than reading the run as still going. From 8d3a3e617167ce1ad5f7e2374065f252ed494d79 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 16:13:41 +0700 Subject: [PATCH 228/245] #795: drop the hardware-parity revision row (test-only effect; the maintainer records it) --- docs/hardware-parity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 4780a0a8..df822981 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -50,7 +50,6 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Recovery copies and the fork/exec window (#334): a child process forked before its `exec` (the `gsettings` read of the reduced-motion setting, any spawn) inherits the running app's open recovery-folder lock, and a `flock` lasts while any descriptor for the file is open. A crashed run whose lock was still held by such a child therefore read as "still running" and its unsaved documents were not recovered at the next start, and the desktop app's tests failed intermittently whenever another test spawned a process in parallel. Taking a folder's lock now waits briefly for one that frees itself, so only a run that is really running keeps its folder. No numbers changed | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | From 6743c682031fa1636b8fbdb118b484e6fd1d1bdd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 16:14:27 +0700 Subject: [PATCH 229/245] #794: drop the Finnish-only tests that pin wordings (the generic catalog tests cover fi) --- crates/ui-egui/src/i18n_tests.rs | 40 -------------------------------- 1 file changed, 40 deletions(-) diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index ea85ad32..d4d81d7b 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -8,46 +8,6 @@ use super::*; /// Languages that must translate every UI string and command label. const COMPLETE: &[&str] = &["es"]; -#[test] -fn finnish_locale_tags_and_integer_plurals() { - let fi = resolve("fi", None); - assert_eq!(LANGS[fi].code, "fi"); - assert_eq!(LANGS[fi].name, "Suomi"); - assert!(LANGS[fi].draft, "AI-assisted Finnish still needs human review"); - let mut session = figcraft_engine::Session::new(); - session.execute("pref.language", &serde_json::json!({"language":"fi"})).unwrap(); - let saved = serde_json::to_string(&session.prefs).unwrap(); - let restored: figcraft_engine::Prefs = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, "fi"); - for locale in ["fi-FI", "fi_FI.UTF-8", "FI-fi", "fi"] { - assert_eq!(resolve("system", Some(locale)), fi, "{locale}"); - } - for (count, expected) in [(0, "0 tasoa"), (1, "1 taso"), (2, "2 tasoa"), (21, "21 tasoa"), (101, "101 tasoa")] { - assert_eq!(fill(counted(fi, "{n} layers", count), &[("n", &count)]), expected); - } - assert_eq!(fill(counted(fi, "{n} minutes ago", 1), &[("n", &1)]), "1 minuutti sitten"); - assert_eq!(fill(counted(fi, "{n} minutes ago", 2), &[("n", &2)]), "2 minuuttia sitten"); - assert_eq!(fill(counted(fi, "{n} people joined", 2), &[("n", &2)]), "2 henkilöä liittyi"); -} - -#[test] -fn finnish_keeps_interpolated_names_and_context_specific_labels() { - let fi = resolve("fi", None); - let text = |key| match lookup(fi, key).unwrap() { - Text::Plain(s) => *s, - _ => panic!("expected a plain Finnish label: {key}"), - }; - assert_eq!(text("Cancel"), "Peruuta"); - assert_eq!(text("Fill"), "Täyttö"); - assert_eq!(text("Fill|sizing"), "Täytä"); - assert_eq!(text("Join|stroke"), "Liitos"); - assert_eq!(text("Open|comments"), "Avoin"); - assert_eq!(fill(text("Actions for {name}"), &[("name", &"File {1}.figcraft")]), "Kohteen File {1}.figcraft toiminnot"); - assert!(lookup(fi, "My layer {name}.svg").is_none()); - assert!(matches!(find(LANGS[fi].commands, "edit.undo"), Some(Text::Plain("Kumoa")))); - assert!(matches!(find(LANGS[fi].menu, "file.import"), Some(Text::Plain("Tuo tiedostosta…")))); -} - /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. fn dynamic_keys() -> BTreeSet { From ddd42b36bd09bc654f7a6daf70f534549b086baf Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 16:14:27 +0700 Subject: [PATCH 230/245] #794: list Finnish with the other catalogs in translating.md instead of a status paragraph --- docs/translating.md | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/docs/translating.md b/docs/translating.md index 3700bab4..d8addcbc 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -15,12 +15,12 @@ Simplified. `pref.language {language}` does the same from the CLI, MCP and the control channel. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay -`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), -Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`) and Indonesian (`id`), machine-drafted -and waiting for native speakers' review. Russian (`ru`, Русский) was reviewed and approved by -VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » -quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, and Swedish (`sv`) -translates every UI string, command label and menu wording. +`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese +(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`) and Finnish +(`fi`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was +reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar +tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, +and Swedish (`sv`) translates every UI string, command label and menu wording. ## The catalogs @@ -68,18 +68,6 @@ language also needs its script in `cjk::language`. ## Adding a language -Finnish (`fi`, Suomi) is an AI-assisted draft awaiting native-speaker review. Its initial catalog -covers the current 1,729 English UI keys, 567 command labels and 31 menu-specific wordings, including -42 plural entries. Counts use singular only for 1 (`1 taso`, `0/2/21 tasoa`); this already follows -the default integer rule. Context keys distinguish paint `Fill` (Täyttö), sizing `Fill|sizing` -(Täytä), stroke `Join|stroke` (Liitos) and comment status `Open|comments` (Avoin). -The build discovers `fi.toml` automatically; `fi-FI` and `fi_FI.UTF-8` resolve to it. Product names, -code identifiers and shortcut notation retain their identity. Like the other unreviewed catalogs, -Finnish remains a draft outside `COMPLETE`; future English additions may fall back until translated. -Noise modes Mono/Duo/Multi describe one, two or many grain colours and read Yksivärinen, -Kaksivärinen and Monivärinen, independently of their stable enum names. -The narrow glass-effect light-intensity field uses Vahvuus so its complete label fits beside the value. - 1. Copy `crates/ui-egui/locales/es.toml` to `.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and set `[meta] name` and `status = "draft — needs native review"`. 2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it, From d1125130f69c379a730a3d0bdb8b8cb70375c53f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 16:14:45 +0700 Subject: [PATCH 231/245] #794: translate the slot-layers import note added on main --- crates/ui-egui/locales/fi.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index eb3ce94c..602574d7 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1627,6 +1627,7 @@ status = "draft — needs native review" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "muuttujatilan asetustoiminnot ohitettiin, koska kokoelmia tai tiloja ei ole muuttujien JSON-tiedostossa" "slot content assignment target is missing or is not slot content" = "sisältöpaikan määrityksen kohde puuttuu tai ei ole sisältöpaikan sisältöä" "slot content assignment uses the empty GUID sentinel and was skipped" = "sisältöpaikan määritys käyttää tyhjää GUID-erikoisarvoa, ja se ohitettiin" +"slot layers without a matching slot property were imported as frames" = "sisältöpaikkatasot, joilla ei ole vastaavaa sisältöpaikkaominaisuutta, tuotiin kehyksinä" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "jotkin vektoripisteet viittaavat tiedostosta puuttuviin pisteasetuksiin; ne käyttävät tason liitostyyliä ja tavallisia kulmia" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "jotkin vektorisegmentit viittaavat tiedostosta puuttuviin tyyliasetuksiin; ne käyttävät tason reunaviivaa" "stale or invalid grid track sizing entries were ignored" = "vanhentuneet tai virheelliset ruudukon kokomääritykset ohitettiin" From be47f99534a9e900f68c7a6385ad11611fcd283e Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:23:44 +0800 Subject: [PATCH 232/245] M1: component sets keep the dashed canvas outline out of the document Node::new gave every component set a purple dashed stroke, so export and screenshot drew the editor frame into the image. Sets now start with no paint; the canvas draws the dashed frame itself, and files written by earlier versions lose exactly that stored decoration when opened. A stroke someone chose is kept. --- crates/doc/src/node.rs | 4 ++-- crates/engine/src/export_behaviour_tests.rs | 14 +++++++++++++ crates/format/src/large_tests.rs | 23 +++++++++++++++++++++ crates/format/src/lib.rs | 19 ++++++++++++++++- crates/ui-egui/src/canvas.rs | 7 +++++++ crates/ui-egui/src/canvas_fixes_tests.rs | 14 +++++++++++++ 6 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 00f7d559..582cdb75 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -565,14 +565,14 @@ impl Node { NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)], _ => vec![], }; + // A component set's purple dashed frame is editor chrome: the canvas draws it, and it is + // never a stroke in the document, so exports don't show it. let strokes = match &kind { NodeKind::Line => vec![Paint::solid(Color::BLACK)], - NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))], _ => vec![], }; let stroke = match &kind { NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() }, - NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() }, _ => StrokeProps::default(), }; let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal }; diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index 6896ce3e..e3652a19 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -317,6 +317,20 @@ fn svg_export_options() { assert!(svg.contains("stroke-width=\"4\""), "an inside stroke on a rectangle simplifies too (the rectangle inset by half the weight)"); } +#[test] +fn a_component_sets_editor_outline_is_not_exported() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40}))); + let b = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 300, "y": 0, "width": 100, "height": 40}))); + let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the purple dashed frame is editor chrome, not a stroke"); + let svg = text(&export(&mut s, json!({"ids": [set], "format": "svg"}))); + assert!(!svg.to_ascii_lowercase().contains("9747ff") && !svg.contains("stroke-dasharray"), "{svg}"); + let png = pixels(&export(&mut s, json!({"ids": [set]}))); + let purple = png.pixels().filter(|p| p[3] > 0 && p[0].abs_diff(0x97) < 24 && p[1].abs_diff(0x47) < 24 && p[2].abs_diff(0xFF) < 24).count(); + assert_eq!(purple, 0, "the exported set has no purple outline pixels"); +} + #[test] fn copy_as_png_svg_and_code() { let mut s = Session::new(); diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 889074f1..45eaf27c 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -186,6 +186,29 @@ fn files_with_every_field_spelled_out_still_open() { assert_same_doc(&d, &again); } +#[test] +fn a_component_sets_old_editor_outline_is_dropped_on_open() { + let mut d = Document::new("Sets"); + let page = d.first_page().unwrap(); + let old = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 100.0, 40.0).unwrap(); + let custom = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Chip", 0.0, 80.0, 100.0, 40.0).unwrap(); + // As 0.6.0 wrote every set: the canvas's purple dashed outline stored as a real stroke. + let n = d.get_mut(old).unwrap(); + n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))]; + n.stroke.dashes = vec![10.0, 5.0]; + // A stroke someone chose is design content and stays. + let n = d.get_mut(custom).unwrap(); + n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x11, 0x22, 0x33))]; + n.stroke.dashes = vec![1.0, 2.0]; + let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); + let (back, _) = from_json(&text).unwrap(); + let old = back.node(old).unwrap(); + assert!(old.strokes.is_empty() && old.stroke.dashes.is_empty(), "the decoration is canvas chrome, not a document stroke"); + let custom = back.node(custom).unwrap(); + assert_eq!(custom.strokes.len(), 1, "a real stroke is not the decoration"); + assert_eq!(custom.stroke.dashes, vec![1.0, 2.0]); +} + #[test] fn the_header_is_read_on_its_own() { // A newer file is refused before its document is looked at, whatever the version's size. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..93e67e5b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -28,7 +28,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Seek, Write}; use std::sync::Arc; -use figcraft_doc::{Document, ImageData}; +use figcraft_doc::color::{Color, Paint}; +use figcraft_doc::{Document, ImageData, NodeKind}; use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor}; use serde::{Deserialize, Serialize, Serializer}; use serde_json::{Value, json}; @@ -309,6 +310,21 @@ impl<'de> Deserialize<'de> for Header { } } +/// Earlier versions gave every component set the purple dashed outline of the canvas as a real +/// stroke, so it exported with the set. New sets carry no paint; files written back then lose +/// exactly that decoration when opened, and the canvas draws the outline itself. +fn drop_component_set_decoration(doc: &mut Document) { + let decoration = Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF)); + let ids: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).map(|n| n.id).collect(); + for id in ids { + let Some(n) = doc.node_mut(id) else { continue }; + if n.strokes.len() == 1 && n.strokes.first() == Some(&decoration) && n.stroke.dashes.as_slice() == [10.0, 5.0] { + n.strokes.clear(); + n.stroke.dashes.clear(); + } + } +} + /// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first /// (a newer file is refused before its document is read), then the document is deserialized /// straight from the text. @@ -321,6 +337,7 @@ fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatE return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX))); } let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; + drop_component_set_decoration(&mut doc); let issues = doc.validate(); if !issues.is_empty() { doc.repair(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d79..627fae56 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -936,6 +936,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } + // Component sets keep Figma's purple dashed frame on the canvas: editor chrome, not a + // document paint, so it never shows in an export. + if matches!(n.kind, NodeKind::ComponentSet { .. }) + && let Some(q) = node_quad(app, &doc, *c) + { + painter.add(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); + } } // Prototype connections. crate::proto_canvas::draw(app, painter); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 4cd32d56..bca1f4fb 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -51,6 +51,20 @@ fn frame_outlines_and_slices_draw_when_shown() { assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again"); } +#[test] +fn component_sets_draw_their_editor_outline() { + let mut s = Session::new(); + let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); + let b = id(&s.execute("node.create", &json!({"type": "component", "x": 200, "y": 0, "width": 100, "height": 40})).unwrap()); + let set = id(&s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap()); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the outline is not a document stroke"); + let mut u = Ui::new(s); + let q = crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), set).unwrap(); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(dashes_along(&shapes, q[0], q[1]) > 3, "the set shows its dashed outline"); +} + /// A job a few hundred milliseconds of rendering big. fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job { crate::render_worker::Job { From 911831fb89b0e380b513b0033fa5a37a6b0f573c Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:23:49 +0800 Subject: [PATCH 233/245] M1: vector.create scales its path to width/height A path's intrinsic box replaced the width and height asked for, so an icon imported at 24 stayed on its source grid. Scale the geometry to the requested size; with one side the other keeps its proportion. A path or point set that draws nothing is an error now, not an invisible 0x0 layer. --- crates/engine/src/cmd/create.rs | 59 +++++++++++++++++---- crates/engine/src/vector_behaviour_tests.rs | 25 +++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 9b3f7067..dedd1e49 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -73,9 +73,13 @@ pub fn specs() -> Vec { spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| { create_kind(s, p, "text") }), - spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind( - s, p, "vector" - )), + spec!( + "vector.create", + "Vector", + "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}", + true, + |s, p| create_kind(s, p, "vector") + ), spec!( "image.place", "Place image", @@ -162,6 +166,27 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// How much a vector's path is scaled to take the size asked for: with both sides, each scales +/// independently (as resizing a vector does); with one, the other keeps its proportion. +fn vector_scale(width: Option, height: Option, bw: f64, bh: f64) -> (f64, f64) { + let fit = |asked: Option, have: f64| match asked { + Some(a) if have > 1e-9 => a.max(0.0) / have, + _ => 1.0, + }; + match (width, height) { + (Some(_), Some(_)) => (fit(width, bw), fit(height, bh)), + (Some(_), None) => { + let s = fit(width, bw); + (s, s) + } + (None, Some(_)) => { + let s = fit(height, bh); + (s, s) + } + (None, None) => (1.0, 1.0), + } +} + pub fn create(s: &mut Session, p: &Value) -> Result { let cmd = "node.create"; let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; @@ -187,20 +212,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result { "Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)), _ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)), }; - // Vectors: path data or points define the shape and its box. + // Vectors: path data or points define the shape and its box. `width`/`height`, when asked + // for, scale the geometry: both sides independently, one side proportionally. let mut offset = (0.0, 0.0); + let asked = (num(p, "width"), num(p, "height")); if let NodeKind::Vector { network } = &mut kind { if let Some(d) = string(p, "path") { let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; use figcraft_geom::Shape; let bb = path.bounding_box(); + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); + } path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height()); + if sx != 1.0 || sy != 1.0 { + path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + } *network = VectorNetwork::from_path(&path); + if network.segments.is_empty() { + return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); + } if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); @@ -208,10 +245,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`points` needs at least two [x, y] pairs")); } let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default(); - let local: Vec = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect(); + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area")); + } + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height()); + let local: Vec = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect(); *network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false)); - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } } diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 8899dc4e..042b3321 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -439,3 +439,28 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() { assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over"); assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer"); } + +#[test] +fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { + let mut s = Session::new(); + let path = "M0 0 L24 0 L24 24 L0 24 Z"; + let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap(); + let n = s.doc().unwrap().node(intrinsic).unwrap(); + assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box"); + // Both sides: each scales independently, like resizing a vector. + let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (16.0, 8.0)); + let n = net(&s, both); + let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y))); + assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled"); + // One side: the other keeps its proportion. + let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap(); + let n = s.doc().unwrap().node(width).unwrap(); + assert_eq!((n.width, n.height), (16.0, 16.0)); + // A path or point set that draws nothing is an error, not an invisible zero-size layer. + let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); + let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); +} From c87baa37cc4a6c6e3ab7ef48c65faf56db7de15f Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:48:51 +0800 Subject: [PATCH 234/245] docs: record the component set outline and vector size behaviour --- docs/components-parity.md | 1 + docs/mcp.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/components-parity.md b/docs/components-parity.md index 2640ed1c..8be23362 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Component sets: the purple dashed frame is canvas chrome again. A new set carries no stroke, the canvas draws the outline, and exports and screenshots leave it out; a file written by 0.6.0 loses exactly that stored decoration when opened. A stroke someone chose stays. Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 786fde0a..caecf372 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -426,6 +426,11 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: ### Editing vectors +`vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale +the geometry to the size asked for (with both, each side independently; with one, the other keeps +its proportion). A path or point set that draws nothing is refused instead of making an invisible +0×0 layer. + Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers are edited at once, the first active; `key` Enter with shapes selected does the same), From de2915908c94fe0ab707af9e6d1ce29d84f05cc8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:02 +0700 Subject: [PATCH 235/245] #798: drop progress-doc edits (maintainer-owned) --- docs/collaboration-parity.md | 5 +---- docs/gaps.md | 11 ++--------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 920a9621..ae503633 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | -| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes, effects, export settings, guides, prototype interactions, accessibility annotations and the Dev Mode links and resources merge item by item. Left: branches in live sessions; text runs conflict as a whole | 1–2 | +| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | | Notifications, activity | yes | no | with sharing | @@ -35,9 +35,6 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change (Enter sends, Escape takes it back, 100 characters, control characters dropped); your avatar's menu (or `collab.spotlight`) asks everyone to follow your view and page, latecomers included, until they move their own view. Both travel as signals: ephemeral presence the host validates, rate-limits (20 a second per person) and passes only to clients that announce they read them, so older clients keep working; never in the document or the log. Live cursors / follow / spotlight row partial → done, 8–15 h → 2–4 h; cursor chat row no → partial (no audio or reactions), 4–8 h → 2–5 h; multiplayer 45–90 h → 42–84 h (it counted these rows only in part); remaining 55–110 h (stale: team libraries had already gone to 4–8 h) → 46–92 h; collaboration ~13% unchanged at this rounding | -| Date | Change | Summary | -|---|---|---| -| 2026-10-18 | minor | Branch merge, gap 17: the layer lists whose items stand on their own merge item by item, not just fills, strokes and effects. Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources now merge item by item, each conflicting only when the same item changed on both sides, an item was removed on one side, or both sides added different items. Text runs are left whole (they live in the node's text data). Numbers unchanged | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | diff --git a/docs/gaps.md b/docs/gaps.md index dd42a75b..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -397,14 +397,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe different items changed on each side, or items added at the end on one side, merge without a conflict; only the same item changed both ways, an item removed on one side, or different items added on both sides conflict (as the whole list). -- **Landed (2026-10-18):** the other layer lists whose items stand on their own merge item by item - too: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode - code links and resources, each with the same rule as fills (the same item changed both ways still - conflicts). Properties that are keyed maps rather than item lists (`bound`, `varModes`, - `overrides`, `propRefs`, `pluginData`) deliberately do not: their items line up by key. -- **Missing:** branches of a file in a live session are refused; text runs still conflict as a - whole (they live in the node's text data, so merging them means splitting a property the change - format keeps whole); the branch history has no entries of its own for +- **Missing:** branches of a file in a live session are refused; other lists (text runs, export + settings, guides…) still conflict as a whole; the branch history has no entries of its own for its creation or updates from main (only saved versions, autosaves and the merge on main). - **Estimate:** 1–2 h. @@ -450,7 +444,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Gap 17: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources merge item by item like fills, strokes and effects (the same item changed both ways still conflicts). Text runs are left whole: they live in the node's text data, so merging them means splitting a property the change format keeps whole. Ranking and hours unchanged | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From a4c7e259abf38485434249551478f7a459ab17b6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:02 +0700 Subject: [PATCH 236/245] #798: layout grids merge item by item, code links merge whole (keyed by language); fix stale doc comments --- crates/collab/src/change.rs | 5 +++-- crates/collab/src/merge.rs | 21 +++++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 32a49184..0608ba67 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -87,8 +87,9 @@ macro_rules! node_props { ($($variant:ident => $field:ident),* $(,)?) => { /// A node property: the tree position (`parent`, `position`) or one saved field of /// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each, - /// replaced whole (three-way merges line fills, strokes and effects up item by item, see - /// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them). + /// replaced whole (three-way merges line the lists whose items stand on their own, such as + /// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the + /// binary form numbers them). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Prop { diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index 381b4393..5132ce75 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -5,13 +5,12 @@ //! A conflict is the same property (or document-level entry) changed on both sides to different //! values, or a layer deleted on one side while the other side edited it or something inside it //! (including adding layers to it or moving layers into or out of it). The layer lists whose items -//! stand on their own (fills, strokes, effects, export settings, guides, prototype interactions, -//! accessibility annotations and the Dev Mode links and resources) merge item by item: both sides +//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype +//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides //! changing different items of the list (or one adding items at its end) is no conflict, only the //! same item changed both ways or the list shortened on a side while the other changed it. Nothing -//! that conflicts is -//! merged until it is resolved: each conflict is resolved for one side, and the other side's -//! changes to it are left out. Everything else from both sides is kept. +//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the +//! other side's changes to it are left out. Everything else from both sides is kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -161,19 +160,21 @@ fn by_key(changes: &[Change]) -> BTreeMap> { out } -/// List properties whose items merge one by item: the items stand on their own, so both sides -/// changing different ones (or one side adding at its end) is no conflict. Properties that are -/// keyed maps rather than item lists (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) -/// are not here: their items line up by key, not by position. +/// List properties whose items merge one by one: the items stand on their own, so both sides +/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps +/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up +/// by key, not by position. const PER_ITEM: &[Prop] = &[ Prop::Fills, Prop::Strokes, Prop::Effects, + Prop::Grids, Prop::Exports, Prop::Guides, Prop::Interactions, Prop::Annotations, - Prop::CodeLinks, + // Not `CodeLinks`: there is one per language, and lining them up by position could leave two + // for one language (the second hidden), so they merge whole. Prop::DevResources, ]; From 53c8308b66072aad100eba6626da7b33d29a406e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:03 +0700 Subject: [PATCH 237/245] #798: test layout grids, code links and interactions (add, change, delete, conflicts) --- crates/collab/src/merge_list_tests.rs | 89 +++++++++++++++++++++------ 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs index 8b237145..e92f6bbb 100644 --- a/crates/collab/src/merge_list_tests.rs +++ b/crates/collab/src/merge_list_tests.rs @@ -1,10 +1,10 @@ -//! The layer lists whose items stand on their own (fills, strokes, effects, export settings, -//! guides, prototype interactions, accessibility annotations, Dev Mode links and resources) merge -//! item by item: different items changed on each side merge, the same item changed both ways +//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export +//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources) +//! merge item by item: different items changed on each side merge, the same item changed both ways //! conflicts. use figcraft_color::{Color, Paint}; -use figcraft_doc::{Document, Effect, NodeId, NodeKind}; +use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto}; use serde_json::{Value, json}; use crate::merge::{Key, Side, merge_items, plan}; @@ -103,30 +103,29 @@ fn the_same_item_changed_both_ways_conflicts() { #[test] fn the_other_item_lists_merge_one_by_one_too() { - // Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode - // links and resources are item lists like fills: both sides changing different items merges. + // Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are + // item lists like fills: both sides changing different items merges. let (mut base, _, _, r) = files(); let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }; let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset }; let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None }; - let link = - |name: &str| figcraft_doc::CodeLink { language: "react".into(), import_path: String::new(), name: name.into(), template: String::new() }; + let grid = |size: f64| LayoutGrid { size, ..Default::default() }; let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() }; let n = base.get_mut(r).unwrap(); n.exports = vec![export("@1"), export("@2")]; n.guides = vec![guide(true, 10.0), guide(false, 20.0)]; n.annotations = vec![annot("a1"), annot("a2")]; - n.code_links = vec![link("L1"), link("L2")]; + n.grids = vec![grid(10.0), grid(20.0)]; n.dev_resources = vec![res("https://a.test"), res("https://b.test")]; let (mut branch, mut main) = (base.clone(), base.clone()); branch.set_client_id(base.highest_id_space() + 1); // The branch changes the first item of each list; main the second. let b = branch.get_mut(r).unwrap(); - (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.code_links[0].name, b.dev_resources[0].url) = - ("@b".into(), 11.0, "b1".into(), "LB".into(), "https://branch.test".into()); + (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) = + ("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into()); let m = main.get_mut(r).unwrap(); - (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.code_links[1].name, m.dev_resources[1].url) = - ("@m".into(), 22.0, "m2".into(), "LM".into(), "https://main.test".into()); + (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) = + ("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into()); both_ways(&base, &branch, &main, |doc| { let n = doc.node(r).unwrap(); let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect(); @@ -135,8 +134,8 @@ fn the_other_item_lists_merge_one_by_one_too() { assert_eq!(offsets, [11.0, 22.0], "guides"); let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect(); assert_eq!(labels, ["b1", "m2"], "annotations"); - let names: Vec<&str> = n.code_links.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, ["LB", "LM"], "code links"); + let sizes: Vec = n.grids.iter().map(|g| g.size).collect(); + assert_eq!(sizes, [12.0, 24.0], "layout grids"); let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect(); assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources"); }); @@ -145,17 +144,71 @@ fn the_other_item_lists_merge_one_by_one_too() { #[test] fn the_same_item_of_another_list_changed_both_ways_conflicts() { let (mut base, _, _, r) = files(); - base.get_mut(r).unwrap().exports = vec![ + let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![ figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() }, figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() }, ]; + n.code_links = vec![link("react"), link("vue")]; let (mut branch, mut main) = (base.clone(), base.clone()); branch.set_client_id(base.highest_id_space() + 1); branch.get_mut(r).unwrap().exports[0].suffix = "@b".into(); main.get_mut(r).unwrap().exports[0].suffix = "@m".into(); + // Code links are one per language, not an item list: different links changed still conflict. + branch.get_mut(r).unwrap().code_links[0].name = "B".into(); + main.get_mut(r).unwrap().code_links[1].name = "M".into(); let p = plan(&base, &branch, &main).unwrap(); - let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); - assert_eq!(keys, [format!("{r}/exports")], "the same item changed both ways still conflicts"); + let mut keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + keys.sort(); + let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")]; + want.sort(); + assert_eq!(keys, want); +} + +#[test] +fn interactions_merge_item_by_item() { + let ia = |duration_ms: u32| proto::Interaction { + trigger: Default::default(), + actions: vec![proto::Action::Back], + transition: Default::default(), + duration_ms, + easing: Default::default(), + delay_ms: 0, + }; + let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::>(); + let (mut base, _, _, r) = files(); + base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)]; + let fork = || { + let (mut branch, main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + (branch, main) + }; + // The branch changes the first interaction and adds one; main changes the second. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions[0].duration_ms = 150; + branch.get_mut(r).unwrap().interactions.push(ia(300)); + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300])); + // The branch deletes one while main leaves them alone. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions.remove(0); + main.get_mut(r).unwrap().fills[0] = red(); + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200])); + // The same interaction changed both ways, or one deleted while the other side changed the + // list, conflicts. + for delete in [false, true] { + let (mut branch, mut main) = fork(); + if delete { + branch.get_mut(r).unwrap().interactions.remove(0); + } else { + branch.get_mut(r).unwrap().interactions[1].duration_ms = 220; + } + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + let p = plan(&base, &branch, &main).unwrap(); + let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}"); + } } #[test] From adb23f0ff19470f43bb7872be2409a117b6ef477 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:03 +0700 Subject: [PATCH 238/245] #798: branch.review docs name the lists that merge item by item --- docs/mcp.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 786fde0a..4f149503 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -972,10 +972,11 @@ branches). Driven with `run_command`: name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables, comments and file settings. A conflict is the same property changed on both sides to different - values (key `/`, e.g. `4/fills`; fills, strokes and effects compare item by - item, so changing different items of one list on each side, or adding items at its end on one - side, merges without a conflict; a layer's parent and position are one - property, `parent`), a layer deleted on one side while the other edited it or what's inside it + values (key `/`, e.g. `4/fills`; fills, strokes, effects, layout grids, + export settings, guides, prototype interactions, annotations and Dev Mode resources compare item + by item, so changing different items of one list on each side, or adding items at its end on one + side, merges without a conflict; other lists, such as code links, compare whole; a layer's parent + and position are one property, `parent`), a layer deleted on one side while the other edited it or what's inside it (`/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides (`style/`, `variable/`, `comment/`, `doc/`…). - `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step, From aff3c4f18bec87927e86f0606bee9639f6f718d4 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:22:48 +0800 Subject: [PATCH 239/245] M1: gate the outline migration on a marker and refuse impossible vector sizes Review follow-up. A file written by this version carries editorOutlines, so a stroke someone chose is kept even when it matches the old decoration; only files without the marker get the one-time legacy cleanup. vector.create now errors when a requested side cannot scale the geometry or when both axes would collapse, instead of creating an invisible layer. --- crates/engine/src/cmd/create.rs | 28 +++++++++++++-------- crates/engine/src/vector_behaviour_tests.rs | 8 ++++++ crates/format/src/large_tests.rs | 11 ++++++-- crates/format/src/lib.rs | 16 ++++++++++-- docs/mcp.md | 3 ++- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dedd1e49..84fa419a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,24 +167,30 @@ pub fn parse_path_data(d: &str) -> Option { } /// How much a vector's path is scaled to take the size asked for: with both sides, each scales -/// independently (as resizing a vector does); with one, the other keeps its proportion. -fn vector_scale(width: Option, height: Option, bw: f64, bh: f64) -> (f64, f64) { - let fit = |asked: Option, have: f64| match asked { - Some(a) if have > 1e-9 => a.max(0.0) / have, - _ => 1.0, +/// independently (as resizing a vector does); with one, the other keeps its proportion. A size +/// asked for on an axis the geometry has none of is an error, not silently ignored. +fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { + let fit = |asked: Option, have: f64, axis: &str| match asked { + Some(a) if have > 1e-9 => Ok(a.max(0.0) / have), + Some(_) => Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })), + None => Ok(1.0), }; - match (width, height) { - (Some(_), Some(_)) => (fit(width, bw), fit(height, bh)), + let (sx, sy) = match (width, height) { + (Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?), (Some(_), None) => { - let s = fit(width, bw); + let s = fit(width, bw, "width")?; (s, s) } (None, Some(_)) => { - let s = fit(height, bh); + let s = fit(height, bh, "height")?; (s, s) } (None, None) => (1.0, 1.0), + }; + if sx <= 0.0 && sy <= 0.0 { + return Err(format!("`width` and `height` would make {what} 0×0")); } + Ok((sx, sy)) } pub fn create(s: &mut Session, p: &Value) -> Result { @@ -225,7 +231,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); } path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); - let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height()); + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; if sx != 1.0 || sy != 1.0 { path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); } @@ -248,7 +254,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { if bb.width() <= 1e-9 && bb.height() <= 1e-9 { return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area")); } - let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height()); + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?; let local: Vec = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect(); *network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false)); w = bb.width() * sx; diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 042b3321..35d61074 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -463,4 +463,12 @@ fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { assert!(e.contains("draws nothing"), "{e}"); let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string(); assert!(e.contains("draws nothing"), "{e}"); + // A size on an axis the geometry has none of is refused, not silently ignored. + let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string(); + assert!(e.contains("horizontal extent"), "{e}"); + let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); + assert!(e.contains("vertical extent"), "{e}"); + // A size that would collapse both axes is refused too. + let e = s.execute("vector.create", &json!({"path": path, "width": 0})).unwrap_err().to_string(); + assert!(e.contains("0×0"), "{e}"); } diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 45eaf27c..143dc82a 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -187,7 +187,7 @@ fn files_with_every_field_spelled_out_still_open() { } #[test] -fn a_component_sets_old_editor_outline_is_dropped_on_open() { +fn a_component_sets_old_editor_outline_is_dropped_only_without_the_marker() { let mut d = Document::new("Sets"); let page = d.first_page().unwrap(); let old = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 100.0, 40.0).unwrap(); @@ -200,8 +200,15 @@ fn a_component_sets_old_editor_outline_is_dropped_on_open() { let n = d.get_mut(custom).unwrap(); n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x11, 0x22, 0x33))]; n.stroke.dashes = vec![1.0, 2.0]; + // A file this version writes carries the marker: the same stroke is a choice, not decoration. let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); - let (back, _) = from_json(&text).unwrap(); + let (current, _) = from_json(&text).unwrap(); + assert_eq!(current.node(old).unwrap().strokes.len(), 1, "a marked file keeps a stroke someone chose"); + assert_eq!(current.node(custom).unwrap().strokes.len(), 1); + // A file without the marker is from a version that stored the decoration; drop exactly that. + let mut legacy = to_json(&d).unwrap(); + legacy.as_object_mut().unwrap().remove("editorOutlines"); + let (back, _) = from_json(&serde_json::to_string(&legacy).unwrap()).unwrap(); let old = back.node(old).unwrap(); assert!(old.strokes.is_empty() && old.stroke.dashes.is_empty(), "the decoration is canvas chrome, not a document stroke"); let custom = back.node(custom).unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 93e67e5b..4ae154e7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -232,13 +232,18 @@ struct DocumentJson<'a> { version: u32, #[serde(rename = "imageIndex", serialize_with = "image_index")] images: &'a BTreeMap, + /// The editor draws component-set outlines itself instead of storing them as strokes. Files + /// without this marker were written by a version that stored the purple dashed outline as a + /// real stroke (see [`drop_component_set_decoration`]). + #[serde(rename = "editorOutlines")] + editor_outlines: bool, #[serde(flatten)] doc: &'a Document, } impl<'a> DocumentJson<'a> { fn new(doc: &'a Document) -> Self { - Self { format: FORMAT, version: VERSION, images: &doc.images, doc } + Self { format: FORMAT, version: VERSION, images: &doc.images, editor_outlines: true, doc } } } @@ -277,6 +282,8 @@ type ImageIndex = Option>; struct Header { version: Option, images: ImageIndex, + /// Whether the writer drew component-set outlines outside the document. + editor_outlines: bool, } impl<'de> Deserialize<'de> for Header { @@ -292,6 +299,7 @@ impl<'de> Deserialize<'de> for Header { while let Some(key) = map.next_key::()? { match key.as_str() { "version" => h.version = map.next_value::()?.as_u64(), + "editorOutlines" => h.editor_outlines = map.next_value::()?.as_bool().unwrap_or(false), "imageIndex" => { h.images = map .next_value::()? @@ -337,7 +345,11 @@ fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatE return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX))); } let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; - drop_component_set_decoration(&mut doc); + // Only files from before the marker can carry the old stored decoration; a file this version + // wrote keeps a stroke someone chose, even one that looks exactly like it. + if !header.editor_outlines { + drop_component_set_decoration(&mut doc); + } let issues = doc.validate(); if !issues.is_empty() { doc.repair(); diff --git a/docs/mcp.md b/docs/mcp.md index caecf372..864bc1f1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -429,7 +429,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale the geometry to the size asked for (with both, each side independently; with one, the other keeps its proportion). A path or point set that draws nothing is refused instead of making an invisible -0×0 layer. +0×0 layer, as is a size on an axis the geometry has none of and a size that would collapse both +axes. Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers From 55aa0165fd8ba2ef9d2e0821ef20a6f7b661a8db Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:41:11 +0700 Subject: [PATCH 240/245] #801: keep a component set's outline as its real stroke (reverts the export change) --- crates/doc/src/node.rs | 4 +-- crates/engine/src/export_behaviour_tests.rs | 14 --------- crates/format/src/large_tests.rs | 30 ------------------- crates/format/src/lib.rs | 33 ++------------------- crates/ui-egui/src/canvas.rs | 7 ----- crates/ui-egui/src/canvas_fixes_tests.rs | 14 --------- docs/components-parity.md | 1 - 7 files changed, 4 insertions(+), 99 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 582cdb75..00f7d559 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -565,14 +565,14 @@ impl Node { NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)], _ => vec![], }; - // A component set's purple dashed frame is editor chrome: the canvas draws it, and it is - // never a stroke in the document, so exports don't show it. let strokes = match &kind { NodeKind::Line => vec![Paint::solid(Color::BLACK)], + NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))], _ => vec![], }; let stroke = match &kind { NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() }, + NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() }, _ => StrokeProps::default(), }; let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal }; diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index e3652a19..6896ce3e 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -317,20 +317,6 @@ fn svg_export_options() { assert!(svg.contains("stroke-width=\"4\""), "an inside stroke on a rectangle simplifies too (the rectangle inset by half the weight)"); } -#[test] -fn a_component_sets_editor_outline_is_not_exported() { - let mut s = Session::new(); - let a = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40}))); - let b = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 300, "y": 0, "width": 100, "height": 40}))); - let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); - assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the purple dashed frame is editor chrome, not a stroke"); - let svg = text(&export(&mut s, json!({"ids": [set], "format": "svg"}))); - assert!(!svg.to_ascii_lowercase().contains("9747ff") && !svg.contains("stroke-dasharray"), "{svg}"); - let png = pixels(&export(&mut s, json!({"ids": [set]}))); - let purple = png.pixels().filter(|p| p[3] > 0 && p[0].abs_diff(0x97) < 24 && p[1].abs_diff(0x47) < 24 && p[2].abs_diff(0xFF) < 24).count(); - assert_eq!(purple, 0, "the exported set has no purple outline pixels"); -} - #[test] fn copy_as_png_svg_and_code() { let mut s = Session::new(); diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 143dc82a..889074f1 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -186,36 +186,6 @@ fn files_with_every_field_spelled_out_still_open() { assert_same_doc(&d, &again); } -#[test] -fn a_component_sets_old_editor_outline_is_dropped_only_without_the_marker() { - let mut d = Document::new("Sets"); - let page = d.first_page().unwrap(); - let old = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 100.0, 40.0).unwrap(); - let custom = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Chip", 0.0, 80.0, 100.0, 40.0).unwrap(); - // As 0.6.0 wrote every set: the canvas's purple dashed outline stored as a real stroke. - let n = d.get_mut(old).unwrap(); - n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))]; - n.stroke.dashes = vec![10.0, 5.0]; - // A stroke someone chose is design content and stays. - let n = d.get_mut(custom).unwrap(); - n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x11, 0x22, 0x33))]; - n.stroke.dashes = vec![1.0, 2.0]; - // A file this version writes carries the marker: the same stroke is a choice, not decoration. - let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); - let (current, _) = from_json(&text).unwrap(); - assert_eq!(current.node(old).unwrap().strokes.len(), 1, "a marked file keeps a stroke someone chose"); - assert_eq!(current.node(custom).unwrap().strokes.len(), 1); - // A file without the marker is from a version that stored the decoration; drop exactly that. - let mut legacy = to_json(&d).unwrap(); - legacy.as_object_mut().unwrap().remove("editorOutlines"); - let (back, _) = from_json(&serde_json::to_string(&legacy).unwrap()).unwrap(); - let old = back.node(old).unwrap(); - assert!(old.strokes.is_empty() && old.stroke.dashes.is_empty(), "the decoration is canvas chrome, not a document stroke"); - let custom = back.node(custom).unwrap(); - assert_eq!(custom.strokes.len(), 1, "a real stroke is not the decoration"); - assert_eq!(custom.stroke.dashes, vec![1.0, 2.0]); -} - #[test] fn the_header_is_read_on_its_own() { // A newer file is refused before its document is looked at, whatever the version's size. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4ae154e7..9f7e51bc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -28,8 +28,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Seek, Write}; use std::sync::Arc; -use figcraft_doc::color::{Color, Paint}; -use figcraft_doc::{Document, ImageData, NodeKind}; +use figcraft_doc::{Document, ImageData}; use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor}; use serde::{Deserialize, Serialize, Serializer}; use serde_json::{Value, json}; @@ -232,18 +231,13 @@ struct DocumentJson<'a> { version: u32, #[serde(rename = "imageIndex", serialize_with = "image_index")] images: &'a BTreeMap, - /// The editor draws component-set outlines itself instead of storing them as strokes. Files - /// without this marker were written by a version that stored the purple dashed outline as a - /// real stroke (see [`drop_component_set_decoration`]). - #[serde(rename = "editorOutlines")] - editor_outlines: bool, #[serde(flatten)] doc: &'a Document, } impl<'a> DocumentJson<'a> { fn new(doc: &'a Document) -> Self { - Self { format: FORMAT, version: VERSION, images: &doc.images, editor_outlines: true, doc } + Self { format: FORMAT, version: VERSION, images: &doc.images, doc } } } @@ -282,8 +276,6 @@ type ImageIndex = Option>; struct Header { version: Option, images: ImageIndex, - /// Whether the writer drew component-set outlines outside the document. - editor_outlines: bool, } impl<'de> Deserialize<'de> for Header { @@ -299,7 +291,6 @@ impl<'de> Deserialize<'de> for Header { while let Some(key) = map.next_key::()? { match key.as_str() { "version" => h.version = map.next_value::()?.as_u64(), - "editorOutlines" => h.editor_outlines = map.next_value::()?.as_bool().unwrap_or(false), "imageIndex" => { h.images = map .next_value::()? @@ -318,21 +309,6 @@ impl<'de> Deserialize<'de> for Header { } } -/// Earlier versions gave every component set the purple dashed outline of the canvas as a real -/// stroke, so it exported with the set. New sets carry no paint; files written back then lose -/// exactly that decoration when opened, and the canvas draws the outline itself. -fn drop_component_set_decoration(doc: &mut Document) { - let decoration = Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF)); - let ids: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).map(|n| n.id).collect(); - for id in ids { - let Some(n) = doc.node_mut(id) else { continue }; - if n.strokes.len() == 1 && n.strokes.first() == Some(&decoration) && n.stroke.dashes.as_slice() == [10.0, 5.0] { - n.strokes.clear(); - n.stroke.dashes.clear(); - } - } -} - /// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first /// (a newer file is refused before its document is read), then the document is deserialized /// straight from the text. @@ -345,11 +321,6 @@ fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatE return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX))); } let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; - // Only files from before the marker can carry the old stored decoration; a file this version - // wrote keeps a stroke someone chose, even one that looks exactly like it. - if !header.editor_outlines { - drop_component_set_decoration(&mut doc); - } let issues = doc.validate(); if !issues.is_empty() { doc.repair(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 627fae56..9e0c1d79 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -936,13 +936,6 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } - // Component sets keep Figma's purple dashed frame on the canvas: editor chrome, not a - // document paint, so it never shows in an export. - if matches!(n.kind, NodeKind::ComponentSet { .. }) - && let Some(q) = node_quad(app, &doc, *c) - { - painter.add(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); - } } // Prototype connections. crate::proto_canvas::draw(app, painter); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index bca1f4fb..4cd32d56 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -51,20 +51,6 @@ fn frame_outlines_and_slices_draw_when_shown() { assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again"); } -#[test] -fn component_sets_draw_their_editor_outline() { - let mut s = Session::new(); - let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); - let b = id(&s.execute("node.create", &json!({"type": "component", "x": 200, "y": 0, "width": 100, "height": 40})).unwrap()); - let set = id(&s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap()); - s.execute("select.set", &json!({"ids": []})).unwrap(); - assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the outline is not a document stroke"); - let mut u = Ui::new(s); - let q = crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), set).unwrap(); - let shapes = flat(&u.frame_output(vec![]).shapes); - assert!(dashes_along(&shapes, q[0], q[1]) > 3, "the set shows its dashed outline"); -} - /// A job a few hundred milliseconds of rendering big. fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job { crate::render_worker::Job { diff --git a/docs/components-parity.md b/docs/components-parity.md index 8be23362..2640ed1c 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -76,7 +76,6 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Component sets: the purple dashed frame is canvas chrome again. A new set carries no stroke, the canvas draws the outline, and exports and screenshots leave it out; a file written by 0.6.0 loses exactly that stored decoration when opened. A stroke someone chose stays. Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | From f1d336249a3dddaf0688eb8c2b58e267e77ca87a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:54:24 +0700 Subject: [PATCH 241/245] #801: vector.create accepts 0 on an axis the geometry has none of and refuses a flattening size; points scaling tests --- crates/engine/src/cmd/create.rs | 24 +++++++++------- crates/engine/src/vector_behaviour_tests.rs | 31 +++++++++++++++++++-- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 84fa419a..3a9f6d67 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,15 +167,23 @@ pub fn parse_path_data(d: &str) -> Option { } /// How much a vector's path is scaled to take the size asked for: with both sides, each scales -/// independently (as resizing a vector does); with one, the other keeps its proportion. A size -/// asked for on an axis the geometry has none of is an error, not silently ignored. +/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on +/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0 +/// or less on an axis it has is refused rather than flattening it. fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { let fit = |asked: Option, have: f64, axis: &str| match asked { - Some(a) if have > 1e-9 => Ok(a.max(0.0) / have), - Some(_) => Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })), None => Ok(1.0), + Some(a) if have <= 1e-9 => { + if a == 0.0 { + Ok(1.0) + } else { + Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })) + } + } + Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have), + Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")), }; - let (sx, sy) = match (width, height) { + Ok(match (width, height) { (Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?), (Some(_), None) => { let s = fit(width, bw, "width")?; @@ -186,11 +194,7 @@ fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: (s, s) } (None, None) => (1.0, 1.0), - }; - if sx <= 0.0 && sy <= 0.0 { - return Err(format!("`width` and `height` would make {what} 0×0")); - } - Ok((sx, sy)) + }) } pub fn create(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 35d61074..fb734085 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -468,7 +468,32 @@ fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { assert!(e.contains("horizontal extent"), "{e}"); let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); assert!(e.contains("vertical extent"), "{e}"); - // A size that would collapse both axes is refused too. - let e = s.execute("vector.create", &json!({"path": path, "width": 0})).unwrap_err().to_string(); - assert!(e.contains("0×0"), "{e}"); + // But 0 on an axis it has none of is what it already is: a horizontal line with height 0. + let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap(); + let n = s.doc().unwrap().node(line).unwrap(); + assert_eq!((n.width, n.height), (16.0, 0.0)); + // A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other. + for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] { + let e = s.execute("vector.create", &p).unwrap_err().to_string(); + assert!(e.contains("positive"), "{p}: {e}"); + } +} + +#[test] +fn vector_points_take_the_requested_size() { + let mut s = Session::new(); + let points = json!([[10, 20], [34, 20], [34, 32]]); + let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::>(); + // Both sides: each axis scales on its own; the vertices move with the box. + let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (48.0, 6.0)); + assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]); + // One side: the other keeps its proportion. + let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap(); + let n = s.doc().unwrap().node(one).unwrap(); + assert_eq!((n.width, n.height), (48.0, 24.0)); + assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]); + let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string(); + assert!(e.contains("positive"), "{e}"); } From 057bf9a28955b5eb427362436acd0dc4dc82736c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:54:25 +0700 Subject: [PATCH 242/245] #801: create_node and mcp.md say width/height scale a vector's path --- crates/mcp/src/tools.rs | 2 +- docs/mcp.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 79ef4945..7714da5a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec { "type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"}, "x": num("Left, relative to the parent frame (default 0)"), "y": num("Top, relative to the parent frame (default 0)"), - "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"), + "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"), "height": num("Height (default 100; ignored for lines/arrows and auto-height text)"), "parent": id_schema("Parent frame/group/component id (default: the current page)"), "index": int("Z-index among the parent's children (default: on top)"), diff --git a/docs/mcp.md b/docs/mcp.md index 6191b741..c5ffe99f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -429,8 +429,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale the geometry to the size asked for (with both, each side independently; with one, the other keeps its proportion). A path or point set that draws nothing is refused instead of making an invisible -0×0 layer, as is a size on an axis the geometry has none of and a size that would collapse both -axes. +0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's +height) and a size of 0 or less on an axis it has, which would flatten it. Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers From fec902b7d554f9d764115dc397ff40d0851d6fc7 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:00:25 +0800 Subject: [PATCH 243/245] M1: refuse negative vector sizes A negative width or height was clamped to zero by the scale helper, producing a layer smaller than asked for; vector.create now says so. Regression test covers width -10 with height 8. --- crates/engine/src/cmd/create.rs | 7 ++++++- crates/engine/src/vector_behaviour_tests.rs | 4 +++- docs/mcp.md | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 84fa419a..d9e64203 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -170,8 +170,13 @@ pub fn parse_path_data(d: &str) -> Option { /// independently (as resizing a vector does); with one, the other keeps its proportion. A size /// asked for on an axis the geometry has none of is an error, not silently ignored. fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { + for (axis, asked) in [("width", width), ("height", height)] { + if asked.is_some_and(|a| a < 0.0) { + return Err(format!("`{axis}` can't be negative")); + } + } let fit = |asked: Option, have: f64, axis: &str| match asked { - Some(a) if have > 1e-9 => Ok(a.max(0.0) / have), + Some(a) if have > 1e-9 => Ok(a / have), Some(_) => Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })), None => Ok(1.0), }; diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 35d61074..7410bc04 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -468,7 +468,9 @@ fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { assert!(e.contains("horizontal extent"), "{e}"); let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); assert!(e.contains("vertical extent"), "{e}"); - // A size that would collapse both axes is refused too. + // A size that would collapse both axes is refused too, and so is a negative one. let e = s.execute("vector.create", &json!({"path": path, "width": 0})).unwrap_err().to_string(); assert!(e.contains("0×0"), "{e}"); + let e = s.execute("vector.create", &json!({"path": path, "width": -10, "height": 8})).unwrap_err().to_string(); + assert!(e.contains("can't be negative"), "{e}"); } diff --git a/docs/mcp.md b/docs/mcp.md index 864bc1f1..cb2cb870 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -429,8 +429,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale the geometry to the size asked for (with both, each side independently; with one, the other keeps its proportion). A path or point set that draws nothing is refused instead of making an invisible -0×0 layer, as is a size on an axis the geometry has none of and a size that would collapse both -axes. +0×0 layer, as is a size on an axis the geometry has none of, a negative size, and a size that +would collapse both axes. Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers From 018b8166b277c812fed857ff0d958a005ce4a8b0 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 18 Oct 2026 18:50:19 +0700 Subject: [PATCH 244/245] M3: a bound colour variable's alpha applies once, not squared (#802) (#804) Fixes #802. Fixes #802. --- crates/engine/src/binding_lists_tests.rs | 2 +- crates/engine/src/bound_alpha_tests.rs | 131 +++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 1 + crates/engine/src/cmd/props.rs | 7 +- crates/engine/src/cmd/styles.rs | 82 +++++++++++++- crates/engine/src/cmd/variables.rs | 6 +- crates/engine/src/codegen.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/ui-egui/src/colorpicker.rs | 21 +++- docs/components-parity.md | 5 +- 10 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/bound_alpha_tests.rs diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 493c8f60..805b0afe 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() { let fills = s.doc().unwrap().node(r).unwrap().fills.clone(); assert_eq!(fills.len(), 2, "the second fill stays"); assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) }); - assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity"); + assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included"); assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) }); // Removing the bound (first) fill takes the binding with it. ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0})); diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs new file mode 100644 index 00000000..b7210f0f --- /dev/null +++ b/crates/engine/src/bound_alpha_tests.rs @@ -0,0 +1,131 @@ +//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps +//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole +//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches). + +use figcraft_doc::NodeId; +use serde_json::json; + +use crate::Session; + +/// 0x80 / 255 and 0x40 / 255. +const HALF: f32 = 128.0 / 255.0; +const QUARTER: f32 = 64.0 / 255.0; + +fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); +} + +/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`). +fn setup(light: &str, dark: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light})); + ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark})); + (s, r) +} + +/// The fill's effective alpha (colour alpha times paint opacity). +fn alpha(s: &Session, r: NodeId) -> f32 { + s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a +} + +/// The alpha byte of the rectangle rendered on its own (transparent backdrop). +fn pixel_alpha(s: &Session, r: NodeId) -> u8 { + let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap(); + let i = ((5 * pix.width() + 5) * 4 + 3) as usize; + pix.data()[i] +} + +fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 2e-3 +} + +#[test] +fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() { + let (s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, HALF)); + assert_eq!(pixel_alpha(&s, r), 128); +} + +#[test] +fn a_bound_variable_alpha_applies_once() { + // The fill and the variable are both #0D6EFD80: the result is 50%, not 25%. + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); + assert_eq!(pixel_alpha(&s, r), 128); + let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap(); + assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}"); + // Binding the same variable again changes nothing. + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF)); +} + +#[test] +fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert_eq!(pixel_alpha(&s, r), 64); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, QUARTER), "{}", p.opacity); + assert_eq!(pixel_alpha(&s, r), 64); + // Undo restores the binding and its paint. + ex(&mut s, "edit.undo", json!({})); + assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty()); + assert_eq!(pixel_alpha(&s, r), 64); +} + +#[test] +fn modes_switch_the_alpha_without_accumulating() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] { + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode})); + assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r)); + } + // An opacity set while bound multiplies on top and survives mode switches. + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"})); + assert!(near(alpha(&s, r), HALF * 0.5)); +} + +#[test] +fn node_set_and_style_bindings_apply_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone(); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}})); + assert!(near(alpha(&s, r), HALF)); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF)); + // A paint style made from a `#RRGGBBAA` colour, bound to the variable. + let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone(); + ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"})); + ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); +} + +#[test] +fn a_fig_round_trip_keeps_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc; + let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap(); + let a = n.fills[0].solid_color().unwrap().a; + assert!(near(a, HALF * 0.5), "{a}"); +} + +#[test] +fn code_gives_a_translucent_stroke_its_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"})); + let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string(); + assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 511e0fc6..bc13bc70 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -548,6 +548,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option = diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ca62962d..8a1f4e1c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -775,10 +775,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + if n.bound.get(field).map(String::as_str) != Some(vid) { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); + } n.bound.insert(field.clone(), vid.to_string()); } None => { - n.bound.remove(field); + if n.bound.remove(field).is_some() { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } } } } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 25e36e3a..adeb2250 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -458,10 +458,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result { match var { Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))), Some((id, _)) => { + if style.bound.get(&key) != Some(&id) + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, true); + } style.bound.insert(key, id); } None => { - style.bound.remove(&key); + if style.bound.remove(&key).is_some() + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, false); + } } } // The style takes its default-mode value, and each layer using it its own. @@ -770,16 +779,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(i)?; match &vid { Some(v) => { + if n.bound.get(&field) != Some(v) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + } n.bound.insert(field.clone(), v.clone()); } None => { - n.bound.remove(&field); + if n.bound.remove(&field).is_some() { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + } } } } ok() } +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts +/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the +/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a +/// typed colour keeps it. An opacity set while bound multiplies on top and stays. +pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { + use super::lists::{BoundList, list_binding}; + let Some((list, i, "color")) = list_binding(field) else { return }; + let paints = match list { + BoundList::Fills => fills, + BoundList::Strokes => strokes, + _ => return, + }; + if let Some(p) = paints.get_mut(i) + && let PaintKind::Solid { color } = &mut p.kind + { + p.opacity = if bind { 1.0 } else { p.opacity * color.a }; + color.a = 1.0; + } +} + /// `variable.setMode`: choose a collection's mode on frames (the page when none are given). pub fn set_mode(s: &mut Session, p: &Value) -> Result { let cmd = "variable.setMode"; @@ -916,6 +950,49 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr apply_bindings_matching(doc, only, |_| true); } +/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too, +/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity +/// (styles' paints and layers') before the bindings are first resolved, so it applies once. +pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) { + use super::lists::{BoundList, list_binding}; + fn alphas(vars: &Variables, bound: &BTreeMap, modes: &BTreeMap) -> Vec<(BoundList, usize, f32)> { + let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) { + ((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)), + _ => None, + }); + found.collect() + } + fn unfold(p: Option<&mut Paint>, a: f32) { + if let Some(p) = p + && matches!(p.kind, PaintKind::Solid { .. }) + { + p.opacity = (p.opacity / a).min(1.0); + } + } + let pending = doc.take_dirty(); + for i in 0..doc.styles.len() { + let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default(); + if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) { + for (_, j, a) in found { + unfold(paints.get_mut(j), a); + } + } + } + let ids: Vec = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect(); + for id in ids { + let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default(); + if found.is_empty() { + continue; + } + let Ok(n) = doc.get_mut(id) else { continue }; + for (list, j, a) in found { + unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a); + } + } + doc.take_dirty(); + doc.mark_dirty(pending); +} + /// Settle imported paint colours before the document is first shown. Other bindings can affect /// layout or component structure, which must be resolved through the normal edit pipeline. pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { @@ -1104,7 +1181,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> { stack.extend(n.children.iter().copied()); for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) { if let Some(c) = paint.solid_color() { - let c = c.with_alpha(c.a * paint.opacity); counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1; } } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 3e6dc69d..53b62ee4 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet) doc.variables.variables.retain(|v| !gone.contains(&v.id)); let users: Vec = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect(); for id in users { - doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v)); + let n = doc.get_mut(id)?; + for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) { + super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } + n.bound.retain(|_, v| !gone.contains(v)); } Ok(()) } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7d35229f..6dd80646 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -256,7 +256,7 @@ fn stroke_color(n: &Node) -> Option { if !n.has_visible_stroke() { return None; } - n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity))) + n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color()) } fn drop_shadow(n: &Node) -> Option<&Shadow> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c32a99bb..312c2e87 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1554,6 +1554,8 @@ mod binding_lists_tests; #[cfg(test)] mod booleans_tests; #[cfg(test)] +mod bound_alpha_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index f4a27c5e..be164835 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -120,8 +120,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { /// Write `paint` to the target. `live` edits are part of an ongoing drag. fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if let PickerTarget::PluginField { plugin, control, .. } = target { - let c = paint.solid_color().unwrap_or(Color::BLACK); - let hex = c.with_alpha(c.a * paint.opacity).to_css_hex(); + let hex = paint.solid_color().unwrap_or(Color::BLACK).to_css_hex(); let (plugin, control) = (plugin.clone(), control.clone()); if !live { crate::plugins_ui::set_field(app, &plugin, &control, &hex); @@ -153,7 +152,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { } PickerTarget::Variable { id, mode } => { let c = paint.solid_color().unwrap_or(Color::BLACK); - app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()})) + app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()})) } PickerTarget::PluginField { .. } => return, PickerTarget::Style { id, index } => { @@ -1181,6 +1180,22 @@ mod selection_color_tests { assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); } + #[test] + fn writing_a_variable_keeps_its_alpha() { + // The picker holds a colour variable's alpha as the paint opacity; writing it back applies it once. + let (mut app, _, _) = setup(); + app.session.execute("variable.createCollection", &json!({"name":"T"})).unwrap(); + let v = app.session.execute("variable.create", &json!({"collection":"T","name":"tint","type":"color","value":"#0D6EFD80"})).unwrap(); + let var = app.session.doc().unwrap().variables.get(v["id"].as_str().unwrap()).unwrap().clone(); + let target = PickerTarget::Variable { id: var.id.clone(), mode: var.values.keys().next().unwrap().clone() }; + for _ in 0..2 { + let paint = current_paint(&app, &target).unwrap(); + write(&mut app, &target, &paint, false); + } + let c = current_paint(&app, &target).unwrap().solid_color().unwrap(); + assert_eq!(c.to_css_hex().to_uppercase(), "#0D6EFD80"); + } + #[test] fn picker_does_not_follow_selection_or_document_switches() { let (mut app, _, _) = setup(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 2640ed1c..46bc0fab 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -65,7 +65,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | From d8ff1441b1cd47cb45f1e58b030409ef6f1e24bf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 08:34:53 -0400 Subject: [PATCH 245/245] docs: drop references to competing commercial software (#786) * docs: drop references to competing commercial software * docs: clarify parity checklist coverage in status callout * #786: say interface design once in the tagline --------- Co-authored-by: bflatastic --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a072a06f..0a259898 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

FigCraft

- Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.
+ Interface design and prototyping: open source, collaborative and built in pure Rust.
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,
in a native app that opens instantly and works offline. Open source, and yours.

@@ -62,7 +62,7 @@

🎛️ Familiar by design

- The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft. + The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.

⚡ Native and fast

@@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] -> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). +> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). ## Privacy and online sharing @@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with. -

ArtCraft
Made by the
ArtCraft team and community.

4~X<{4-ShH4S{YfDa+)8$50lf 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≫^!0U|ih2QF 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">"# + ) +} + +fn names(s: &Session, ids: &[NodeId]) -> Vec { + let doc = s.doc().unwrap(); + ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn buffer_is_found_in_the_html() { + let file = copied_file(); + assert_eq!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(figma_clipboard_buffer("

;!41v7M9eqnF|3dagaT~ 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ⅇ_6Ve}>is+_t&KI;Pe}7SW zM^v^%S?1`dsw|fJZ<-#90xIz_SuiK8DIQVqR}!D+b%jkuLU2_1H;PH>LXUdW;mwVz zfUp=L-KIT;R1w3&4bG(7u%f<_@|+atg7Hd0hf`4jon_x+yh86&V7v0%pws(DC_<|! z!KN%O*r0jzu8nWGLe;q@Cx1u%R`Qdm66!YgqV6h;+Fb9y@t+NJ%SL zlP&3EC-skKZ|2h;J$8~6)+SBEC+?=cG>P)HT}*PgzZY$kKVJluEDF&Xxz)O7(iLuQ zs!va}Xz0bfhN%A(s1u*{)=kOo7Q(_`mUPwsiElt1HuNNcqx`ccZanoDlNl~Dz+w(7 zseh;Ul^o&+hmu@cpytH4U^bc2Ufp z7B|r1HC)WJTXR!#-@v`#2-agsI_}YwK0-aTTfveLrumF7^md#)DzpX}KP+|h!PDxS zrkXr;XQNKB#(5^HLYn;yfG{1A_!(0cHEx{SZ#Z%}#FEIRQHYJaG;=pFs2OFeC&%Z{ z8-G>Dn0KlfZh#oFTbAfz?g50$K4by2*Dsy4WoZ>!axW7>A(OS5Wt1K*GPo;>FTRbX z@n;@eD4|YmK;GfpV-pz|vjfW?!i!6cMV!sac~wGs>K)oun}%0A=t6>U7D7+1Em8%^ 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 Aw*UYD literal 0 HcmV?d00001 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7..ee5093be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (native-density canvas textures aligned to device pixels; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; 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: @@ -92,6 +92,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| | 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`) | +| Canvas texture sharpness | done (ours) | At native render density the canvas image starts on whole device pixels, avoiding extra linear-filter blending after fractional zoom or pan and at fractional display scales. Placement changes by at most half a device pixel; document geometry and overlays keep their coordinates. Stale zoom renders and pixel preview retain their existing projection (`canvas_image_rect_tests`). This does not establish text-rendering parity with Figma. | | 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; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | @@ -164,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Align native-density canvas image placement to the device-pixel grid, avoiding interpolation blur at fractional zoom, pan and display scaling. No readiness score change. | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | | 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen | From 39d0f2d87262f96a409679c1d9bbdf163eb869e1 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 00:15:01 +0200 Subject: [PATCH 003/245] M7: show windowed text and sidebar sharpness close-ups --- docs/images/shot-windowed-canvas-after-en.png | Bin 261324 -> 120841 bytes .../images/shot-windowed-canvas-before-en.png | Bin 305165 -> 186314 bytes docs/images/shot-windowed-layers-after-en.png | Bin 0 -> 11407 bytes .../images/shot-windowed-layers-before-en.png | Bin 0 -> 13237 bytes docs/images/shot-windowed-text-after-en.png | Bin 0 -> 19446 bytes docs/images/shot-windowed-text-before-en.png | Bin 0 -> 30082 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/shot-windowed-layers-after-en.png create mode 100644 docs/images/shot-windowed-layers-before-en.png create mode 100644 docs/images/shot-windowed-text-after-en.png create mode 100644 docs/images/shot-windowed-text-before-en.png diff --git a/docs/images/shot-windowed-canvas-after-en.png b/docs/images/shot-windowed-canvas-after-en.png index 4805d0e7513f89d2a4966234aa0c74f4ef9315e2..0a05703a4b6506d9725f7466b36288e3c7fcc47b 100644 GIT binary patch literal 120841 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�@fC}6hoFui|D}@k9 zQT2gW!{qF#)zP$F>1f1{5x=`T^0IFX&}a5e>U^GAcuSgRKiA-P+m$ ziuF0r=_4c7(b|&GaS)`dq0tQf8!!(*ef)i4p%%LK!1KVa=*OI-E3!Xewci#Z1~9|% zN&$+hc2JK)jDVx5U}><=>Xf)2{2ZnjP8G1%Rxd7)CzdXQWoLGMlQ5SQxB)>@Nj{`Dug$7bHlTiHWE+)1^9RvjFtwlEU;tm#0e*8Ssj^3p9_giNjPj>JhX<(J6)fRo z*5E6OcrrkS5W)y$fJeg@bA_qS;rTH6*>6LHs2Y#eSJe0)Bzjhg%{}v<6(E#x+;2PgzDadgnfTO%2W-?=Yo3xUmBu^ zWS2`u3kwzTHaVy*V;LFxPl9u^uux1V(|2vAPsfEa$i?;c{sOwz*QLK6SpsZcJ+g5w zQ=K;2rG4G<+PMt^-mg0_%gcgeT^wAR1ml+XqkZ9$-s%*xw2m|a7LU}dfeJG{Mg=r3%I7=~u7>lVE?iow%BOqo z+{Mk4NJ_(9BGHDmbBD$%UC7>aY>4fE`hKJCL|DSbXyr&+5vgdwT~JT4yg6K{L54t+ExGTgxYH^>j}o|9AxLF zfNBL#XDvYkiW31RG6x3-Q0L%JvlN9Cw{IgC>NXkcUg7X(4%3?#H&;E71!gs(p{wf} zGzSSA*fp>h@FY^z719)y7 zT&S|InZpF1teo%eIR|v%tqKexs^My&;FM04)^j=3>6!jiFeMY1zd-m|13TpnU*97C zJp{R+;9wxhB7Qhq1JW*mGrBh9ILavW^2mlc$=%&Tk0Ep~>)0;i2|NBYIF9RR@0b52 z^sr)32eNCM3ptW;%{5dgv3S-mWLx5Ow2p}j-|Gu*U^4-=mxPqG{i#e5FjS)*5Hs_1Tx!B42pj=Le{nM6dc1e&{}w z@Bff=SK5FGL?@tb=H3+#AaxOfk%hW?UQJ4;!+j^sOT6#6!n&nSd;It<0L}vLPV)N^mYXZAY6lz_h<`NzV%|C-U@}wY(a0Hr?%7MEAPnpkWfW{ubeq1K%$hnRvPcMKPJmdeBQM@F! zj-RjU?As5#x2%$)csg)6!+-N7kQ=%gXd}3gPgVj(=Y1Fqm3oTpZ;LqHSIgOvJo7{E zN#V4Eu18$f*wQjL-NVt55nTg=XFU#pJYbUOR6(qQ8BAkda|8Pr7}9hFPaB)nF#|wLiiz=rT@S59dH_0L z=%+q@{HS6cnseae9#v9cUq9|ML|X#DE5NFxr_2Y6qr8w1Qk+N}mx*?8yI!Ov9lDE7 zItPz}F&3OxNeaG@Q}ex%ys~>Q4TKI03ooy(_EP_Tg0l3cMGqc4fE^0$9>PPIx<|D; z+YtP3G=@+M8yAQ`xhf_G!G8_V%@?pCLvQ}|bDI7N+>t?69OS7$NvU+_s)F;;8?#$f zzr4Hn4;2kHdi9;XQr2#_;*4_94$(7bs4Z20Esi!qZUFS;=IJ^Axl9bj;$|i$`~m_s z;PX2gmj@*=IAeUiMH?SIdi3ix^aK4ud+KC?oE`^PDRitrJ>)y~It=wYC@9=SBZhGS z80^Hc`941X0g^ES zhzSZ0A2ul7UjdyDJadAyTSM%_cu@@Y*(E7xE~w!>Ml}kpDc+dTsk(Z(3mkv*z<-$X z8C)UV$N)nJu&+zBjm|N|Rcn4wQX8|fv*{rA%|h)M+Ni5k3f0(?u}v^QL8EJ8Vj^hK zV3)06VMpr4!D9#o5gh1PzSSzP1!g0SRCQb`vIK zjU|SE%Zq(aJh}h^ z5YPWdccNiu2e-D!wiRmeg$GKY+5aB&xBDI-Lz3yZ56B5G=8g+i)z_jj!fum-(Zf1u zxyaHW2-?!3lprZm+{FlbAjJY|P3htqBG*Ze!Y=Kw=&T{Cpglg^+ghR)#!3Z+aL`uJejWrYL&TfKjrl9PP zArXd7Gk3yUzTHL^Xy$@;v8XC}q(dYX(go`A4Q&L`Wf!PA&c8$ux!uiwA^mILXZnx8 z!E1RN+VLloPI6yOri0y|3i6bn)HYymW(Q0|-O!NbW&@>IY$Y==MKpRYd!WsW%R_wX zD;+;>w}FTRgf*~CW`X=qi@YMPLxCfKK2pSvGc>2?W1y703vMYayRjuvk zu?=h(l>0{Ps@d5QWRV0q$=?4cg%$_el{@eMw{*s>jZIA??Dh$W_K6_Oq|ix*taBx& zL42n*Y=s@SvH@)7E{BTwA3l@>WD}OrKqAq~i3}-btapzM^- z)dNU}v}@q`Kfg@EKyiduMutXOr$lAZd~QRAb4;deY-(B^AF9ikG5=3T-8kPfZ+Em1 zfIEVKj0)l$h;*njJG*{%_q%sFqn>tr>o}cw4|v#4ekRz^GBQGb=~4KpKMFpf3857t z<8G4}C~D0v`*(0y4^)PZklJ@NGAX3D$`lk_ph3da^g$*BU|x8>{ADsc1WA+{AxTQ* z-5<`j;(TwvZVQ=zaKsNrz~NzPU9x?*&|Rh6UFFjexDODm+GPWP?@510p1&GnkY*6*P9pgX0RYf~m}5ggFeTmYA5BX7&$~Nd+eZO@s3~W=XpmZmqr0YnTyN z(f0haRuRJ@+jMv^dCPxs`PE@}1N~ zc$Q67JBTOXd54E1h@F2W4uCS~9{A)TNdmK3>M{&mLYt8dqAZQSYvgN=r~O8CbOLj7 zVT5}WvxkEmrOK1(z&a%Ep8^6sAl+1X0(cBSjD4zdSY2el*L3$GA)$BwfJ7jdfqj5_ z5+LH8+}s(UrQn$&$Dus{#OZQqMG68N&qH9RqnM9?8}N2eQ0y~a%&iwA)~#LoWW%Eq zeR-hA!j)&a4^p`M!Rzg?aiq)xvg}xKlM)3k^)B{KHmiqb>c-Tp9 z4?2;}&AdU-lY(mbl;PhmzK06o*YDpb$=hFqUD6PgzPE2Q&^M^55ZdzAY?rRl_%0;l2ybjv=Hp+?r>YzoHg9mgE*f8~9L_i8d z>#&UZ8R(a@g6u1d*0|^pe zERO_+hTu7VkJOXCwgD1qkqdymlRH*UPVTi18H0iy|E=y(_-+X_-VcRXEeYrJy4SVQ z<4}iTS-xAA0IW z=KMkE>dhdjl22%FFXz6VXMgui(?(x?@K;5)3f=^;475tNg5Cdr11Du+gzphgk+ux*?S%2LCyEjz@j^a_5={v#}Dom_eVzf zs@z9qq~ssP)F1BYnZB;rnyaW)(|>XN2VC`9WJ2N}t)9Vof=x^P5x~tT@-r)OfP28DK%dUl zFRwZq8`EH00sZ?_)*P&8_~FL;y%Z=x{;jg2$VvC*)LA_<&8rp@h|KpZBRcc6!cx4Mou2vr830 zCwUj}T&U#*Z6$*q=Kty3-o0C!ebMm9pnSMhcnAjXeYNf~X-rqlP1a&}2 z1V9CR!XlK(yvJ-@Tmqm6F5hSah*Q8W>^whv&0XNBK~3=7irgNEQ63%1DnJSM*9Iu5 zPQrP@!800w(v^i~fYD>pu&FXL`7jua$CcA>Kh(Df>CMn}fM#sk8E6H9F^U@bP)6Nm z5?YH1yVWQGPvC~$vm;=ahj|q>E1m=i3zq^#QT}e?L(qq7fxh|n!-x5gnNchp?CI(0 z;L`6Nq=y#+!!kSpE8n7W|3O}8L#U9`O7DUQ5fBjI*^Mkoh1o?DXjo* zT$vwa<8dEvriMe@Z<|!TJ{AtDDpa3`g_(l;qx0%CeH|IK3hM9RH%VB*q=w6&BW#lV zotRThIRwcr(x@p+fYYqY9%>UrdTvA-c1NmuxKX~Dq+JI$d)pE;?N$Gt&K0X#BpDhF zX0n7t4OABI7(Ljah68qP`uo>pW@c)q{|0aX@=MM3?-^7lglG>(5SIcK-KWo=eL*vX zCk}$C)Uo8{u9TU|y@@&b*ZJ$f{qd1Q>-2$I!lL_Mro6hnXu$$t1R>HU0dNOWfuYqwM_Fbr+oxK{QYi&IO4+Darab@vs!heG8B?&lQ|6y^_37QrT-A_t| z9K3G-E)jLQar{5RzB?YvwvYR=_Z}gegotD%$x3EfNmeRUR7O^)>`_+9NKr^p385jf zlO!Y3!iCBzQ7K9Fe$Pwyc%JupKkxlV_uZ|y&htFZ?u-@s&x&&F16f#qu0W-|li2 z;E7;`x3Zz+sq64hf!kJoz9a^P{r9LUn3+B6KymwWti2vDjE-=Xrv&ywK0H3Zv8 zJe}W9CWD5YM1ns=WCR^UE6%vNTdcrCvGmUvOPggVpt;vO58Ia~{r&w}YiGso9cSu38}AqGNQeDNfzLsOmH0L97nYN5RivALJr2K5ghuU1631 zyy`npmGhW+;+?a+W?*X?C=jpjX- zI91^3#L2>8`wW}d51pO$RwQh-s^ty}!jsBF#6W6m3e z7fDFs9$~TkjiFL{o9J=zQ^pt#N^>Vz#sxg~!e_xu7amCaK$SUj@*87AIIVUTuS-qk z1i7~Znv!!LC}D41m6F2)3(_|UA@_7Y`ORiskY)^WE;ra4>ZJh%WoNa)+AsfX}<@n)S+Y5DSW!BL>zbhRMT3CQ? zunBP3aj%Hz+q97%Wh;4?*vV1sjko@NBt#Ug6yH0P;NajO`K#+u`V%Ez&Bz$nce(p1 zwfQr7E>am&oy>^eSE?6}h*|;dE2ov~5-SU2ZK3!_x~v3a4IfZ>)cYZX@9?CH#gV6a zZ!c7S9Lvnn-p)W_T|xDhP;_)%(K9yw@$)xs{OL&!ZY2a+?R3eqoxpD#K3WVg4s zl4>Y5DUZ6sT#q64Ydvj#zUR8&%}X3I9uy+r^rOKgg#Hw|T|~R$AUu%Zq}6>rR9}Jr z$B!R7VL5eH08;9UJ50^sgGfa(tbfbk=Cwp_o?OB6mNh?uKy(1=_RTj4HZPIjB(8EP z&;2+aJLa7uz@~icVs0SewsP@E{HK(9n$sw#2uBmt1~*nIED?(uh+fkwB~%D+PO_^I zRqhu0BS0b{`ZhcIez5ijY7!K&s8TTx*2f^!6XFTx7;IBR0|PlPpJIr;Iuo-tjAA6C zp%e8xe0Wx(=5kye2Y{-@g@YtgIS0KE`Cx+O2g<>P7u#^+uNQMk3&hE{hT%cy33gR` z2M7LfSj=fhccEyWX@zV`<@HJh1@j;=4Gj&-Mmp09CW^$M3OoV`6)XI}UUG|x9Y=|R zXRuCF(;Pe%2~~)(;R_}#jQLv4ffp*CozQ3DgaOQS1lqd!dTc6}Ksu0fGg7ikRpz`TpTu-0lyB}>&sm*}7W1wW&223#}&5$>GlvsbUq zEjU7zbMy;)?*M~@t-XExQ88ZZP6R)ftCNX^#jz&XclU0|s+R6 z-=Jen8$^;sRjGgI&>=K+283NLPHYot?)SaMTLE4jU4R0WFiL~nA4J|lOmw2=R#c>V zf8FpEt9+FGGw8D%Sc&;NQON;^v}AZ#)J21B!Djg8TT#%0Ws7gA zJah#dtKbWSYg4on#G47=VcO5HtFxrWC@0Xq}DC{7=Vt5A(Qk0y&zk&kC z&=50SD=ur*@RhbjNxD;^`rt0EcBzS9xzpa!ad}NA`bXR=*rOz({g$8-tV7uZ=JmaR zDgNclLxC7X6D|;$ija+X^+WUrZp}{b-#}+^fS^?;uTv08qMyBxu}%{TJ*CWcwz7VR zi!ww}DB5uLS^XnK@F&j9Y-W)ED0l2nulBd%Oqbl;BC-C!S~l>p5wK6|v86HHEiewZQ`h|BY>W5b>y_kzN(B+P^URijYa z-`OYPe*WZskqH&l;drp%W`J%6v)3N-QIu7`ettYI6%8jj)#Jay?wf(Rw)y8#D9X`Z zOdNCEwoMI9#O35d{);bcIk;)_3H4p@OnBRk`}gm|`N6}}b8@rTQPqPlRZHyd;V{8? z;-q0d=o+IrL4!mMXDIm{glJrCHOwB+*KRoSNg7N5tan_`hu&fyeY9PAa9Pby-hES< zIep>{wo{`aB~q5)flkd9uA$-WQ{GyHNs+jzn#&i~ho@qP8M!Lgt^r38u zXe#BbTCFW^!S-hb6M&AsZQ)ZCAcxTsxGqXhbzvw8UW#Q=|dFo6?hKHNmBFq(?tNpQ+pGZpD z0E-(GIz3OHZj$UKT#nmTULgJeTCNR$`H%7i@HSlo^PHdZKpSdSh);qyuG^JrNrn0b zlS7{QK7q(t7PUkA!Fp~u0QmV89p@uOD~7mE*84L&l@U~1mg>gK$*pgy@{(8DpEuDk z7$TJ@c2|yQc*qg8Eh9OZ)lB7)B={*fW!CiftuuoNSPAAVUXM$WI_T|f$I1^sJbVxFaVhq0zPR3F- zNf)fa8r9(=4x96cqBbc*3w~u*V)U1_Vo0=((*?eHRv0_bMz=}*F&D}A@1iJ7Fsu`& zC2SUHsjWb0NsnpwufL`bFFJ~+4Pyof?Z=?71gGOEqb`LZT*ev!%!!VePXx1Ha$DvZ z$3&5t{7VpY=e$#@RQN1hkbt1wsuTfB7J#H(XJ~1)D z9Mjm}uK`%-f@z_3A*@v0-RbD)td`65^z?A*_X+|~C@KZ(awae=wx&jVvJ$@1=qA5u z!?gxb8Drj;FaCF(rpL$OKi~uw^?;*~zrQec#laE@;LWJEl24q--!2Ez8rWw3j9zPU_6@lD8zHB`9?^wJ+v0<&w6{bhMiR903(3oo-?V_UF&3h+8=FIEdG z7}-%^s0H;{s>%^G}hzQDSea6EV& z0*n>+fti_krfdi;2pZP?bL1r3b=ca9*B04cou3b$l%ms#8r&c7>N_}rP)Xrg;6L!q z@7&RJ5^#Eniy0r^r)|f<#MJii;lA1Z%(cpw>0S7Qq_|>S9ngyOL|N^R|m2H zq!Q0Ao@?3pbBEo7Axs9GmO)z7pBXwQGZV+cg=LHQ?tE19XY)13!EP@Do5|XXA+7$ zID7$-Zu+(=vmNDCQO?tzp0Kd6nV$>azk{FA{?-pGp~UVa2dw!ks;c_f6qz_U`fH_P z^oQTQGm?vKd;Iv)6Y;b*Fzm4kcWwxd8y_DBK>0okW#LwHb77c(>C0k{vv%!Td3oj` z>Od2mPdMP6SF&uIfBxM5y>8z5WfpQDr>D2@aW6B`=+Oh2pl{M>&zfvCZf7~6XPNqW zme=}1WO`@(?)1#v8hiUzt#ZQw{p#DX-+D{zvNsebg+z#2~>Gb9diXz*(T77kBp+=s#nHD@IgNNi4f2!{OcZp7T&b(u9MFG9%&^d zrF7E(V3aWWKlV6l4Q>v+tkIfIPfvqg3(N@&0|0=%_Re{hw6wG!?cv66GBmV|!=zxK zvr`F|sipODV8FT97IOZRp#D|6pG;0RVx*&`ee>oGS(Zuoqd0ehPmQjwZg@e#9wQ@| zOTdL{D;j|NF}QcIH}Q;yv9144E);fnN)5;F-MtG?2usm&S)WMZHCuVYetUczj$Wwo zOjkgF;5j2KBJyEsYKlzO6^SWL&&|DID<2gZITP^t)2A};!aH}K!&)AGv){J7^}Bn> zHXaAYv17krjbtm|hjO+2mucbY>Y{u1LYwNrALm2yi1Om0+ms9Hd>kS6bkav40Q?6I1!^+nX8jHn3laeu)oi zyu%Dj3N8Eo{lzd?kdw=+k-(j$bctf#o$c)>6B1@WeZqSL`yUH)^X6N_)6*`tw$Yr` zu%KZNz$(T5Gv5P)1j%ieva+%Ox($nfz=hf^S!^RrU?`rTJ?(?jICmyDk*9;Ho+wNifzkDFM4wJe#ja8UQ9j@-~bFk)+S+Sz<(j~)9 zn`%M8X-s3i>7&+5wb|TK%6u`spg>CW3qge4aZ`Hb$}!-E@a@u^q7^+)h#ua&xpet5 z>MOD>ukEooY#4YIz?qq!pT~YO>GOVfed$k_t-;^S&D*b7&MO1{JKjX|9>9n>YV}?ZQ;$}VO`2x_-eSO#bTEBXA^1Fj! z=H`hYX9kd=vH+hBwhxGNkh_xNw7Ng^bE%~)PDkuVw({Gs;{5zn2D69?i=LLYxwUml z^k&%iPmtJ}e!q0-(&NYGcx9nE5fc*&Q$nM6Mki^@J?wF;%*@yYI9OQH+TYIKD8C}E zrpCAG31Z!$@;waOChRBCEAOvzcqG_cbjnr8GrmCE=je-9uSOS4 zw`^f&W7_~8<1PM2(V)fT=HwimdZt+W^xfOHIod~VUcb)TzX>}%ng-CsF#amqvrV4w z{)x=YWrG4(-lL;Y8rFKc*xLdBHpNzBv0xto?7*FXYt%O|z^U+s+65z%SiZ8`w_C3y zB_uS&)cH?v7nUey_`|AO5k4!x5I{0Vp@*^*euz!k zYFA7)q@0xzbJrs5`r3Bi=H}S&g$4x-hE~bS)<>{{7K3e~;RX+i zh0JzPQMTkrNK50{u9TNIEw+t~i`(nrLAbri$;nAe)86wC5^;r)#{-+&8AJTP=b!yR z;<{d=pVy_PxPANf^M(d_mVnsf;^J)D?25XNVaE03@k>ceS@rXbO-&nPa1b=PaBCYd zydwQ#HaK$XOX5zMv|WO;VLVk17CDxP<}w#baB!1J@CjufpKOJOX$h6 zU8>oo(OQbRveAm1jyI?GCq<+r>6+DXi*hQtCK~b*sswPts7VqH91IMqwBb9-Dl453 z!~h<8QTQU>c+??a$0N<;B*v)9pB4=*ir?n22P-Ft@bntpb-H19ZT7&K=xExZ5TA?3 zY{p8*`2%G_f`B9`UNn)23S#NYiLm3^{ z0y^2f9oZ|$ZBoLlWvagpnwXX1B-+yQ-3A^~I1F)mW9UgH`%0XG0!Cpy2$%!h=5e{|Q_VHjgz zu%`it4yax3ch{*u@1or`5n*BI-Eg{9YLUG`OSE9SRPOblv$M0me|wNi8@jrpw9zlZ zz1uC0c%tO%^;Fv;dJ3BeFYl#q2Q4ivpZxa2xmjX|#xAxZ6h}A|RIM#7#G5Ub;Gc9t zUOsR{)k_)$WHp!Ib8qOyjQW=8m|)p}uW}(RE$z%1LvQ^M{^_ZyRon?PFL2zr!3^vq zRci>juJ}j5wKy%BC)XkcMsf7vJbrOyx`X*T7g@y6Vhx4n_7~3{Fg=MJM9VQc?>|4ORix9O&rSvlnLb z%w?W>R)Lq&x^Dfzz`*P;fP=Jv}|VlejodX{xcg z<`*j=|g$v#q$l*cGqy*q!tCm-c?PVzDk3gsa6N~s2@RK{JVSK^_q^-IXa!w98oSv!vc?#$;CXlWaw_y~Q&Z=%vrio6bj_sFdDh*1 z>QPkqPj)mPLJT8Ks3rn9wxZ8YPJYl2IH1q$i)>ok?mc_3no;%Rxy^Wr2am&#^op%K zO2(p??L0g@Xa(>UpJTKGJ6O+5QHdol^Xb0FSw-+=u$ShzM63;ZhmzJr3q1$m=#Wvz zffN9I_?_nMeQs{j_bX8>@vE<0Yvawww{z#t+D~q7ZdejznPsJR28tgeSuO^#plfYy z-Q2l>7nl(IK6Hbp!J8 zCr&sPJ)9+uGXfkDq7V)W18R^WD_ctMUD_)_T~CaGzC+P38v~ zEh+R+!bV)T*Na5>d&sbzdwYtVRPSX73>x_+RFtHESj8s?+}#7fzC|_VX$Ig|K~d4m z(-R-C9Cji|?*TxxQd(Mb{rXbpd+M5+u&8Q6Q4FTRg`;0z0Jnu-qe5mo{?txK#~&a( z;UX|Zfv#tVy*;CXw|18-7Fg(~Hwi9_>+~N!efpX34qLM)wt>R=Y*Jk|NMo%$Dh6st z;wi>`?jD`FBG`@xHej*Kr!%kbXQhDvM*Nv*eDw~@1}mmOkvmXt$wJaJMx*roR8*8A zcofLt!ZnLx+#BFOu2nE+dLJq*!-n$u8IbLvBOg}FLxW6&z0 z%INs?w!NvbvHqSOBQ~T9YxgEsR8&kG0lUSeKuz^ZVH5Wtua|vZ#*!qSMoYQ1Hz^#4 zB8-k>=GMq?jS3MqYR{{dU)-iy#74aDW>dNkMGH;4+UhLy+WX` zub_(CL)3!c0UbGVxBs^aMCP?k8bE0QwIK8AN-^!R@OmN;s~@l;rbAGjKY#Hep=AVv zhXWA2K(+_ztdSEPA%54D$YSSNr?fB*?6u=LV{=Oj(s>|~d7Q*xodO ztC=3du2+Bg;INZo_3YT$mk1{myFP~+cNDJ|jx7{J`D|j+c6gnF0^TtquL)8>sQSk6 z<4|4)=VAK`X~e<2DEQ7enE<AP z!9Wti3`HCM?s$^798Oy`e4$T_j%Me%$k3}WUf(4ey;q*l0gyy=ag(EtoKr*7#uy6} zn^`+DDi^dF@n|S(*#_C_Nn3iKX7@BWQs>==#R|Ng2qqCl=L&8(;6RA6D3j$ZZEDg1 zy~?Np3QM1wRK}_>`~*Xe_X`5_;{;7m{A%Xo2H3`SMf}BcrwnCDq_e42CzwejdIQ_Q zL%Y6wNQ{GgGNLFwfkG$$W8VaKls_2*8BwrgS+{Ccd+&y8YF^LI<3_?~sW*lQ48sPI z=E6&sS@iMI{Y=HUyun`6pO>+cA^s|S2q;`FThNO9+v7L>@w-ByMvG1b9RwCW`mbgD z{CGhthjjp}qRqXeHZ?LLnbro)x;mjF-P1b#yqqSJO+Maye$6Jj>pYz5sXKwiMQ`ro zE(EAs_}6m%ADI)&{GZnxIgBL>A5x{A_qFk=1Ibsfx?(J?l9RFr&kYAXAQBu27}mtT z{lq{^vz|+sJE6H$G{I2+AYQ`w($dlxx{YD7r23td+>jZTVE0ED{nad)-w@PoPF2I? zSsus=Hv9Yd`c7c1j02u9c!UQPUX}@%JTT_rjpXl3OHIAF{UOjkY(emQAbhcKnT&aW z1n}FDk=>qJCfL8ABjRJ;1Wgwt=F6v_p_xQp7RJ!1Wx=dlE&(gmb+FTp(LZ(W9AJ}s zM}GtkoP@UTI|4Dc$@1bfQ#t$i;XV3z91qf`ai^80aLVLd%7vj3wrKFowqu7o(+~bP zyJSsq3&oGACj1|Z4O12BKi;zxHu%B_RhcxpwX8fM)2|182?oMMQq$_(%BI zaw((4i}xp?k=L!qo;!R{=6C!M>@Z6Ez zH1XjUH1gnLgRh4pw)w#Wyc1xGc@YJ{=*>T zI?WE@Z=QEM1j;y=K$h(BtO7gv5eyT)W0xH*1@j3@=sdhj`#hl!t6P{{6`hKF9Ylf+ znt&OCnLeTO0>SM)xoZG^b9#x=!g5{Cm7X;_e`C&8k7hI5GVmza0+@3l1z z05deIuT0O_e5teCIU$nYz0W9F_y}Htj3G(gN#1nyi_r9s)*b)-^Bd2c<$bN1maNa8 zN0zaZE|c<465h4KtT}~L`JO~1PVoLF!qjb(3H*|9hVA2RP>t;x8{1w84{e;F1cd^e zxPy2mz-Mr^DI)9D_dH;QCnS6pR*8#?uU!is+)WU}@DC1ALVJc`RX=_@VHk>}0gD{W z0jy-wa3T|*2;LDrKfdAtyHt3f-*iN%CZ<^MI=DLavHmzbEr#XjG!DJC$LWkvGqhHi z7kj8|AZ-EPr$e6>DJ{;y!FRy6w!5~$cZ4=KUJYwidwa}FYLf0MshaI>;H1Dy>Q4X> zRG7&#LR5czrLOM^2e&sVoC;G$_*oKm0Z{QH^$QaQxM6|BO87c+>ErzZG742fe*Oqd zHGjY*9)`Edm^*+$j}2z^j+>W?pW^60^y-#{0{_q*Uw~oyA74QVS=eXe>|Bd{H$Z;h zcPai@tkh17T-94FXT>Qtf1&L4YH?+CsTnjm~( z&B`D+QXhYfG~b7Q)Zs50zOqhjpXE9haMYY(9AJbtoxF%YDO6k-Wb_gPA0OqQSO*kB z&#{}JPTw^hNvZ)guZ*)HqTjqhLbncf7pT*?^m{WP4tkQU#uAC_qiBXpWdVPy0okD+ ziThigw2Goyg1$2TVZ-p~Xu&;?N92$qMMwi18eq+4S7bmMH}G^CTHGL|pwOtdb1+(rKgUMSgAUv+!ma0$g<^%ru@<rvS27Vq1Q#2(th# zNDz8`#n}wmnD&9lr{7UQ3D(LdZzqzqD5i!r>@eseXypQl_W48vRw`Z13eul}=q_r^ zM&qlNa7Du6_Af=!q9IA{`8GsxkRB(xg4ld-2Ut!_5e0Pri`S-0Oauc02sH9tY|-r% zhHPBJe1iYY21I?KK}#9Vg7yV#$g9l+e?*7m&BXX#7Al1N|CY4>{i2dwPqo@f68DOl zN;H*V0o|au(7eeb*!K%RK?k3G3R4JtMF|d^sL*`m%@hgk5s8J-qt~!EHJmZN~$c8SZ&h7(EwtYuHy`G5+r zU$4Ki#ng|AjY{13V&-E=!{Xu_pGFi(OBupllDLQ5&`>>H32Jo|ei*1Gh8w7;E~;@= zjdrS(E3?y*o(=cgaE8SFk!gX<&LsOU!rGE80#rPpve??|!Wd3m0EA6p7`DX=En@cj zU@L`9`nOaG+WA>(*Tw4~%WA*9;o!DwS_RH&K4luNi;j_(0}H;N*eNiyYvdHg%sBRs z^VO1&7K3}o6ZQ(XDl5UNt?rq<){_)%^PsfPb82!DX4tS0kUZU*?!cp^2^0JEyBecm zFyLth4+}u6BLZ;5WMpRkG&9rn>KF(Q02IMK%Pw{bcU|MB{kUN-ea#9e^@u<&#;wFV zK^S|AKY~`YaqTg}R0ti>9~#po_P-Qb#|C0jwZkP(J+P!Ge1b>qnm!t4B+UvDCrv(Vb# zjGT#dr#)PuTHiZD$leL6`3>bI6SS7x2HK@ni}k-na8>ps|WH18gCd*#N} zRv{xSj7}E1YxRtK;C6xJjwB?DoVRFtaYh)_v@|u1V((*6mE5~H%j z*#+bOy+DL4c9{P(yZy3?q|;c+aA^WtMAq&OLU>A?I**63k5Lvdkv!7{Hu}Q@v<_p* zcfL<%OCVP6*(vUIu9Zbzl5OKavVfe;3;}X^QRS5h7a#Czfo64dRNcB|#0XRuh#sg~ zFr4Dt?1KaP9wMR2%Al=^n17D4h=R1^&@|d%jVcEelzdBBNFko)8Gun6f^Hcs?YmA- zN`o|UFW?1+zXz=r>SRnt-2H5E(nI3jQo5dwmg&n0Lf^3TF8<}<_*Y03p-?2;Xp4+4 z259GTC(zFFb0-cpMm8TfxBFD3FGDL)(XLvxGh|{#v?U055Sb+(-iD+Y5HNp$d8Ee( zrZ6(L_n}DdwL+7K1k^$3dJGL|2N9w(J~c%I31BSs=rn`|hYugd#W>j7s)GKCB6#4@ zBYA0QH*kV#%J`gBg)Cs$fB_SFRk$OhG*zm54UPYbIJWS()PK8mntCvUk``!2quBzX(Um09|BH*}a4A8V9NK(7o+HLhq&VF@ZS7%@ zUO;pC31*gDx@nTp=a*ik2Z!$0Vx?ndi@K_XJsDXkI>#0TJ`zeHD9q2JpGLFo_4G2^ zAP|s3yK6whfX*4`yy)mS2>Ts)w0PIfWk96`tCkSl1hCxdzP~%CT|y|`I^f?X%?)Bp z$2kokj)<96z=Tg#)mEAtY3BV;pB@JL080W+O6Z&5cIM*Z0#1&K{ytc@U{Hb>2EmQT zwDliCMFYb%#4R|Mx#}4k#{-i8A6h@_{tqxDgL|Hgd6qIJ6!0@2!spSr#!AYAB{mHI zU~KpI&`Ss+uG~Ox`=x&MFVQ!xot7*A^$#N1tWGb$KS2|Mec}^>>qo^SK^fVq^Icj_ zuDJUrobT+w0vj6}8y)2l5;~y)`7-b*1?TE7VDNR8?mc*L@7^H1v!FRbX>|*|89-%fQn%{!ljQJg{N=G2mv9nL276$`$&HOUK{jR*j^y1o8%4L zds$!9@qB=b&~|d3Z0S|Hg_Z^H8Kf(~o0Mw5oUW@o{+GD~9%f(d0&d9poE$(Zdl!9s zE|~z?D_KQy<({8b_Fyq?JA_$NnW$ccmJcW8U|KTxBaBqrTPVzHT`#|}?hi5|!Y~s- zO1(_lPq9|+epXO!gVCQzuO6M_Kt)6B3z^Qt9@BZz#EXN3J+2soc51!*`Sx9ZhK zh1M^|hY^B&6(M)9hubE<4avN@dv4+;;10uqzc?TfyV#7Xn9pA75}LYP!N%n{<^CMS ze|vuKqs@{MK(f+VPq4Z7onnvQSbtCVqTKt!gX7jIfZ#2UgI)0xaZ%5HeofLCtUrGK z27`p*+Z8RUP7dyv7|wG*dNl9>MpgRnMvLVDr+;FpcRLZ}#9v0@Rn{KS|JOovYExZFg`u!6~F2JOdu*8}O(h#W}s% zb1U-FaqM8|Q&*hXO!Q_D2$)WE72B*lW)Y<<@+{+4%?^=%|3fV}w9%R59I8k6fnH4@ z*9~k~&#I7@mHQF7)gg9#u^hokc#klyIA#20AZQ4~CIoJ8fR7lWApmrM;s7q_z#U)k zm1@w4gIx$QadGpC>Or-szk@k~2X@Ca8yppm0R$HC7A-)C%01N3xACBSADm)ITF0lN25?0s18tzzo~ zCRMTPLn~Kqr8W4OJmK68!FWWQ%b-z4?mZ??T&Glj&^GH3bakiNkN|8A zI2<%`t@2tC!oa!fPSs&a-vFm$2$a45^Q$BJQIHG3UXBW)*~gntt&otdt>2p{0T2?I zB^2-8KVhj>TXnP6xhEQWT>k=uAy0)HKHcyO}mh_(appOP`SI6hRj z@yS=ji2&%?U?|@q^c!Ut0Chr(hCUVqVx!vSZoB*2k~C~=;|rI3L~u?JDQn5e>OsCF z(LG_CshFrCeK6w3z18|fH8sL<#~!-f#Nq?H*9km%93tkcTX%?%Dl9#IYFtMHX=>z8VJp74dC}b63WuQ6OPvn z3c%qA+9m$2i~{CO*(F!6O0JDGn0vE^Ao=bS4tDGz49}3YJ($5cpL?GEk1qw1OTuRn z1{~umWDra(q>Dohv_XjMw)JE5AvH|y@T_uhl5>mo7LS(CwR6n62L;1Su*9mo)x%FD zjV5&?4Ydh(zdwGwXnP<0*c^O`fNts~g)4nxb%hz~<(hXZ629KI#pUa{FWckCA<}m# z4m*AsJR~SuX^IBXN>37Zj8xB@?Wseo7Sdrd)qZA?Gp6n?8$kGbEcXmd;XF{y5A&P#CP+ls?$I!d9No;)N_PGo6LRxtAC8KRO<8ecTz_p3=Qd z)C?rMa~sY}8Bo`LTC3L?94{ov{r1;o$|9s>)q+F?At|ZLbyp#QoNzK&l|D@D9Y)Oy zMjt$zsOEx0LWq&Pgy0o_OZjwAdSgYvs^Deb1X#LY2y^%A;kR$!#-Y*FP^X^ui(G!t1AU>m)z4q*An505v5J&p}@g))hWh&bEY+FDzWOiw4ry~em5#Q|d;G)t%g zE@&U&{>U#N5Z`HjSc>3*Tf;i`k(){l#ARH+F^onM*A7cdu%GagkS=-@F>H&AXzTn> zLN<_*o~{Nq&LVJ8l^d2$>e!XQM}5Dl+q3Gcl!#S<5^Z{G2oX%rKd3eZ(n!)D-V|ZlSLJBf23g&DsSQMM{V2})UFiJF(Sb3Ir zs;eKgvt*h(igi2SAM+a<#V?;5(cb+K!-6 z6p)~y2NyW426wMz`8 z<$dy)*Pps6$Qqb2k-`7U+NNp|4g%o7Lb8aHd-6O%-mX@AOMckijvn4?oEIE$d_0)7 zxF<}xdwtlKH z<3@$|&NK0#m)E)T=M%@)1BW$I`guJW09>qS_TBZC|G%TTrMd&=;7g^t4n(Fj(9bLJi^4)6hKJ+ z_UThkj!kM#&OjHP4{ijgZIcBKB@e!>PHV$Sz9}hs_DE5Ra)SQg;2?_B=f*yMf_I$`d3l}O`xG~8Do0UV+!as~1_0QO>OUXFJ74q} z)whiw=;M@ICV1RzMX8CI*%X3{f2nJ0JAGRuSe#F0UzDZAYrVRt7llNW(`jk2R4-@S z6V+PB?&RvZ{zecS%TV&+#*_VeI@d`m*O3@5EJK#k$SPjbR&^)pzd z-!(H7c39hOh5sj#U3@O;85z;9hIdtRd^{r?8yhn-sQR-dSFT?--^#EUyE6!zixi&= z*Ro~(U3uZ(@@n+KD90PCqH=Rs`y>cI@?A9d_CwRF-y(yT;spFlaFb!EuYkJ@!CkO8 zIOAx_%9fcZF~m*ByzyJd&%nda4+f<=%qren_<-HZF0}Y*r$)kctg480A(u~QWV|Dj zIl=TE9~Tf3y6Si?_?U&B9#vt@A$Ovu8$O;;yi!eVo6;7BS3Q_v@7cXu$fL5wQHWkg zRcM2r#e;?i-|_yMq{$4I5@hX~8XpNX%3#;t*hts<9>ZHz?Y$u(3$S&BTQaCKI1F%^ zV06!Z#~=0=;l~X}0xTW`#?SA zh^V(>nlDeCjH$0UY(!B%P};VLpyj)fOg&eW1X0C+z_J3X|esT7XK+fQVs@ zaB&Uee1zZ9b@nf7oyNWR?>RvHJ0~oR>lFhY7xU39&k{|jYIbKMBBU`h#=QW;3N<|r zDm+%ZYv2}wy$10R@Nb}-zNYLOvdK5PCmQUv=459>>ak5hQ&lCbF8AgCbwMV#}M~ETVp+W;U3(4?i6dmV6++t>IS{ z_Q9}^$;rt$goA~WO$%S%Kf0bTC~9nCoUf@gH+HN!@GamnP{U#L&CAQn&mY?FghE3Q3^(6iR(2t%E$e^DI7!!LH|}k6uWuYlnO_y=Tq_CmbU10=ez6_>6AK$# zOC5H;Z?P_GS!-7QYw|StN`Rg=fuyf*BlS)XCBYU=S`t53Vhqax&o!cI6Kr~OGoL@3 zm2S?nl$`<-x^P-QNZF{iD6`ECO(iJx`1D{xgXtD50Ae%FNqv3l+!(!0KOkS+jHS{q zmDUFG1X%VHX2}Mk{!MeBsaN^S-e1kG>U#QIc($Bw-qe@8VwLm6h|ZMirX7CTndQ_eeRDTGES0|V&5du8guDejPlO-Pq$GjVo3!>Q zb`XNN(wR~(MVP)DWhMe8Q8{a_;UKN3m}ENHy>G>CHDViyo`=k431llX- z5p6Za>DEM6q0q-`dJ@2o7pISQXa zFGUf|OV^z|J+q4MF76hb;+%d{V$ABs7Vg8_+xeKzTCkF6B^#;3I8i1{H1|SQKGKZxf6x1^{W} zhq`mtS_8Y<+p4;L=5SC@zoSIqt|t$TAj(RNj~9S+k1xGzyvU|zASKjGdzVyeCT!AHnl zKx-%4v(~h9bg*5q#M=)7N!0UZf~05qmx5uO!lY$y1)LAZ5)g zAGQ!3-tuUT_CjAM$A$Cf!J`Ce+~-{+-gDHe7)G?Y-84FEtxmi03Nq`r%kuWVPQuxR zlmg9ix&V3Ypr(O=4GIbe02N{b?UfXCZz1y<06QVXx z^d1WKv#2=4Rg*Q1eZTno`Vhfw#fqrJIyx+5(%h%Q4Qze#8L%3kR$0eczR>ku<3Hw0 zE>!jZ_GNDFp#6lc8|@aA$e|g?yMzTsTsZV)vH9e!Vi?Qm_+CdAxB#NII%SjsFqio7~#c?=g0Wa!i%q=e_x}n6MT6Oyn?a1aqd5r3InPQ`N(!eH4mb$NW}k~*iDPABE9{}N zydMj?ee7ClA-a`UK<=EuMnqq(DMtItadjp%5TkW?OgO8GyXdtKc+Gn(=XgImzGn66 zEycEAY?DjTui)*N9oNv(awxV%M(xNZU37v)a~7!6ZmgegzJtZOut4;a!$H`<)UI5X zgTM{NgqzBXGjcYc{OhN=A(yMEly@0J%L|3q@SIIR+{p77D9)gK72xHqgqrNV0^~Yi z0ku|eV0SK03_gDzW$Ki2qbF29wWh6d#y*R5PZko)-j0vkL#MVfm)v|qK3yJGB)3_z zAS=*NKf~CcdF`4P?&1CQPPPLxR1n7$pUTZmZj$xyj7_&Hwk2B8UcL=J@=4GEDeT9B za&^6^pI4uD$UouaNwck6TiM0m-~T=wC+6eCt&>`9BQJ-$c&`!dgx_7^&_JKFXqC>L zaA<_k#EKr!mMl-pT;%bc)q4v%!wn43d^xR7fda}}<8ix}@}dwo3}pXGh~4D|(nr{( ztJVBOe@Z_;rz!DOF#>YLEnA*o^%DDU<_)~u$w^6tH}z4%{zA&ntvOV7*d8YD$6cZN z!RWuaESP*^YdR|1D^~bQ^}TyjE-Nq&3VO>mfMK^L^pTee3$Z1R1~|@0;4|;N-`vy< z$!D166SsucW7x`zZ6QQc6(09+bHllu8G>`CDWX!;b|E4BH_KN#Y$p%j`Vq~%<10|> z^)y!OwZVs3fL1p&^wrCkBW-%UHR%u-W7$HwR&!@RO!d`*X!`Y=I1`^ge~#S+Erg?^ zW6cRu)RMPmfcd(1&*Y5sN>o3%kefUG_HAETy{7L0507_9Wg%p{1H)C`vfHVXW%GKo zFp9=V=i$SP%!yGJWlykeSWG0Qb-UaPWK$C6;p8OZh+t`BWX1=X0OEQ+0v4pYz_T)& zz#VQO{YZWJs_rr_9K1~l&T=g%=q9e*CX4Z^lW<9dvZulwEculyrzX8;c=_HP#{Gq`_rC`7C-D6d%pN+X znJS84c<1Nr_5XfR9cDpYRlnYI10yT{mY z?fT2R98P%bpFGf!k<@lJU~Q~W!P=uz^)olpO;@m6t$FVg)w%V!vBA-q4x!SGYAFd< zhair|>aE?D+QGtfgg=p%z<^yBU-!aaQXtkLTzTK#Jv8j>svtd}H-n?|h5$^8P9r@5 zfkJEh*mbL;qEa}F4^wGzGTLg5cb(Y)#v?WYpcp;CD^_j`@bT$#ZjAlN*;hl@Nl-|@ z3{x5`?CiSA&mCTUa^>T*;lWD_`XkW$$%6z997C8p6J$-$h%tNWaAvMw)Rp&xeqYzN z)L5C4ni&!8dvV&Cye$p}KzPBCiVi)jwKez8VT3VKthsk5)fJaA_9HP@i?fO(MnV{` zJvBS~o8jT^u6q%jTA;)=m9aY`IstI?3T~5tfH|P&5X}-BobY5e2liQbD?@f!8LM>W>FuH7hM8(uXHUaEyF0gU5r;_@?ni zaK6uM;GD}Uh$wRmC!ISuvB{WAAq`(*H$oVIb^%Zq zk^#^i9spm1V6QrFFU3AsV3d@Nd^#K<3*SBtf#RV)O;4ts;XPo-5WLNJ$+S8CL~8yL zI}fNcCF%pbJ|h@W>uw9+dqY-chE@yA=H|@n*Yb^+^oflE9n=Vlce;^hpS&Ft`)|Zoa#d5sX{< z`z@jy2%p>+`+j^vdK038FkXAs+B$}}b8jrZHiPP|Y8#LAphx zb=H^OHsVwQ+a4jl(xwc#GJ?BA;+KhcE!na2bSZ+@K@yen+Qup1#@%|XD|!G7v|Hp>fD7%5|-rsAJg~X zCj>|m^FsV>oUMh{goPPqeUP&dK~@;TY98O*Bx5bjjp7f<0Jt&nEhCeYo;CR&u;76# z3WFH$+~)zmC*{QaGySEf|Lv)fLTFP){7`3SGlK>@>w|40iXJeI^KeZiYc z-xALre8?E}JqT~$cx<<~FY5Cog5luZlwjxwI|bC%VMwKdyC5u