From b640069961f7a3a3fbd17297bdc8252d3a6a1cb9 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 21:48:25 +0200 Subject: [PATCH 1/3] M7: drop files anywhere to open them - Files dragged over the window show a "Drop to open" (home screen) or "Drop to import" (canvas) overlay. - On the home screen each dropped file the app opens becomes a document of its own; any other file is turned away with a toast. - On the canvas dropped files import as before, at the drop point. - A small card ("Opening as a new file", "Adding to {document}") is on screen before a large file blocks the window; the files then open a frame later. - A canvas import stays with the document and page that received the drop, keeps its placement, restores the page and selection chosen meanwhile, and is cancelled with a message if that document or page is gone. - ui.inspect reports pendingOpens; every new string, including the cancel messages, goes through tr() with en, de and es entries. --- ROADMAP.md | 3 +- crates/ui-egui/locales/de.toml | 9 + crates/ui-egui/locales/en.toml | 9 + crates/ui-egui/locales/es.toml | 9 + crates/ui-egui/src/canvas.rs | 34 ++-- crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/home.rs | 29 +++ crates/ui-egui/src/home_tests.rs | 241 ++++++++++++++++++++++++ crates/ui-egui/src/layers_drag_tests.rs | 11 +- crates/ui-egui/src/lib.rs | 59 +++++- crates/ui-egui/src/pending_open.rs | 131 +++++++++++++ 11 files changed, 510 insertions(+), 27 deletions(-) create mode 100644 crates/ui-egui/src/pending_open.rs diff --git a/ROADMAP.md b/ROADMAP.md index 389d8c41..7a11c7fd 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-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (files dropped anywhere on the window open from the home screen or import onto the canvas; Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 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 | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 36305e84..cd1e3482 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -64,6 +64,7 @@ status = "draft — needs native review" "Add variant with a new value" = "Variante mit neuem Wert hinzufügen" "Added" = "Hinzugefügt" "Added the library \"{name}\"" = "Bibliothek „{name}“ hinzugefügt" +"Adding to {name}" = "Hinzufügen zu {name}" "Adjust" = "Anpassen" "Adjustment presets" = "Vorgaben" "Advanced" = "Erweitert" @@ -392,6 +393,9 @@ status = "draft — needs native review" "Drag the stroke to add a width point" = "Ziehen Sie an der Kontur, um einen Breitenpunkt hinzuzufügen" "Draw" = "Zeichnen" "Drawing tools" = "Zeichenwerkzeuge" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .json-, .svg- oder Bilddatei ab." +"Drop to import" = "Zum Importieren ablegen" +"Drop to open" = "Zum Öffnen ablegen" "Drop shadow" = "Schlagschatten" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Doppelt lizenziert unter MIT OR Apache-2.0. Entwickelt vom ArtCraft-Team und der Community." "Duo" = "Duo" @@ -857,6 +861,8 @@ status = "draft — needs native review" "Open prototype preview" = "Prototyp-Vorschau öffnen" "Open variables ({count})" = "Variablen öffnen ({count})" "Opened “{name}” in a new tab" = "„{name}“ in neuem Tab geöffnet" +"Opening the file…" = "Datei wird geöffnet…" +"Opening {n} files as new files" = { one = "Wird als neue Datei geöffnet", other = "{n} Dateien werden als neue Dateien geöffnet" } "Open|comments" = "Offen" "Options" = "Optionen" "Orange" = "Orange" @@ -1256,6 +1262,9 @@ status = "draft — needs native review" "Texts" = "Texte" "Texture" = "Textur" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Die Arbeitsfläche zeigt Farben in sRGB an; exportierte PNG-, JPG- und WebP-Dateien werden als Display P3 gekennzeichnet." +"The destination document is unavailable. Nothing was imported." = "Das Zieldokument ist nicht verfügbar. Es wurde nichts importiert." +"The destination document was closed. Nothing was imported." = "Das Zieldokument wurde geschlossen. Es wurde nichts importiert." +"The destination page was removed. Nothing was imported." = "Die Zielseite wurde entfernt. Es wurde nichts importiert." "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." = "Die Datei wird vom Server gelöscht: Niemand kann sie von dort wieder öffnen oder ihr beitreten, und das lässt sich nicht rückgängig machen. Geöffnete Kopien bleiben bei denen, die sie haben." "The file now holds another library" = "Die Datei enthält jetzt eine andere Bibliothek" "The file now holds another library (\"{name}\")" = "Die Datei enthält jetzt eine andere Bibliothek („{name}“)" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index d313c57e..3c127fc6 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -66,6 +66,7 @@ name = "English" "Add variant with a new value" = "Add variant with a new value" "Added" = "Added" "Added the library \"{name}\"" = "Added the library \"{name}\"" +"Adding to {name}" = "Adding to {name}" "Adjust" = "Adjust" "Adjustment presets" = "Adjustment presets" "Advanced" = "Advanced" @@ -407,6 +408,9 @@ name = "English" "Draw" = "Draw" "Draw a frame" = "Draw a frame" "Drawing tools" = "Drawing tools" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Drop a .figcraft, .fig, .json, .svg or image file." +"Drop to import" = "Drop to import" +"Drop to open" = "Drop to open" "Drop shadow" = "Drop shadow" "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" @@ -900,6 +904,8 @@ name = "English" "Open prototype preview" = "Open prototype preview" "Open variables ({count})" = "Open variables ({count})" "Opened “{name}” in a new tab" = "Opened “{name}” in a new tab" +"Opening the file…" = "Opening the file…" +"Opening {n} files as new files" = { one = "Opening as a new file", other = "Opening {n} files as new files" } "Open|comments" = "Open" "Options" = "Options" "Orange" = "Orange" @@ -1320,6 +1326,9 @@ name = "English" "Texts" = "Texts" "Texture" = "Texture" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." +"The destination document is unavailable. Nothing was imported." = "The destination document is unavailable. Nothing was imported." +"The destination document was closed. Nothing was imported." = "The destination document was closed. Nothing was imported." +"The destination page was removed. Nothing was imported." = "The destination page was removed. Nothing was imported." "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." = "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." "The file now holds another library" = "The file now holds another library" "The file now holds another library (\"{name}\")" = "The file now holds another library (\"{name}\")" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index c9b27846..901283f1 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -68,6 +68,7 @@ status = "draft — needs native review" "Add variant with a new value" = "Añadir variante con un valor nuevo" "Added" = "Añadido" "Added the library \"{name}\"" = "Biblioteca \"{name}\" añadida" +"Adding to {name}" = "Añadiendo a {name}" "Adjust" = "Ajustar" "Adjustment presets" = "Preajustes" "Advanced" = "Avanzado" @@ -408,6 +409,9 @@ status = "draft — needs native review" "Draw" = "Dibujar" "Draw a frame" = "Dibuja un marco" "Drawing tools" = "Herramientas de dibujo" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .json, .svg o de imagen." +"Drop to import" = "Suelta para importar" +"Drop to open" = "Suelta para abrir" "Drop shadow" = "Sombra paralela" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Con doble licencia MIT OR Apache-2.0. Hecho por el equipo y la comunidad de ArtCraft." "Duo" = "Duo" @@ -901,6 +905,8 @@ status = "draft — needs native review" "Open prototype preview" = "Abrir vista previa del prototipo" "Open variables ({count})" = "Abrir variables ({count})" "Opened “{name}” in a new tab" = "“{name}” abierta en una pestaña nueva" +"Opening the file…" = "Abriendo el archivo…" +"Opening {n} files as new files" = { one = "Abriendo como archivo nuevo", other = "Abriendo {n} archivos como archivos nuevos" } "Open|comments" = "Abiertos" "Options" = "Opciones" "Orange" = "Naranja" @@ -1321,6 +1327,9 @@ status = "draft — needs native review" "Texts" = "Textos" "Texture" = "Textura" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "El lienzo muestra los colores en sRGB; los archivos PNG, JPG y WebP exportados se etiquetan como Display P3." +"The destination document is unavailable. Nothing was imported." = "El documento de destino no está disponible. No se importó nada." +"The destination document was closed. Nothing was imported." = "Se cerró el documento de destino. No se importó nada." +"The destination page was removed. Nothing was imported." = "Se eliminó la página de destino. No se importó nada." "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." = "El archivo se elimina del servidor: nadie podrá volver a abrirlo ni unirse a él desde allí, y no se puede deshacer. Las copias abiertas se quedan con quien las tenga." "The file now holds another library" = "El archivo ahora contiene otra biblioteca" "The file now holds another library (\"{name}\")" = "El archivo ahora contiene otra biblioteca (\"{name}\")" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad94b596..d4aad233 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -183,6 +183,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); + crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import")); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -414,19 +415,27 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); let typing_elsewhere = crate::a11y::field_focused(&ctx); app.canvas.space_pan = space && !editing_text && !typing_elsewhere; - // Dropped files import onto the canvas. + // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]). let dropped = ctx.input(|i| i.raw.dropped_files.clone()); for f in dropped { let path = f.path().to_path_buf(); let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); - if path.exists() { - app.run("file.import", json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y})); - } else if let Some(bytes) = dropped_bytes(&f) { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); + let params = if path.exists() { + json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}) + } else if let Some(bytes) = crate::dropped_bytes(&f) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); - app.run("file.import", json!({"data": b64, "name": name, "x": pt.x, "y": pt.y})); - } + json!({"data": b64, "name": name, "x": pt.x, "y": pt.y}) + } else { + continue; + }; + let Some(target) = + app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) + else { + continue; + }; + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) }); } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { // A drag released off the canvas, or off the window, ends where the pointer was last seen. @@ -585,17 +594,6 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { app.ui.context_menu = Some((pos, app.session.selection())); } -#[cfg(not(target_arch = "wasm32"))] -fn dropped_bytes(f: &std::sync::Arc) -> Option> { - f.bytes().ok() -} - -#[cfg(target_arch = "wasm32")] -fn dropped_bytes(_f: &std::sync::Arc) -> Option> { - // Browser drops arrive asynchronously; not supported yet. - None -} - fn stroke(c: Color32, w: f32) -> Stroke { Stroke::new(w, c) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7f4991dd..236f8e0d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -158,6 +158,8 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } 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); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index e6c78947..4a3741b5 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -137,6 +137,7 @@ fn listed(ui: &mut Ui, t: &Tokens, id: Id, height: f32, row: &FileRow) -> bool { pub fn show(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + dropped(app, ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { let rect = ui.max_rect(); let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height())); @@ -162,6 +163,34 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { }); }); }); + // The whole window is the drop target, like the incumbent. + crate::drop_overlay(ui.ctx(), ui.ctx().content_rect(), tr("Drop to open")); +} + +/// Files dropped on the home screen: each one the app opens becomes a document of its own (the +/// editor then shows it), behind an "Opening as a new file" card ([`crate::pending_open`]); anything +/// else is turned away with a toast. A file that fails to open keeps the engine's own error. +fn dropped(app: &mut FigApp, ctx: &egui::Context) { + let files = ctx.input(|i| i.raw.dropped_files.clone()); + for f in files { + let path = f.path().to_path_buf(); + if !crate::opens(&path) { + app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); + continue; + } + // `opens` saw an extension, so there is a file name to tell the format by. + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let params = if path.exists() { + json!({"path": path.to_string_lossy()}) + } else if let Some(bytes) = crate::dropped_bytes(&f) { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + json!({"data": data, "name": name}) + } else { + continue; + }; + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None }); + } } fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index af651992..87363656 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -327,3 +327,244 @@ fn tab_walks_the_sidebar_in_order_before_a_sections_controls() { let at = |name: &str| names.iter().position(|n| n == name).unwrap_or_else(|| panic!("Tab never reached {name:?}: {names:?}")); assert!(at("Recents") < at("Online") && at("Online") < at("Templates") && at("Templates") < at("Open file"), "{names:?}"); } + +/// A temporary directory of its own for a test's files, removed with them when dropped. +struct Temp(std::path::PathBuf); + +impl Temp { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("figcraft-home-drop-{}-{stamp}-{n}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn path(&self, name: &str) -> std::path::PathBuf { + self.0.join(name) + } +} + +impl Drop for Temp { + fn drop(&mut self) { + if let Ok(files) = std::fs::read_dir(&self.0) { + for file in files.flatten() { + let _ = std::fs::remove_file(file.path()); + } + } + let _ = std::fs::remove_dir(&self.0); + } +} + +/// A file dropped on the window, as the windowing backend hands it over (read from its path). +#[derive(Debug)] +struct Dropped(std::path::PathBuf); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } +} + +/// Drag `path` over the window (one frame: what it shows), then drop it (one frame). +fn drop_file(u: &mut Ui, path: &std::path::Path) -> Vec { + let hovered = egui::HoveredFile { path: Some(path.to_path_buf()), mime: String::new() }; + let hovering = flat(&u.frame_raw(egui::RawInput { hovered_files: vec![hovered], ..Default::default() }).shapes); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path.to_path_buf())); + u.frame_raw(egui::RawInput { dropped_files: vec![file], ..Default::default() }); + hovering +} + +#[test] +fn dropping_a_native_file_on_the_home_screen_opens_it() { + let dir = Temp::new(); + let path = dir.path("Dropped.figcraft"); + let mut saved = Session::new(); + saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(path.exists()); + let mut u = first_run(); + u.app.ui.home = true; + let s = drop_file(&mut u, &path); + assert!(has_text(&s, "Drop to open"), "dragging over the home screen says what a drop does"); + // The file waits behind its card, which is on screen before it opens. + assert_eq!(u.app.ui.pending_opens.len(), 1); + assert!(u.app.session.docs.is_empty(), "nothing opens in the frame of the drop"); + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Opening as a new file") && has_text(&s, "Opening the file…") && has_text(&s, "Dropped.figcraft"), "the card shows"); + assert_eq!(u.app.ui.pending_opens.len(), 1); + assert!(u.app.session.docs.is_empty(), "the card is drawn first"); + assert_eq!(u.app.inspect()["pendingOpens"], 1); + u.frame(vec![]); + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.session.docs.len(), 1); + assert_eq!(u.app.session.docs[0].path.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(!u.app.ui.home, "and the editor shows it"); + assert!(u.app.ui.toast.as_ref().is_none_or(|t| !t.0.starts_with("Drop a")), "no turning away"); +} + +#[test] +fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() { + let dir = Temp::new(); + let path = dir.path("Canvas import.figcraft"); + let mut source = Session::new(); + source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + let target_page = session.page().unwrap(); + let target_name = session.doc().unwrap().name.clone(); + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path.clone())); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + let pending = u.app.ui.pending_opens.first().unwrap(); + assert_eq!(pending.target.as_ref().map(|target| (target.uid, target.page)), Some((target_uid, target_page))); + assert_eq!(pending.target.as_ref().map(|target| target.name.as_str()), Some(target_name.as_str())); + + u.frame(vec![]); + u.app.session.active = 1; + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the UI switched tabs while the card was displayed"); + assert_eq!(u.app.ui.pending_opens.len(), 1, "the switch happens before dispatch"); + u.frame(vec![]); + + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front"); + assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]); +} + +#[test] +fn canvas_import_uses_queued_page_and_restores_the_page_selected_while_waiting() { + let dir = Temp::new(); + let path = dir.path("Queued.svg"); + std::fs::write(&path, r##""##) + .unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + let first_page = session.page().unwrap(); + session.execute("page.add", &json!({"name": "Second page"})).unwrap(); + let second_page = session.page().unwrap(); + let second_page_shape = session.execute("node.create", &json!({"type": "rectangle", "x": 30, "y": 40, "width": 12, "height": 14})).unwrap()["id"] + .as_u64() + .map(figcraft_doc::NodeId) + .unwrap(); + session.execute("page.select", &json!({"id": first_page})).unwrap(); + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + let queued_params = u.app.ui.pending_opens[0].params.clone(); + assert_eq!(u.app.ui.pending_opens[0].target.as_ref().map(|target| (target.uid, target.page)), Some((target_uid, first_page))); + + u.frame(vec![]); + u.app.session.execute("page.select", &json!({"id": second_page})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [second_page_shape]})).unwrap(); + u.app.session.active = 1; + u.frame(vec![]); + let target_index = u.app.session.docs.iter().position(|doc| doc.uid == target_uid).unwrap(); + let imported = u.app.session.docs[target_index].doc.node(first_page).unwrap().children[0]; + let doc = &u.app.session.docs[target_index].doc; + assert_eq!(doc.page_of(imported), Some(first_page)); + let bounds = doc.world_bounds(imported).unwrap(); + assert!((bounds.x0 - queued_params["x"].as_f64().unwrap()).abs() < 1.0); + assert!((bounds.y0 - queued_params["y"].as_f64().unwrap()).abs() < 1.0); + assert_eq!(doc.node(second_page).unwrap().children, [second_page_shape]); + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(u.app.session.docs[target_index].page, second_page, "the page selected during the wait is restored"); + assert_eq!(u.app.session.docs[target_index].selection, [second_page_shape], "the restored page keeps its selection"); + + u.app.session.active = target_index; + u.app.session.execute("edit.delete", &json!({})).unwrap(); + let doc = &u.app.session.docs[target_index].doc; + assert!(doc.node(second_page).unwrap().children.is_empty(), "the next edit acts on the restored page selection"); + assert_eq!(doc.node(first_page).unwrap().children.len(), 1, "the imported object on the queued page remains"); +} + +#[test] +fn queued_canvas_import_is_cancelled_if_its_page_is_deleted() { + let dir = Temp::new(); + let path = dir.path("Removed target.svg"); + std::fs::write(&path, r#""#).unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + session.execute("page.add", &json!({"name": "Keep this page"})).unwrap(); + let target_page = session.page().unwrap(); + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + u.app.session.execute("page.delete", &json!({"id": target_page})).unwrap(); + let other_before = u.app.session.docs.iter().find(|doc| doc.uid == other_uid).unwrap().doc.clone(); + u.app.ui.pending_open_frames = 2; + crate::pending_open::pending_opens(&mut u.app, &u.ctx); + assert_eq!(u.app.session.docs.iter().find(|doc| doc.uid == other_uid).unwrap().doc, other_before); + let target = u.app.session.docs.iter().find(|doc| doc.uid == target_uid).unwrap(); + assert_eq!(target.doc.pages.len(), 1); + assert!(!target.doc.nodes().any(|node| node.name == "Removed target")); + assert!(u.app.ui.last_error.as_deref().is_some_and(|error| error.contains("destination page was removed"))); +} + +#[test] +fn queued_canvas_import_is_cancelled_if_its_document_is_closed() { + let dir = Temp::new(); + let path = dir.path("Closed target.svg"); + std::fs::write(&path, r#""#).unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + u.frame(vec![]); + let other_before = u.app.session.docs.iter().find(|doc| doc.uid == other_uid).unwrap().doc.clone(); + let target_index = u.app.session.docs.iter().position(|doc| doc.uid == target_uid).unwrap(); + u.app.session.docs.remove(target_index); + u.app.session.active = 0; + + while !u.app.ui.pending_opens.is_empty() { + u.frame(vec![]); + } + assert_eq!(u.app.session.docs.len(), 1); + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(u.app.session.doc().unwrap(), other_before.as_ref()); + assert!(u.app.ui.last_error.as_deref().is_some_and(|error| error.contains("destination document was closed"))); +} + +#[test] +fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { + let dir = Temp::new(); + let path = dir.path("unsupported.txt"); + std::fs::write(&path, "not a design").unwrap(); + let mut u = first_run(); + u.app.ui.home = true; + drop_file(&mut u, &path); + assert!(u.app.session.docs.is_empty()); + assert!(u.app.ui.home); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file.")); +} diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index 10900680..37002f9f 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -35,10 +35,15 @@ impl Ui { /// [`Self::frame_output`] with `modifiers` held. pub(crate) fn frame_with(&mut self, mut events: Vec, modifiers: egui::Modifiers) -> egui::FullOutput { - self.time += 1.0 / 60.0; events.insert(0, Event::ModifiersChanged(modifiers)); - let input = - RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + self.frame_raw(RawInput { events, ..Default::default() }) + } + + /// One frame of `raw` input (files dropped on or hovered over the window, say), on the test + /// screen and clock. + pub(crate) fn frame_raw(&mut self, raw: RawInput) -> egui::FullOutput { + 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), ..raw }; let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 936c32a9..fa406571 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod live_text; pub mod measure_overlay; pub mod menus; pub mod online; +pub mod pending_open; pub mod plugins_ui; pub mod present; pub mod proto_canvas; @@ -187,7 +188,7 @@ mod windows_tests; use std::collections::BTreeSet; use std::future::Future; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; use std::task::{Context as TaskContext, Poll, Wake, Waker}; @@ -446,6 +447,10 @@ pub struct UiState { pub follow_system: bool, pub tool_flyout: Option<&'static str>, pub last_error: Option, + /// Files dropped on the home screen or the canvas, opened once their card has been on screen + /// ([`pending_open::pending_opens`]), and the frames it has been drawn. + pub pending_opens: Vec, + pub pending_open_frames: u8, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -552,6 +557,8 @@ impl Default for UiState { follow_system: false, tool_flyout: None, last_error: None, + pending_opens: Vec::new(), + pending_open_frames: 0, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -948,10 +955,9 @@ impl FigApp { } pub fn open_dialog(&mut self) { - let exts = ["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; // Opening needs no file in front: it goes ahead even if that file closed meanwhile. self.start_dialog( - |services| services.pick_open(i18n::tr("Open"), &exts), + |services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS), false, |app, p| { let ps = p.to_string_lossy().to_string(); @@ -1444,7 +1450,7 @@ impl FigApp { /// UI state as JSON (control channel `ui.inspect`). pub fn inspect(&self) -> Value { - json!({ + let mut state = json!({ "rightTab": self.ui.right_tab, "leftTab": self.ui.left_tab, "home": self.ui.home, @@ -1503,9 +1509,52 @@ impl FigApp { "panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed}, "status": self.session.status(), "lastError": self.ui.last_error, - }) + }); + state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state } } +/// The file types File ▸ Open and a drop on the home screen open. +pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; + +/// Whether `path` names a file type that opens as a document (the extension, in any case). +pub(crate) fn opens(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) +} + +/// The contents of a dropped file that has no file on disk to read by path. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option> { + f.bytes().ok() +} + +/// The contents of a dropped file that has no file on disk to read by path. +#[cfg(target_arch = "wasm32")] +pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option> { + // Browser drops arrive asynchronously; not supported yet. + None +} + +/// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, +/// over everything else, saying what a drop does there. +pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { + if ctx.input(|i| i.raw.hovered_files.is_empty()) { + return; + } + let t = theme::Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); + let area = rect.shrink(4.0); + painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); + painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + let galley = painter.layout_no_wrap(label.to_owned(), theme::semibold(14.0), t.text); + let pill = egui::Rect::from_center_size(area.center(), galley.size() + egui::vec2(32.0, 16.0)); + painter.rect_filled(pill, egui::CornerRadius::same(8), t.panel); + painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.galley(pill.center() - galley.size() / 2.0, galley, t.text); + // Hovering sends no pointer events on every platform: keep the overlay current. + ctx.request_repaint(); +} + #[cfg(test)] mod typography_scrub_tests; diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs new file mode 100644 index 00000000..63c213ff --- /dev/null +++ b/crates/ui-egui/src/pending_open.rs @@ -0,0 +1,131 @@ +//! Files dropped on the home screen or the canvas open a frame later, behind a card that says what +//! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen +//! before it does (rather than a window that freezes without a word). + +use figcraft_doc::NodeId; +use serde_json::Value; + +use crate::FigApp; +use crate::i18n::{tr, trf, trn}; +use crate::theme::{Tokens, semibold}; + +/// A dropped file waiting to be opened (`file.open`, a document of its own) or imported +/// (`file.import`, into the current document). +pub struct PendingOpen { + /// What the card shows for it (the file name). + pub label: String, + pub params: Value, + pub command: &'static str, + /// Canvas imports stay with the document and page that received the drop. + pub target: Option, +} + +pub struct ImportTarget { + pub uid: u64, + pub page: NodeId, + pub name: String, +} + +fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { + let target = match pending.target { + Some(target) => target, + None if pending.command != "file.import" => return app.run(pending.command, pending.params), + None => { + cancel(app, tr("The destination document is unavailable. Nothing was imported.").to_string()); + return None; + } + }; + let Some(index) = app.session.docs.iter().position(|doc| doc.uid == target.uid) else { + cancel(app, tr("The destination document was closed. Nothing was imported.").to_string()); + return None; + }; + if !app.session.docs[index].doc.pages.contains(&target.page) { + cancel(app, tr("The destination page was removed. Nothing was imported.").to_string()); + return None; + } + + let previous = app.session.ds().ok().map(|doc| doc.uid); + let previous_page = app.session.docs[index].page; + let previous_selection = app.session.docs[index].selection.clone(); + app.session.active = index; + app.session.docs[index].page = target.page; + let result = app.run(pending.command, pending.params); + if previous_page != target.page + && let Some(doc) = app.session.docs.iter_mut().find(|doc| doc.uid == target.uid) + && doc.doc.pages.contains(&previous_page) + { + doc.page = previous_page; + doc.selection = previous_selection; + doc.clean_selection(); + } + if let Some(index) = previous.and_then(|uid| app.session.docs.iter().position(|doc| doc.uid == uid)) { + app.session.active = index; + } + result +} + +/// An import that has no destination left: nothing is imported, and the user is told why. +fn cancel(app: &mut FigApp, message: String) { + app.ui.last_error = Some(message.clone()); + app.toast(message); +} + +/// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first +/// frame, so the second one is the first the window shows. +const FRAMES_BEFORE_OPENING: u8 = 2; + +/// The card while files wait, then (once it has been on screen) open them. Called before the import +/// report, so a report for these files shows after them. +pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { + if app.ui.pending_opens.is_empty() { + app.ui.pending_open_frames = 0; + return; + } + ctx.request_repaint(); + if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING { + app.ui.pending_open_frames = 0; + for pending in std::mem::take(&mut app.ui.pending_opens) { + let command = pending.command; + if run_pending(app, pending).is_some() && command == "file.open" { + app.ui.home = false; + } + } + return; + } + app.ui.pending_open_frames += 1; + let title = match app.ui.pending_opens.first().map(|p| p.command) { + Some("file.import") => { + let name = app + .ui + .pending_opens + .first() + .and_then(|pending| pending.target.as_ref().map(|target| target.name.clone())) + .or_else(|| app.session.doc().ok().map(|d| d.name.clone())) + .unwrap_or_default(); + trf("Adding to {name}", &[("name", &name)]) + } + _ => trn("Opening {n} files as new files", app.ui.pending_opens.len(), &[]), + }; + let t = Tokens::get(ctx); + let pending = &app.ui.pending_opens; + // Shown at once: the frame after this one may block the window while the file opens. + crate::dialogs::window("", ctx).id(egui::Id::new("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( + ctx, + |ui| { + ui.set_width(480.0); + ui.label(egui::RichText::new(title).font(semibold(13.0))); + ui.add_space(12.0); + for p in pending { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(16.0)); + ui.label(p.label.as_str()); + }); + } + ui.add_space(16.0); + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(12.0)); + ui.label(egui::RichText::new(tr("Opening the file…")).color(t.text_secondary)); + }); + }, + ); +} From d27ae11d150fd4c159c574f675fa818e28f9180f Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 15 Oct 2026 23:06:31 +0300 Subject: [PATCH 2/3] M1: a duplicate's fill stays its own (#586) Regression test for #586: with the colour picker open on a red rectangle or pen path, duplicating it (edit.duplicate, or copy and paste) and recolouring the copy leaves the original red. The cause was the picker staying bound to the original after the selection moved to the copy, fixed by #523; without that fix this test fails. The duplicated paint is already independent data. --- crates/ui-egui/src/duplicate_paint_tests.rs | 71 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + 2 files changed, 73 insertions(+) create mode 100644 crates/ui-egui/src/duplicate_paint_tests.rs diff --git a/crates/ui-egui/src/duplicate_paint_tests.rs b/crates/ui-egui/src/duplicate_paint_tests.rs new file mode 100644 index 00000000..b8ca81c6 --- /dev/null +++ b/crates/ui-egui/src/duplicate_paint_tests.rs @@ -0,0 +1,71 @@ +//! A duplicate's fill is its own: recolouring it, with the picker that was open on the original, +//! leaves the original as it was (#586). + +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().or_else(|| v["ids"][0].as_u64()).unwrap()) +} + +fn fill(u: &Ui, id: NodeId) -> String { + u.app.session.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_css_hex() +} + +/// A red rectangle or pen path, selected, with the colour picker of its fill open. +fn red(vector: bool) -> (Ui, NodeId) { + let mut s = Session::new(); + let a = if vector { + id(&s.execute("vector.create", &json!({"points": [[0, 0], [60, 0], [30, 50]], "closed": true})).unwrap()) + } else { + id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()) + }; + s.execute("fill.set", &json!({"ids": [a], "color": "#FF0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + u.frame(vec![]); + (u, a) +} + +/// Duplicate the selection the way `how` names, moved clear of the original; the copy's id. +fn duplicate(u: &mut Ui, how: &str) -> NodeId { + let copy = match how { + "duplicate" => id(&u.app.session.execute("edit.duplicate", &json!({"dx": 100})).unwrap()), + _ => { + u.app.session.execute("edit.copy", &json!({})).unwrap(); + id(&u.app.session.execute("edit.paste", &json!({"x": 100, "y": 0})).unwrap()) + } + }; + u.frame(vec![]); + u.frame(vec![]); + copy +} + +#[test] +fn recolouring_a_duplicate_leaves_the_original() { + for vector in [false, true] { + for how in ["duplicate", "paste"] { + let (mut u, a) = red(vector); + let b = duplicate(&mut u, how); + assert_ne!(a, b); + assert_eq!(u.app.session.selection(), vec![b], "{how} selects the copy"); + // The picker opened for the original doesn't follow it onto the copy's edits. + let picker = u.app.ui.picker.clone(); + assert!( + !matches!(&picker, Some(PickerTarget::Paint { ids, .. }) if ids.contains(&a)), + "vector {vector}, {how}: the picker still edits the original: {picker:?}" + ); + // Recolour the selection as the fill row does. + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: u.app.session.selection() }); + u.app.session.execute("fill.set", &json!({"ids": u.app.session.selection(), "color": "#0000FF"})).unwrap(); + u.frame(vec![]); + assert_eq!(fill(&u, b), "#0000FF", "vector {vector}, {how}"); + assert_eq!(fill(&u, a), "#FF0000", "vector {vector}, {how}: the original keeps its fill"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 936c32a9..d5d6bdfd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,8 @@ mod cursors_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod duplicate_paint_tests; +#[cfg(test)] mod export_preview_tests; #[cfg(test)] mod field_tests; From 65f5771a6e82913cf6c01541b8082749467e89ba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 05:04:00 +0700 Subject: [PATCH 3/3] Home: describe the drop target without naming other software --- crates/ui-egui/src/home.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 4a3741b5..abbd0440 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -163,7 +163,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { }); }); }); - // The whole window is the drop target, like the incumbent. + // The whole window is the drop target, not only the canvas. crate::drop_overlay(ui.ctx(), ui.ctx().content_rect(), tr("Drop to open")); }