diff --git a/Cargo.lock b/Cargo.lock
index 04c8da5f2..b7990a4ee 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1812,6 +1812,7 @@ dependencies = [
"windows",
"winit",
"winresource",
+ "winsafe",
"zbus",
]
diff --git a/apps/figcraft-web/src/api.rs b/apps/figcraft-web/src/api.rs
index 167ea7687..cb82820b7 100644
--- a/apps/figcraft-web/src/api.rs
+++ b/apps/figcraft-web/src/api.rs
@@ -37,7 +37,7 @@ use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
-use wasm_bindgen_futures::{JsFuture, future_to_promise};
+use wasm_bindgen_futures::JsFuture;
use crate::saved_files::SavedFiles;
@@ -150,15 +150,6 @@ async fn sleep(ms: i32) {
let _ = JsFuture::from(promise).await;
}
-/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
-fn outcome(reply: &Value) -> Result {
- if reply.get("ok").and_then(Value::as_bool) == Some(true) {
- Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
- } else {
- Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
- }
-}
-
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
@@ -169,20 +160,20 @@ pub fn install() -> Result<(), JsValue> {
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
let window = web_sys::window().ok_or("no window")?;
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
- set_fn(
- &obj,
- "inspect",
- Closure:: js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("document.inspect", json!({})).await) }))
- .into_js_value(),
- )?;
+ // `inspect` is `docInspect` under its earlier name: the crate's function, so it times out too.
+ let doc_inspect = js_sys::Reflect::get(&obj, &"docInspect".into())?;
+ js_sys::Reflect::set(&obj, &"inspect".into(), &doc_inspect)?;
+ // `openFile` runs `file.open` through the crate's `execute`, so a call the app does not answer times out and is cancelled.
+ let execute: js_sys::Function = js_sys::Reflect::get(&obj, &"execute".into())?.unchecked_into();
+ let this = obj.clone();
set_fn(
&obj,
"openFile",
- Closure:: js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
+ Closure:: JsValue>::new(move |name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
- future_to_promise(async move {
- outcome(&send("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})).await)
- })
+ execute
+ .call2(&this, &"file.open".into(), &to_js(&json!({"name": name, "data": data})))
+ .unwrap_or_else(|e| js_sys::Promise::reject(&e).into())
})
.into_js_value(),
)?;
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
index 386cb1f33..8e26ac161 100644
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -39,6 +39,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
+# The custom title bar's window styles (src/window_frame.rs): safe Win32 wrappers (MIT); the `user`
+# feature is User32.dll only. Same version as DesignCraft and PhotoCraft.
+winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
index 66b5f73ee..1d24e511b 100644
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -29,6 +29,7 @@ mod graphics;
mod recovery;
mod services;
mod startup;
+mod window_frame;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
@@ -63,6 +64,8 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
+ /// Keeps the custom title bar's undecorated windows borderless (Windows, #1343).
+ window_frame: window_frame::WindowFrame,
/// Documents and quit requests from Finder, the Dock and Open With.
#[cfg(target_os = "macos")]
apple_events: fmv_macos_events::Inbox,
@@ -111,6 +114,9 @@ impl eframe::App for App {
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
+ // winit gives an undecorated window its caption back when it changes the window's flags
+ // (maximize, restore, ...): take it off again before the frame is laid out (#1343).
+ self.window_frame.run();
// Pointer and key events sent over the control channel (`ui.input`), for the window
// about to draw.
if let Some(events) = with_app(|fig| figcraft_ui_egui::windows::take_input(fig, raw_input.viewport_id)) {
@@ -268,6 +274,7 @@ fn main() -> eframe::Result {
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
+ let custom_titlebar = fig.ui.title_bar == figcraft_ui_egui::TitleBar::Custom;
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
@@ -304,6 +311,9 @@ fn main() -> eframe::Result {
ctx_slot,
title: String::new(),
recovery,
+ // The custom title bar's undecorated window must stay borderless, or its caption
+ // and border offset the content and the pointer on some Windows systems (#1343).
+ window_frame: window_frame::WindowFrame::new(custom_titlebar),
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
}))
diff --git a/apps/figcraft/src/window_frame.rs b/apps/figcraft/src/window_frame.rs
new file mode 100644
index 000000000..0033444ec
--- /dev/null
+++ b/apps/figcraft/src/window_frame.rs
@@ -0,0 +1,177 @@
+//! Keeps the custom (undecorated) title bar windows truly borderless on Windows (#1343).
+//!
+//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
+//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems the
+//! desktop compositor still reserves that frame for the window surface: the content is drawn about
+//! 30 px low (a black band above it) while the pointer keeps the un-offset client coordinates, so
+//! clicks land on something other than what is drawn under them.
+//!
+//! winit 0.27 omitted those styles for undecorated windows and had no offset. [`WindowFrame`]
+//! restores that: it strips `WS_CAPTION` and `WS_BORDER` from every window of this process that
+//! draws its own title bar, and has Windows recalculate the frame. winit puts the styles back
+//! whenever it changes the window flags (maximize, restore, ...), so the step runs every frame; it
+//! reads each style and writes only when it differs, so it costs next to nothing.
+//!
+//! The same fix is in DesignCraft (#2139) and PhotoCraft (#2401). The long-term fix is in winit
+//! (storytold/winit#17); this module goes when the pinned winit has it.
+//!
+//! The Windows calls go through safe `winsafe` wrappers (the workspace forbids `unsafe`). Which
+//! windows are ours is found by enumerating this process's top-level windows of winit's window
+//! class, so the main window and every other window opened later are covered alike, and a file
+//! dialog or message box of the same process is left alone.
+
+/// `WS_BORDER`.
+#[cfg(any(windows, test))]
+const WS_BORDER: u32 = 0x0080_0000;
+/// `WS_CAPTION`, which already includes `WS_BORDER`.
+#[cfg(any(windows, test))]
+const WS_CAPTION: u32 = 0x00C0_0000;
+
+/// `style` without the OS title bar and frame (`WS_CAPTION`, `WS_BORDER`). The sizing border
+/// (`WS_THICKFRAME`) and the window buttons stay, so edge resizing, Aero snap and the system menu
+/// keep working, as they did with winit 0.27's undecorated windows.
+#[cfg(any(windows, test))]
+pub fn borderless_style(style: u32) -> u32 {
+ style & !(WS_CAPTION | WS_BORDER)
+}
+
+/// The per-frame step that keeps the windows borderless; a no-op where the OS draws the frame
+/// (macOS, Linux, the BSDs), where the app uses the system title bar, and in the browser.
+pub struct WindowFrame {
+ step: Option>,
+}
+
+impl WindowFrame {
+ /// The step for an app whose windows draw their own title bar when `custom_titlebar`.
+ pub fn new(custom_titlebar: bool) -> Self {
+ Self { step: platform::step(custom_titlebar) }
+ }
+
+ /// Run the step; call it once per frame, before the frame is laid out.
+ pub fn run(&mut self) {
+ if let Some(step) = self.step.as_mut() {
+ step();
+ }
+ }
+
+ #[cfg(test)]
+ fn with_step(step: Box) -> Self {
+ Self { step: Some(step) }
+ }
+}
+
+#[cfg(windows)]
+mod platform {
+ use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
+
+ /// winit's window class (`WindowAttributes`' default `class_name`).
+ const WINIT_CLASS: &str = "Window Class";
+
+ /// A step that strips the frame styles from this process's windows each call, or `None` when
+ /// the app uses the system title bar (the windows are decorated).
+ pub fn step(custom_titlebar: bool) -> Option> {
+ if !custom_titlebar {
+ return None;
+ }
+ let pid = std::process::id();
+ Some(Box::new(move || {
+ // The windows are handed over as real `HWND`s; nothing is wrapped from a raw handle.
+ let _ = winsafe::EnumWindows(|hwnd| {
+ strip_if_ours(&hwnd, pid);
+ true
+ });
+ }))
+ }
+
+ /// Strip `hwnd` when it is one of this process's winit windows.
+ fn strip_if_ours(hwnd: &HWND, pid: u32) {
+ if hwnd.GetWindowThreadProcessId().1 != pid || !matches!(hwnd.GetClassName().as_deref(), Ok(WINIT_CLASS)) {
+ return;
+ }
+ strip(hwnd);
+ }
+
+ /// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame; a no-op once they
+ /// are gone.
+ fn strip(hwnd: &HWND) {
+ let style = hwnd.style();
+ // The decision is the tested arithmetic; the write uses the same bits as winsafe's flags.
+ if super::borderless_style(style.raw()) == style.raw() {
+ return;
+ }
+ hwnd.set_style(style & !(co::WS::CAPTION | co::WS::BORDER));
+ let _ = hwnd.SetWindowPos(
+ HwndPlace::None,
+ POINT::new(),
+ SIZE::new(),
+ co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
+ );
+ }
+}
+
+#[cfg(not(windows))]
+mod platform {
+ pub fn step(_custom_titlebar: bool) -> Option> {
+ None
+ }
+}
+
+#[cfg(test)]
+mod window_frame_tests {
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ use super::{WS_BORDER, WS_CAPTION, WindowFrame, borderless_style};
+
+ #[cfg(windows)]
+ #[test]
+ fn the_style_bits_match_winsafes() {
+ use winsafe::co;
+ assert_eq!(co::WS::CAPTION.raw(), WS_CAPTION);
+ assert_eq!(co::WS::BORDER.raw(), WS_BORDER);
+ }
+
+ const WS_SYSMENU: u32 = 0x0008_0000;
+ const WS_THICKFRAME: u32 = 0x0004_0000;
+ const WS_MINIMIZEBOX: u32 = 0x0002_0000;
+ const WS_MAXIMIZEBOX: u32 = 0x0001_0000;
+ const WS_VISIBLE: u32 = 0x1000_0000;
+
+ #[test]
+ fn the_borderless_style_drops_the_caption_and_border_only() {
+ let decorated = WS_CAPTION | WS_BORDER | WS_SYSMENU | WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
+ let bare = borderless_style(decorated);
+ assert_eq!(bare & WS_CAPTION, 0, "caption gone");
+ assert_eq!(bare & WS_BORDER, 0, "border gone");
+ assert_eq!(bare & WS_THICKFRAME, WS_THICKFRAME, "edge resizing kept");
+ assert_eq!(bare & (WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU), WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU, "window buttons kept");
+ assert_eq!(bare & WS_VISIBLE, WS_VISIBLE, "unrelated bits kept");
+ }
+
+ #[test]
+ fn an_already_borderless_style_is_unchanged_so_no_frame_change_is_made() {
+ let bare = WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
+ assert_eq!(borderless_style(bare), bare);
+ assert_eq!(borderless_style(borderless_style(bare | WS_CAPTION)), bare);
+ }
+
+ #[test]
+ fn the_step_runs_each_time_the_frame_runs_it() {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let n = calls.clone();
+ let mut frame = WindowFrame::with_step(Box::new(move || {
+ n.fetch_add(1, Ordering::SeqCst);
+ }));
+ for _ in 0..3 {
+ frame.run();
+ }
+ assert_eq!(calls.load(Ordering::SeqCst), 3);
+ }
+
+ #[test]
+ fn no_step_is_fine() {
+ // Decorated windows and other platforms have no step; running must do nothing.
+ let mut frame = WindowFrame::new(false);
+ frame.run();
+ }
+}
diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs
index 189e06192..479ca439b 100644
--- a/crates/doc/src/geometry.rs
+++ b/crates/doc/src/geometry.rs
@@ -355,14 +355,15 @@ pub fn enter_target(doc: &Document, leaf: NodeId, selection: &[NodeId]) -> NodeI
inner.unwrap_or_else(|| select_target(doc, leaf, selection, false))
}
-/// Nodes on `page` whose world bounds intersect `rect` (marquee): top-level children, or the
-/// children of top-level frames when the marquee starts inside a frame (`within`).
+/// Nodes a marquee over `rect` touches, one level only: the children of `within` (a focus view's
+/// frame), or the top-level layers of `page`. Frames are not opened or enclosed here; see
+/// [`marquee_select`] for a plain marquee.
pub fn marquee(doc: &Document, page: NodeId, rect: Rect, within: Option) -> Vec {
let parent = within.unwrap_or(page);
doc.node(parent).map(|p| marquee_children(doc, p, rect)).unwrap_or_default()
}
-/// Nodes on `page` a Cmd/Ctrl marquee selects: the innermost layers whose bounds touch `rect`
+/// Nodes on `page` a Cmd/Ctrl marquee selects: the innermost layers the marquee touches
/// (frames, groups and instances are looked into; a container none of whose children are touched
/// counts itself; boolean groups count as one shape).
pub fn marquee_deep(doc: &Document, page: NodeId, rect: Rect) -> Vec {
@@ -384,25 +385,133 @@ pub fn marquee_deep(doc: &Document, page: NodeId, rect: Rect) -> Vec {
out
}
-/// The visible, unlocked children of `p` whose world bounds touch `rect` (on both axes: lines and
-/// other flat layers too).
+/// The visible, unlocked children of `p` the marquee touches (see [`marquee_touches`]).
fn marquee_children(doc: &Document, p: &Node, rect: Rect) -> Vec {
- p.children
- .iter()
- .copied()
- .filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked))
- .filter(|c| doc.world_bounds(*c).is_some_and(|b| b.x0 <= rect.x1 && b.x1 >= rect.x0 && b.y0 <= rect.y1 && b.y1 >= rect.y0))
- .collect()
+ p.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked && marquee_touches(doc, *c, n, rect))).collect()
}
-/// Figma marquee rule: a marquee that fully contains a top-level frame selects the frame; one
-/// starting on empty canvas selects top-level objects it touches.
-pub fn marquee_select(doc: &Document, page: NodeId, rect: Rect, start_inside: Option) -> Vec {
- if let Some(frame) = start_inside {
- let inside = marquee(doc, page, rect, Some(frame));
- return inside;
+/// The outline a rotated shape is touched by. One line: return the turned box instead
+/// (`figcraft_geom::rounded_rect(n.width, n.height, [0.0; 4], 0.0)`) if Figma proves to test that.
+fn marquee_outline(n: &Node) -> Option {
+ shape_path(n)
+}
+
+/// Whether the marquee touches layer `id`: its world box overlaps `rect` (inclusive, so lines and
+/// zero-size marquees count) and, for a rotated shape, its outline in page space does too.
+fn marquee_touches(doc: &Document, id: NodeId, n: &Node, rect: Rect) -> bool {
+ let box_hit = doc.world_bounds(id).is_some_and(|b| b.x0 <= rect.x1 && b.x1 >= rect.x0 && b.y0 <= rect.y1 && b.y1 >= rect.y0);
+ if !box_hit
+ || !matches!(
+ n.kind,
+ NodeKind::Rectangle
+ | NodeKind::Ellipse { .. }
+ | NodeKind::Polygon { .. }
+ | NodeKind::Star { .. }
+ | NodeKind::Line
+ | NodeKind::Vector { .. }
+ )
+ {
+ return box_hit;
}
- marquee(doc, page, rect, None)
+ let xf = doc.world_transform(id);
+ let [a, b, c, d, e, f] = xf.as_coeffs();
+ const EPS: f64 = 1e-9;
+ let rotated = !((b.abs() < EPS && c.abs() < EPS) || (a.abs() < EPS && d.abs() < EPS));
+ if !rotated {
+ return true;
+ }
+ let det = a * d - b * c;
+ if ![a, b, c, d, e, f, det].iter().all(|v| v.is_finite()) || det.abs() < EPS {
+ return true;
+ }
+ marquee_outline(n).is_none_or(|path| path_touches_rect(n, &path, xf, rect))
+}
+
+/// Whether the marquee and the shape drawn as local `path` under `xf` share a point: a path end
+/// inside the marquee, a path segment crossing a marquee edge, or the marquee inside the filled shape.
+fn path_touches_rect(n: &Node, path: &BezPath, xf: figcraft_geom::Affine, rect: Rect) -> bool {
+ use figcraft_geom::kurbo::{Line, ParamCurve, PathSeg};
+ let inside = |p: Point| p.x >= rect.x0 && p.x <= rect.x1 && p.y >= rect.y0 && p.y <= rect.y1;
+ let mut world = path.clone();
+ world.apply_affine(xf);
+ if world.segments().any(|s| inside(s.start()) || inside(s.end())) {
+ return true;
+ }
+ let (tl, tr, br, bl) = (Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1));
+ let edges = [(tl, tr), (tr, br), (br, bl), (bl, tl)];
+ for (p, q) in edges {
+ if (q - p).hypot() < 1e-9 {
+ continue;
+ }
+ let line = Line::new(p, q);
+ if world.segments().any(|s: PathSeg| s.intersect_line(line).iter().any(|h| (0.0..=1.0).contains(&h.line_t))) {
+ return true;
+ }
+ }
+ is_closed(n) && fills_point(n, path, xf.inverse() * tl)
+}
+
+/// Whether a marquee takes a touched top-level layer whole, opens it for the layers in it, or skips it.
+enum Touch {
+ Whole,
+ /// The frame the drag started in: the layers in it the marquee touches, or the frame itself
+ /// (whole) once the marquee is at the top level.
+ Open,
+ /// A partly covered frame or section: the layers in it, or the frame itself once the marquee is at the top level.
+ Partial,
+ Skip,
+}
+
+/// Figma's marquee rule (measured in the web app, 2026-11-11): each top-level layer the marquee
+/// touches is selected whole, except a frame with children or a section with its contents shown:
+/// enclosed by the marquee it is selected itself, otherwise the marquee takes the layers in it that
+/// it touches. The frame the drag started in (`start_inside`) is selected only when the marquee also
+/// takes another top-level frame (then whole); otherwise only the layers in it the marquee touches.
+/// Selecting a frame or section whole keeps the marquee at the top level: no frame opens then, and a
+/// partly covered one is selected whole. Components, instances, board items and sections with hidden
+/// contents are selected only when enclosed.
+pub fn marquee_select(doc: &Document, page: NodeId, rect: Rect, start_inside: Option) -> Vec {
+ let Some(page_node) = doc.node(page) else { return Vec::new() };
+ let touched = marquee_children(doc, page_node, rect);
+ let classified: Vec<(NodeId, Touch, bool)> = touched
+ .iter()
+ .filter_map(|t| {
+ let n = doc.node(*t)?;
+ let enclosed = doc.world_bounds(*t).is_some_and(|b| rect.contains(b.origin()) && rect.contains(Point::new(b.x1, b.y1)));
+ let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
+ let opens = match &n.kind {
+ NodeKind::Frame | NodeKind::Section { .. } => !n.is_board_atom() && !n.hides_contents() && !n.children.is_empty(),
+ _ => false,
+ };
+ let plain_empty_frame = matches!(n.kind, NodeKind::Frame) && n.children.is_empty() && !n.is_board_atom();
+ let act = if start_inside == Some(*t) {
+ Touch::Open
+ } else if opens {
+ if enclosed { Touch::Whole } else { Touch::Partial }
+ } else if plain_empty_frame || !(container || n.is_board_atom()) || enclosed {
+ Touch::Whole
+ } else {
+ Touch::Skip
+ };
+ let whole_container = container && matches!(act, Touch::Whole);
+ Some((*t, act, whole_container))
+ })
+ .collect();
+ let top_level_mode = classified.iter().any(|(_, _, whole_container)| *whole_container);
+ let mut out = Vec::new();
+ for (t, act, _) in &classified {
+ match act {
+ Touch::Whole => out.push(*t),
+ Touch::Open | Touch::Partial if top_level_mode => out.push(*t),
+ Touch::Open | Touch::Partial => {
+ if let Some(n) = doc.node(*t) {
+ out.extend(marquee_children(doc, n, rect));
+ }
+ }
+ Touch::Skip => {}
+ }
+ }
+ out
}
/// Bounds of a node's outline in page coordinates for snapping (the box corners and centre).
diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs
index cb273fc51..06c82563a 100644
--- a/crates/engine/src/assets/components.rs
+++ b/crates/engine/src/assets/components.rs
@@ -298,6 +298,30 @@ pub fn prop_list(doc: &Document, c: NodeId) -> Vec<(String, PropDef)> {
}
}
+/// The properties an instance of `component` lists: [`prop_list`] without the boolean, text and
+/// instance swap properties that no layer of the component reads. A set's variants share its
+/// definitions, so one a variant's layers don't bind (another variant's text, a leftover of a
+/// deleted variant) stays defined, and shows on the set, but not on this variant's instances
+/// (#2022). Slots stay. Walks as [`slots_of`] does: a nested instance counts, its own layers and
+/// the content put into slots are not entered.
+pub fn instance_prop_list(doc: &Document, component: NodeId) -> Vec<(String, PropDef)> {
+ let mut used: std::collections::HashSet = std::collections::HashSet::new();
+ let mut stack = vec![component];
+ let mut seen = 0usize;
+ while let Some(c) = stack.pop() {
+ seen += 1;
+ if seen > 1_000_000 {
+ break;
+ }
+ let Some(n) = doc.node(c) else { continue };
+ used.extend(n.prop_refs.values().cloned());
+ if c == component || (!is_instance(doc, c) && !holds_content(n)) {
+ stack.extend(n.children.iter().rev().copied());
+ }
+ }
+ prop_list(doc, component).into_iter().filter(|(k, d)| !takes_value(d) || used.contains(k)).collect()
+}
+
/// Whether instances store a value for the property (every kind but slots).
pub fn takes_value(d: &PropDef) -> bool {
!matches!(d, PropDef::Slot { .. })
diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs
index ffaac176f..fc7d21e86 100644
--- a/crates/engine/src/assets/components/tests.rs
+++ b/crates/engine/src/assets/components/tests.rs
@@ -395,8 +395,11 @@ fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId,
ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
assert!(node(s, title).prop_refs.is_empty());
assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
- let desc = ex(s, "component.properties", json!({"id": i}));
+ // "Show dot" is still defined but no layer reads it any more: the main lists it, the instance doesn't (#2022).
+ let desc = ex(s, "component.properties", json!({"id": c}));
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
+ let desc = ex(s, "component.properties", json!({"id": i}));
+ assert!(desc["properties"].as_array().unwrap().is_empty());
// A variant property on a lone component makes it a component set.
let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs
index fb802353e..1e9c645e3 100644
--- a/crates/engine/src/canvas/tools/pointer.rs
+++ b/crates/engine/src/canvas/tools/pointer.rs
@@ -47,16 +47,23 @@ impl Session {
/// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them.
pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> {
let (doc, page) = (self.doc()?, self.page()?);
- let mut hits =
- if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) };
- if let Some(f) = self.ds()?.focused() {
+ let focused = self.ds()?.focused();
+ let mut hits = match (deep, focused) {
+ (true, _) => figcraft_doc::geometry::marquee_deep(doc, page, r),
+ (false, Some(f)) => figcraft_doc::geometry::marquee(doc, page, r, Some(f)),
+ (false, None) => figcraft_doc::geometry::marquee_select(doc, page, r, within),
+ };
+ if let Some(f) = focused {
hits.retain(|h| *h != f && doc.is_ancestor(f, *h));
}
- // A marquee over a frame from outside selects the frame only when it covers it fully.
- if within.is_none() && !deep {
+ // A widget's layers are part of the widget: a marquee from outside never opens one.
+ if !deep {
hits.retain(|h| {
- let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1)));
- full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
+ doc.node(*h)
+ .and_then(|n| n.parent)
+ .filter(|p| Some(*p) != within)
+ .and_then(|p| doc.node(p))
+ .is_none_or(|p| !crate::widgets::is_widget(p))
});
}
// Shift toggles: what was selected and is touched again comes out.
diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs
index 2d97d6f0a..97bef3ce2 100644
--- a/crates/engine/src/cmd/component/props.rs
+++ b/crates/engine/src/cmd/component/props.rs
@@ -8,7 +8,7 @@ use super::{
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
variant_options, variant_props, variant_set,
};
-use crate::components::prop_list;
+use crate::components::{instance_prop_list, prop_list};
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
/// boolean property takes a boolean variable, a text or variant property a string one).
@@ -606,7 +606,8 @@ pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
}
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
- for (k, d) in prop_list(doc, comp) {
+ let defs = if values.is_some() { instance_prop_list(doc, comp) } else { prop_list(doc, comp) };
+ for (k, d) in defs {
let mut v = def_json(&k, &d);
if !takes_value(&d) {
let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 3e213d04b..2627d7efd 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -423,6 +423,8 @@ mod live_tests;
#[cfg(test)]
mod lock_behaviour_tests;
#[cfg(test)]
+mod marquee_behaviour_tests;
+#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod masks_behaviour_tests;
diff --git a/crates/engine/src/marquee_behaviour_tests.rs b/crates/engine/src/marquee_behaviour_tests.rs
new file mode 100644
index 000000000..cede8c253
--- /dev/null
+++ b/crates/engine/src/marquee_behaviour_tests.rs
@@ -0,0 +1,218 @@
+//! Marquee selection, as measured in Figma's web app (2026-11-11, the "marquee rules" and "confirming
+//! pass" readings, and the two evening re-measures) and described in the help-center article "Select layers and objects": a drag
+//! selects each top-level layer it touches; a partly covered frame or section gives the layers in it
+//! the marquee touches, an enclosed one is selected itself; a drag started inside a frame selects that frame
+//! only when the marquee also takes another top-level frame (then whole); selecting a frame whole keeps the marquee at the top level; a rotated shape is touched by
+//! its outline, an unrotated one by its box.
+
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::params::id_of;
+use crate::tools::{PointerEvent, PointerKind};
+use figcraft_doc::NodeId;
+use figcraft_geom::Point;
+
+fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
+ s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
+}
+
+fn id(v: &Value) -> NodeId {
+ id_of(&v["id"]).unwrap()
+}
+
+fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option) -> NodeId {
+ let mut p = json!({"x": x, "y": y, "width": w, "height": h});
+ if let Some(parent) = parent {
+ p["parent"] = json!(parent);
+ }
+ id(&ex(s, "shape.rectangle", p))
+}
+
+fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
+ PointerEvent::at(kind, x, y, 1.0)
+}
+
+fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64)) {
+ s.pointer(&ev(PointerKind::Down, a.0, a.1)).unwrap();
+ s.pointer(&ev(PointerKind::Move, b.0, b.1)).unwrap();
+ s.pointer(&ev(PointerKind::Up, b.0, b.1)).unwrap();
+}
+
+fn sorted(mut v: Vec) -> Vec {
+ v.sort();
+ v
+}
+
+#[allow(dead_code)]
+struct Scene {
+ f1: NodeId,
+ r1: NodeId,
+ r2: NodeId,
+ e: NodeId,
+ u: NodeId,
+ rr: NodeId,
+ re: NodeId,
+ f2: NodeId,
+ g: NodeId,
+ s: NodeId,
+ r5: NodeId,
+}
+
+/// The confirming pass's layout: `f1` (0,0 300x200) holding `r1` (20,20) and `r2` (220,120), both 60x60;
+/// ellipse `e` (500,0 200x200); rectangle `u` (900,0 100x100); `rr` rectangle (0,400 200x100) and ellipse
+/// `re` (500,400 200x100) turned 45 degrees about their centres; the empty frame `f2` (0,700 200x150);
+/// the group `g` of two rectangles (300,700) and (360,760); the section `s` (600,700 300x200) holding `r5`.
+fn scene(s: &mut Session) -> Scene {
+ let f1 = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
+ let r1 = rect(s, 20.0, 20.0, 60.0, 60.0, Some(f1));
+ let r2 = rect(s, 220.0, 120.0, 60.0, 60.0, Some(f1));
+ let e = id(&ex(s, "shape.ellipse", json!({"x": 500, "y": 0, "width": 200, "height": 200})));
+ let u = rect(s, 900.0, 0.0, 100.0, 100.0, None);
+ let rr = rect(s, 0.0, 400.0, 200.0, 100.0, None);
+ ex(s, "node.set", json!({"id": rr, "props": {"rotation": 45}}));
+ let re = id(&ex(s, "shape.ellipse", json!({"x": 500, "y": 400, "width": 200, "height": 100})));
+ ex(s, "node.set", json!({"id": re, "props": {"rotation": 45}}));
+ let f2 = id(&ex(s, "frame.create", json!({"x": 0, "y": 700, "width": 200, "height": 150})));
+ let g1 = rect(s, 300.0, 700.0, 80.0, 80.0, None);
+ let g2 = rect(s, 360.0, 760.0, 80.0, 80.0, None);
+ let g = id(&ex(s, "object.group", json!({"ids": [g1, g2]})));
+ let sec = id(&ex(s, "node.create", json!({"type": "section", "x": 600, "y": 700, "width": 300, "height": 200})));
+ let r5 = rect(s, 20.0, 20.0, 60.0, 60.0, Some(sec));
+ ex(s, "select.none", json!({}));
+ Scene { f1, r1, r2, e, u, rr, re, f2, g, s: sec, r5 }
+}
+
+fn selected(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> Vec {
+ ex(s, "select.none", json!({}));
+ drag(s, a, b);
+ let sel = sorted(s.selection());
+ ex(s, "select.none", json!({}));
+ sel
+}
+
+#[test]
+fn a_partly_covered_frame_gives_the_layers_it_touches() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (-50.0, -50.0), (60.0, 60.0)), vec![k.r1], "measured case 2: Rectangle 1, not F1");
+}
+
+#[test]
+fn an_enclosed_frame_is_selected_itself() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (-20.0, -20.0), (320.0, 220.0)), vec![k.f1], "measured b");
+}
+
+#[test]
+fn an_empty_frame_is_selected_on_touch() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (-20.0, 680.0), (50.0, 720.0)), vec![k.f2], "measured c: Frame 2");
+}
+
+#[test]
+fn a_group_is_selected_whole_on_touch() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (290.0, 690.0), (310.0, 710.0)), vec![k.g], "measured c: Group 1, not the rectangle");
+}
+
+#[test]
+fn a_partly_covered_section_gives_the_layers_it_touches() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (590.0, 690.0), (650.0, 750.0)), vec![k.r5], "measured c: Rectangle 5, not the section");
+}
+
+#[test]
+fn an_unrotated_shape_is_touched_by_its_box() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (490.0, -10.0), (505.0, 5.0)), vec![k.e], "measured case 3: the ellipse's box corner selects it");
+ assert_eq!(selected(&mut s, (890.0, -10.0), (905.0, 5.0)), vec![k.u], "control");
+}
+
+#[test]
+fn a_rotated_rectangle_is_touched_by_its_outline() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (-30.0, 320.0), (4.0, 354.0)), Vec::::new(), "measured case 3: the box corner selects nothing");
+ assert_eq!(selected(&mut s, (-30.0, 320.0), (100.0, 450.0)), vec![k.rr]);
+}
+
+#[test]
+fn a_rotated_ellipse_is_not_touched_by_its_axis_aligned_box() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ assert_eq!(selected(&mut s, (470.0, 320.0), (504.0, 354.0)), Vec::::new(), "measured case 3: the box corner selects nothing");
+ assert_eq!(selected(&mut s, (470.0, 320.0), (600.0, 450.0)), vec![k.re]);
+}
+
+#[test]
+fn a_rotated_ellipse_is_not_touched_through_the_corner_of_its_turned_box() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ // The re-measure's literal marquees (evening, section 1): corner A, the deeper B, and the control.
+ let p = Point::new(692.0, 419.0);
+ let doc = s.doc().unwrap();
+ let n = doc.node(k.re).unwrap();
+ assert!(n.local_rect().contains(doc.world_transform(k.re).inverse() * p), "the probe is inside the turned box");
+ assert_eq!(selected(&mut s, (715.0, 411.0), (692.0, 419.0)), Vec::::new(), "measured marquee A");
+ assert_eq!(selected(&mut s, (715.0, 411.0), (683.0, 419.0)), Vec::::new(), "measured marquee B");
+ assert_eq!(selected(&mut s, (715.0, 411.0), (667.0, 419.0)), vec![k.re], "measured control");
+}
+
+#[test]
+fn a_drag_from_inside_a_frame_selects_only_what_it_touches() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ let start_x = |s: &Session| s.doc().unwrap().world_bounds(k.f1).map(|b| (b.x0, b.y0));
+ assert_eq!(selected(&mut s, (150.0, 100.0), (180.0, 150.0)), Vec::::new(), "measured e1");
+ assert_eq!(start_x(&s), Some((0.0, 0.0)));
+ assert_eq!(selected(&mut s, (150.0, 100.0), (180.0, 250.0)), Vec::::new(), "measured e2");
+ assert_eq!(start_x(&s), Some((0.0, 0.0)));
+ assert_eq!(selected(&mut s, (150.0, 100.0), (600.0, 100.0)), vec![k.e], "measured e3: the ellipse only");
+ assert_eq!(start_x(&s), Some((0.0, 0.0)));
+ assert_eq!(selected(&mut s, (150.0, 100.0), (250.0, 150.0)), vec![k.r2], "today's rule, not measured");
+ assert_eq!(start_x(&s), Some((0.0, 0.0)));
+}
+
+#[test]
+fn selecting_a_frame_whole_keeps_the_marquee_at_the_top_level() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ // Encloses the empty f2, covers rr, and partly covers f1 over r1: no frame opens, f1 comes whole.
+ assert_eq!(
+ selected(&mut s, (-20.0, 50.0), (210.0, 860.0)),
+ sorted(vec![k.f2, k.f1, k.rr]),
+ "the article: selecting a top-level frame selects only top-level layers; measured: a partly covered frame comes whole"
+ );
+}
+
+#[test]
+fn a_marquee_enclosing_an_empty_frame_takes_a_partly_covered_frame_whole() {
+ let mut s = Session::new();
+ let f1 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
+ let _r1 = rect(&mut s, 20.0, 20.0, 60.0, 60.0, Some(f1));
+ let f2 = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 200, "height": 150})));
+ assert_eq!(selected(&mut s, (650.0, 170.0), (40.0, -10.0)), sorted(vec![f1, f2]), "measured re-measure: F2 and F1, not R1");
+}
+
+#[test]
+fn a_drag_from_inside_a_frame_takes_it_whole_when_it_takes_another_top_level_frame() {
+ let mut s = Session::new();
+ let f1 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
+ let r1 = rect(&mut s, 20.0, 20.0, 60.0, 60.0, Some(f1));
+ let _r2 = rect(&mut s, 220.0, 120.0, 60.0, 60.0, Some(f1));
+ let f2 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 150})));
+ let rr = rect(&mut s, 400.0, 400.0, 200.0, 100.0, None);
+ ex(&mut s, "select.none", json!({}));
+ assert_eq!(selected(&mut s, (150.0, 100.0), (90.0, 720.0)), sorted(vec![f1, f2]), "re-measure 2, case 1");
+ let sel = selected(&mut s, (150.0, 50.0), (50.0, 720.0));
+ assert_eq!(sel, sorted(vec![f1, f2]), "re-measure 2, case 2b: R1 is covered in part and still not selected");
+ assert!(!sel.contains(&r1));
+ assert_eq!(selected(&mut s, (150.0, 100.0), (450.0, 450.0)), sorted(vec![f1, f2, rr]), "re-measure 2, case 3");
+ assert_eq!(selected(&mut s, (290.0, 100.0), (450.0, 450.0)), vec![rr], "re-measure 2, case 3b: the frame only when another is taken");
+}
diff --git a/crates/engine/src/prop_order_tests.rs b/crates/engine/src/prop_order_tests.rs
index 02f329e15..3b996e250 100644
--- a/crates/engine/src/prop_order_tests.rs
+++ b/crates/engine/src/prop_order_tests.rs
@@ -77,8 +77,9 @@ fn every_kind_of_property_keeps_its_place_on_rename() {
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon_f]})));
let nested = idv(&ex(&mut s, "instance.create", json!({"component": icon, "x": 60, "y": 10, "parent": f})));
let small = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ let zoom_layer = idv(&ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 10, "width": 10, "height": 10, "parent": small})));
// Made in an order that isn't by name: Zoom (boolean), Label (text), Glyph (instance swap), Body (slot).
- ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean"}));
+ ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean", "layer": zoom_layer}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Label", "type": "text", "layer": label}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Glyph", "type": "instanceSwap", "layer": nested}));
ex(&mut s, "component.makeSlot", json!({"id": body}));
@@ -133,3 +134,27 @@ fn make_slot_takes_the_name_typed() {
by_name.sort();
assert_eq!(listed, by_name);
}
+
+/// An instance lists the properties its own variant's layers use (#2022): in Figma's data a set
+/// has one text property per variant (and leftovers of deleted ones), measured: an instance of
+/// variant A lists `State` and `A label`, never `B label` or a property no layer reads.
+#[test]
+fn an_instance_lists_only_the_properties_its_variant_uses() {
+ let mut s = Session::new();
+ let mut variants = Vec::new();
+ let mut texts = Vec::new();
+ for (k, name) in ["Type=Flag, State=Default", "Type=Flag, State=Selected"].iter().enumerate() {
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": k * 60, "width": 120, "height": 40, "name": name})));
+ texts.push(idv(&ex(&mut s, "text.create", json!({"text": "Label", "x": 4, "y": 4, "parent": f, "name": "Label"}))));
+ variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
+ }
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
+ ex(&mut s, "component.addProperty", json!({"id": set, "name": "Default label", "type": "text", "layer": texts[0]}));
+ ex(&mut s, "component.addProperty", json!({"id": set, "name": "Selected label", "type": "text", "layer": texts[1]}));
+ ex(&mut s, "component.addProperty", json!({"id": set, "name": "Gone label", "type": "text"}));
+ assert_eq!(names(&mut s, set), ["Type", "State", "Default label", "Selected label", "Gone label"], "the set keeps every definition");
+ let inst = idv(&ex(&mut s, "instance.create", json!({"component": variants[0], "x": 400, "y": 0})));
+ assert_eq!(names(&mut s, inst), ["Type", "State", "Default label"]);
+ ex(&mut s, "instance.setProperties", json!({"ids": [inst], "properties": {"State": "Selected"}}));
+ assert_eq!(names(&mut s, inst), ["Type", "State", "Selected label"]);
+}
diff --git a/crates/engine/src/select_area_tests.rs b/crates/engine/src/select_area_tests.rs
index 8f1d4cf75..30de60d4c 100644
--- a/crates/engine/src/select_area_tests.rs
+++ b/crates/engine/src/select_area_tests.rs
@@ -1,6 +1,6 @@
//! `select.area`: what a marquee over a box selects, without a pointer (keyboard box selection in
-//! the app, agents over MCP): top-level layers, a frame only when fully covered, the children of
-//! the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the
+//! the app, agents over MCP): top-level layers, a frame only when fully covered (otherwise the
+//! layers in it the box touches), the children of the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the
//! app to draw one.
use serde_json::{Value, json};
@@ -40,8 +40,8 @@ fn a_box_selects_like_a_marquee() {
let inner = rect(&mut s, 20.0, 20.0, Some(frame));
let a = rect(&mut s, 0.0, 0.0, None);
let b = rect(&mut s, 100.0, 0.0, None);
- // Top-level layers it touches; a frame only touched from outside isn't selected.
- assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b]));
+ // Top-level layers it touches; a frame only touched from outside gives the layers in it the box touches.
+ assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b, inner]));
// A negative size draws the box the other way.
assert_eq!(area(&mut s, 130.0, 30.0, -140.0, -40.0, json!({})), sorted(vec![a, b]));
// Covering the frame selects it.
diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs
index 6498b8cb5..2e447c5de 100644
--- a/crates/engine/src/selection_behaviour_tests.rs
+++ b/crates/engine/src/selection_behaviour_tests.rs
@@ -214,8 +214,8 @@ fn marquee_selects_what_it_touches() {
(450.0, 40.0),
NONE,
vec![],
- vec![],
- "from the canvas, a top-level frame is selected only when the marquee covers all of it",
+ vec![k.g, k.n],
+ "from the canvas, a partly covered top-level frame gives the layers in it the marquee touches (measured); the nested frame comes whole",
),
((-10.0, -10.0), (450.0, 310.0), NONE, vec![], vec![k.f], "a marquee covering a top-level frame selects the frame"),
(
diff --git a/crates/engine/src/selection_depth_tests.rs b/crates/engine/src/selection_depth_tests.rs
index 5897ac6fd..a754e7343 100644
--- a/crates/engine/src/selection_depth_tests.rs
+++ b/crates/engine/src/selection_depth_tests.rs
@@ -66,10 +66,10 @@ fn cmd_click_selects_the_nested_layer() {
#[test]
fn cmd_marquee_selects_nested_layers() {
let mut s = Session::new();
- let (f, _, [a, b, c], out) = scene(&mut s);
- // From empty canvas, a plain marquee across the frame only touches top-level layers (and the
- // frame isn't fully covered).
- assert_eq!(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false), vec![out]);
+ let (f, g, [a, b, c], out) = scene(&mut s);
+ // From empty canvas, a plain marquee across the frame (not fully covered) gives the layers in it
+ // it touches (the group whole), plus the top-level layer.
+ assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false)), sorted(vec![g, out]));
// Cmd/Ctrl: the innermost layers it touches, through the frame and the group.
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, true)), sorted(vec![a, b, out]));
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (100.0, 190.0), false, true)), sorted(vec![a, b, c]));
diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs
index adb96d541..4fbe5066a 100644
--- a/crates/engine/src/slot_tests.rs
+++ b/crates/engine/src/slot_tests.rs
@@ -417,12 +417,16 @@ fn assert_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
assert!(html.contains(&format!(">{expected}
")), "slot Label HTML: {html}");
}
-fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
+fn assert_stored_slot_text(s: &Session, instance: NodeId, expected: &str) {
assert!(
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))),
"instance {instance} must store its own Text value {expected:?}: {:?}",
node(s, instance).kind
);
+}
+
+fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
+ assert_stored_slot_text(s, instance, expected);
let properties = props_of(s, instance);
let text = properties.iter().find(|p| p["name"] == "Text").unwrap_or_else(|| panic!("instance {instance} has no Text property: {properties:?}"));
assert_eq!(text["value"], expected, "instance {instance} Text property");
@@ -475,14 +479,15 @@ fn slot_text_properties_do_not_read_an_unbound_base_property() {
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
let nested_base = kids(&s, i)[0];
- assert_local_slot_text(&mut s, nested_base, "Wrong owner");
+ // Bound to no layer, the base's Text isn't listed on its instance (#2269); its value stays stored.
+ assert_stored_slot_text(&s, nested_base, "Wrong owner");
let nested = copy_of(&s, i, main_content);
assert_local_slot_text(&mut s, nested, "Default");
assert_slot_text(&mut s, nested, "Default");
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
assert_local_slot_text(&mut s, nested, "Custom");
assert_slot_text(&mut s, nested, "Custom");
- assert_local_slot_text(&mut s, nested_base, "Wrong owner");
+ assert_stored_slot_text(&s, nested_base, "Wrong owner");
assert_slot_text(&mut s, main_content, "Default");
healthy(&s);
}
diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs
index 24248d32a..150cac39a 100644
--- a/crates/format/src/fig/prop_value_tests.rs
+++ b/crates/format/src/fig/prop_value_tests.rs
@@ -186,3 +186,43 @@ fn property_values_written_only_as_variables_are_applied() {
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
}
+
+/// #2022: a set keeps each named text definition under its own name (Figma writes one per variant,
+/// plus leftovers of deleted variants), and each variant's text reads its own copy.
+#[test]
+fn a_set_keeps_each_named_text_definition_and_layers_read_their_own() {
+ let text = |chars: &str| o(vec![("textValue", o(vec![("characters", s(chars))]))]);
+ let names = ["Type=Flag / State=Default label", "Type=Flag / State=Selected label", "Type=Issue / State=Default label"];
+ let set_defs: Vec> =
+ names.iter().zip([70, 71, 72]).zip(["One", "Two", "Gone"]).map(|((n, id), c)| definition(id, n, "TEXT", text(c))).collect();
+ let copies = |first: u32| Kv::Array((0..3).map(|k| o(vec![("id", g(first + k)), ("parentPropDefId", g(70 + k))])).collect());
+ let msg = message(vec![
+ node(9, "FRAME", "Chip", Some(2), vec![("isStateGroup", Kv::Bool(true)), ("componentPropDefs", Kv::Array(set_defs))]),
+ node(10, "SYMBOL", "Type=Flag, State=Default", Some(9), vec![("componentPropDefs", copies(80))]),
+ node(11, "TEXT", "Label", Some(10), vec![bound("TEXT_DATA", 80)]),
+ node(20, "SYMBOL", "Type=Flag, State=Selected", Some(9), vec![("componentPropDefs", copies(90))]),
+ node(21, "TEXT", "Label", Some(20), vec![bound("TEXT_DATA", 91)]),
+ ]);
+ let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
+ let defs = &by_id(&rest["document"], "9:9")["componentPropertyDefinitions"];
+ for (k, n) in names.iter().enumerate() {
+ assert_eq!(defs[format!("{n}#9:{}", 70 + k)]["type"], "TEXT", "{n}");
+ }
+ assert_eq!(defs.as_object().map(|d| d.len()), Some(3), "{defs}");
+ assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["characters"], format!("{}#9:70", names[0]));
+ assert_eq!(by_id(&rest["document"], "9:21")["componentPropertyReferences"]["characters"], format!("{}#9:71", names[1]));
+
+ let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
+ let refs: Vec