From 4c7cf899f47c29dec7fb2df9b943016efb2e0c4c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 20:04:31 +0700 Subject: [PATCH] M6: Dev Mode: compare changes against any saved version --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/section.rs | 28 ++++-- crates/engine/src/devcompare.rs | 51 ++++++---- crates/engine/src/devmode_tail_tests.rs | 48 +++++++++ crates/ui-egui/locales/de.toml | 7 ++ crates/ui-egui/locales/en.toml | 7 ++ crates/ui-egui/locales/es.toml | 7 ++ crates/ui-egui/locales/fr.toml | 7 ++ crates/ui-egui/locales/ja.toml | 7 ++ crates/ui-egui/locales/ko.toml | 7 ++ crates/ui-egui/locales/pt-BR.toml | 7 ++ crates/ui-egui/locales/zh-Hans.toml | 7 ++ crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/dev_compare.rs | 122 ++++++++++++++++++----- crates/ui-egui/src/dev_mode.rs | 14 +-- crates/ui-egui/src/devmode_tail_tests.rs | 40 ++++++++ crates/ui-egui/src/lib.rs | 6 +- 17 files changed, 313 insertions(+), 62 deletions(-) create mode 100644 crates/ui-egui/src/devmode_tail_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8122af42..e096bb50 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -337,7 +337,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } } -fn read_version(v: &Version) -> Result> { +pub(crate) fn read_version(v: &Version) -> Result> { v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) } diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index ac7fb1ee..08a5a7cc 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { spec!( "dev.compare", "Compare changes", - "{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)", + "{id? (default the first selected layer), version? (a version id from version.list: compare with that saved version instead; any top-level frame or section), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked, or since the version (`snapshot` is false when that isn't known: a file from before snapshots were kept)", false, compare ), @@ -104,14 +104,30 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result { fn compare(s: &mut Session, p: &Value) -> Result { let cmd = "dev.compare"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?; - let doc = s.doc()?; + let version = match get(p, "version") { + None | Some(Value::Null) => None, + Some(Value::String(v)) => Some(v.clone()), + Some(_) => return Err(bad(cmd, "version: a version id (version.list)")), + }; + let ds = s.ds()?; + let doc = &ds.doc; let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?; - if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev { - return Err(bad(cmd, "compare a frame or section marked ready for dev")); + if !has_status(doc, id) { + return Err(bad(cmd, "compare a top-level frame or a section")); } - let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null); + let (cmp, against) = match &version { + Some(v) => { + let ver = ds.versions.iter().find(|x| x.id == *v).ok_or_else(|| bad(cmd, format!("no version `{v}`")))?; + let before = crate::cmd::file::read_version(ver)?; + (crate::devcompare::compare_with(&before, doc, id), json!({"id": ver.id, "name": ver.name, "time": ver.time})) + } + None if n.dev_status != DevStatus::ReadyForDev => return Err(bad(cmd, "compare a frame or section marked ready for dev, or pass `version`")), + None => (crate::devcompare::compare(doc, id), Value::Null), + }; + let mut v = serde_json::to_value(cmp).unwrap_or(Value::Null); + v["version"] = against; if boolean(p, "show") == Some(true) { - s.request_ui("view.devCompare", json!({"id": id})); + s.request_ui("view.devCompare", json!({"id": id, "version": version})); } Ok(v) } diff --git a/crates/engine/src/devcompare.rs b/crates/engine/src/devcompare.rs index 58cf9aa8..ef40a2fb 100644 --- a/crates/engine/src/devcompare.rs +++ b/crates/engine/src/devcompare.rs @@ -1,6 +1,7 @@ //! Dev Mode "Compare changes": what changed in a ready-for-dev frame or section since it was -//! marked. The document keeps a snapshot of the layer from just before its first change -//! ([`figcraft_doc::handoff`]); this compares it with the layer as it is now, layer by layer +//! marked, or in any frame or section since a saved version. The document keeps a snapshot of a +//! ready-for-dev layer from just before its first change ([`figcraft_doc::handoff`]); this compares +//! it (or the layer in the version) with the layer as it is now, layer by layer //! (matched by id): layers added and removed, and for the others their properties formatted the //! way Inspect shows them (`width 120 → 136`, `fill #FF0000 → #E03030`). @@ -93,20 +94,25 @@ pub struct Comparison { pub truncated: bool, } -/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed). -pub fn compare(doc: &Document, id: NodeId) -> Comparison { - let n = doc.node(id); - let mut out = Comparison { +/// Nothing compared yet for layer `id` of `doc` (`snapshot` false: the before isn't known). +pub fn empty(doc: &Document, id: NodeId) -> Comparison { + Comparison { id, - name: n.map(|n| n.name.clone()).unwrap_or_default(), - changed: n.is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed), + name: doc.node(id).map(|n| n.name.clone()).unwrap_or_default(), + changed: false, snapshot: false, changes: Vec::new(), added: 0, removed: 0, modified: 0, truncated: false, - }; + } +} + +/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed). +pub fn compare(doc: &Document, id: NodeId) -> Comparison { + let mut out = empty(doc, id); + out.changed = doc.node(id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed); if !out.changed { return out; } @@ -117,6 +123,21 @@ pub fn compare(doc: &Document, id: NodeId) -> Comparison { out } +/// The changes to layer `id` of `now` since `before` (a saved version of the same file: layers +/// match by id). A layer the version doesn't have was added as a whole. +pub fn compare_with(before: &Document, now: &Document, id: NodeId) -> Comparison { + let mut out = empty(now, id); + out.snapshot = true; + let mut w = Walk { before, now, now_ids: bounded_set(now, id), before_ids: bounded_set(before, id), out: &mut out }; + if before.contains(id) { + w.layer(id, 0); + } else { + w.whole(now, id, 0, ChangeKind::Added); + } + out.changed = !out.changes.is_empty(); + out +} + /// The before and after documents of layer `id` (the snapshot's copy and the document), for /// rendering the two side by side. pub fn before_doc(doc: &Document, id: NodeId) -> Option { @@ -127,17 +148,7 @@ pub fn before_doc(doc: &Document, id: NodeId) -> Option { /// Every layer that changed between two versions of a whole document, page by page (pages added /// or removed as a whole), for reviewing a branch. pub fn compare_docs(before: &Document, now: &Document) -> Comparison { - let mut out = Comparison { - id: NodeId(0), - name: now.name.clone(), - changed: false, - snapshot: true, - changes: Vec::new(), - added: 0, - removed: 0, - modified: 0, - truncated: false, - }; + let mut out = Comparison { name: now.name.clone(), snapshot: true, ..empty(now, NodeId(0)) }; let mut w = Walk { before, now, now_ids: now.ids().collect(), before_ids: before.ids().collect(), out: &mut out }; for p in &now.pages { if before.contains(*p) { diff --git a/crates/engine/src/devmode_tail_tests.rs b/crates/engine/src/devmode_tail_tests.rs index 3c3db7fe..1e1f513a 100644 --- a/crates/engine/src/devmode_tail_tests.rs +++ b/crates/engine/src/devmode_tail_tests.rs @@ -113,3 +113,51 @@ fn slots_and_instance_swaps_bring_their_connected_imports() { ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": heart}})); assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\n\n} />\n"); } + +#[test] +fn compare_changes_takes_any_saved_version_as_the_before() { + let mut s = Session::new(); + let screen = frame(&mut s, "Screen", None); + let button = + idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 120, "height": 40, "parent": screen, "props": {"name": "Button"}}))); + let old = idv(&ex(&mut s, "shape.ellipse", json!({"x": 200, "y": 20, "width": 20, "height": 20, "parent": screen, "props": {"name": "Dot"}}))); + let v1 = ex(&mut s, "file.saveVersion", json!({"name": "Kickoff"}))["id"].as_str().unwrap().to_string(); + ex(&mut s, "node.set", json!({"id": button, "props": {"width": 136}})); + ex(&mut s, "edit.delete", json!({"ids": [old]})); + let added = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 20, "y": 100, "parent": screen, "props": {"name": "Note"}}))); + let v2 = ex(&mut s, "file.saveVersion", json!({"name": "Review"}))["id"].as_str().unwrap().to_string(); + ex(&mut s, "node.set", json!({"id": button, "props": {"width": 150}})); + + // Any frame, ready for dev or not. + let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v1})); + assert_eq!(c["version"]["name"], "Kickoff"); + assert_eq!( + (c["changed"].as_bool(), c["added"].as_u64(), c["removed"].as_u64(), c["modified"].as_u64()), + (Some(true), Some(1), Some(1), Some(1)), + "{c}" + ); + let change = + |c: &serde_json::Value, id: NodeId| c["changes"].as_array().unwrap().iter().find(|l| l["id"] == json!(id)).cloned().unwrap_or_default(); + assert_eq!(change(&c, button)["properties"][0]["text"], "width 120 → 150"); + assert_eq!(change(&c, old)["change"], "removed"); + assert_eq!(change(&c, added)["change"], "added"); + // A later version: only what changed since then. + let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v2})); + assert_eq!(c["changes"].as_array().map(Vec::len), Some(1), "{c}"); + assert_eq!(change(&c, button)["properties"][0]["text"], "width 136 → 150"); + // A frame the version doesn't have was added as a whole. + let fresh = frame(&mut s, "Settings", None); + let c = ex(&mut s, "dev.compare", json!({"id": fresh, "version": v1})); + assert_eq!(c["changes"][0]["change"], "added"); + // The snapshot from when it was marked stays the default, for ready-for-dev layers only. + assert!(s.execute("dev.compare", &json!({"id": screen})).is_err(), "not ready for dev"); + ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); + let c = ex(&mut s, "dev.compare", json!({"id": screen})); + assert_eq!((c["changed"].as_bool(), c["version"].is_null()), (Some(false), true), "{c}"); + for bad in [json!({"id": screen, "version": "nope"}), json!({"id": screen, "version": 5}), json!({"id": button, "version": v1})] { + assert!(s.execute("dev.compare", &bad).is_err(), "{bad}"); + } + // The app opens on the version asked for. + ex(&mut s, "dev.compare", json!({"id": screen, "version": v2, "show": true})); + assert!(s.ui_requests.iter().any(|u| u.kind == "view.devCompare" && u.data["version"] == json!(v2))); +} diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index affda97d..541ec58d 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -260,6 +260,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Unternehmen" "Compare changes" = "Änderungen vergleichen" +"Compare with" = "Vergleichen mit" "Completed" = "Abgeschlossen" "Component" = "Komponente" "Component name" = "Komponentenname" @@ -811,6 +812,7 @@ status = "draft — needs native review" "Not read yet" = "Noch nicht gelesen" "Not shared" = "Nicht geteilt" "Note {n}" = "Notiz {n}" +"Nothing changed since this version." = "Seit dieser Version hat sich nichts geändert." "Nothing changed since this was marked ready for dev." = "Seit der Markierung als bereit für die Entwicklung hat sich nichts geändert." "Nothing here yet. Set a frame's status to list it." = "Noch nichts hier. Legen Sie den Status eines Rahmens fest, um ihn aufzulisten." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Noch nichts online. Stellen Sie eine Datei über „Teilen“ online, dann erscheint sie hier." @@ -1002,6 +1004,7 @@ status = "draft — needs native review" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" "Removed since it was marked" = "Seit der Markierung entfernt" +"Removed since this version" = "Seit dieser Version entfernt" "Rename" = "Umbenennen" "Rename duplicated layers" = "Duplizierte Ebenen umbenennen" "Rename folder" = "Ordner umbenennen" @@ -1275,6 +1278,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Dieser Server ist öffentlich zugänglich: kein Schlüssel nötig." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Dieser Server braucht seinen Schlüssel: Fügen Sie der Adresse ?key=… hinzu (`figcraft-server key` gibt ihn auf dem Rechner des Servers aus)." "This server takes files only with its key: see Advanced." = "Dieser Server nimmt Dateien nur mit seinem Schlüssel an: siehe Erweitert." +"This version can't be read." = "Diese Version kann nicht gelesen werden." +"This version doesn't have it: all of it is new." = "Diese Version enthält es nicht: alles daran ist neu." "Tidy up" = "Aufräumen" "Tile" = "Kachel" "Tile type" = "Kacheltyp" @@ -1372,10 +1377,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket-URL kopiert" "Wedge" = "Keil" "Weight" = "Stärke" +"What changed since a saved version" = "Was sich seit einer gespeicherten Version geändert hat" "What changed since it was marked ready for dev" = "Was sich seit der Markierung als bereit für die Entwicklung geändert hat" "What is wrong with this file? The server's operator gets your report." = "Was stimmt mit dieser Datei nicht? Der Betreiber des Servers erhält Ihre Meldung." "What it's for" = "Wofür sie gedacht ist" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Der Ausgangsstand dieser Änderung ist unbekannt: Sie erfolgte, bevor FigCraft Kopien von Rahmen mit dem Status „Bereit für Entwicklung“ aufbewahrte. Erledigen Sie die Änderung (oder markieren Sie den Rahmen erneut als bereit), um ab jetzt zu vergleichen." +"When marked ready" = "Beim Markieren als bereit" "While hovering" = "Beim Überfahren" "While pressing" = "Beim Drücken" "White" = "Weiß" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 08ac64ba..5a1973f1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -269,6 +269,7 @@ name = "English" "Commits" = "Commits" "Company" = "Company" "Compare changes" = "Compare changes" +"Compare with" = "Compare with" "Completed" = "Completed" "Component" = "Component" "Component name" = "Component name" @@ -850,6 +851,7 @@ name = "English" "Not read yet" = "Not read yet" "Not shared" = "Not shared" "Note {n}" = "Note {n}" +"Nothing changed since this version." = "Nothing changed since this version." "Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev." "Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nothing online yet. Use Share on a file to put it online and it is listed here." @@ -1050,6 +1052,7 @@ name = "English" "Removed" = "Removed" "Removed from the server" = "Removed from the server" "Removed since it was marked" = "Removed since it was marked" +"Removed since this version" = "Removed since this version" "Rename" = "Rename" "Rename duplicated layers" = "Rename duplicated layers" "Rename folder" = "Rename folder" @@ -1339,6 +1342,8 @@ name = "English" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." +"This version can't be read." = "This version can't be read." +"This version doesn't have it: all of it is new." = "This version doesn't have it: all of it is new." "Tidy up" = "Tidy up" "Tile" = "Tile" "Tile type" = "Tile type" @@ -1440,11 +1445,13 @@ name = "English" "WebSocket URL copied" = "WebSocket URL copied" "Wedge" = "Wedge" "Weight" = "Weight" +"What changed since a saved version" = "What changed since a saved version" "What you try here changes this preview and the code below, not the file" = "What you try here changes this preview and the code below, not the file" "What changed since it was marked ready for dev" = "What changed since it was marked ready for dev" "What is wrong with this file? The server's operator gets your report." = "What is wrong with this file? The server's operator gets your report." "What it's for" = "What it's for" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." +"When marked ready" = "When marked ready" "While hovering" = "While hovering" "While pressing" = "While pressing" "White" = "White" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3200e891..a0d6f1b6 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -270,6 +270,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar cambios" +"Compare with" = "Comparar con" "Completed" = "Completado" "Component" = "Componente" "Component name" = "Nombre del componente" @@ -851,6 +852,7 @@ status = "draft — needs native review" "Not read yet" = "Aún no leída" "Not shared" = "Sin compartir" "Note {n}" = "Nota {n}" +"Nothing changed since this version." = "Nada cambió desde esta versión." "Nothing changed since this was marked ready for dev." = "Nada cambió desde que se marcó como listo para desarrollo." "Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Aún no hay nada en línea. Usa Compartir en un archivo para ponerlo en línea y aparecerá aquí." @@ -1051,6 +1053,7 @@ status = "draft — needs native review" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" "Removed since it was marked" = "Eliminada desde que se marcó" +"Removed since this version" = "Eliminado desde esta versión" "Rename" = "Cambiar nombre" "Rename duplicated layers" = "Renombrar capas duplicadas" "Rename folder" = "Cambiar nombre de la carpeta" @@ -1340,6 +1343,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)." "This server takes files only with its key: see Advanced." = "Este servidor solo acepta archivos con su clave: mira Avanzado." +"This version can't be read." = "No se puede leer esta versión." +"This version doesn't have it: all of it is new." = "Esta versión no lo tiene: todo es nuevo." "Tidy up" = "Ordenar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" @@ -1441,11 +1446,13 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL de WebSocket copiada" "Wedge" = "Cuña" "Weight" = "Grosor" +"What changed since a saved version" = "Qué cambió desde una versión guardada" "What you try here changes this preview and the code below, not the file" = "Lo que pruebes aquí cambia esta vista previa y el código de abajo, no el archivo" "What changed since it was marked ready for dev" = "Qué cambió desde que se marcó como listo para desarrollo" "What is wrong with this file? The server's operator gets your report." = "¿Qué tiene de malo este archivo? El operador del servidor recibirá tu denuncia." "What it's for" = "Para qué sirve" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "No se sabe desde qué cambió: cambió antes de que FigCraft guardara copias de los marcos listos para desarrollo. Resuelve el cambio (o vuelve a marcarlo como listo) para comparar a partir de ahora." +"When marked ready" = "Al marcarlo como listo" "While hovering" = "Mientras se pasa el puntero" "While pressing" = "Mientras se presiona" "White" = "Blanco" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 69798340..4a21123d 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -260,6 +260,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Entreprise" "Compare changes" = "Comparer les modifications" +"Compare with" = "Comparer avec" "Completed" = "Terminé" "Component" = "Composant" "Component name" = "Nom du composant" @@ -811,6 +812,7 @@ status = "draft — needs native review" "Not read yet" = "Pas encore lu" "Not shared" = "Non partagé" "Note {n}" = "Note {n}" +"Nothing changed since this version." = "Rien n’a changé depuis cette version." "Nothing changed since this was marked ready for dev." = "Rien n’a changé depuis que ceci a été marqué prêt pour le dev." "Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici." @@ -1002,6 +1004,7 @@ status = "draft — needs native review" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" "Removed since it was marked" = "Supprimé depuis le marquage" +"Removed since this version" = "Supprimé depuis cette version" "Rename" = "Renommer" "Rename duplicated layers" = "Renommer les calques dupliqués" "Rename folder" = "Renommer le dossier" @@ -1275,6 +1278,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)." "This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé." +"This version can't be read." = "Cette version est illisible." +"This version doesn't have it: all of it is new." = "Cette version ne le contient pas : tout est nouveau." "Tidy up" = "Ranger" "Tile" = "Mosaïque" "Tile type" = "Type de mosaïque" @@ -1372,10 +1377,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL WebSocket copiée" "Wedge" = "Coin" "Weight" = "Graisse" +"What changed since a saved version" = "Ce qui a changé depuis une version enregistrée" "What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev" "What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement." "What it's for" = "À quoi il sert" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant." +"When marked ready" = "Au marquage prêt" "While hovering" = "Pendant le survol" "While pressing" = "Pendant l’appui" "White" = "Blanc" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index fb81b042..ee61e607 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -272,6 +272,7 @@ status = "draft — needs native review" "Commits" = "コミット" "Company" = "会社" "Compare changes" = "変更を比較" +"Compare with" = "比較対象" "Completed" = "完了" "Component" = "コンポーネント" "Component name" = "コンポーネント名" @@ -853,6 +854,7 @@ status = "draft — needs native review" "Not read yet" = "未読" "Not shared" = "共有していない" "Note {n}" = "メモ {n}" +"Nothing changed since this version." = "このバージョンから変更なし。" "Nothing changed since this was marked ready for dev." = "開発準備完了にしてから変更なし。" "Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" @@ -1053,6 +1055,7 @@ status = "draft — needs native review" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" "Removed since it was marked" = "マーク後に削除" +"Removed since this version" = "このバージョン以降に削除" "Rename" = "名前を変更" "Rename duplicated layers" = "複製したレイヤーの名前を変更" "Rename folder" = "フォルダー名を変更" @@ -1342,6 +1345,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "このサーバーは一般公開されている:キーは不要。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "このサーバーにはキーが必要:アドレスに ?key=… を追加(サーバーのマシンで `figcraft-server key` を実行すると表示)。" "This server takes files only with its key: see Advanced." = "このサーバーはキーがないとファイルを受け付けない:「詳細設定」を参照。" +"This version can't be read." = "このバージョンを読み込めません。" +"This version doesn't have it: all of it is new." = "このバージョンにはありません。すべて新規です。" "Tidy up" = "整列" "Tile" = "タイル" "Tile type" = "タイルの種類" @@ -1443,11 +1448,13 @@ status = "draft — needs native review" "Websites" = "Webサイト" "Wedge" = "くさび" "Weight" = "太さ" +"What changed since a saved version" = "保存したバージョンからの変更" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" "What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。" "What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない" +"When marked ready" = "準備完了にした時点" "While hovering" = "ホバー中" "While pressing" = "押している間" "White" = "白" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index aed3529a..f2059c91 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -261,6 +261,7 @@ status = "draft — needs native review" "Commits" = "커밋" "Company" = "회사" "Compare changes" = "변경 사항 비교" +"Compare with" = "비교 대상" "Completed" = "완료됨" "Component" = "컴포넌트" "Component name" = "컴포넌트 이름" @@ -812,6 +813,7 @@ status = "draft — needs native review" "Not read yet" = "아직 읽지 않음" "Not shared" = "공유 안 함" "Note {n}" = "메모 {n}" +"Nothing changed since this version." = "이 버전 이후 변경된 내용 없음." "Nothing changed since this was marked ready for dev." = "개발 준비 완료로 표시된 뒤 변경된 내용 없음." "Nothing here yet. Set a frame's status to list it." = "아직 아무것도 없음. 프레임의 상태를 설정하면 목록에 표시됨." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "아직 온라인에 올린 파일이 없음. 파일에서 공유를 사용해 온라인에 올리면 여기에 표시됨." @@ -1003,6 +1005,7 @@ status = "draft — needs native review" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" "Removed since it was marked" = "표시된 뒤 제거됨" +"Removed since this version" = "이 버전 이후 삭제됨" "Rename" = "이름 바꾸기" "Rename duplicated layers" = "복제한 레이어 이름 바꾸기" "Rename folder" = "폴더 이름 바꾸기" @@ -1276,6 +1279,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "이 서버는 누구에게나 열려 있음: 키가 필요 없음." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "이 서버는 키가 필요함: 주소에 ?key=…를 추가하세요(서버 컴퓨터에서 `figcraft-server key`로 확인)." "This server takes files only with its key: see Advanced." = "이 서버는 키가 있어야만 파일을 받음: 고급을 참고하세요." +"This version can't be read." = "이 버전을 읽을 수 없습니다." +"This version doesn't have it: all of it is new." = "이 버전에는 없습니다. 모두 새 항목입니다." "Tidy up" = "정돈" "Tile" = "타일" "Tile type" = "타일 유형" @@ -1373,10 +1378,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket URL 복사됨" "Wedge" = "쐐기" "Weight" = "굵기" +"What changed since a saved version" = "저장된 버전 이후 변경된 내용" "What changed since it was marked ready for dev" = "개발 준비 완료로 표시된 뒤 변경된 내용" "What is wrong with this file? The server's operator gets your report." = "이 파일에 어떤 문제가 있나요? 신고는 서버 운영자에게 전달됨." "What it's for" = "용도" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "무엇에서 바뀌었는지 알 수 없음: FigCraft가 개발 준비 완료 프레임의 사본을 보관하기 전에 변경됨. 변경을 해결(또는 다시 준비 완료로 표시)하면 이후부터 비교함." +"When marked ready" = "준비 완료로 표시한 시점" "While hovering" = "마우스를 올린 동안" "While pressing" = "누르는 동안" "White" = "흰색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 97500236..71ad304a 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -259,6 +259,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar alterações" +"Compare with" = "Comparar com" "Completed" = "Concluído" "Component" = "Componente" "Component name" = "Nome do componente" @@ -810,6 +811,7 @@ status = "draft — needs native review" "Not read yet" = "Ainda não lido" "Not shared" = "Não compartilhado" "Note {n}" = "Nota {n}" +"Nothing changed since this version." = "Nada mudou desde esta versão." "Nothing changed since this was marked ready for dev." = "Nada mudou desde que isto foi marcado como pronto para dev." "Nothing here yet. Set a frame's status to list it." = "Ainda não há nada aqui. Defina o status de um quadro para listá-lo." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nada online ainda. Use Compartilhar em um arquivo para colocá-lo online e ele aparecerá aqui." @@ -1001,6 +1003,7 @@ status = "draft — needs native review" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" "Removed since it was marked" = "Removido desde a marcação" +"Removed since this version" = "Removido desde esta versão" "Rename" = "Renomear" "Rename duplicated layers" = "Renomear camadas duplicadas" "Rename folder" = "Renomear pasta" @@ -1274,6 +1277,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor é aberto ao público: não precisa de chave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor precisa da própria chave: adicione ?key=… ao endereço (`figcraft-server key` a mostra na máquina do servidor)." "This server takes files only with its key: see Advanced." = "Este servidor só aceita arquivos com a própria chave: veja Avançado." +"This version can't be read." = "Não é possível ler esta versão." +"This version doesn't have it: all of it is new." = "Esta versão não o tem: tudo é novo." "Tidy up" = "Organizar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" @@ -1371,10 +1376,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL do WebSocket copiada" "Wedge" = "Cunha" "Weight" = "Espessura" +"What changed since a saved version" = "O que mudou desde uma versão salva" "What changed since it was marked ready for dev" = "O que mudou desde que foi marcado como pronto para dev" "What is wrong with this file? The server's operator gets your report." = "O que há de errado com este arquivo? O operador do servidor recebe a sua denúncia." "What it's for" = "Para que serve" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Não se sabe de onde esta alteração partiu: ela ocorreu antes de o FigCraft guardar cópias dos quadros prontos para dev. Resolva a alteração (ou marque-o como pronto de novo) para comparar daqui em diante." +"When marked ready" = "Ao marcar como pronto" "While hovering" = "Enquanto passa o cursor" "While pressing" = "Enquanto pressiona" "White" = "Branco" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d1887735..f51d5e72 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -262,6 +262,7 @@ status = "draft — needs native review" "Commits" = "提交" "Company" = "公司" "Compare changes" = "比较更改" +"Compare with" = "比较对象" "Completed" = "已完成" "Component" = "组件" "Component name" = "组件名称" @@ -813,6 +814,7 @@ status = "draft — needs native review" "Not read yet" = "尚未读取" "Not shared" = "未分享" "Note {n}" = "备注 {n}" +"Nothing changed since this version." = "自此版本以来没有任何更改。" "Nothing changed since this was marked ready for dev." = "自标记为可供开发以来没有任何更改。" "Nothing here yet. Set a frame's status to list it." = "这里还没有内容。设置画框的状态即可将其列出。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "还没有在线文件。对文件使用“分享”将其放到网上后,它会列在这里。" @@ -1004,6 +1006,7 @@ status = "draft — needs native review" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" "Removed since it was marked" = "标记后已删除" +"Removed since this version" = "自此版本以来已移除" "Rename" = "重命名" "Rename duplicated layers" = "重命名副本图层" "Rename folder" = "重命名文件夹" @@ -1277,6 +1280,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "此服务器对公众开放:无需密钥。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "此服务器需要密钥:请在地址后加上 ?key=…(在服务器所在的机器上运行 `figcraft-server key` 可显示)。" "This server takes files only with its key: see Advanced." = "此服务器只接受带密钥的文件:请查看“高级”。" +"This version can't be read." = "无法读取此版本。" +"This version doesn't have it: all of it is new." = "此版本中没有它:全部是新的。" "Tidy up" = "整理" "Tile" = "平铺" "Tile type" = "平铺类型" @@ -1374,10 +1379,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "已复制 WebSocket URL" "Wedge" = "楔形" "Weight" = "粗细" +"What changed since a saved version" = "自已保存版本以来的更改" "What changed since it was marked ready for dev" = "自标记为可供开发以来的更改" "What is wrong with this file? The server's operator gets your report." = "此文件有什么问题?你的举报会发送给服务器的运营者。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "无法得知更改前的状态:它在 FigCraft 开始保存可供开发画框的副本之前就已更改。请解决此更改(或重新标记为可供开发),以便今后进行比较。" +"When marked ready" = "标记为就绪时" "While hovering" = "悬停时" "While pressing" = "按住时" "White" = "白" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 480824d0..0fb28cd2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -551,12 +551,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) { app.ui.export_thumb = v; } - // Dev Mode's compare view: a ready-for-dev frame or section opens it, null closes it; + // Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion` + // the saved version it compares with (null: when it was marked ready for dev), // `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1). if let Some(v) = p.get("devCompare") { - app.ui.dev.compare = figcraft_engine::params::id_of(v).map(crate::dev_compare::CompareView::new); + app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None)); } if let Some(c) = app.ui.dev.compare.as_mut() { + if let Some(v) = p.get("devCompareVersion") { + c.version = v.as_str().map(str::to_string); + } match p.get("devCompareMode").and_then(Value::as_str) { Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay, Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide, diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/dev_compare.rs index 2e21eb54..c27026e6 100644 --- a/crates/ui-egui/src/dev_compare.rs +++ b/crates/ui-egui/src/dev_compare.rs @@ -1,12 +1,13 @@ -//! Dev Mode's compare view: a ready-for-dev frame or section as it was when it was marked next to -//! how it is now (side by side, or one over the other with a slider), the changed layers outlined, -//! and the list of what changed (`dev.compare`). Rendered once per document version. The branch -//! review ([`crate::branch_ui`]) shows two versions with the same pieces. +//! Dev Mode's compare view: a frame or section as it was when it was marked ready for dev, or in +//! a saved version picked at the bottom, next to how it is now (side by side, or one over the +//! other with a slider), the changed layers outlined, and the list of what changed +//! (`dev.compare`). Rendered once per document version. The branch review ([`crate::branch_ui`]) +//! shows two versions with the same pieces. use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{DevStatus, Document, NodeId}; use figcraft_engine::devcompare::{self, ChangeKind, Comparison, LayerChange}; use serde_json::json; @@ -38,25 +39,41 @@ pub struct CompareView { pub mode: Mode, /// Where the overlay slider is (0 = all new, 1 = all old). pub split: f32, + /// The saved version compared with (its id); `None`: the snapshot from when it was marked + /// ready for dev. + pub version: Option, /// The change row under the pointer (its layer is highlighted). hovered: Option, cache: Option, } impl CompareView { - pub fn new(id: NodeId) -> Self { - Self { id, mode: Mode::default(), split: 0.5, hovered: None, cache: None } + pub fn new(id: NodeId, version: Option) -> Self { + Self { id, mode: Mode::default(), split: 0.5, version, hovered: None, cache: None } + } + + /// The view `dev.compare {show}` (or `ui.set`) asks for: `data` holds the layer's `id` and the + /// `version` to compare with. + pub fn requested(data: &serde_json::Value) -> Option { + let id = data.get("id").and_then(figcraft_engine::params::id_of)?; + Some(Self::new(id, data.get("version").and_then(serde_json::Value::as_str).map(str::to_string))) } } -/// What the view shows, built for one version of the document. +/// What the view shows, built for one version of the document and one "before". struct Cached { doc: Arc, + version: Option, + /// The version's document (read once per version picked). + version_doc: Option>, cmp: Comparison, before: Option, after: Option, } +/// A saved version as the picker lists it: (id, name, when). +type VersionRow = (String, String, u64); + /// One version rendered: its texture, the page area it shows and where each listed layer is. pub(crate) struct Shot { tex: egui::TextureHandle, @@ -129,15 +146,33 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(mut view) = app.ui.dev.compare.take() else { return }; let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); - if doc.node(view.id).is_none() { + let Some(node) = doc.node(view.id) else { return }; + let ready = node.dev_status == DevStatus::ReadyForDev; + // Newest first; a version deleted meanwhile falls back to the ready-for-dev snapshot (or the + // newest version, for a layer that isn't ready for dev). + let versions: Vec = ds.versions.iter().rev().map(|v| (v.id.clone(), v.name.clone(), v.time)).collect(); + if view.version.as_ref().is_some_and(|v| !versions.iter().any(|(id, ..)| id == v)) || (!ready && view.version.is_none()) { + view.version = if ready { None } else { versions.first().map(|(id, ..)| id.clone()) }; + } + if view.version.is_none() && !ready { return; } - if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc)) { - let cmp = devcompare::compare(&doc, view.id); - let before = devcompare::before_doc(&doc, view.id).and_then(|b| Shot::of_changes(ctx, &b, view.id, &cmp.changes, true)); + if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc) || c.version != view.version) { + let kept = view.cache.take().filter(|c| c.version == view.version).and_then(|c| c.version_doc); + let version_doc = match &view.version { + Some(v) => kept.or_else(|| ds.versions.iter().find(|x| x.id == *v).and_then(|x| x.document().ok())), + None => None, + }; + let (cmp, before) = match &version_doc { + Some(b) => (devcompare::compare_with(b, &doc, view.id), Some(b.clone())), + None if view.version.is_some() => (devcompare::empty(&doc, view.id), None), + None => (devcompare::compare(&doc, view.id), devcompare::before_doc(&doc, view.id).map(Arc::new)), + }; + let before = before.and_then(|b| Shot::of_changes(ctx, &b, view.id, &cmp.changes, true)); let after = Shot::of_changes(ctx, &doc, view.id, &cmp.changes, false); - view.cache = Some(Cached { doc: doc.clone(), cmp, before, after }); + view.cache = Some(Cached { doc: doc.clone(), version: view.version.clone(), version_doc, cmp, before, after }); } + let version_name = view.version.as_ref().and_then(|v| versions.iter().find(|(id, ..)| id == v)).map(|(_, name, _)| name.clone()); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let size = vec2(1120.0_f32.min(screen.width() - 64.0).max(560.0), 700.0_f32.min(screen.height() - 96.0).max(320.0)); @@ -163,7 +198,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let images_w = size.x - LIST_W - 1.0; let (r, _) = ui.allocate_exact_size(vec2(images_w, body_h), Sense::hover()); if !cache.cmp.snapshot { - let msg = if cache.cmp.changed { + let msg = if view.version.is_some() { + tr("This version can't be read.") + } else if cache.cmp.changed { tr( "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on.", ) @@ -171,18 +208,23 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { tr("Nothing changed since this was marked ready for dev.") }; message(ui, &t, r, msg); + } else if !cache.cmp.changed && view.version.is_some() { + message(ui, &t, r, tr("Nothing changed since this version.")); } else if let (Some(before), Some(after)) = (&cache.before, &cache.after) { - images(ui, &t, r, [(before, tr("Before")), (after, tr("Now"))], view.mode, &mut view.split, view.hovered); + let before_label = version_name.as_deref().unwrap_or(tr("Before")); + images(ui, &t, r, [(before, before_label), (after, tr("Now"))], view.mode, &mut view.split, view.hovered); + } else if view.version.is_some() { + message(ui, &t, r, tr("This version doesn't have it: all of it is new.")); } let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover()); ui.painter().rect_filled(line, CornerRadius::ZERO, t.border); ui.allocate_ui_with_layout(vec2(LIST_W, body_h), top_down, |ui| { - if let Some(a) = list(ui, &t, &cache.cmp, &mut view.hovered) { + if let Some(a) = list(ui, &t, &cache.cmp, view.version.is_some(), &mut view.hovered) { action = Some(a); } }); }); - if let Some(a) = footer(ui, &t, &cache.cmp) { + if let Some(a) = footer(ui, &t, &cache.cmp, ready, &versions, view.version.as_deref()) { action = Some(a); } }); @@ -197,6 +239,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } Some(Action::Close) => open = false, + Some(Action::Against(v)) => view.version = v, None => {} } if open { @@ -208,6 +251,8 @@ enum Action { Select(NodeId), Resolve, Close, + /// Compare with this saved version (`None`: the snapshot from when it was marked ready). + Against(Option), } /// The title row: `title` and `name`, the side by side / overlay switch and the close button. @@ -325,7 +370,7 @@ fn pane(p: &egui::Painter, t: &Tokens, place: Place, shot: &Shot, hovered: Optio } /// The changed layers with their property changes; a click on a layer selects it. -fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) -> Option { +fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, from_version: bool, hovered: &mut Option) -> Option { let mut out = None; let summary = if !cmp.snapshot { tr("Changes unknown").to_string() @@ -345,7 +390,11 @@ fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) egui::ScrollArea::vertical().id_salt("compare_list").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for c in &cmp.changes { - let tip = if c.change == ChangeKind::Removed { tr("Removed since it was marked") } else { tr("Select layer") }; + let tip = match (c.change, from_version) { + (ChangeKind::Removed, false) => tr("Removed since it was marked"), + (ChangeKind::Removed, true) => tr("Removed since this version"), + _ => tr("Select layer"), + }; let resp = change_row(ui, t, c.change, &c.name, c.depth, c.layers, false, tip); if resp.hovered() { now_hovered = Some(c.id); @@ -419,17 +468,42 @@ pub(crate) fn property_lines<'a>(ui: &mut Ui, t: &Tokens, depth: usize, lines: i } } -fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison) -> Option { +/// Resolve and Close, and left of them what it compares with: the snapshot from when it was +/// marked ready for dev (`ready`) or one of the saved `versions` (`version`, the one picked). +fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison, ready: bool, versions: &[VersionRow], version: Option<&str>) -> Option { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H + 1.0), Sense::hover()); ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 1.0)), CornerRadius::ZERO, t.border); let mut out = None; let inner = Rect::from_min_max(pos2(r.min.x + 16.0, r.min.y + 1.0), pos2(r.max.x - 16.0, r.max.y)); + ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + if !versions.is_empty() { + widgets::label(ui, tr("Compare with"), t.text_secondary); + let picked = version.and_then(|v| versions.iter().find(|(id, ..)| id == v)); + let label = picked.map_or_else(|| tr("When marked ready").to_string(), |(_, name, _)| name.clone()); + let resp = widgets::dropdown(ui, &label, 220.0, Some(Icon::Clock)); + crate::right::menu_popup(&resp).show(|ui| { + if ready && crate::right::menu_check(ui, version.is_none(), tr("When marked ready"), "") { + out = Some(Action::Against(None)); + ui.close(); + } + for (id, name, time) in versions { + if crate::right::menu_check(ui, version == Some(id.as_str()), name, &crate::comments::ago_long(*time)) { + out = Some(Action::Against(Some(id.clone()))); + ui.close(); + } + } + }); + } + }); ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| { ui.spacing_mut().item_spacing.x = 8.0; - let resolve = widgets::button(ui, tr("Resolve change"), true, cmp.changed) - .on_hover_text(tr("Back to Ready for dev: the next change compares with how it is now")); - if resolve.clicked() { - out = Some(Action::Resolve); + if version.is_none() { + let resolve = widgets::button(ui, tr("Resolve change"), true, cmp.changed) + .on_hover_text(tr("Back to Ready for dev: the next change compares with how it is now")); + if resolve.clicked() { + out = Some(Action::Resolve); + } } if widgets::button(ui, tr("Close"), false, true).clicked() { out = Some(Action::Close); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 0fedccd7..43c00a20 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -500,18 +500,18 @@ pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| crate::section_panel::status_dropdown(app, ui, doc, sel, w)); if let [id] = sel { let changed = doc.node(*id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed); - let focused = app.session.ds().ok().and_then(figcraft_engine::DocState::focused) == Some(*id); + let ds = app.session.ds().ok(); + let focused = ds.and_then(figcraft_engine::DocState::focused) == Some(*id); + // Unchanged since it was marked (or not ready for dev): compared with the newest saved version. + let newest = ds.and_then(|d| d.versions.last()).map(|v| v.id.clone()).filter(|_| !changed); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; if !focused && widgets::button(ui, tr("Focus view"), false, true).on_hover_text(tr("Show only this on the canvas")).clicked() { app.run("dev.focus", json!({"id": id})); } - if changed - && widgets::button(ui, tr("Compare changes"), true, true) - .on_hover_text(tr("What changed since it was marked ready for dev")) - .clicked() - { - app.run("dev.compare", json!({"id": id, "show": true})); + let tip = if changed { tr("What changed since it was marked ready for dev") } else { tr("What changed since a saved version") }; + if (changed || newest.is_some()) && widgets::button(ui, tr("Compare changes"), changed, true).on_hover_text(tip).clicked() { + app.run("dev.compare", json!({"id": id, "version": newest, "show": true})); } }); } diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs new file mode 100644 index 00000000..9bf3551a --- /dev/null +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -0,0 +1,40 @@ +//! Dev Mode in the UI, the tail: the compare view compares with a saved version picked at its +//! bottom, and falls back when that version goes away. + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dev_compare::CompareView; +use crate::present_tests::{Ui, id}; + +#[test] +fn compare_view_compares_with_a_saved_version() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let r = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 300, "width": 120, "height": 40, "parent": f})).unwrap()); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + // Neither ready for dev nor any version: nothing to compare with, the view doesn't stay. + u.app.ui.dev.compare = Some(CompareView::new(f, None)); + u.frames(2, Vec::new); + assert!(u.app.ui.dev.compare.is_none()); + let v = u.run("file.saveVersion", json!({"name": "Kickoff"})); + let v = v["id"].as_str().unwrap().to_string(); + u.run("node.set", json!({"id": r, "props": {"width": 160}})); + u.run("dev.compare", json!({"id": f, "version": v, "show": true})); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(v)); + // Opened without one, a layer that isn't ready compares with the newest version. + u.app.ui.dev.compare = Some(CompareView::new(f, None)); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(v)); + // Ready for dev: the snapshot from when it was marked is there too, and a deleted version + // falls back to it. + u.run("node.set", json!({"id": f, "props": {"devStatus": "readyForDev"}})); + u.run("node.set", json!({"id": r, "props": {"height": 48}})); + u.run("version.delete", json!({"id": v})); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(null)); + assert!(u.app.ui.dev.compare.is_some()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..28fadb72 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,8 @@ mod cursors_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod devmode_tail_tests; +#[cfg(test)] mod export_preview_tests; #[cfg(test)] mod field_tests; @@ -694,7 +696,7 @@ impl FigApp { "panel.assets" => self.ui.left_tab = LeftTab::Assets, "panel.versionHistory" => versions::open(self), "view.devCompare" => { - self.ui.dev.compare = r.data.get("id").and_then(figcraft_engine::params::id_of).map(dev_compare::CompareView::new); + self.ui.dev.compare = dev_compare::CompareView::requested(&r.data); } "panel.libraries" => { self.ui.left_tab = LeftTab::Assets; @@ -1186,7 +1188,7 @@ impl FigApp { "variables": self.ui.variables.is_some(), "versionHistory": self.ui.versions.open, "branchReview": self.ui.branch_review.as_ref().map(|r| json!({"id": r.id, "picks": r.picks, "mode": if r.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }})), - "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split})), + "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split, "version": c.version})), "devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused), "annotationFilter": self.ui.dev.categories.id(), "colorProfileDialog": self.ui.color_profile.map(|p| p.id()),