mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 02:47:02 -06:00
Merge pull request #606 from storytold/community/batch-8
Community batch 8: drop files anywhere, duplicate fill tests
This commit is contained in:
+2
-1
@@ -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 (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-15 · **Change:** minor (bounded canvas pan overscan; 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 | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | 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. |
|
||||
|
||||
@@ -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}“)"
|
||||
|
||||
@@ -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}\")"
|
||||
|
||||
@@ -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}\")"
|
||||
|
||||
@@ -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();
|
||||
@@ -479,19 +480,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.
|
||||
@@ -650,17 +659,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<dyn egui::DroppedFile + Send + Sync>) -> Option<Vec<u8>> {
|
||||
f.bytes().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn dropped_bytes(_f: &std::sync::Arc<dyn egui::DroppedFile + Send + Sync>) -> Option<Vec<u8>> {
|
||||
// Browser drops arrive asynchronously; not supported yet.
|
||||
None
|
||||
}
|
||||
|
||||
fn stroke(c: Color32, w: f32) -> Stroke {
|
||||
Stroke::new(w, c)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, not only the canvas.
|
||||
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) {
|
||||
|
||||
@@ -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<Vec<u8>, 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<Shape> {
|
||||
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::<Vec<_>>(), [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##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#f00"/></svg>"##)
|
||||
.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#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10"/></svg>"#).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#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10"/></svg>"#).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."));
|
||||
}
|
||||
|
||||
@@ -35,10 +35,15 @@ impl Ui {
|
||||
|
||||
/// [`Self::frame_output`] with `modifiers` held.
|
||||
pub(crate) fn frame_with(&mut self, mut events: Vec<Event>, 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();
|
||||
|
||||
@@ -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;
|
||||
@@ -87,6 +88,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;
|
||||
@@ -187,7 +190,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};
|
||||
@@ -449,6 +452,10 @@ pub struct UiState {
|
||||
pub follow_system: bool,
|
||||
pub tool_flyout: Option<&'static str>,
|
||||
pub last_error: Option<String>,
|
||||
/// 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<pending_open::PendingOpen>,
|
||||
pub pending_open_frames: u8,
|
||||
pub screenshot_requests: Vec<(u64, String)>,
|
||||
pub frame_ms: f64,
|
||||
pub render_ms: f64,
|
||||
@@ -555,6 +562,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,
|
||||
@@ -951,10 +960,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();
|
||||
@@ -1460,7 +1468,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,
|
||||
@@ -1519,9 +1527,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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
// 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;
|
||||
|
||||
@@ -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<ImportTarget>,
|
||||
}
|
||||
|
||||
pub struct ImportTarget {
|
||||
pub uid: u64,
|
||||
pub page: NodeId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option<Value> {
|
||||
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));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user