diff --git a/contributors/people.toml b/contributors/people.toml index f7ee0420e..745bb84c3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -104,3 +104,7 @@ real_name = "Oleksii Sukholita" [people.helenkwok] real_name = "Helen Kwok" + +[people.denisg0mes] +real_name = "Denis Gomes" +display_name = "Denis Gomes" diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 01e8d3244..d9107ad63 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -1352,6 +1352,15 @@ pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> { doc.libraries.import_of(root).map(|r| r.library.as_str()) } +pub fn library_source(doc: &Document, id: NodeId) -> Option<(&str, NodeId)> { + if !doc.is_remote(id) { + return None; + } + let record = doc.libraries.import_of(asset_root(doc, id)?)?; + let key = record.ids.iter().find(|(_, local)| **local == id).map_or(record.key, |(key, _)| *key); + Some((record.library.as_str(), key)) +} + /// The display name of library `id` in `doc` (its link's name, else the id). pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str { doc.libraries.link(id).map_or(id, |l| l.name.as_str()) diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index bb65f8885..733c3ba8c 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -189,8 +189,6 @@ fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) { let style = s.doc().unwrap().styles[0].id.clone(); assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); assert!(s.execute("style.delete", &json!({"style": style})).is_err()); - let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err(); - assert!(e.to_string().contains("library"), "{e}"); assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected"); assert!(s.doc().unwrap().pages.contains(&s.page().unwrap())); assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err()); @@ -399,6 +397,75 @@ fn add_place_and_update_keep_overrides() { let _ = std::fs::remove_dir_all(&d); } +fn go_to_main(s: &mut Session, id: NodeId) -> Value { + s.active = 0; + ex(s, "component.goToMain", json!({"id": id})) +} + +#[test] +fn going_to_a_library_main_opens_the_library_file() { + let d = dir("go-to-main"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let k = kit(&mut lib); + let mut chips = Vec::new(); + for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() { + let f = idv(&ex(&mut lib, "frame.create", json!({"x": 400.0 + i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"}))); + ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}})); + chips.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]})))); + } + ex(&mut lib, "component.combineVariants", json!({"ids": chips})); + let kit_page = lib.doc().unwrap().page_of(k.card).unwrap(); + ex(&mut lib, "page.add", json!({"name": "Notes"})); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let card = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0}))); + let nested_button = node(&s, card).children[0]; + let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 200}))); + ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}})); + s.ds_mut().unwrap().view.vw = 900.0; + s.ds_mut().unwrap().view.vh = 500.0; + let using = s.doc().unwrap().clone(); + + let r = go_to_main(&mut s, card); + assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(k.card), Some(1)), "{r}"); + let ds = s.ds().unwrap(); + assert_eq!(ds.path.as_deref(), Some(lib_path.as_str())); + assert_eq!((ds.selection.clone(), ds.page), (vec![k.card], kit_page)); + assert_eq!((ds.view.vw, ds.view.vh), (900.0, 500.0), "the canvas it opens on is the one shown, so the fit stands"); + let shown = ds.doc.world_bounds(k.card).unwrap().center(); + assert_eq!((ds.view.cx, ds.view.cy), (shown.x, shown.y)); + + for (layer, main) in [(nested_button, k.button), (chip, chips[1])] { + let r = go_to_main(&mut s, layer); + assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(main), Some(1)), "{r}"); + assert_eq!(s.ds().unwrap().selection, vec![main]); + } + assert_eq!(s.docs.len(), 2, "the open library file comes to the front"); + assert_eq!(s.docs[0].doc.as_ref(), &using, "the file going there is unchanged"); + + s.active = 1; + ex(&mut s, "edit.delete", json!({"ids": [k.card]})); + let r = go_to_main(&mut s, card); + assert!(r["id"].is_null() && r["index"] == 1, "{r}"); + assert!(s.message.take().is_some_and(|m| m.contains("isn't in this file any more"))); + + ex(&mut s, "file.close", json!({"index": 1, "force": true})); + std::fs::rename(&lib_path, d.join("moved.figcraft")).unwrap(); + s.active = 0; + let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err(); + assert!(e.to_string().contains("isn't at"), "{e}"); + assert_eq!(s.docs.len(), 1); + + let NodeKind::Instance { component: copy, .. } = node(&s, card).kind else { panic!("not an instance") }; + s.doc_mut().unwrap().libraries_mut().components.retain(|r| r.root != copy); + let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err(); + assert!(e.to_string().contains("no link to its library"), "an imported copy without its record: {e}"); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn library_styles_apply_and_import_with_pages() { let d = dir("styles"); diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 50ce61500..e0b2e02f4 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -186,28 +186,42 @@ pub fn specs() -> Vec { true, reset_override ), - spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| { - let cmd = "component.goToMain"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc()?; - let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; - let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { - return Err(bad(cmd, "not an instance")); - }; - if !doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + spec!( + "component.goToMain", + "Go to main component", + "{id?} selects the instance's main component; a library component's main is in its library file, which opens in a tab (or comes to the front) with the main selected. Returns {id} (the main in the file now shown), and `index` (the tab) when it went to a library file", + false, + |s, p| { + let cmd = "component.goToMain"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc()?; + let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { + return Err(bad(cmd, "not an instance")); + }; + if !doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + } + if let Some((library, key)) = crate::library::library_source(doc, component) { + let name = crate::library::library_name(doc, library).to_string(); + let library = library.to_string(); + return go_to_library_main(s, &library, &name, key); + } + if doc.is_remote(component) { + return Err(EngineError::Disabled( + cmd.into(), + "the main component is a library copy with no link to its library (it came with an imported file): enable that library to reach it" + .into(), + )); + } + super::edit::select_and_page(s, vec![component])?; + let ds = s.ds_mut()?; + if let Some(b) = ds.doc.world_bounds(component) { + ds.view.fit(b, 2.0); + } + Ok(json!({"id": component})) } - if let Some(lib) = crate::library::library_of(doc, component) { - let name = crate::library::library_name(doc, lib); - return Err(EngineError::Disabled(cmd.into(), format!("the main component is in the library \"{name}\": open that file to edit it"))); - } - super::edit::select_and_page(s, vec![component])?; - let ds = s.ds_mut()?; - if let Some(b) = ds.doc.world_bounds(component) { - ds.view.fit(b, 2.0); - } - Ok(json!({"id": component})) - }), + ), spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes), spec!( "component.restoreMain", @@ -283,6 +297,43 @@ pub fn specs() -> Vec { ] } +fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result { + let cmd = "component.goToMain"; + let refuse = |why: String| EngineError::Disabled(cmd.into(), why); + let path = s + .doc()? + .libraries + .link(library) + .map(|l| l.path.clone()) + .ok_or_else(|| refuse(format!("the library \"{name}\" was removed from this file")))?; + if crate::library_server::is_server_link(&path) { + return Err(refuse(format!("the main component is in the team library \"{name}\": open its file from the server to edit it"))); + } + if !craft_docpath::is_local(&path) { + return Err(refuse(format!("{path}: a library is opened only from this computer"))); + } + if !crate::file_access::exists(&path) { + return Err(refuse(format!("the library file of \"{name}\" isn't at {path} any more"))); + } + let viewport = s.ds().map(|d| (d.view.vw, d.view.vh))?; + let opened = super::file::open(s, &json!({"path": path}))?; + let ds = s.ds_mut()?; + (ds.view.vw, ds.view.vh) = viewport; + let doc = &ds.doc; + let same_library = doc.published.as_ref().is_some_and(|p| p.manifest.id == library); + let main = doc.node(key).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })); + if !same_library || !main || doc.is_remote(key) { + s.message = Some(format!("{name}: the main component isn't in this file any more")); + return Ok(json!({"id": null, "index": opened["index"]})); + } + super::edit::select_and_page(s, vec![key])?; + let ds = s.ds_mut()?; + if let Some(b) = ds.doc.world_bounds(key) { + ds.view.fit(b, 2.0); + } + Ok(json!({"id": key, "index": opened["index"]})) +} + /// Place an instance of `component` centred on page point (`x`, `y`), in the frame or auto layout /// slot under it (`instance.place`). pub(crate) fn place(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0cfdd1ffa..637d0d4a2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -37,7 +37,8 @@ pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variable pub use open::MAX_RECENT; #[cfg(test)] use open::append_pages; -use open::{import, open, open_recent, revert}; +pub(crate) use open::open; +use open::{import, open_recent, revert}; pub(crate) use save::save_ends_preview; pub use save::{FileSnapshot, file_bytes, thumbnail}; use save::{color_profile, mark_saved, save, save_as}; diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 59afad98d..4114b773e 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result { Ok(json!({"files": files})) } -pub(super) fn open(s: &mut Session, p: &Value) -> Result { +pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let variables = variables_param("file.open", p)?; if string(p, "path").is_none() && let Some(d) = string(p, "data") diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5ccfe5d46..684294c44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -195,21 +195,37 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers /// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); - app.canvas.rect = rect; + let old_rect = std::mem::replace(&mut app.canvas.rect, rect); let ctx = ui.ctx().clone(); let ppp = ctx.pixels_per_point(); let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag()); let key_box = app.ui.key_box; crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box)); - if let Ok(ds) = app.session.ds_mut() - && ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5) - { - // Keep the centre; the first sizing after opening re-fits. + if let Ok(ds) = app.session.ds_mut() { + let resized = (ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5; + // The first sizing after opening re-fits. let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0; - ds.view.vw = f64::from(rect.width()); - ds.view.vh = f64::from(rect.height()); - if first { - ds.zoom_to_fit(); + // While a button is held that went down on the canvas (a click that selects shows the + // right panel, minimized, and a click that deselects hides it; the left panel and rail + // can appear too) the page point under a still pointer stays where it is: the gesture's + // own pointer events would otherwise map to another page point and the move handling + // would take that for a drag. `to_page` is `origin + (p - rect.min) / z` with + // `origin = c - vw / 2 / z`, so the centre moves by half the size change plus the + // rect's own move, over the zoom. + let held = resp.is_pointer_button_down_on() && !first && old_rect.is_finite(); + let z = ds.view.zoom.max(1e-9); + let (dw, dh) = if resized { (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh) } else { (0.0, 0.0) }; + if resized { + // Otherwise keep the centre. + ds.view.vw = f64::from(rect.width()); + ds.view.vh = f64::from(rect.height()); + if first { + ds.zoom_to_fit(); + } + } + if held { + ds.view.cx += (dw / 2.0 + f64::from(rect.min.x - old_rect.min.x)) / z; + ds.view.cy += (dh / 2.0 + f64::from(rect.min.y - old_rect.min.y)) / z; } } let own_cursor = input(app, ui, &resp); diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index ff065a48f..bae7aaca9 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -129,9 +129,6 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, f64, f64)> { Some(readable) => readable, None => with_pixel_size(encoded_clipboard_image(app)?)?, }; - if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS { - return None; - } let density = figcraft_format::pixel_density(&png); let (w, h) = figcraft_format::placed_size(w, h, density, figcraft_engine::cmd::create::PLACED_IMAGE_MAX_SIDE); Some((png, w, h)) @@ -140,8 +137,14 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, f64, f64)> { const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000; fn with_pixel_size(png: Vec) -> Option<(Vec, (u32, u32))> { - let size = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?; - Some((png, size)) + use image::ImageDecoder as _; + let decoder = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_decoder().ok()?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS { + return None; + } + image::DynamicImage::from_decoder(decoder).ok()?; + Some((png, (w, h))) } fn encoded_clipboard_image(app: &mut FigApp) -> Option> { diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index 357b13669..1bc17e7a1 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -346,3 +346,130 @@ fn a_narrow_window_shows_one_side_panel_at_a_time() { let wide = canvas_width(&mut app, vec2(1440.0, 900.0)); assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})"); } + +/// Five frames side by side at 46% zoom with the interface minimized and nothing selected. +fn minimized_row() -> (Ui, Vec) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for k in 0..5 { + let r = s.execute("frame.create", &json!({"x": k * 470, "y": 0, "width": 400, "height": 600})).unwrap(); + ids.push(figcraft_engine::params::id_of(&r["id"]).unwrap()); + } + s.execute("select.set", &json!({"ids": []})).unwrap(); + let mut u = Ui::new(s); + u.app.run("view.minimize", json!({"on": true})); + u.app.run("view.set", json!({"zoom": 0.46, "cx": 1500, "cy": 300})); + for _ in 0..4 { + u.frame(vec![]); + } + (u, ids) +} + +fn frame_x(u: &Ui, id: figcraft_doc::NodeId) -> f64 { + u.app.session.doc().unwrap().world_bounds(id).unwrap().x0 +} + +/// A click on a frame while minimized selects it and shows the right panel (the canvas gets +/// narrower): the frame stays where it was, and no step is added to the history. +#[test] +fn a_click_that_shows_the_right_panel_does_not_move_the_frame() { + let (mut u, ids) = minimized_row(); + assert!(u.app.ui.right_panel_rect.is_none(), "nothing selected: no right panel"); + let wide = u.app.canvas.rect.width(); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..4 { + u.frame(vec![]); + } + assert_eq!(u.app.session.selection(), vec![ids[4]], "the click selects the frame"); + assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows"); + assert!(u.app.canvas.rect.width() < wide - 100.0, "the canvas is narrower"); + assert_eq!(frame_x(&u, ids[4]), 1880.0, "a click did not move the frame"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step"); +} + +/// A real drag still moves the frame, by the pointer's screen movement over the zoom, in one step. +#[test] +fn a_drag_after_the_right_panel_shows_moves_the_frame_by_the_pointer() { + let (mut u, ids) = minimized_row(); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![Event::PointerMoved(p + vec2(50.0, 0.0))]); + u.frame(vec![Event::PointerMoved(p + vec2(100.0, 0.0))]); + u.frame(vec![Event::PointerButton { + pos: p + vec2(100.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows"); + let moved = frame_x(&u, ids[4]) - 1880.0; + assert!((moved - 100.0 / 0.46).abs() < 6.0, "moved by 100 points over the zoom, got {moved}"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one step"); +} + +/// Clicking empty canvas deselects and hides the right panel again; the marquee that starts there +/// has no offset: nothing moves and nothing is selected. +#[test] +fn a_click_that_hides_the_right_panel_does_not_move_the_frame() { + let (mut u, ids) = minimized_row(); + u.app.run("select.set", json!({"ids": [ids[4]]})); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_some()); + let p = u.app.to_screen(1880.0 + 200.0, 1000.0); + let steps = u.app.session.ds().unwrap().history.undo.len(); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_none(), "deselected: no right panel"); + assert_eq!(frame_x(&u, ids[4]), 1880.0); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps); +} + +/// The left sidebar and rail appearing while a press is held (Minimize toggled off mid-press) +/// moves the canvas's left edge: a still pointer keeps its page point, so the release moves nothing. +#[test] +fn a_held_press_keeps_its_page_point_when_the_canvas_moves() { + let (mut u, ids) = minimized_row(); + u.app.run("select.set", json!({"ids": [ids[3]]})); + for _ in 0..3 { + u.frame(vec![]); + } + let steps = u.app.session.ds().unwrap().history.undo.len(); + let left = u.app.canvas.rect.min.x; + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + let before = u.app.to_page(p); + u.app.run("view.minimize", json!({"on": false})); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.canvas.rect.min.x > left + 100.0, "the left sidebar and rail moved the canvas's edge"); + let after = u.app.to_page(p); + assert!((after.x - before.x).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "the pointer's page point moved: {before:?} to {after:?}"); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(frame_x(&u, ids[4]), 1880.0, "the frame did not move"); + assert_eq!(frame_x(&u, ids[3]), 1410.0); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step"); +} diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index e70ea0968..756c93067 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -549,3 +549,18 @@ fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() { let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image"); assert_eq!((pasted.width, pasted.height), (2.0, 2.0)); } + +#[test] +fn a_clipboard_png_with_a_cut_off_body_falls_back_to_the_decoded_image() { + let mut w = Win::new(); + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let png = png.into_inner(); + let cut_off = png[..png.len() - 20].to_vec(); + assert_eq!(image::ImageReader::new(std::io::Cursor::new(&cut_off)).with_guessed_format().unwrap().into_dimensions().unwrap(), (4, 4)); + *w.clip.borrow_mut() = Clip { png: Some(cut_off), image: Some((2, 2, vec![255; 16])), ..Clip::default() }; + w.cmd_v(); + let doc = w.app.session.doc().unwrap(); + let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image"); + assert_eq!((pasted.width, pasted.height), (2.0, 2.0)); +} diff --git a/docs/components-parity.md b/docs/components-parity.md index 8120f7787..deaa50a3f 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; a bound glass light angle past ±180 turns round; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -50,6 +50,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. |---|---|---| | Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files | | Enable a library in a file; read-only linked copies | done | | +| Go to main component from an instance of a library component | done | opens the library file in a tab, or brings it to the front, on the page holding the main, selected and zoomed to; a variant goes to that variant. Team libraries on a server are refused with a note; no Return to instance yet | | Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) | | Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) | | Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart | @@ -80,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. |---|---|---| | 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. | | 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects//`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. | +| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects//`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. | | 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |