From ce094f397a44a0ab52b425e2fb15cef7cb3d35ae Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 23:24:55 +0700 Subject: [PATCH] M1: Hide when publishing from the Assets panel component.hideWhenPublishing {ids?, on?} keeps this file's components and sets out of the library it publishes. The model's sign of it is the name, so the command writes it: hiding puts a "." before the name, showing takes the leading "." and "_" away (one undo step, saved and synced like a rename). The Assets panel's context menu on a local component toggles it (checked while hidden) and the tile shows a crossed eye; also under Libraries in the main menu. --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/component.rs | 33 +++++ crates/engine/src/hide_publishing_tests.rs | 140 +++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/library.rs | 14 +++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/src/assets_panel.rs | 26 +++- 8 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/hide_publishing_tests.rs diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 7e7ec30..3e6acaf 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -249,6 +249,7 @@ pub static MENU: &[MenuItem] = &[ // Libraries m!("Libraries", "Open libraries", "libraries.open"), m!("Libraries", "Publish library…", "libraries.publish"), + m!("Libraries", "Hide when publishing", "component.hideWhenPublishing"), // Help m!("Help", "Keyboard shortcuts", "help.shortcuts"), m!("Help", "Help page", "help.page"), diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 9dc7d5d..5305e2d 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -11,6 +11,32 @@ use crate::components::*; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; +/// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and +/// sets (those `ids` are or are in), put on or taken off. +fn hide_when_publishing(s: &mut Session, p: &Value) -> Result { + use crate::library::{asset_root, hidden_name, with_hidden_name}; + let cmd = "component.hideWhenPublishing"; + let doc = s.doc()?; + let mut ids: Vec = Vec::new(); + for i in req_targets(cmd, s, p)? { + let root = asset_root(doc, i).ok_or_else(|| bad(cmd, format!("{i} isn't a component, a component set or a layer in one")))?; + if doc.is_remote(root) { + return Err(EngineError::Disabled(cmd.into(), crate::library::READ_ONLY.into())); + } + if !ids.contains(&root) { + ids.push(root); + } + } + let hidden = |doc: &Document, i: &NodeId| doc.node(*i).is_some_and(|n| hidden_name(&n.name)); + let on = boolean(p, "on").unwrap_or_else(|| !ids.iter().all(|i| hidden(doc, i))); + let doc = s.doc_mut()?; + for i in &ids { + let n = doc.get_mut(*i)?; + n.name = with_hidden_name(&n.name, on); + } + Ok(json!({"ids": ids, "hidden": on})) +} + pub fn specs() -> Vec { vec![ spec!( @@ -121,6 +147,13 @@ pub fn specs() -> Vec { true, reset_slot ), + spec!( + "component.hideWhenPublishing", + "Hide when publishing", + "{ids? (components, sets or layers in them; default the selection), on? (default: toggle, hiding unless all are hidden)} keeps them out of the library this file publishes, the way a name starting with `.` or `_` does: hiding puts a `.` before the name, showing takes the leading `.` and `_` away → {ids, hidden}", + true, + hide_when_publishing + ), spec!("component.detach", "Detach instance", "{ids?}", true, detach), spec!("component.resetInstance", "Reset all changes", "{ids?}", true, reset_instance), spec!( diff --git a/crates/engine/src/hide_publishing_tests.rs b/crates/engine/src/hide_publishing_tests.rs new file mode 100644 index 0000000..48ff59a --- /dev/null +++ b/crates/engine/src/hide_publishing_tests.rs @@ -0,0 +1,140 @@ +//! Hide when publishing (`component.hideWhenPublishing`, the Assets panel's context menu): keeps a +//! component or a set out of the library its file publishes. The model's sign of it is the name +//! (a leading `.` or `_`), so the command writes the name: one undo step, saved and synced like +//! any rename, and honoured by the next publish. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::library::{hidden_name, with_hidden_name}; +use crate::params::id_of; +use crate::{EngineError, Session}; + +const CMD: &str = "component.hideWhenPublishing"; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn component(s: &mut Session, name: &str, x: f64) -> NodeId { + let f = id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 80, "height": 40, "name": name}))["id"]).unwrap(); + id_of(&ex(s, "component.create", json!({"ids": [f]}))["id"]).unwrap() +} + +fn name(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().name.clone() +} + +/// The names of the components the library published last offers. +fn offered(s: &Session) -> Vec { + let lib = s.doc().unwrap().published.clone().expect("published"); + let mut names: Vec = lib.manifest.components.iter().filter(|a| a.published).map(|a| a.name.clone()).collect(); + names.sort(); + names +} + +#[test] +fn names_gain_and_lose_the_mark() { + // (name, hide, result) + let rows = [ + ("Button", true, ".Button"), + (".Button", true, ".Button"), + ("_Button", true, "_Button"), + ("Buttons/Primary", true, ".Buttons/Primary"), + (".Button", false, "Button"), + ("_Button", false, "Button"), + ("._ Button", false, "Button"), + ("Button", false, "Button"), + ("...", false, "Component"), + ("", true, "."), + ]; + for (name, hide, want) in rows { + let got = with_hidden_name(name, hide); + assert_eq!(got, want, "{name:?} hidden={hide}"); + assert_eq!(hidden_name(&got), hide, "{got:?} reads back as hidden={hide}"); + } +} + +#[test] +fn the_command_toggles_sets_and_is_one_undo_step() { + let mut s = Session::new(); + let (a, b) = (component(&mut s, "Button", 0.0), component(&mut s, "Card", 100.0)); + // The selection by default; toggling hides. + ex(&mut s, "select.set", json!({"ids": [a]})); + let r = ex(&mut s, CMD, json!({})); + assert_eq!((r["hidden"].as_bool(), name(&s, a).as_str(), name(&s, b).as_str()), (Some(true), ".Button", "Card")); + // Several at once: hidden unless all of them are already. + let r = ex(&mut s, CMD, json!({"ids": [a, b]})); + assert_eq!((r["hidden"].as_bool(), name(&s, a), name(&s, b)), (Some(true), ".Button".to_string(), ".Card".to_string())); + let undo = s.ds().unwrap().history.undo.len(); + let r = ex(&mut s, CMD, json!({"ids": [a, b]})); + assert_eq!((r["hidden"].as_bool(), name(&s, a), name(&s, b)), (Some(false), "Button".to_string(), "Card".to_string())); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step for both"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((name(&s, a), name(&s, b)), (".Button".to_string(), ".Card".to_string())); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(name(&s, a), "Button"); + // `on` sets, whatever it was. + ex(&mut s, CMD, json!({"ids": [a], "on": false})); + assert_eq!(name(&s, a), "Button"); + ex(&mut s, CMD, json!({"ids": [a], "on": true})); + ex(&mut s, CMD, json!({"ids": [a], "on": true})); + assert_eq!(name(&s, a), ".Button"); +} + +#[test] +fn a_layer_in_a_component_or_a_variant_hides_the_whole_asset() { + let mut s = Session::new(); + let a = component(&mut s, "Button", 0.0); + let inner = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 4, "y": 4, "width": 8, "height": 8, "parent": a}))["id"]).unwrap(); + let r = ex(&mut s, CMD, json!({"ids": [inner]})); + assert_eq!(r["ids"], json!([a])); + assert_eq!(name(&s, a), ".Button"); + // A variant: its set is the asset. + let (v1, v2) = (component(&mut s, "Size=Small", 200.0), component(&mut s, "Size=Large", 300.0)); + let set = id_of(&ex(&mut s, "component.combineVariants", json!({"ids": [v1, v2]}))["id"]).unwrap(); + let before = name(&s, v1); + ex(&mut s, CMD, json!({"ids": [v1]})); + assert!(hidden_name(&name(&s, set)), "the set is hidden: {}", name(&s, set)); + assert_eq!(name(&s, v1), before, "the variant keeps its name (its property values)"); +} + +#[test] +fn what_isnt_a_component_is_refused() { + let mut s = Session::new(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"]).unwrap(); + for p in [json!({"ids": [r]}), json!({"ids": []}), json!({"ids": [999_999]}), json!({"ids": "x"}), json!({"on": "yes"})] { + ex(&mut s, "select.none", json!({})); + let e = s.execute(CMD, &p).expect_err(&p.to_string()); + assert!(!matches!(e, EngineError::Internal { .. }), "{p}: {e}"); + } + assert!(!hidden_name(&name(&s, r)), "nothing renamed"); + assert!(s.ds().unwrap().history.undo.len() == 1, "and nothing to undo but the rectangle"); +} + +#[test] +fn publishing_leaves_hidden_components_out_and_brings_them_back() { + let mut s = Session::new(); + let (a, _b) = (component(&mut s, "Button", 0.0), component(&mut s, "Card", 100.0)); + ex(&mut s, "library.publish", json!({})); + assert_eq!(offered(&s), ["Button", "Card"]); + ex(&mut s, CMD, json!({"ids": [a]})); + ex(&mut s, "library.publish", json!({})); + assert_eq!(offered(&s), ["Card"], "hidden: taken out of the library"); + ex(&mut s, CMD, json!({"ids": [a]})); + ex(&mut s, "library.publish", json!({})); + assert_eq!(offered(&s), ["Button", "Card"], "shown again: back in the library"); +} + +#[test] +fn it_is_synced_like_any_edit() { + // What a live session sends is the change between two documents: the rename is one. + let mut s = Session::new(); + let a = component(&mut s, "Button", 0.0); + let mut replica = figcraft_collab::Replica::new(s.doc().unwrap().clone()); + ex(&mut s, CMD, json!({"ids": [a]})); + let d = figcraft_collab::diff(&replica, s.doc().unwrap()).unwrap(); + assert!(!d.changes.is_empty(), "the change is one a session sends"); + replica.apply(&d.changes, figcraft_collab::Mode::Strict).unwrap(); + assert_eq!(replica.document().node(a).unwrap().name, ".Button"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 662d4b2..5e32a40 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1389,6 +1389,8 @@ mod fx_behaviour_tests; #[cfg(test)] mod gesture_preview_tests; #[cfg(test)] +mod hide_publishing_tests; +#[cfg(test)] mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 52edd6f..936d798 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -46,6 +46,20 @@ pub fn hidden_name(name: &str) -> bool { name.starts_with('.') || name.starts_with('_') } +/// `name` with or without the mark that keeps an asset out of a library by default: hiding puts +/// a `.` before it, showing takes every leading `.` and `_` away (a name of nothing else keeps +/// one letter's worth: "Component"). +pub fn with_hidden_name(name: &str, hidden: bool) -> String { + match (hidden, hidden_name(name)) { + (true, false) => format!(".{name}"), + (false, true) => { + let shown = name.trim_start_matches(['.', '_']).trim_start(); + if shown.is_empty() { "Component".to_string() } else { shown.to_string() } + } + _ => name.to_string(), + } +} + /// Where the Assets panel files asset `id`: under its page, the frames, sections and groups /// holding it, then the folders its name makes with slashes (`Buttons/Primary` is `Primary` in /// `Buttons`). Returns the folders, outermost first, and the name shown on its tile. diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b713618..af11b26 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -542,6 +542,7 @@ name = "English" "Hide contents" = "Hide contents" "Hide from publishing" = "Hide from publishing" "Hide section contents" = "Hide section contents" +"Hide when publishing" = "Hide when publishing" "Highlight layers on hover" = "Highlight layers on hover" "Highlights" = "Highlights" "Hold Alt" = "Hold Alt" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b899c87..83d2779 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -543,6 +543,7 @@ status = "draft — needs native review" "Hide contents" = "Ocultar contenido" "Hide from publishing" = "Ocultar al publicar" "Hide section contents" = "Ocultar contenido de la sección" +"Hide when publishing" = "Ocultar al publicar" "Highlight layers on hover" = "Resaltar capas al pasar el cursor" "Highlights" = "Luces" "Hold Alt" = "Mantén Alt" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "component.editProperty" = "Editar propiedad de componente" "component.exposeInstance" = "Exponer instancia anidada" "component.goToMain" = "Ir al componente principal" +"component.hideWhenPublishing" = "Ocultar al publicar" "component.list" = "Listar componentes" "component.makeSlot" = "Convertir en ranura" "component.properties" = "Propiedades del componente" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index c402bed..3748556 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -66,6 +66,8 @@ struct Tile { /// The name on the tile: its last slash part. label: String, set: bool, + /// Kept out of the library this file publishes (its name starts with `.` or `_`). + hidden: bool, } impl Tile { @@ -73,7 +75,7 @@ impl Tile { fn new(doc: &Document, id: NodeId, name: &str, set: bool) -> (String, Self) { let (folders, label) = library::asset_folder(doc, id); let label = if label.is_empty() { name.to_string() } else { label }; - (folders.join(" / "), Self { id, name: name.to_string(), label, set }) + (folders.join(" / "), Self { id, name: name.to_string(), label, set, hidden: library::hidden_name(name) }) } } @@ -158,6 +160,8 @@ enum Act { Drag(AssetDrag), Style(Source, String), Toggle(String), + /// Hide the local component when publishing, or show it again. + Hide(NodeId, bool), } fn drag_id() -> Id { @@ -443,7 +447,22 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, want ), } let label = Rect::from_min_size(pos2(x, r.max.y + 2.0), vec2(tile, TILE_LABEL - 4.0)); - crate::bidi::paint_name(ui.painter(), pos2(x, label.center().y), &item.label, regular(11.0), t.text, tile); + // Hidden when publishing: a crossed eye on the tile and a dimmer name. + let own_hidden = *src == Source::Local && item.hidden; + if own_hidden { + let badge = Rect::from_min_size(r.right_top() + vec2(-22.0, 6.0), vec2(16.0, 16.0)); + icons::paint(ui.painter(), badge, Icon::EyeOff, t.icon_secondary); + } + let name_colour = if own_hidden { t.text_secondary } else { t.text }; + crate::bidi::paint_name(ui.painter(), pos2(x, label.center().y), &item.label, regular(11.0), name_colour, tile); + if *src == Source::Local { + crate::right::context_popup(&resp).show(|ui| { + if crate::right::menu_check(ui, item.hidden, tr("Hide when publishing"), "") { + *act = Some(Act::Hide(item.id, !item.hidden)); + ui.close(); + } + }); + } let resp = resp.on_hover_text(trf( "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it.", &[("name", &item.name)], @@ -633,6 +652,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { place(app, &src, id, cx, cy); } Some(Act::Style(src, style)) => apply_style(app, &src, &style), + Some(Act::Hide(id, on)) => { + app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on})); + } Some(Act::Toggle(id)) => { let mut c = collapsed; if !c.remove(&id) {