diff --git a/crates/ui-egui/src/canvas/proto_canvas.rs b/crates/ui-egui/src/canvas/proto_canvas.rs index 1178ed6b2..b6955e956 100644 --- a/crates/ui-egui/src/canvas/proto_canvas.rs +++ b/crates/ui-egui/src/canvas/proto_canvas.rs @@ -119,7 +119,7 @@ fn segment_distance(a: Pos2, b: Pos2, q: Pos2) -> f32 { } /// Handle centres of the selected layers (right edge, vertically centred). -fn handles(app: &FigApp, doc: &Document, sel: &[NodeId]) -> Vec<(NodeId, Pos2)> { +pub(crate) fn handles(app: &FigApp, doc: &Document, sel: &[NodeId]) -> Vec<(NodeId, Pos2)> { sel.iter() .take(32) .filter(|i| doc.node(**i).is_some_and(|n| !n.is_page())) @@ -135,6 +135,46 @@ fn frame_at(app: &FigApp, doc: &Document, page: NodeId, p: Pos2) -> Option NodeId { + match self { + Target::Navigate(n) | Target::ChangeTo(n) => n, + } + } +} + +/// The variant (a child of a component set) the layer is, or sits inside. +fn variant_of(doc: &Document, source: NodeId) -> Option { + let is_set = |i: NodeId| doc.node(i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::ComponentSet { .. })); + std::iter::once(source).chain(doc.ancestors(source)).find(|v| doc.node(*v).and_then(|n| n.parent).is_some_and(is_set)) +} + +/// What a connection dragged from `source` and released at screen point `p` targets: a sibling +/// variant of the source's own variant (Change to), else a frame other than the source's own +/// (Navigate to). +fn target_at(app: &FigApp, doc: &Document, page: NodeId, source: NodeId, p: Pos2) -> Option { + if let Some(v) = variant_of(doc, source) + && let Some(set) = doc.node(v).and_then(|n| n.parent) + && doc.node(set).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::ComponentSet { .. })) + { + let pt = app.to_page(p); + let siblings = doc.node(set).map(|n| n.children.clone()).unwrap_or_default(); + if let Some(w) = siblings.into_iter().rev().find(|w| doc.world_bounds(*w).is_some_and(|b| b.contains(pt))) { + return (w != v).then_some(Target::ChangeTo(w)); + } + } + frame_at(app, doc, page, p).filter(|f| doc.top_level_frame(source) != Some(*f)).map(Target::Navigate) +} + fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) { let tip = pts[3]; let dir = (pts[3] - bezier(pts, 0.9)).normalized(); @@ -200,9 +240,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { } match dragging { Some(d) => { - if let Some(f) = frame_at(app, doc, page, d.pos) - && doc.top_level_frame(d.source) != Some(f) - && let Some(r) = screen_rect(app, doc, f) + if let Some(t) = target_at(app, doc, page, d.source, d.pos) + && let Some(r) = screen_rect(app, doc, t.node()) { painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Outside); } @@ -259,9 +298,12 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { ctx.request_repaint(); if released || !down { ctx.data_mut(|m| m.remove::(drag_id())); - if let Some(dest) = frame_at(app, &doc, page, d.pos) - && doc.top_level_frame(d.source) != Some(dest) - && let Some(r) = app.run("proto.interaction.add", json!({"ids": [d.source], "dest": dest})) + let params = target_at(app, &doc, page, d.source, d.pos).map(|t| match t { + Target::Navigate(dest) => json!({"ids": [d.source], "dest": dest}), + Target::ChangeTo(dest) => json!({"ids": [d.source], "action": {"type": "changeTo", "dest": dest}}), + }); + if let Some(params) = params + && let Some(r) = app.run("proto.interaction.add", params) { let k = r.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; crate::proto_panel::open_editor(&ctx, Some((d.source, k))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60c68f526..a9e620a7f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -233,6 +233,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod proto_variant_connect_tests; +#[cfg(test)] mod region_fill_rows_tests; #[cfg(test)] mod resources_tests; diff --git a/crates/ui-egui/src/proto_variant_connect_tests.rs b/crates/ui-egui/src/proto_variant_connect_tests.rs new file mode 100644 index 000000000..b9f6ba812 --- /dev/null +++ b/crates/ui-egui/src/proto_variant_connect_tests.rs @@ -0,0 +1,126 @@ +//! A connection dragged out of a variant (or a layer in it) onto a sibling variant makes a Change +//! to it (On click, Instant), as in Figma (#2158); onto the set's background or its own variant it +//! makes nothing, and onto a frame outside the set it still navigates. + +use egui::{Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_doc::proto::{Action, Transition, Trigger}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::FigApp; +use crate::RightTab; +use crate::canvas::proto_canvas::handles; +use crate::present_tests::{Ui, id}; + +fn screen_centre(app: &FigApp, n: NodeId) -> Pos2 { + let b = app.session.doc().unwrap().world_bounds(n).unwrap(); + app.to_screen((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0) +} + +struct Setup { + u: Ui, + set: NodeId, + default: NodeId, + hover: NodeId, + layer: NodeId, + outside: NodeId, +} + +fn setup() -> Setup { + let mut s = Session::new(); + let mut variant = |name: &str, x: f64| { + let f = id(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 100, "height": 80, "name": name})).unwrap()); + id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()) + }; + let default = variant("Property 1=Default", 0.0); + let hover = variant("Property 1=Hover", 300.0); + let layer = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 20, "parent": default})).unwrap()); + let outside = id(&s.execute("frame.create", &json!({"x": 0, "y": 500, "width": 200, "height": 150, "name": "Outside"})).unwrap()); + let set = id(&s.execute("component.combineVariants", &json!({"ids": [default, hover]})).unwrap()); + let mut u = Ui::new(s); + u.app.ui.right_tab = RightTab::Prototype; + Setup { u, set, default, hover, layer, outside } +} + +/// Select `source`, then drag its connection handle to the centre of `to` (or `at`) and release. +fn drag(t: &mut Setup, source: NodeId, to: Pos2) { + t.u.run("select.set", json!({"ids": [source]})); + t.u.run("view.zoomSelection", json!({})); + t.u.run("view.zoomFit", json!({})); + t.u.frames(2, Vec::new); + let doc = t.u.app.session.doc().unwrap().clone(); + let (_, at) = *handles(&t.u.app, &doc, &[source]).first().unwrap(); + t.u.button(at, true); + t.u.button(pos2((at.x + to.x) / 2.0, (at.y + to.y) / 2.0), true); + t.u.button(to, true); + t.u.button(to, false); + t.u.frames(2, Vec::new); +} + +fn interactions(t: &Setup, n: NodeId) -> Vec { + t.u.app.session.doc().unwrap().node(n).unwrap().interactions.clone() +} + +fn assert_change_to(t: &Setup, source: NodeId, dest: NodeId) { + let its = interactions(t, source); + assert_eq!(its.len(), 1, "{its:?}"); + assert_eq!(its[0].trigger, Trigger::OnClick); + assert_eq!(its[0].transition, Transition::Instant); + assert!(matches!(its[0].actions.as_slice(), [Action::ChangeTo { dest: d }] if *d == dest), "{:?}", its[0].actions); + assert_eq!(crate::proto_panel::editor(&t.u.ctx), Some((source, 0))); +} + +#[test] +fn a_variants_connection_dropped_on_a_sibling_variant_changes_to_it() { + let mut t = setup(); + let to = screen_centre(&t.u.app, t.hover); + let src = t.default; + drag(&mut t, src, to); + assert_change_to(&t, t.default, t.hover); +} + +#[test] +fn the_reverse_connection_changes_back() { + let mut t = setup(); + let to = screen_centre(&t.u.app, t.default); + let src = t.hover; + drag(&mut t, src, to); + assert_change_to(&t, t.hover, t.default); +} + +#[test] +fn a_layer_inside_a_variant_changes_its_set_to_the_sibling() { + let mut t = setup(); + let to = screen_centre(&t.u.app, t.hover); + let src = t.layer; + drag(&mut t, src, to); + assert_change_to(&t, t.layer, t.hover); +} + +#[test] +fn dropped_on_the_sets_background_or_its_own_variant_it_adds_nothing() { + let mut t = setup(); + // Between the two variants (x 100..300) and inside the set's padding. + let (a, b) = (screen_centre(&t.u.app, t.default), screen_centre(&t.u.app, t.hover)); + let gap = pos2((a.x + b.x) / 2.0, a.y); + let src = t.default; + drag(&mut t, src, gap); + assert!(interactions(&t, t.default).is_empty()); + let own = screen_centre(&t.u.app, t.default); + let src = t.default; + drag(&mut t, src, own); + assert!(interactions(&t, t.default).is_empty()); + let _ = t.set; +} + +#[test] +fn a_variant_dropped_on_a_frame_outside_the_set_still_navigates() { + let mut t = setup(); + let to = screen_centre(&t.u.app, t.outside); + let src = t.default; + drag(&mut t, src, to); + let its = interactions(&t, t.default); + assert_eq!(its.len(), 1, "{its:?}"); + assert!(matches!(its[0].actions.as_slice(), [Action::Navigate { dest, .. }] if *dest == t.outside), "{:?}", its[0].actions); +} diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 7c216bcae..07e3c691d 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-06 · **Change:** patch (`proto.settings` refuses an unknown device with the list of devices instead of saving it, takes a known one in any case, and answers the page's settings, so `proto.settings {}` reads them (#2027); Hide UI in the presenter's Options menu, and Cmd/Ctrl+\ as in the editor: the bar above the prototype and the navigation bar under it go away, a toast names the key that brings them back, `proto.presentOptions {hideUi}` (#1820); the Controller menu names each button as PlayStation and Switch controllers do; "Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** patch (a connection dragged from a variant, or a layer in it, onto a sibling variant makes Change to, On click, Instant, with the sibling outlined (#2158); `proto.settings` refuses an unknown device with the list of devices instead of saving it, takes a known one in any case, and answers the page's settings, so `proto.settings {}` reads them (#2027); Hide UI in the presenter's Options menu, and Cmd/Ctrl+\ as in the editor: the bar above the prototype and the navigation bar under it go away, a toast names the key that brings them back, `proto.presentOptions {hideUi}` (#1820); the Controller menu names each button as PlayStation and Switch controllers do; "Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -57,6 +57,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Open link | pass | a new tab or browser window by default; with "Open in new tab" off, the web build's own tab goes to the link (the desktop app has no tab of its own to replace, so it opens the browser either way) | | Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` | | Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` | +| Drag a variant's (or a layer in it) connection onto a sibling variant: Change to, On click, Instant; the sibling is outlined | **fixed** | the drop only found top-level frames, and a variant's top-level frame is its set, so nothing happened. Dropped on the set's background or on its own variant it still adds nothing; on a frame outside the set it still navigates. Measured in Figma's web app, 2026-11-09; `a_variants_connection_dropped_on_a_sibling_variant_changes_to_it`, `the_reverse_connection_changes_back`, `a_layer_inside_a_variant_changes_its_set_to_the_sibling`, `dropped_on_the_sets_background_or_its_own_variant_it_adds_nothing`, `a_variant_dropped_on_a_frame_outside_the_set_still_navigates` | | Presentation tabs: one per page of a file, named "file - page" (renames follow). Play on a page that has a tab shows it as it was (from another start frame it starts again). Tabs keep their own copy of the prototype and play on after their file closes; a saved file opened again gets its tabs back. A tab never changes a file that isn't in front. Its X or Escape ends it and goes back to its file, or Home | **fixed** | one "Present" tab: a second presentation replaced the first, Play restarted it, and closing the file ended it; `present_tab_tests` | | Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead | | Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` | @@ -107,6 +108,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Date | Change | Summary | |---|---|---| +| 2026-11-09 | patch | M7, #2158: a connection dragged from a variant (or a layer in it) onto a sibling variant of its set did nothing, because the drop looked only for top-level frames and a variant's top-level frame is its set. It now creates Change to the sibling (On click, Instant, as measured in Figma's web app) and outlines the sibling while dragging; the set's background and the variant itself still add nothing, and a frame outside the set still gets Navigate to | | 2026-11-06 | patch | M5, #2027: `proto.settings` took any string as the device (`"Pixel Nonexistent"`, `"iphone 16"`) and saved it in the file; the presenter then drew no device while its picker showed the name as if it were one, and the settings couldn't be read back over MCP (`proto.settings {}` answered `{ok: true}`). An unknown name (or a non-string) is now refused with every name the picker offers (`None`, `Custom size` and the devices), as an unknown colour is refused with the device's colours, and nothing changes; a known name is taken in any case and stored as the picker spells it. Every call answers the page's settings after it: device, background, rotation, the colour shown, the screen as presented, the custom size and responsive. Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back` | | 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. | | 2026-10-30 | minor | Opening a link in place: Open link has "Open in new tab", on by default; off, the web build's own tab goes to the link after the notice (or at once with it skipped); the desktop app has no tab of its own and opens the browser either way. Saved in files only when off (`openInNewTab`) and read from REST files. |