From 545cd630fd55e12e89ae186ca53c0e352ca86611 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Fri, 16 Oct 2026 09:20:15 +0200 Subject: [PATCH] M5: overlay positions relative to the layer that opens them From the Figma forum request "Set overlay position relative to element, not to page": an open overlay action takes anchor: screen (default) | triggerInside | triggerOutside. With a trigger anchor the position preset is measured on the layer that opens the overlay instead of the screen. Outside puts top presets above it and bottom presets below it with the edges lined up (a dropdown is Bottom left + Outside); Center centres it on that layer. Manual is unchanged. The interaction editor gets Relative to (Whole screen / Trigger layer) and Inside / Outside. The player, the command (and so MCP and the control channel) carry it; the command's params doc lists it. Figma has no such setting, so .fig export writes it as a manual offset. Old files and JSON without anchor read as screen and write unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/colors_tests.rs | 1 + crates/doc/src/lib.rs | 2 + crates/doc/src/proto.rs | 43 +++++++++++ crates/doc/src/proto_overlay_anchor_tests.rs | 61 +++++++++++++++ crates/engine/src/cmd/proto.rs | 2 +- crates/engine/src/lib.rs | 2 + .../engine/src/proto_overlay_anchor_tests.rs | 70 +++++++++++++++++ crates/format/src/fig/export.rs | 20 +++-- crates/format/src/fig/export_tests.rs | 5 +- crates/format/src/rest/proto.rs | 4 +- crates/mcp/tests/it/main.rs | 2 + crates/mcp/tests/it/overlay_anchor.rs | 25 +++++++ crates/ui-egui/locales/bn.toml | 3 + crates/ui-egui/locales/cs.toml | 3 + crates/ui-egui/locales/de.toml | 3 + crates/ui-egui/locales/en.toml | 3 + crates/ui-egui/locales/es.toml | 3 + crates/ui-egui/locales/fi.toml | 3 + crates/ui-egui/locales/fr.toml | 3 + crates/ui-egui/locales/id.toml | 3 + crates/ui-egui/locales/ja.toml | 3 + crates/ui-egui/locales/ka.toml | 3 + crates/ui-egui/locales/ko.toml | 3 + crates/ui-egui/locales/nl.toml | 3 + crates/ui-egui/locales/pl.toml | 3 + crates/ui-egui/locales/pt-BR.toml | 3 + crates/ui-egui/locales/ru.toml | 3 + crates/ui-egui/locales/sv.toml | 3 + crates/ui-egui/locales/tr.toml | 3 + crates/ui-egui/locales/zh-Hans.toml | 3 + crates/ui-egui/src/present.rs | 32 +++++++- crates/ui-egui/src/present_behaviour_tests.rs | 2 +- .../src/present_overlay_anchor_tests.rs | 75 +++++++++++++++++++ crates/ui-egui/src/proto_panel.rs | 58 ++++++++++---- docs/mcp.md | 6 +- docs/prototyping-parity.md | 4 +- 36 files changed, 439 insertions(+), 29 deletions(-) create mode 100644 crates/doc/src/proto_overlay_anchor_tests.rs create mode 100644 crates/engine/src/proto_overlay_anchor_tests.rs create mode 100644 crates/mcp/tests/it/overlay_anchor.rs create mode 100644 crates/ui-egui/src/present_overlay_anchor_tests.rs diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index 3a97a5d0..9a4d2327 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -28,6 +28,7 @@ fn colourful() -> (Document, NodeId) { close_on_outside: false, background: Some(RED), offset: None, + anchor: Default::default(), }], transition: Default::default(), duration_ms: 300, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 70797dd8..d979101b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1244,6 +1244,8 @@ mod proto_expr_parse_tests; #[cfg(test)] mod proto_expr_tests; #[cfg(test)] +mod proto_overlay_anchor_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod vector_regions_tests; diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index a844774c..d3830bfb 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -507,6 +507,46 @@ impl OverlayPosition { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum OverlayAnchor { + #[default] + Screen, + TriggerInside, + TriggerOutside, +} + +impl OverlayAnchor { + pub fn is_screen(&self) -> bool { + *self == OverlayAnchor::Screen + } + + /// Gives None for Screen and for Manual. + pub fn offset(self, position: OverlayPosition, trigger: [f64; 2], overlay: [f64; 2]) -> Option<[f64; 2]> { + use OverlayPosition as P; + let ([tw, th], [ow, oh]) = (trigger, overlay); + let outside = match self { + OverlayAnchor::Screen => return None, + OverlayAnchor::TriggerInside => false, + OverlayAnchor::TriggerOutside => true, + }; + let x = match position { + P::TopLeft | P::BottomLeft => 0.0, + P::Center | P::TopCenter | P::BottomCenter => (tw - ow) / 2.0, + P::TopRight | P::BottomRight => tw - ow, + P::Manual => return None, + }; + let y = match position { + P::TopLeft | P::TopCenter | P::TopRight if outside => -oh, + P::TopLeft | P::TopCenter | P::TopRight => 0.0, + P::BottomLeft | P::BottomCenter | P::BottomRight if outside => th, + P::BottomLeft | P::BottomCenter | P::BottomRight => th - oh, + P::Center | P::Manual => (th - oh) / 2.0, + }; + Some([x, y]) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Action { @@ -538,6 +578,9 @@ pub enum Action { /// opened it (x, y). None = right below that layer. #[serde(default, skip_serializing_if = "Option::is_none")] offset: Option<[f64; 2]>, + /// Figma does not have this. The .fig export writes it as a manual offset. + #[serde(default, skip_serializing_if = "OverlayAnchor::is_screen")] + anchor: OverlayAnchor, }, SwapOverlay { dest: NodeId, diff --git a/crates/doc/src/proto_overlay_anchor_tests.rs b/crates/doc/src/proto_overlay_anchor_tests.rs new file mode 100644 index 00000000..d902d1c2 --- /dev/null +++ b/crates/doc/src/proto_overlay_anchor_tests.rs @@ -0,0 +1,61 @@ +use serde_json::json; + +use crate::NodeId; +use crate::proto::*; + +const TRIGGER: [f64; 2] = [100.0, 40.0]; +const OVERLAY: [f64; 2] = [60.0, 200.0]; + +fn at(anchor: OverlayAnchor, p: OverlayPosition) -> Option<[f64; 2]> { + anchor.offset(p, TRIGGER, OVERLAY) +} + +#[test] +fn outside_sits_above_or_below_with_edges_lined_up() { + use OverlayPosition as P; + let out = OverlayAnchor::TriggerOutside; + assert_eq!(at(out, P::BottomLeft), Some([0.0, 40.0])); + assert_eq!(at(out, P::BottomCenter), Some([20.0, 40.0])); + assert_eq!(at(out, P::BottomRight), Some([40.0, 40.0])); + assert_eq!(at(out, P::TopLeft), Some([0.0, -200.0])); + assert_eq!(at(out, P::TopCenter), Some([20.0, -200.0])); + assert_eq!(at(out, P::TopRight), Some([40.0, -200.0])); + assert_eq!(at(out, P::Center), Some([20.0, -80.0])); +} + +#[test] +fn inside_uses_the_presets_on_the_trigger() { + use OverlayPosition as P; + let ins = OverlayAnchor::TriggerInside; + assert_eq!(at(ins, P::TopLeft), Some([0.0, 0.0])); + assert_eq!(at(ins, P::TopRight), Some([40.0, 0.0])); + assert_eq!(at(ins, P::BottomLeft), Some([0.0, -160.0])); + assert_eq!(at(ins, P::BottomCenter), Some([20.0, -160.0])); + assert_eq!(at(ins, P::Center), Some([20.0, -80.0])); +} + +#[test] +fn the_screen_and_manual_positions_have_no_preset_offset() { + assert_eq!(at(OverlayAnchor::Screen, OverlayPosition::BottomLeft), None); + assert_eq!(at(OverlayAnchor::TriggerOutside, OverlayPosition::Manual), None); + assert_eq!(at(OverlayAnchor::TriggerInside, OverlayPosition::Manual), None); +} + +#[test] +fn json_keeps_the_anchor_and_old_actions_stay_on_the_screen() { + let a = Action::OpenOverlay { + dest: NodeId(7), + position: OverlayPosition::BottomLeft, + close_on_outside: true, + background: None, + offset: None, + anchor: OverlayAnchor::TriggerOutside, + }; + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v["anchor"], "triggerOutside"); + assert_eq!(serde_json::from_value::(v).unwrap(), a); + + let old: Action = serde_json::from_value(json!({"type": "openOverlay", "dest": 7, "position": "bottomLeft"})).unwrap(); + assert!(matches!(old, Action::OpenOverlay { anchor: OverlayAnchor::Screen, .. })); + assert!(serde_json::to_value(&old).unwrap().get("anchor").is_none()); +} diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index be6907b5..43a08704 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { spec!( "proto.interaction.add", "Add interaction", - "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}", + "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}", true, add ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 29b084f2..3b82ccc3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1821,6 +1821,8 @@ mod proto_delay_tests; #[cfg(test)] mod proto_multi_tests; #[cfg(test)] +mod proto_overlay_anchor_tests; +#[cfg(test)] mod proto_polish_tests; #[cfg(test)] mod proto_present_tests; diff --git a/crates/engine/src/proto_overlay_anchor_tests.rs b/crates/engine/src/proto_overlay_anchor_tests.rs new file mode 100644 index 00000000..ebf9a30c --- /dev/null +++ b/crates/engine/src/proto_overlay_anchor_tests.rs @@ -0,0 +1,70 @@ +use serde_json::{Value, json}; + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::NodeId; +use figcraft_doc::proto::{Action, OverlayAnchor, OverlayPosition}; + +fn frame(s: &mut Session, x: f64, w: f64, h: f64, name: &str) -> NodeId { + id_of(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": w, "height": h, "name": name})).unwrap()["id"]).unwrap() +} + +fn button(s: &mut Session, parent: NodeId) -> NodeId { + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 30, "width": 100, "height": 40, "parent": parent, "name": "Button"})); + id_of(&r.unwrap()["id"]).unwrap() +} + +fn action(s: &Session, id: NodeId) -> Action { + s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].clone() +} + +fn dropdown(s: &mut Session) -> (NodeId, NodeId) { + let screen = frame(s, 0.0, 400.0, 800.0, "Screen"); + let menu = frame(s, 600.0, 60.0, 200.0, "Menu"); + let b = button(s, screen); + let open = json!({"type": "openOverlay", "dest": menu, "position": "bottomLeft", "anchor": "triggerOutside"}); + s.execute("proto.interaction.add", &json!({"id": b, "action": open})).unwrap(); + (b, menu) +} + +#[test] +fn the_anchor_is_set_read_and_undone() { + let mut s = Session::new(); + let (b, menu) = dropdown(&mut s); + assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, position: OverlayPosition::BottomLeft, .. })); + s.execute( + "proto.interaction.set", + &json!({"id": b, "index": 0, "action": {"type": "openOverlay", "dest": menu, "position": "topCenter", "anchor": "triggerInside"}}), + ) + .unwrap(); + assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerInside, position: OverlayPosition::TopCenter, .. })); + assert!(s.undo().unwrap()); + assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, .. })); +} + +#[test] +fn fig_writes_a_manual_offset_from_the_trigger() { + let mut s = Session::new(); + let (b, _) = dropdown(&mut s); + let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap(); + let hot = back.nodes().find(|n| n.name == "Button").unwrap(); + assert!( + matches!(hot.interactions[0].actions[0], Action::OpenOverlay { position: OverlayPosition::Manual, offset: Some([0.0, 40.0]), .. }), + "{:?} (from {b:?})", + hot.interactions[0].actions + ); +} + +#[test] +fn junk_anchors_are_refused_not_fatal() { + let junk = [json!(true), json!(-1), json!("above"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))]; + for j in &junk { + let mut s = Session::new(); + let (a, menu) = (frame(&mut s, 0.0, 100.0, 100.0, "A"), frame(&mut s, 200.0, 50.0, 50.0, "M")); + let p: Value = json!({"id": a, "action": {"type": "openOverlay", "dest": menu, "anchor": j}}); + let r = s.execute("proto.interaction.add", &p); + assert!(!matches!(r, Err(EngineError::Internal { .. })), "{p}: {r:?}"); + assert!(s.doc().unwrap().node(a).unwrap().interactions.is_empty(), "{j}"); + } +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index dad09d95..8cd493cf 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -448,6 +448,7 @@ struct Exporter<'d> { tracks: BTreeMap, Vec)>, aux: u32, overlays: BTreeMap, + trigger_size: [f64; 2], /// Flow starting points: layer → (order, name). starts: BTreeMap, /// Shared styles, variable collections and variables → local id of their node's GUID (session @@ -486,6 +487,7 @@ impl<'d> Exporter<'d> { tracks: BTreeMap::new(), aux: 0, overlays: BTreeMap::new(), + trigger_size: [0.0, 0.0], starts: BTreeMap::new(), style_ids: BTreeMap::new(), set_ids: BTreeMap::new(), @@ -574,8 +576,9 @@ impl<'d> Exporter<'d> { _ => {} } for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) { - if let Action::OpenOverlay { dest, position, close_on_outside, background, .. } = a { - let o = Overlay { position: *position, close_on_outside: *close_on_outside, background: *background }; + if let Action::OpenOverlay { dest, position, close_on_outside, background, anchor, .. } = a { + let position = if !anchor.is_screen() && *position != OverlayPosition::Manual { OverlayPosition::Manual } else { *position }; + let o = Overlay { position, close_on_outside: *close_on_outside, background: *background }; match self.overlays.get(dest) { Some(old) if (old.position, old.close_on_outside, old.background) != (o.position, o.close_on_outside, o.background) => { self.warn("overlays opened with different settings keep the first one's"); @@ -2086,6 +2089,7 @@ impl<'d> Exporter<'d> { /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with. fn prototype(&mut self, n: &Node, f: &mut Fields) { + self.trigger_size = [n.width, n.height]; let list: Vec = n.interactions.iter().filter_map(|it| self.interaction(it)).collect(); if !list.is_empty() { f.push(("prototypeInteractions", arr(list))); @@ -2174,10 +2178,16 @@ impl<'d> Exporter<'d> { } Some(act) } - Action::OpenOverlay { dest, position, offset, .. } => { + Action::OpenOverlay { dest, position, offset, anchor, .. } => { let mut act = node(self, dest, "OVERLAY")?; - if let (Kv::Object(f), OverlayPosition::Manual, Some([x, y])) = (&mut act, position, offset) { - f.push(("overlayRelativePosition", o(vec![("x", fl(*x)), ("y", fl(*y))]))); + let size = self.doc.node(*dest).map_or([0.0, 0.0], |d| [d.width, d.height]); + let offset = match anchor.offset(*position, self.trigger_size, size) { + Some(o) => Some(o), + None if *position == OverlayPosition::Manual => *offset, + None => None, + }; + if let (Kv::Object(f), Some([x, y])) = (&mut act, offset) { + f.push(("overlayRelativePosition", o(vec![("x", fl(x)), ("y", fl(y))]))); } Some(act) } diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 1f0fe172..5c29cf0a 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -7,7 +7,9 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind}; use figcraft_doc::geom::{Affine, Point}; -use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger}; +use figcraft_doc::proto::{ + Action, Direction, Easing, Flow, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition, Transition, Trigger, +}; use figcraft_doc::*; use serde_json::Value; @@ -511,6 +513,7 @@ impl Gen { close_on_outside: true, background: Some(Color::new(0.0, 0.0, 0.0, 0.5)), offset: None, + anchor: OverlayAnchor::Screen, }, 3 => Action::SwapOverlay { dest }, 4 => Action::ChangeTo { dest }, diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 56e50e5e..61869916 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -4,7 +4,7 @@ use figcraft_doc::proto::{ Action, ConditionalBlock, DeviceRotation, Direction, Easing, Expr, ExprOp, Interaction, KeyMods, MAX_ACTION_DEPTH, MAX_EXPR_DEPTH, Overflow, - OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, gamepad_button, + OverlayAnchor, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, gamepad_button, }; use figcraft_doc::{Node, NodeId, VariableType, VariableValue}; use serde_json::{Value, json}; @@ -322,7 +322,7 @@ impl Ctx<'_> { .and_then(|v| Some([num(v, "x")?, num(v, "y")?])) .filter(|o| o.iter().all(|c| c.is_finite())) .map(|o| o.map(|c| c.clamp(-MAX_OFFSET, MAX_OFFSET))); - Action::OpenOverlay { dest, position, close_on_outside, background, offset } + Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor: OverlayAnchor::Screen } } Some(_) => { self.warn("prototype navigations of unknown types were skipped"); diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index b16fc98e..b24882ae 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -15,6 +15,8 @@ mod garbage; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod inspect_response_limit; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod overlay_anchor; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod protocol; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod screenshot_pixel_limit; diff --git a/crates/mcp/tests/it/overlay_anchor.rs b/crates/mcp/tests/it/overlay_anchor.rs new file mode 100644 index 00000000..c9cd3473 --- /dev/null +++ b/crates/mcp/tests/it/overlay_anchor.rs @@ -0,0 +1,25 @@ +use crate::common; + +use common::Client; +use serde_json::json; + +#[test] +fn an_agent_anchors_a_dropdown_to_its_button() { + let mut c = Client::new(); + let listed = c.ok("list_commands", json!({"filter": "proto.interaction.add"})); + assert!(listed.as_str().unwrap().contains("triggerOutside"), "{listed}"); + + let screen = c.create(json!({"type": "frame", "name": "Screen", "x": 0, "y": 0, "width": 400, "height": 800})); + let menu = c.create(json!({"type": "frame", "name": "Menu", "x": 600, "y": 0, "width": 160, "height": 200})); + let button = c.create(json!({"type": "frame", "parent": screen, "name": "Button", "x": 20, "y": 40, "width": 160, "height": 40})); + let open = json!({"type": "openOverlay", "dest": menu, "position": "bottomLeft", "anchor": "triggerOutside"}); + c.run("proto.interaction.add", json!({"id": button, "action": open})); + + let action = &c.node(&button)["interactions"][0]["actions"][0]; + assert_eq!((action["position"].as_str(), action["anchor"].as_str()), (Some("bottomLeft"), Some("triggerOutside")), "{action}"); + + let bad = json!({"type": "openOverlay", "dest": menu, "anchor": "nextToIt"}); + let err = c.err("run_command", json!({"command": "proto.interaction.add", "params": {"id": button, "action": bad}})); + assert!(!err.is_empty()); + assert_eq!(c.node(&button)["interactions"].as_array().unwrap().len(), 1); +} diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 364c9632..fd3174ff 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1083,6 +1083,7 @@ status = "draft — needs native review" "Reduce motion" = "মোশন কমান" "Refraction" = "প্রতিসরণ" "Regular expression" = "রেগুলার এক্সপ্রেশন" +"Relative to" = "এর সাপেক্ষে" "Remove" = "সরান" "Remove all" = "সব সরান" "Remove annotation" = "অ্যানোটেশন সরান" @@ -1442,6 +1443,7 @@ status = "draft — needs native review" "Triangle" = "ত্রিভুজ" "Triangle arrow" = "ত্রিভুজ তীর" "Trigger" = "ট্রিগার" +"Trigger layer" = "ট্রিগার লেয়ার" "True" = "সত্য (True)" "Truncate text" = "টেক্সট ট্রাঙ্কেট করুন" "Truncate with an ellipsis" = "উপবৃত্ত (ellipsis) দিয়ে ট্রাঙ্কেট করুন" @@ -1539,6 +1541,7 @@ status = "draft — needs native review" "While pressing" = "চেপে ধরে রাখার সময়" "White" = "সাদা" "Who runs it, its terms and how long it keeps files" = "এটি কে পরিচালনা করে, এর শর্তাবলী এবং এটি কতদিন ফাইল রাখে" +"Whole screen" = "সম্পূর্ণ স্ক্রিন" "Why it changed" = "কেন এটি পরিবর্তিত হয়েছে" "Widget" = "উইজেট" "Widgets" = "উইজেটসমূহ" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index b05427b2..42d6c307 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1062,6 +1062,7 @@ status = "draft — needs native review" "Reduce motion" = "Omezit pohyb" "Refraction" = "Lom světla" "Regular expression" = "Regulární výraz" +"Relative to" = "Vzhledem k" "Remove" = "Odebrat" "Remove all" = "Odebrat vše" "Remove annotation" = "Odebrat anotaci" @@ -1413,6 +1414,7 @@ status = "draft — needs native review" "Triangle" = "Trojúhelník" "Triangle arrow" = "Trojúhelníková šipka" "Trigger" = "Spouštěč" +"Trigger layer" = "Spouštěcí vrstva" "True" = "Pravda" "Truncate text" = "Zkrátit text" "Truncate with an ellipsis" = "Zkrátit výpustkou" @@ -1506,6 +1508,7 @@ status = "draft — needs native review" "While pressing" = "Při stisku" "White" = "Bílá" "Who runs it, its terms and how long it keeps files" = "Kdo ho provozuje, jeho podmínky a doba uchovávání souborů" +"Whole screen" = "Celá obrazovka" "Why it changed" = "Důvod změny" "Widgets" = "Widgety" "Width" = "Šířka" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index da2a177a..d9369fa8 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1031,6 +1031,7 @@ status = "draft — needs native review" "Reduce motion" = "Bewegung reduzieren" "Refraction" = "Brechung" "Regular expression" = "Regulärer Ausdruck" +"Relative to" = "Relativ zu" "Remove" = "Entfernen" "Remove all" = "Alle entfernen" "Remove annotation" = "Anmerkung entfernen" @@ -1374,6 +1375,7 @@ status = "draft — needs native review" "Triangle" = "Dreieck" "Triangle arrow" = "Dreieckspfeil" "Trigger" = "Auslöser" +"Trigger layer" = "Auslösende Ebene" "True" = "Wahr" "Truncate text" = "Text kürzen" "Truncate with an ellipsis" = "Mit Auslassungspunkten kürzen" @@ -1467,6 +1469,7 @@ status = "draft — needs native review" "While pressing" = "Beim Drücken" "White" = "Weiß" "Who runs it, its terms and how long it keeps files" = "Wer ihn betreibt, seine Bedingungen und wie lange er Dateien aufbewahrt" +"Whole screen" = "Ganzer Bildschirm" "Why it changed" = "Warum es sich geändert hat" "Widgets" = "Widgets" "Width" = "Breite" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e1ed336e..b939bd78 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1092,6 +1092,7 @@ name = "English" "Reduce motion" = "Reduce motion" "Refraction" = "Refraction" "Regular expression" = "Regular expression" +"Relative to" = "Relative to" "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." "Remove" = "Remove" "Remove all" = "Remove all" @@ -1462,6 +1463,7 @@ name = "English" "Triangle" = "Triangle" "Triangle arrow" = "Triangle arrow" "Trigger" = "Trigger" +"Trigger layer" = "Trigger layer" "True" = "True" "Truncate text" = "Truncate text" "Truncate with an ellipsis" = "Truncate with an ellipsis" @@ -1562,6 +1564,7 @@ name = "English" "While pressing" = "While pressing" "White" = "White" "Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files" +"Whole screen" = "Whole screen" "Why it changed" = "Why it changed" "Widget" = "Widget" "Widgets" = "Widgets" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index a4323c87..1c2423f5 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -20,6 +20,9 @@ status = "draft — needs native review" "Compatibility notes" = "Notas de compatibilidad" "Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" +"Relative to" = "Relativa a" +"Trigger layer" = "Capa que la abre" +"Whole screen" = "Pantalla completa" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index fe979678..2c5620b3 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1087,6 +1087,7 @@ status = "draft — needs native review" "Reduce motion" = "Vähennä liikettä" "Refraction" = "Taittuminen" "Regular expression" = "Säännöllinen lauseke" +"Relative to" = "Suhteessa" "Remove" = "Poista" "Remove all" = "Poista kaikki" "Remove annotation" = "Poista huomautus" @@ -1447,6 +1448,7 @@ status = "draft — needs native review" "Triangle" = "Kolmio" "Triangle arrow" = "Kolmionuoli" "Trigger" = "Laukaisin" +"Trigger layer" = "Laukaiseva taso" "True" = "Tosi" "Truncate text" = "Lyhennä tekstiä" "Truncate with an ellipsis" = "Lyhennä kolmella pisteellä" @@ -1544,6 +1546,7 @@ status = "draft — needs native review" "While pressing" = "Painettaessa" "White" = "Valkoinen" "Who runs it, its terms and how long it keeps files" = "Ylläpitäjä, käyttöehdot ja tiedostojen säilytysaika" +"Whole screen" = "Koko näyttö" "Why it changed" = "Muutoksen syy" "Widget" = "Pienoisohjelma" "Widgets" = "Pienoisohjelmat" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index bb771e9f..b1a9cf50 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1086,6 +1086,7 @@ status = "draft — needs native review" "Reduce motion" = "Réduire les animations" "Refraction" = "Réfraction" "Regular expression" = "Expression régulière" +"Relative to" = "Par rapport à" "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct." "Remove" = "Retirer" "Remove all" = "Tout retirer" @@ -1448,6 +1449,7 @@ status = "draft — needs native review" "Triangle" = "Triangle" "Triangle arrow" = "Flèche triangulaire" "Trigger" = "Déclencheur" +"Trigger layer" = "Calque déclencheur" "True" = "Vrai" "Truncate text" = "Tronquer le texte" "Truncate with an ellipsis" = "Tronquer avec des points de suspension" @@ -1547,6 +1549,7 @@ status = "draft — needs native review" "While pressing" = "Pendant l’appui" "White" = "Blanc" "Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers" +"Whole screen" = "Écran entier" "Why it changed" = "Pourquoi cela a changé" "Widget" = "Widget" "Widgets" = "Widgets" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a516db9f..36ab9886 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -8,6 +8,9 @@ name = "Bahasa Indonesia" status = "draft — needs native review" [ui] +"Relative to" = "Relatif terhadap" +"Trigger layer" = "Layer pemicu" +"Whole screen" = "Seluruh layar" "% of all commits" = "% dari semua commit" "(no longer published)" = "(tidak lagi dipublikasikan)" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 641ec1f6..d3fa08f3 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1074,6 +1074,7 @@ status = "draft — needs native review" "Reduce motion" = "動きを減らす" "Refraction" = "屈折" "Regular expression" = "正規表現" +"Relative to" = "基準" "Remove" = "削除" "Remove all" = "すべて削除" "Remove annotation" = "注釈を削除" @@ -1431,6 +1432,7 @@ status = "draft — needs native review" "Triangle" = "三角形" "Triangle arrow" = "三角矢印" "Trigger" = "トリガー" +"Trigger layer" = "トリガーレイヤー" "True" = "true" "Truncate text" = "テキストを切り詰める" "Truncate with an ellipsis" = "省略記号で切り詰める" @@ -1529,6 +1531,7 @@ status = "draft — needs native review" "While pressing" = "押している間" "White" = "白" "Who runs it, its terms and how long it keeps files" = "運営者、利用規約、ファイルの保管期間" +"Whole screen" = "画面全体" "Why it changed" = "変更理由" "Widgets" = "ウィジェット" "Width" = "幅" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index b556e8d7..d839db53 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1085,6 +1085,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Reduce motion" = "მოძრაობის შემცირება" "Refraction" = "გარდატეხა" "Regular expression" = "რეგულარული გამოსახულება" +"Relative to" = "მიმართ" "Remove" = "წაშლა" "Remove all" = "ყველას წაშლა" "Remove annotation" = "ანოტაციის წაშლა" @@ -1444,6 +1445,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Triangle" = "სამკუთხედი" "Triangle arrow" = "სამკუთხა ისარი" "Trigger" = "გამშვები" +"Trigger layer" = "გამშვები შრე" "True" = "ჭეშმარიტი" "Truncate text" = "ტექსტის შეკვეცა" "Truncate with an ellipsis" = "შეკვეცა მრავალწერტილით" @@ -1541,6 +1543,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "While pressing" = "დაჭერისას" "White" = "თეთრი" "Who runs it, its terms and how long it keeps files" = "ვინ მართავს, მისი პირობები და რამდენ ხანს ინახავს ფაილებს" +"Whole screen" = "მთელი ეკრანი" "Why it changed" = "რატომ შეიცვალა" "Widget" = "ვიჯეტი" "Widgets" = "ვიჯეტები" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 053348f2..7e550039 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1025,6 +1025,7 @@ status = "draft — needs native review" "Reduce motion" = "동작 줄이기" "Refraction" = "굴절" "Regular expression" = "정규식" +"Relative to" = "기준" "Remove" = "제거" "Remove all" = "모두 제거" "Remove annotation" = "주석 제거" @@ -1365,6 +1366,7 @@ status = "draft — needs native review" "Triangle" = "삼각형" "Triangle arrow" = "삼각 화살표" "Trigger" = "트리거" +"Trigger layer" = "트리거 레이어" "True" = "참" "Truncate text" = "텍스트 자르기" "Truncate with an ellipsis" = "줄임표로 자르기" @@ -1458,6 +1460,7 @@ status = "draft — needs native review" "While pressing" = "누르는 동안" "White" = "흰색" "Who runs it, its terms and how long it keeps files" = "운영자, 약관, 파일 보관 기간" +"Whole screen" = "전체 화면" "Why it changed" = "변경 이유" "Widgets" = "위젯" "Width" = "너비" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index bc4e0cfa..55c28339 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1087,6 +1087,7 @@ status = "draft — needs native review" "Reduce motion" = "Beweging verminderen" "Refraction" = "Breking" "Regular expression" = "Reguliere expressie" +"Relative to" = "Ten opzichte van" "Remove" = "Verwijderen" "Remove all" = "Alles verwijderen" "Remove annotation" = "Annotatie verwijderen" @@ -1447,6 +1448,7 @@ status = "draft — needs native review" "Triangle" = "Driehoek" "Triangle arrow" = "Driehoekspijl" "Trigger" = "Trigger" +"Trigger layer" = "Activerende laag" "True" = "Waar" "Truncate text" = "Tekst afkappen" "Truncate with an ellipsis" = "Afkappen met een beletselteken" @@ -1544,6 +1546,7 @@ status = "draft — needs native review" "While pressing" = "Tijdens het indrukken" "White" = "Wit" "Who runs it, its terms and how long it keeps files" = "Wie het beheert, de voorwaarden en hoelang het bestanden bewaart" +"Whole screen" = "Hele scherm" "Why it changed" = "Waarom het is veranderd" "Widget" = "Widget" "Widgets" = "Widgets" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 25f287f9..6c117f9d 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1083,6 +1083,7 @@ status = "draft — needs native review" "Reduce motion" = "Ogranicz ruch" "Refraction" = "Załamanie światła (refrakcja)" "Regular expression" = "Wyrażenie regularne" +"Relative to" = "Względem" "Remove" = "Usuń" "Remove all" = "Usuń wszystko" "Remove annotation" = "Usuń adnotację" @@ -1442,6 +1443,7 @@ status = "draft — needs native review" "Triangle" = "Trójkąt" "Triangle arrow" = "Strzałka trójkątna" "Trigger" = "Wyzwalacz" +"Trigger layer" = "Warstwa wyzwalająca" "True" = "Prawda" "Truncate text" = "Utnij tekst" "Truncate with an ellipsis" = "Utnij tekst z wielokropkiem (…)" @@ -1539,6 +1541,7 @@ status = "draft — needs native review" "While pressing" = "Podczas naciskania" "White" = "Biały" "Who runs it, its terms and how long it keeps files" = "Kto zarządza serwerem, jego regulamin i czas przechowywania plików" +"Whole screen" = "Cały ekran" "Why it changed" = "Dlaczego to się zmieniło" "Widget" = "Widżet" "Widgets" = "Widżety" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index cb7b08b5..212a8406 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1024,6 +1024,7 @@ status = "draft — needs native review" "Reduce motion" = "Reduzir movimento" "Refraction" = "Refração" "Regular expression" = "Expressão regular" +"Relative to" = "Relativa a" "Remove" = "Remover" "Remove all" = "Remover tudo" "Remove annotation" = "Remover anotação" @@ -1364,6 +1365,7 @@ status = "draft — needs native review" "Triangle" = "Triângulo" "Triangle arrow" = "Seta triangular" "Trigger" = "Gatilho" +"Trigger layer" = "Camada que a abre" "True" = "Verdadeiro" "Truncate text" = "Truncar texto" "Truncate with an ellipsis" = "Truncar com reticências" @@ -1457,6 +1459,7 @@ status = "draft — needs native review" "While pressing" = "Enquanto pressiona" "White" = "Branco" "Who runs it, its terms and how long it keeps files" = "Quem o mantém, os termos dele e por quanto tempo guarda os arquivos" +"Whole screen" = "Tela inteira" "Why it changed" = "Por que mudou" "Widgets" = "Widgets" "Width" = "Largura" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index ed1cc0eb..d2c3695e 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1089,6 +1089,7 @@ name = "Русский" "Reduce motion" = "Уменьшить движение" "Refraction" = "Преломление" "Regular expression" = "Регулярное выражение" +"Relative to" = "Относительно" "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Относительно масштаба экрана в системе (100% — собственный размер системы). Масштаб документа задаётся отдельно." "Remove" = "Удалить" "Remove all" = "Удалить всё" @@ -1456,6 +1457,7 @@ name = "Русский" "Triangle" = "Треугольник" "Triangle arrow" = "Треугольная стрелка" "Trigger" = "Триггер" +"Trigger layer" = "Слой-триггер" "True" = "Истина" "Truncate text" = "Обрезать текст" "Truncate with an ellipsis" = "Обрезать с многоточием" @@ -1554,6 +1556,7 @@ name = "Русский" "While pressing" = "При удержании" "White" = "Белый" "Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов" +"Whole screen" = "Весь экран" "Why it changed" = "Причина изменения" "Widget" = "Виджет" "Widgets" = "Виджеты" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index bcb819e5..f9ba1ceb 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -7,6 +7,9 @@ name = "Svenska" [ui] +"Relative to" = "Relativt till" +"Trigger layer" = "Utlösande lager" +"Whole screen" = "Hela skärmen" "% of all commits" = "% av alla commits" "(no longer published)" = "(publiceras inte längre)" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 4275f06a..6b438e0d 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1066,6 +1066,7 @@ name = "Türkçe" "Reduce motion" = "Hareketi azalt" "Refraction" = "Kırılma" "Regular expression" = "Düzenli ifade" +"Relative to" = "Göre" "Remove" = "Kaldır" "Remove all" = "Tümünü kaldır" "Remove annotation" = "Ek açıklamayı kaldır" @@ -1417,6 +1418,7 @@ name = "Türkçe" "Triangle" = "Üçgen" "Triangle arrow" = "Üçgen ok" "Trigger" = "Tetikleyici" +"Trigger layer" = "Tetikleyici katman" "True" = "Doğru" "Truncate text" = "Metni kısalt" "Truncate with an ellipsis" = "Üç noktayla kısalt" @@ -1510,6 +1512,7 @@ name = "Türkçe" "While pressing" = "Basılıyken" "White" = "Beyaz" "Who runs it, its terms and how long it keeps files" = "Kimin işlettiği, koşulları ve dosyaları ne kadar sakladığı" +"Whole screen" = "Tüm ekran" "Why it changed" = "Neden değişti" "Widgets" = "Widget'lar" "Width" = "Genişlik" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index da070c0e..db97da4d 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1026,6 +1026,7 @@ status = "draft — needs native review" "Reduce motion" = "减少动态效果" "Refraction" = "折射" "Regular expression" = "正则表达式" +"Relative to" = "相对于" "Remove" = "移除" "Remove all" = "全部移除" "Remove annotation" = "移除标注" @@ -1366,6 +1367,7 @@ status = "draft — needs native review" "Triangle" = "三角形" "Triangle arrow" = "三角箭头" "Trigger" = "触发器" +"Trigger layer" = "触发图层" "True" = "真" "Truncate text" = "截断文本" "Truncate with an ellipsis" = "以省略号截断" @@ -1459,6 +1461,7 @@ status = "draft — needs native review" "While pressing" = "按住时" "White" = "白" "Who runs it, its terms and how long it keeps files" = "谁在运营、其条款以及文件保留多久" +"Whole screen" = "整个屏幕" "Why it changed" = "更改原因" "Widgets" = "小组件" "Width" = "宽度" diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index fae1e1a7..1b483a2c 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -26,7 +26,8 @@ use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use figcraft_color::{Color, Paint, PaintKind}; use figcraft_doc::proto::{ - Action, Device, DeviceColor, Direction, Easing, KeyMods, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, + Action, Device, DeviceColor, Direction, Easing, KeyMods, Overflow, OverlayAnchor, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, + Trigger, }; use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint}; use figcraft_geom::{Affine, Point}; @@ -70,6 +71,7 @@ pub struct OverlayState { pub anchor: Option, /// Manual position: the overlay's top-left from the anchor's (page units). pub offset: Option<[f64; 2]>, + pub anchor_to: OverlayAnchor, opened: f64, transition: Transition, dur: f64, @@ -830,7 +832,7 @@ impl Presenter { } true } - Action::OpenOverlay { dest, position, close_on_outside, background, offset } => { + Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor } => { if !doc.contains(*dest) { return false; } @@ -842,6 +844,7 @@ impl Presenter { background: *background, anchor: rect, offset: *offset, + anchor_to: *anchor, opened: now, transition: tr, dur, @@ -870,6 +873,7 @@ impl Presenter { background: None, anchor: rect, offset: None, + anchor_to: OverlayAnchor::Screen, opened: now, transition: tr, dur, @@ -1466,6 +1470,23 @@ pub fn overlay_rect(pos: OverlayPosition, base: Rect, size: Vec2, anchor: Option Rect::from_min_size(pos2(x, y), size) } +/// If no layer on the screen opened the overlay, the screen places it. +pub fn anchored_overlay_rect( + pos: OverlayPosition, + anchor_to: OverlayAnchor, + base: Rect, + size: Vec2, + anchor: Option, + offset: Option, +) -> Rect { + let on_trigger = + anchor.and_then(|a| anchor_to.offset(pos, [f64::from(a.width()), f64::from(a.height())], [f64::from(size.x), f64::from(size.y)])); + match on_trigger { + Some([x, y]) => overlay_rect(OverlayPosition::Manual, base, size, anchor, Some(vec2(x as f32, y as f32))), + None => overlay_rect(pos, base, size, anchor, offset), + } +} + /// The hotspots of the screen `root` (its visible layers with interactions, bottom-most first) /// placed on screen and clipped by `screen`. fn hotspots(doc: &Document, root: NodeId, place: &Place, screen: Rect) -> Vec<(NodeId, Rect)> { @@ -1626,7 +1647,7 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { let ob = disp.world_bounds(o.id)?; let size = vec2((ob.width() * scale) as f32, (ob.height() * scale) as f32); let offset = o.offset.map(|[x, y]| vec2((x * scale) as f32, (y * scale) as f32)); - let r = overlay_rect(o.position, screen, size, o.anchor, offset); + let r = anchored_overlay_rect(o.position, o.anchor_to, screen, size, o.anchor, offset); Some((o.id, Place { page_min: (ob.x0, ob.y0), origin: r.min, scale }, r)) }; let overlays = p.overlays.iter().filter_map(place_overlay).collect(); @@ -2877,6 +2898,10 @@ mod tail_tests; #[path = "present_delay_tests.rs"] mod delay_tests; +#[cfg(test)] +#[path = "present_overlay_anchor_tests.rs"] +mod overlay_anchor_tests; + #[cfg(test)] mod tests { use super::*; @@ -3020,6 +3045,7 @@ mod tests { background: None, anchor: None, offset: None, + anchor_to: OverlayAnchor::Screen, opened, transition, dur: 0.3, diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs index e9a036f2..f5417311 100644 --- a/crates/ui-egui/src/present_behaviour_tests.rs +++ b/crates/ui-egui/src/present_behaviour_tests.rs @@ -218,7 +218,7 @@ fn act(p: &mut Presenter, a: Action, now: f64) -> bool { } fn open(dest: NodeId, close_on_outside: bool) -> Action { - Action::OpenOverlay { dest, position: OverlayPosition::Center, close_on_outside, background: None, offset: None } + Action::OpenOverlay { dest, position: OverlayPosition::Center, close_on_outside, background: None, offset: None, anchor: Default::default() } } /// Screens A and B and three overlay frames M1, M2, M3 (all top-level). diff --git a/crates/ui-egui/src/present_overlay_anchor_tests.rs b/crates/ui-egui/src/present_overlay_anchor_tests.rs new file mode 100644 index 00000000..1cb3e310 --- /dev/null +++ b/crates/ui-egui/src/present_overlay_anchor_tests.rs @@ -0,0 +1,75 @@ +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_doc::proto::{OverlayAnchor, OverlayPosition}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use super::anchored_overlay_rect; +use crate::present_tests::{Ui as Harness, id}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn frame(s: &mut Session, name: &str, x: f64, w: f64, h: f64) -> NodeId { + id(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": w, "height": h, "name": name}))) +} + +fn rect(s: &mut Session, parent: NodeId, [x, y, w, h]: [f64; 4]) -> NodeId { + id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h, "parent": parent}))) +} + +fn hotspot(u: &Harness, layer: NodeId) -> Rect { + let hot = u.app.inspect()["presenter"]["hotspots"].as_array().cloned().unwrap_or_default(); + let h = hot.iter().find(|h| h["id"] == json!(layer)).unwrap_or_else(|| panic!("no hotspot on {layer}: {hot:?}")); + let r: Vec = h["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + Rect::from_min_max(pos2(r[0], r[1]), pos2(r[2], r[3])) +} + +fn button(at: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn presets_sit_on_the_trigger_or_on_the_screen() { + let base = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 800.0)); + let size = vec2(60.0, 200.0); + let trigger = Rect::from_min_size(pos2(30.0, 100.0), vec2(100.0, 40.0)); + let at = |p, a, t| anchored_overlay_rect(p, a, base, size, t, None).min; + use OverlayAnchor::*; + use OverlayPosition as P; + for (p, a, want, why) in [ + (P::BottomLeft, TriggerOutside, pos2(30.0, 140.0), "below the trigger, left edges lined up"), + (P::BottomRight, TriggerOutside, pos2(70.0, 140.0), "below, right edges: 30 + 100 - 60"), + (P::TopCenter, TriggerOutside, pos2(50.0, -100.0), "above, centred: 30 + 20, 100 - 200"), + (P::TopLeft, TriggerInside, pos2(30.0, 100.0), "on the trigger's top-left"), + (P::Center, TriggerInside, pos2(50.0, 20.0), "centred on the trigger"), + (P::BottomLeft, Screen, pos2(0.0, 600.0), "the screen's bottom left, as before"), + ] { + assert_eq!(at(p, a, Some(trigger)), want, "{p:?} {a:?}: {why}"); + } + assert_eq!(at(P::BottomLeft, TriggerOutside, None), pos2(0.0, 600.0), "opened by nothing on screen: the screen's preset"); +} + +#[test] +fn a_dropdown_opens_right_under_its_button() { + let mut s = Session::new(); + let a = frame(&mut s, "A", 0.0, 300.0, 400.0); + let menu = frame(&mut s, "Menu", 400.0, 120.0, 80.0); + let hot = rect(&mut s, a, [50.0, 50.0, 100.0, 30.0]); + let item = rect(&mut s, menu, [0.0, 0.0, 120.0, 20.0]); + let open = json!({"type": "openOverlay", "dest": menu, "position": "bottomLeft", "anchor": "triggerOutside"}); + ex(&mut s, "proto.interaction.add", json!({"id": hot, "action": open, "transition": {"type": "instant"}})); + ex(&mut s, "proto.interaction.add", json!({"id": item, "action": {"type": "closeOverlay"}, "transition": {"type": "instant"}})); + let mut u = Harness::new(s); + u.run("proto.present", json!({"start": a})); + u.frames(2, Vec::new); + let h = hotspot(&u, hot); + u.frame(vec![Event::PointerMoved(h.center())]); + u.frame(vec![button(h.center(), true)]); + u.frame(vec![button(h.center(), false)]); + u.frames(2, Vec::new); + let got = hotspot(&u, item).min; + let want = pos2(h.min.x, h.max.y); + assert!((got - want).length() < 0.01, "{got:?} vs {want:?}"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 747dbaf6..8ee1285e 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -5,8 +5,8 @@ use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_color::Paint; use figcraft_doc::proto::{ - Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, - Trigger, is_gamepad_button, + Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition, + Transition, Trigger, is_gamepad_button, }; use figcraft_doc::*; use serde_json::{Value, json}; @@ -252,9 +252,14 @@ pub(crate) fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId, Action::ScrollTo { dest, offset: [0.0, 0.0] } } "Open link" => Action::OpenUrl { url: String::new() }, - "Open overlay" => { - Action::OpenOverlay { dest: frame_dest?, position: OverlayPosition::Center, close_on_outside: true, background: None, offset: None } - } + "Open overlay" => Action::OpenOverlay { + dest: frame_dest?, + position: OverlayPosition::Center, + close_on_outside: true, + background: None, + offset: None, + anchor: OverlayAnchor::Screen, + }, "Swap overlay" => Action::SwapOverlay { dest: frame_dest? }, "Close overlay" => Action::CloseOverlay, "Set variable" => { @@ -904,9 +909,9 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } }); } - Action::OpenOverlay { dest, position, close_on_outside, background, offset } => { - let (dest, position, close_on_outside, background, offset) = (*dest, *position, *close_on_outside, *background, *offset); - let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background, offset }; + Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor } => { + let (dest, position, close_on_outside, background, offset, anchor) = (*dest, *position, *close_on_outside, *background, *offset, *anchor); + let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background, offset, anchor }; dest_dropdown(app, ui, doc, &frames, Some(dest), tr("Overlay"), mk, slot); widgets::sublabels(ui, tr("Overlay position"), None); row(ui, 32.0, |ui| { @@ -914,12 +919,33 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: menu_popup(&resp).show(|ui| { for p in OverlayPosition::ALL { if menu_item(ui, tr(p.label()), "") { - set_action(app, slot, Action::OpenOverlay { dest, position: p, close_on_outside, background, offset }); + set_action(app, slot, Action::OpenOverlay { dest, position: p, close_on_outside, background, offset, anchor }); ui.close(); } } }); }); + if position != OverlayPosition::Manual { + widgets::sublabels(ui, tr("Relative to"), None); + row(ui, 32.0, |ui| { + let on_trigger = !anchor.is_screen(); + let opts = [tr("Whole screen"), tr("Trigger layer")]; + if let Some(i) = widgets::segmented_text(ui, &opts, usize::from(on_trigger)) { + let anchor = if i == 0 { OverlayAnchor::Screen } else { OverlayAnchor::TriggerOutside }; + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor }); + } + }); + if !anchor.is_screen() { + row(ui, 32.0, |ui| { + let opts = [tr("Inside"), tr("Outside")]; + let at = usize::from(anchor == OverlayAnchor::TriggerOutside); + if let Some(i) = widgets::segmented_text(ui, &opts, at) { + let anchor = if i == 0 { OverlayAnchor::TriggerInside } else { OverlayAnchor::TriggerOutside }; + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor }); + } + }); + } + } if position == OverlayPosition::Manual { // From the top-left of the layer that opens it; empty: right below that layer. row(ui, 32.0, |ui| { @@ -949,20 +975,20 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: if let Some(slot_v) = o.get_mut(i) { *slot_v = v.clamp(-100_000.0, 100_000.0).round(); } - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background, offset: Some(o) }); + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background, offset: Some(o), anchor }); } } }); } row(ui, 28.0, |ui| { if widgets::checkbox(ui, close_on_outside, tr("Close when clicking outside")) { - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside: !close_on_outside, background, offset }); + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside: !close_on_outside, background, offset, anchor }); } }); row(ui, 28.0, |ui| { if widgets::checkbox(ui, background.is_some(), tr("Add background behind overlay")) { let bg = if background.is_some() { None } else { Some(figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25)) }; - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: bg, offset }); + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: bg, offset, anchor }); } }); if let Some(bg) = background { @@ -971,7 +997,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: if let Some(h) = widgets::text_field(ui, slot.id("proto_ovbg"), &bg.to_hex(), 96.0, "") && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c), offset }); + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c), offset, anchor }); } let alpha = (bg.a * 100.0).round(); if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = @@ -979,7 +1005,11 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: && v.is_finite() { let a = v.clamp(0.0, 100.0) as f32 / 100.0; - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset }); + set_action( + app, + slot, + Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset, anchor }, + ); } }); } diff --git a/docs/mcp.md b/docs/mcp.md index 8361286d..f5488d79 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -640,7 +640,11 @@ the scroll position with `preserveScroll: true`; `{type: "scrollTo", dest, offse the layer that far from the top-left of the innermost frame scrolling it, e.g. below a fixed header; `delayMs` makes a mouse enter, leave, down or up trigger wait before it acts; `{type: "openOverlay", dest, position: "manual", offset?: [x, y]}` puts the overlay that far from -the top-left of the layer that opens it, and right below that layer without an offset). A key trigger takes a key or a combination (`{type: "keyPress", key: "Shift+K"}`; Ctrl, +the top-left of the layer that opens it, and right below that layer without an offset; +`anchor: "triggerInside" | "triggerOutside"` measures any other position on the layer that opens +the overlay instead of the screen, so `{position: "bottomLeft", anchor: "triggerOutside"}` opens a +dropdown right under its button, left edges lined up; Figma has no such setting, and `.fig` export +writes it as a manual offset). A key trigger takes a key or a combination (`{type: "keyPress", key: "Shift+K"}`; Ctrl, Alt, Shift and Cmd, all held and no others) or a controller button (`key: "Gamepad A"`; `Gamepad` followed by `A`, `B`, `X`, `Y`, `LB`, `RB`, `LT`, `RT`, `View`, `Menu`, `LS`, `RS`, `Up`, `Down`, `Left`, `Right` or `Home`: the standard layout, face buttons by position), which the player reads diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 2ded779d..39501a76 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-10-23 · **Change:** patch (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-10-23 · **Change:** patch (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; overlay positions relative to the layer that opens them; 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, @@ -51,6 +51,7 @@ case pins the current behaviour). | Open overlay: positions, background dimming, close on a click outside (on by default, can be turned off) | pass | `a_click_outside_an_overlay_closes_it_unless_turned_off`, `overlay_positions` | | Overlays stack; swap overlay replaces the top one; close overlay closes it | pass | `overlays_stack_swap_close_and_go_with_navigation` | | Manual overlay position relative to the hotspot | **added** | `offset: [x, y]` on the action, from the top-left of the layer that opens it (X / Y fields in the interaction editor; without one, below that layer); `present_delay_tests` | +| Overlay position presets relative to the layer that opens it, inside or outside (not in Figma: [forum request](https://forum.figma.com/suggest-a-feature-11/set-overlay-position-relative-to-element-not-to-page-35366)) | **added** | `anchor: triggerInside \| triggerOutside` on the open overlay action; Relative to (Whole screen / Trigger layer) and Inside / Outside in the interaction editor. Outside: top presets above the layer, bottom presets below it, edges lined up (a dropdown is Bottom left + Outside); Center centres it on the layer. No flipping at the screen edge. `.fig` export writes a manual offset; `proto_overlay_anchor_tests`, `present_overlay_anchor_tests`, MCP `overlay_anchor` | | Delay on mouse enter / leave / down / up | **added** | `delayMs` on the interaction, a Delay field in the editor; fires once after the delay while its screen or overlay still shows (a pointer that leaves early: silent, it still fires); `mouse_triggers_wait_out_their_delay` | | Open link | pass | always a new tab or browser window (opening in place would leave the prototype) | | Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` | @@ -106,6 +107,7 @@ case pins the current behaviour). | 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged | | 2026-10-22 | minor | Change to, set variable and set variable mode lay out what they change in the player as the canvas does: text sizes to what it shows and hugging frames follow (an instance with a longer label changed to the main's width; a bound text kept its old size). Two player tests. Breadth and ready unchanged. | | 2026-10-17 | minor | Presentation tabs: one per page of a file instead of a single "Present" tab, named "file - page" (renames follow); Play shows a page's tab as it was; tabs outlive their file and come back when a saved file opens again. Numbers unchanged. | +| 2026-10-17 | minor | Overlay positions relative to the layer that opens them (M5, beyond Figma, from a long-standing forum request): `anchor: triggerInside \| triggerOutside` on the open overlay action measures the position preset on the trigger instead of the screen (outside: top presets above it, bottom presets below it, edges lined up; Center centres it on the trigger). Relative to and Inside / Outside controls in the interaction editor, the player, MCP and `.fig` export (as a manual offset). Numbers unchanged. | | 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h |