diff --git a/ROADMAP.md b/ROADMAP.md index 0cc1847..71c6093 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,8 +93,9 @@ dimension measured side by side. a library collection's mode on a frame copies the variables in as read-only linked copies, so the file renders without the library; library components keep their variable bindings and modes; "Library updates available" covers changed variables and bound layers follow). Explicit variable - modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own. - *Left:* library collections in the Design panel's mode picker. 1 h. + modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own, with + enabled libraries' collections listed under the library's name (picking a mode copies the + collection in). *Left:* QA with larger real-world libraries. 1 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, @@ -108,8 +109,13 @@ dimension measured side by side. pane); presentation options (fit, fit width, fill, 100%, with Z to cycle them and R to restart, the device frame shown or hidden, kept between presentations; what overflows the window pans); scroll momentum (drags fling and slow down the same at any frame rate, the wheel - coasts, a drag past the ends stretches and springs back). *Left:* responsive presentation - (frames resized to the window), device colours and custom device sizes. 1–2 h. + coasts, a drag past the ends stretches and springs back); responsive presentation (the presented + frame resized to the device's screen, or the window without a device, its constraints and auto + layout responding live; in the Options menu and the Prototype panel, saved in the file); device + colours (each model's colour variants, bodies drawn in code) and custom device sizes (a bare + screen with width and height fields, imported from REST files); flow descriptions in the + presenter's flow picker and under each flow. *Left:* conditional actions and expressions, + hotspot hints on click, comments and inspect in the presenter. 4–8 h. 6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import` / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry paths, auto layout, constraints, grids, components, sets, instances with overrides, component @@ -152,8 +158,7 @@ dimension measured side by side. ### Where we're going -- **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); responsive - presentation. +- **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA). - **Then:** interop depth (6: .fig best effort), plugins and collaboration (8). - **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 6e34c04..008c6dc 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -617,14 +617,47 @@ pub struct PrototypeSettings { pub background: figcraft_color::Color, /// Applies to devices that turn (phones and tablets). pub rotation: DeviceRotation, + /// The device's colour: one of its model's [`Device::colors`] by name (empty or one the model + /// doesn't come in: its first). + #[serde(skip_serializing_if = "String::is_empty")] + pub color: String, + /// The screen of the [`CUSTOM_DEVICE`](Self::CUSTOM_DEVICE) (points, width × height). + #[serde(alias = "customSize", skip_serializing_if = "is_custom_default")] + pub custom_size: [f64; 2], + /// Responsive: the presented frame is resized to the screen (the device's, else the window) + /// and its constraints and auto layout respond, instead of showing at its own size. + #[serde(skip_serializing_if = "is_false")] + pub responsive: bool, } impl Default for PrototypeSettings { fn default() -> Self { - Self { device: "None".into(), background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E), rotation: DeviceRotation::Portrait } + Self { + device: "None".into(), + background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E), + rotation: DeviceRotation::Portrait, + color: String::new(), + custom_size: PrototypeSettings::CUSTOM_DEFAULT, + responsive: false, + } } } +fn is_custom_default(s: &[f64; 2]) -> bool { + *s == PrototypeSettings::CUSTOM_DEFAULT +} + +/// A colour a device model comes in (its body; the glass around the screen stays black). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DeviceColor { + pub name: &'static str, + pub rgb: [u8; 3], +} + +const fn c(name: &'static str, r: u8, g: u8, b: u8) -> DeviceColor { + DeviceColor { name, rgb: [r, g, b] } +} + /// A device prototypes are presented on: its name and screen size in points (held upright). #[derive(Clone, Copy, Debug, PartialEq)] pub struct Device { @@ -633,24 +666,93 @@ pub struct Device { pub height: f64, /// It can be turned to landscape (phones and tablets; laptops and desktops can't). pub rotates: bool, + /// The colours it comes in, the default first (none for a custom size: just a screen). + pub colors: &'static [DeviceColor], } impl PrototypeSettings { /// The devices to pick from ("None" presents the frame at its own size). pub const DEVICES: [Device; 6] = [ - Device { name: "iPhone 16", width: 393.0, height: 852.0, rotates: true }, - Device { name: "iPhone 16 Pro Max", width: 440.0, height: 956.0, rotates: true }, - Device { name: "Android Compact", width: 412.0, height: 917.0, rotates: true }, - Device { name: "iPad mini", width: 744.0, height: 1133.0, rotates: true }, - Device { name: "MacBook Air", width: 1280.0, height: 832.0, rotates: false }, - Device { name: "Desktop", width: 1440.0, height: 1024.0, rotates: false }, + Device { + name: "iPhone 16", + width: 393.0, + height: 852.0, + rotates: true, + colors: &[ + c("Black", 0x3A, 0x3B, 0x3F), + c("White", 0xEE, 0xED, 0xE8), + c("Pink", 0xEC, 0xBF, 0xCB), + c("Teal", 0xA3, 0xCF, 0xC6), + c("Blue", 0x72, 0x84, 0xD4), + ], + }, + Device { + name: "iPhone 16 Pro Max", + width: 440.0, + height: 956.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)], + }, + Device { + name: "Android Compact", + width: 412.0, + height: 917.0, + rotates: true, + colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)], + }, + Device { + name: "iPad mini", + width: 744.0, + height: 1133.0, + rotates: true, + colors: &[c("Grey", 0x6A, 0x6B, 0x6F), c("Silver", 0xE0, 0xE1, 0xE3), c("Blue", 0x9C, 0xB1, 0xC9), c("Purple", 0xC6, 0xBC, 0xD7)], + }, + Device { + name: "MacBook Air", + width: 1280.0, + height: 832.0, + rotates: false, + colors: &[c("Silver", 0xC9, 0xCA, 0xCE), c("Grey", 0x7B, 0x7C, 0x80), c("Dark blue", 0x2E, 0x33, 0x3D), c("Cream", 0xE0, 0xD9, 0xCB)], + }, + Device { + name: "Desktop", + width: 1440.0, + height: 1024.0, + rotates: false, + colors: &[c("Silver", 0xB8, 0xB9, 0xBD), c("Black", 0x2C, 0x2C, 0x2E)], + }, ]; + /// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it). + pub const CUSTOM_DEVICE: &'static str = "Custom size"; + /// A new custom size. + pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0]; + /// The largest side a custom size may have (points). + pub const CUSTOM_MAX: f64 = 10_000.0; + + /// A custom screen side from a command or file value: finite, at least 1, at most + /// [`CUSTOM_MAX`](Self::CUSTOM_MAX). + pub fn custom_side(v: f64) -> Option { + (v.is_finite() && v >= 1.0).then(|| v.min(Self::CUSTOM_MAX).round()) + } + /// The chosen device model, held upright (None for "None" or a name we don't know). pub fn model(&self) -> Option { + if self.device == Self::CUSTOM_DEVICE { + let ([w, h], [dw, dh]) = (self.custom_size, Self::CUSTOM_DEFAULT); + let (width, height) = (Self::custom_side(w).unwrap_or(dw), Self::custom_side(h).unwrap_or(dh)); + return Some(Device { name: Self::CUSTOM_DEVICE, width, height, rotates: false, colors: &[] }); + } Self::DEVICES.iter().find(|d| d.name == self.device).copied() } + /// The chosen device's colour (its model's first unless another is picked; None without a + /// body). + pub fn device_color(&self) -> Option { + let colors = self.model()?.colors; + colors.iter().find(|c| c.name == self.color).or(colors.first()).copied() + } + /// The device turned to landscape (only devices that turn do). pub fn landscape(&self) -> bool { self.rotation == DeviceRotation::Landscape && self.model().is_some_and(|d| d.rotates) diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 882181a..fd9ca0d 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -109,29 +109,9 @@ pub fn specs() -> Vec { spec!( "proto.settings", "Prototype settings", - "{device?, background?, rotation? portrait|landscape (or 0|90; phones and tablets turn)}", + "{device? (a device name, \"Custom size\" or \"None\"), background?, rotation? portrait|landscape (or 0|90; phones and tablets turn), color? (one of the device's colours by name), width?, height? (the custom size's screen, 1–10000 points; picks \"Custom size\"), responsive? bool (frames resize to the screen and their constraints and auto layout respond)}", true, - |s, p| { - let page = s.page()?; - let bg = color(p, "background"); - let dev = string(p, "device"); - let rot = match p.get("rotation") { - None | Some(Value::Null) => None, - Some(v) => Some(rotation_of(v).ok_or_else(|| bad("proto.settings", "rotation: portrait|landscape"))?), - }; - if let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind { - if let Some(c) = bg { - prototype.background = c; - } - if let Some(d) = dev { - prototype.device = d; - } - if let Some(r) = rot { - prototype.rotation = r; - } - } - ok() - } + settings ), spec!( "proto.present", @@ -176,6 +156,91 @@ pub fn specs() -> Vec { ] } +/// `proto.settings`: everything is checked before anything changes. +fn settings(s: &mut Session, p: &Value) -> Result { + let cmd = "proto.settings"; + let page = s.page()?; + let bg = color(p, "background"); + let dev = string(p, "device"); + let rot = match p.get("rotation") { + None | Some(Value::Null) => None, + Some(v) => Some(rotation_of(v).ok_or_else(|| bad(cmd, "rotation: portrait|landscape"))?), + }; + let side = |key: &str| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().and_then(PrototypeSettings::custom_side).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 1–10000 points"))), + } + }; + let (w, h) = (side("width")?, side("height")?); + let responsive = match p.get("responsive") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "responsive: true|false"))?), + }; + let col = match p.get("color") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad(cmd, "color: one of the device's colour names"))?.trim().to_string()), + }; + let NodeKind::Page { prototype, .. } = &s.doc()?.get(page)?.kind else { return Err(bad(cmd, "not a page")) }; + let mut next = prototype.clone(); + if let Some(c) = bg { + next.background = c; + } + if let Some(d) = dev { + next.device = d; + } + if let Some(r) = rot { + next.rotation = r; + } + // A width or height is the custom size's: it picks "Custom size". + if w.is_some() || h.is_some() { + next.custom_size = [w.unwrap_or(next.custom_size[0]), h.unwrap_or(next.custom_size[1])]; + next.device = PrototypeSettings::CUSTOM_DEVICE.into(); + } + if let Some(r) = responsive { + next.responsive = r; + } + let colors = next.model().map_or(&[][..], |m| m.colors); + match col { + Some(c) if c.is_empty() => next.color.clear(), + Some(c) => { + let found = colors.iter().find(|x| x.name.eq_ignore_ascii_case(&c)).ok_or_else(|| { + let names: Vec<&str> = colors.iter().map(|x| x.name).collect(); + bad(cmd, if names.is_empty() { "this device has no colours".to_string() } else { format!("color: {}", names.join("|")) }) + })?; + next.color = found.name.into(); + } + // Another device keeps the colour only if it comes in it. + None if !colors.iter().any(|x| x.name == next.color) => next.color.clear(), + None => {} + } + if next != *prototype + && let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind + { + *prototype = next; + } + ok() +} + +/// Resize top-level `frame` to a screen of `(w, h)` as a responsive prototype does: its children +/// follow their constraints and auto layout flows again (fill layers stretch, text wraps), exactly +/// as when the frame is resized in the editor; an axis the frame hugs keeps hugging its content. +/// Used by the player on its own copy of the document. +pub fn respond(doc: &mut Document, frame: NodeId, (w, h): (f64, f64)) { + if !(w.is_finite() && h.is_finite()) || doc.node(frame).is_none_or(|n| !n.kind.is_frame_like()) { + return; + } + let (w, h) = (w.clamp(1.0, 100_000.0), h.clamp(1.0, 100_000.0)); + if doc.node(frame).is_some_and(|n| (n.width - w).abs() < 1e-9 && (n.height - h).abs() < 1e-9) { + return; + } + doc.take_dirty(); + let before = doc.clone(); + if super::props::resize_node(doc, frame, w, h).is_ok() { + crate::normalize(doc, &before); + } +} + /// The presenter's scale options (`proto.presentOptions {scale}`). pub const PRESENT_SCALES: [&str; 4] = ["fit", "fitWidth", "fill", "actual"]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d05164b..48d040d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -898,6 +898,8 @@ mod proto_multi_tests; #[cfg(test)] mod proto_present_tests; #[cfg(test)] +mod proto_responsive_tests; +#[cfg(test)] mod proto_run_tests; #[cfg(test)] mod rotated_select_tests; diff --git a/crates/engine/src/proto_responsive_tests.rs b/crates/engine/src/proto_responsive_tests.rs new file mode 100644 index 0000000..a60e532 --- /dev/null +++ b/crates/engine/src/proto_responsive_tests.rs @@ -0,0 +1,210 @@ +//! Responsive presentation (frames laid out again for a screen: constraints and auto layout +//! respond), device colours and custom device sizes: the settings through `proto.settings`, saved +//! in the file, and [`respond`] on a document. + +use figcraft_doc::proto::PrototypeSettings; +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::proto::respond; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + match s.execute(cmd, &p) { + Ok(v) => v, + Err(e) => panic!("{cmd} {p}: {e}"), + } +} + +fn idv(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn settings(s: &Session) -> PrototypeSettings { + let doc = s.doc().unwrap(); + match &doc.node(s.page().unwrap()).unwrap().kind { + NodeKind::Page { prototype, .. } => prototype.clone(), + _ => panic!("not a page"), + } +} + +fn set(s: &mut Session, id: NodeId, props: Value) { + ex(s, "node.set", json!({"id": id, "props": props})); +} + +/// `id`'s box in its parent: (x, y, width, height). +fn rect(doc: &Document, id: NodeId) -> (f64, f64, f64, f64) { + let n = doc.node(id).unwrap(); + let c = n.transform.as_coeffs(); + (c[4], c[5], n.width, n.height) +} + +#[test] +fn device_colours_pick_by_name_and_follow_the_device() { + let mut s = Session::new(); + // Every device model comes in colours (the first is the default); names are unique per model. + for d in PrototypeSettings::DEVICES { + assert!(!d.colors.is_empty(), "{}", d.name); + let mut names: Vec<&str> = d.colors.iter().map(|c| c.name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), d.colors.len(), "{}", d.name); + } + ex(&mut s, "proto.settings", json!({"device": "iPhone 16"})); + assert_eq!(settings(&s).device_color().map(|c| c.name), Some("Black"), "the first by default"); + // Case doesn't matter; the stored name is the model's. + ex(&mut s, "proto.settings", json!({"color": "teal"})); + assert_eq!(settings(&s).color, "Teal"); + assert_eq!(settings(&s).device_color().map(|c| c.rgb), Some([0xA3, 0xCF, 0xC6])); + // A colour the device doesn't come in is refused and changes nothing. + for bad in [json!("Plaid"), json!(3), json!({}), json!(["Teal"])] { + assert!(s.execute("proto.settings", &json!({"color": bad})).is_err(), "{bad}"); + } + assert_eq!(settings(&s).color, "Teal"); + // Another device keeps the colour only if it comes in it; an empty name is the default. + ex(&mut s, "proto.settings", json!({"device": "iPad mini", "color": "Blue"})); + assert_eq!(settings(&s).device_color().map(|c| c.name), Some("Blue")); + ex(&mut s, "proto.settings", json!({"device": "MacBook Air"})); + assert_eq!((settings(&s).color.as_str(), settings(&s).device_color().map(|c| c.name)), ("", Some("Silver"))); + ex(&mut s, "proto.settings", json!({"color": "Grey"})); + ex(&mut s, "proto.settings", json!({"color": ""})); + assert_eq!(settings(&s).device_color().map(|c| c.name), Some("Silver")); + // Undoable; no device, no colours. + ex(&mut s, "edit.undo", json!({})); + assert_eq!(settings(&s).color, "Grey"); + ex(&mut s, "proto.settings", json!({"device": "None"})); + assert_eq!(settings(&s).device_color(), None); + assert!(s.execute("proto.settings", &json!({"color": "Black"})).is_err()); +} + +#[test] +fn custom_sizes_are_a_screen_of_their_own() { + let mut s = Session::new(); + // Width or height alone picks "Custom size", keeping the other side. + ex(&mut s, "proto.settings", json!({"width": 1024})); + let p = settings(&s); + assert_eq!(p.device, PrototypeSettings::CUSTOM_DEVICE); + let d = p.device().unwrap(); + assert_eq!((d.width, d.height, d.rotates), (1024.0, 720.0, false)); + assert!(d.colors.is_empty(), "just a screen"); + assert_eq!(p.device_color(), None); + ex(&mut s, "proto.settings", json!({"width": 360.4, "height": 640})); + assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((360.0, 640.0))); + // It doesn't turn: the sides are the screen. + ex(&mut s, "proto.settings", json!({"rotation": "landscape"})); + assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((360.0, 640.0))); + // Out of range or not a number: refused, nothing changes (huge sizes are capped). + for bad in [json!(0), json!(-5), json!("wide"), json!(0.5), json!(true)] { + assert!(s.execute("proto.settings", &json!({"width": bad})).is_err(), "{bad}"); + assert!(s.execute("proto.settings", &json!({"height": bad, "device": "iPhone 16"})).is_err(), "{bad}"); + } + assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((360.0, 640.0))); + ex(&mut s, "proto.settings", json!({"height": 1e12})); + assert_eq!(settings(&s).device().map(|d| d.height), Some(PrototypeSettings::CUSTOM_MAX)); + // Picking another device keeps the custom size for later. + ex(&mut s, "proto.settings", json!({"device": "Desktop"})); + ex(&mut s, "proto.settings", json!({"device": "Custom size"})); + assert_eq!(settings(&s).device().map(|d| d.width), Some(360.0)); + // A file with junk in it still presents something sensible. + let junk: PrototypeSettings = serde_json::from_value(json!({"device": "Custom size", "custom_size": [-1, 1e300]})).unwrap(); + assert_eq!(junk.device().map(|d| (d.width, d.height)), Some((1280.0, PrototypeSettings::CUSTOM_MAX))); +} + +#[test] +fn settings_are_saved_and_old_files_read_as_before() { + let mut s = Session::new(); + ex(&mut s, "proto.settings", json!({"device": "Android Compact", "color": "Green", "responsive": true})); + ex(&mut s, "proto.settings", json!({"width": 800, "height": 600})); + ex(&mut s, "proto.settings", json!({"device": "Android Compact", "color": "Green"})); + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap(); + let NodeKind::Page { prototype, .. } = &doc.node(doc.pages[0]).unwrap().kind else { panic!("not a page") }; + assert_eq!(prototype, &settings(&s)); + assert!(prototype.responsive); + assert_eq!((prototype.color.as_str(), prototype.custom_size), ("Green", [800.0, 600.0])); + // Defaults aren't written; files without the fields read as before. + let plain = serde_json::to_value(PrototypeSettings::default()).unwrap(); + for k in ["color", "custom_size", "responsive"] { + assert!(plain.get(k).is_none(), "{plain}"); + } + let old: PrototypeSettings = serde_json::from_value(json!({"device": "iPhone 16", "rotation": "landscape"})).unwrap(); + assert!(!old.responsive); + assert_eq!(old.device_color().map(|c| c.name), Some("Black")); + assert_eq!(old.custom_size, PrototypeSettings::CUSTOM_DEFAULT); + // Responsive must be a boolean. + assert!(s.execute("proto.settings", &json!({"responsive": "yes"})).is_err()); + ex(&mut s, "proto.settings", json!({"responsive": false})); + assert!(!settings(&s).responsive); +} + +#[test] +fn responding_frames_follow_constraints_and_auto_layout() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 50, "y": 20, "width": 400, "height": 300}))); + let right = idv(&ex(&mut s, "shape.rectangle", json!({"x": 340, "y": 10, "width": 50, "height": 20, "parent": f}))); + set(&mut s, right, json!({"constraints": {"horizontal": "right", "vertical": "top"}})); + let stretch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 250, "width": 380, "height": 40, "parent": f}))); + set(&mut s, stretch, json!({"constraints": {"horizontal": "leftRight", "vertical": "bottom"}})); + let centred = idv(&ex(&mut s, "shape.rectangle", json!({"x": 175, "y": 125, "width": 50, "height": 50, "parent": f}))); + set(&mut s, centred, json!({"constraints": {"horizontal": "center", "vertical": "center"}})); + // An auto layout row inside, stretching with the frame, whose fill child takes the free width. + let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 60, "width": 400, "height": 40, "parent": f}))); + ex(&mut s, "layout.add", json!({"ids": [row], "direction": "horizontal"})); + set(&mut s, row, json!({"sizingH": "fixed", "width": 400, "sizingV": "fixed", "height": 40, "gap": 0, "padding": 0})); + set(&mut s, row, json!({"constraints": {"horizontal": "leftRight", "vertical": "top"}})); + let fixed = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": row}))); + let fill = idv(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 300, "height": 40, "parent": row}))); + set(&mut s, fill, json!({"sizingH": "fill"})); + let before = s.doc().unwrap().clone(); + assert_eq!(rect(&before, fill).2, 300.0); + + let mut d = before.clone(); + respond(&mut d, f, (600.0, 500.0)); + assert_eq!(rect(&d, f), (50.0, 20.0, 600.0, 500.0), "keeps its place"); + assert_eq!(rect(&d, right), (540.0, 10.0, 50.0, 20.0)); + assert_eq!(rect(&d, stretch), (10.0, 450.0, 580.0, 40.0)); + assert_eq!(rect(&d, centred), (275.0, 225.0, 50.0, 50.0)); + assert_eq!(rect(&d, row).2, 600.0); + assert_eq!(rect(&d, fixed).2, 100.0); + assert_eq!(rect(&d, fill), (100.0, 0.0, 500.0, 40.0), "the fill child takes the new room"); + // From the original again (never from a previous size): smaller this time. + let mut d = before.clone(); + respond(&mut d, f, (300.0, 200.0)); + assert_eq!(rect(&d, right).0, 240.0); + assert_eq!(rect(&d, fill).2, 200.0); + // The editor's document is untouched; junk sizes and ids change nothing or stay finite. + assert_eq!(s.doc().unwrap().node(f).unwrap().width, 400.0); + for size in [(f64::NAN, 10.0), (10.0, f64::INFINITY)] { + let mut d = before.clone(); + respond(&mut d, f, size); + assert_eq!(rect(&d, f), rect(&before, f)); + } + let mut d = before.clone(); + respond(&mut d, f, (0.0, 1e12)); + assert_eq!((rect(&d, f).2, rect(&d, f).3), (1.0, 100_000.0)); + assert!(d.subtree(f).iter().all(|i| { + let r = rect(&d, *i); + r.0.is_finite() && r.1.is_finite() && r.2 >= 0.0 && r.3 >= 0.0 + })); + respond(&mut d, right, (10.0, 10.0)); + respond(&mut d, NodeId(987_654), (10.0, 10.0)); + assert!(d.validate().is_empty(), "{:?}", d.validate()); +} + +#[test] +fn a_frame_that_hugs_keeps_hugging_its_content() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"})); + set(&mut s, f, json!({"sizingH": "fixed", "width": 400, "sizingV": "hug", "gap": 0, "padding": 0})); + let item = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 400, "height": 900, "parent": f}))); + set(&mut s, item, json!({"sizingH": "fill"})); + let before = s.doc().unwrap().clone(); + assert_eq!(rect(&before, f).3, 900.0); + let mut d = before.clone(); + respond(&mut d, f, (393.0, 852.0)); + // The width is the screen's; the height still hugs the content (it scrolls on the screen). + assert_eq!((rect(&d, f).2, rect(&d, f).3), (393.0, 900.0)); + assert_eq!(rect(&d, item).2, 393.0); +} diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 73f2dd3..235e246 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -52,6 +52,12 @@ pub(super) fn page_settings(c: &Value) -> Option { if let Some(d) = device { s.device = device_name(d).to_string(); s.rotation = text(d, "rotation").and_then(DeviceRotation::parse).unwrap_or_default(); + // A custom size keeps its screen (turned: its sides swap, as it doesn't turn here). + if let Some((w, h)) = custom_size(d) { + s.device = PrototypeSettings::CUSTOM_DEVICE.into(); + s.custom_size = if s.rotation == DeviceRotation::Landscape { [h, w] } else { [w, h] }; + s.rotation = DeviceRotation::Portrait; + } } if let Some(b) = background { s.background = b.with_alpha(1.0); @@ -59,8 +65,17 @@ pub(super) fn page_settings(c: &Value) -> Option { Some(s) } -/// The closest of our device frames: preset identifiers by the device family they name, custom -/// sizes by their shorter side. +/// The screen of a `CUSTOM` device, when its size is usable. +fn custom_size(d: &Value) -> Option<(f64, f64)> { + if text(d, "type") != Some("CUSTOM") { + return None; + } + let s = d.get("size")?; + Some((PrototypeSettings::custom_side(num(s, "width")?)?, PrototypeSettings::custom_side(num(s, "height")?)?)) +} + +/// The closest of our device frames: preset identifiers by the device family they name, unknown +/// presets (and custom sizes without a usable size) by their shorter side. fn device_name(d: &Value) -> &'static str { let size = d.get("size").and_then(|s| Some((num(s, "width")?, num(s, "height")?))); let by_size = || match size { diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs index 521e65e..e7598aa 100644 --- a/crates/format/src/rest/proto_variable_tests.rs +++ b/crates/format/src/rest/proto_variable_tests.rs @@ -285,13 +285,19 @@ fn scrolling_flows_and_prototype_device() { (json!({"type": "PRESET", "presetIdentifier": "GOOGLE_PIXEL_8", "rotation": "NONE"}), "Android Compact"), (json!({"type": "PRESET", "presetIdentifier": "APPLE_IPAD_MINI", "rotation": "NONE"}), "iPad mini"), (json!({"type": "PRESET", "presetIdentifier": "MACBOOK_PRO_14", "rotation": "NONE"}), "MacBook Air"), - (json!({"type": "CUSTOM", "size": {"width": 1920, "height": 1080}, "rotation": "NONE"}), "Desktop"), - (json!({"type": "CUSTOM", "size": {"width": 375, "height": 667}, "rotation": "NONE"}), "iPhone 16"), + (json!({"type": "CUSTOM", "size": {"width": 0, "height": 1080}, "rotation": "NONE"}), "None"), + (json!({"type": "CUSTOM", "size": {"width": "wide", "height": 667}, "rotation": "NONE"}), "None"), ] { let got = device(d).device; assert_eq!(got, want); assert!(got == "None" || PrototypeSettings::DEVICES.iter().any(|d| d.name == got), "{got}"); } + // Custom sizes keep their screen (turned ones swap their sides). + for (rotation, want) in [("NONE", (1920.0, 1080.0)), ("CCW_90", (1080.0, 1920.0))] { + let got = device(json!({"type": "CUSTOM", "size": {"width": 1920, "height": 1080}, "rotation": rotation})); + assert_eq!(got.device, PrototypeSettings::CUSTOM_DEVICE); + assert_eq!(got.device().map(|d| (d.width, d.height)), Some(want)); + } // A device turned a quarter turn presents in landscape. let turned = device(json!({"type": "PRESET", "presetIdentifier": "APPLE_IPHONE_16", "rotation": "CCW_90"})); assert_eq!((turned.device.as_str(), turned.rotation), ("iPhone 16", DeviceRotation::Landscape)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 51ed7b9..4e66fb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,6 +50,8 @@ mod present_tests; #[cfg(test)] mod present_view_tests; #[cfg(test)] +mod proto_responsive_tests; +#[cfg(test)] mod titlebar_tests; use std::collections::BTreeSet; diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index dd2ac4f..4106c25 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -5,7 +5,11 @@ //! offsets change it without touching the user's file) and copies the editor's document again //! whenever it changes, so the preview pane follows edits live. On a device, the presented frame //! is sized to the device's screen ([`fit_viewport`]): what doesn't fit is clipped, or scrolls when -//! the frame's overflow allows it. Every frame is rendered through `figcraft_render::render_node` +//! the frame's overflow allows it. A responsive prototype's frame is instead resized to the screen +//! (the device's, else the player's whole area) and laid out again, its constraints and auto layout +//! responding (`figcraft_engine::cmd::proto::respond`, on the player's copy, from the design each +//! time). Device bodies are drawn in code in the device's colour. Every frame is rendered through +//! `figcraft_render::render_node` //! into a texture. Smart animate interpolates a temporary document between the outgoing and //! incoming states ([`smart_interpolate`]) and renders it each animation frame; the other //! transitions move or fade the two cached textures ([`transition_frames`]). @@ -20,7 +24,9 @@ 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, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger}; +use figcraft_doc::proto::{ + Action, Device, DeviceColor, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, +}; use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint}; use figcraft_geom::{Affine, Point}; use serde_json::{Value, json}; @@ -221,7 +227,13 @@ pub struct Presenter { pub render_ms: f64, start: NodeId, original: Option, + /// The player's document as designed, with what the prototype changed (variables set, + /// variants changed); `doc` is it laid out for the screen. + design: Option, + /// The player's document as presented: `design` with the presented frame resized to `fit`. doc: Option, + /// The screen a responsive prototype's frame is resized to (None: frames keep their size). + fit: Option<(f64, f64)>, rev: u64, display: Option<(u64, Document)>, scroll_anim: Option, @@ -277,7 +289,9 @@ impl Presenter { render_ms: 0.0, start, original: None, + design: None, doc: None, + fit: None, rev: 0, display: None, scroll_anim: None, @@ -378,7 +392,8 @@ impl Presenter { self.source = Some(doc.clone()); let copy = Document::clone(doc); self.original = Some(copy.clone()); - self.doc = Some(copy); + self.design = Some(copy); + self.relayout(); if first { self.arrived = now; return; @@ -398,11 +413,33 @@ impl Presenter { self.scroll_anim = None; self.stop_scrolling(); self.scroll.retain(|f, _| doc.contains(*f)); - let clamped: Vec<(NodeId, (f64, f64))> = self.scroll.iter().map(|(f, s)| (*f, clamp_scroll(*s, self.range(doc, *f)))).collect(); - self.scroll.extend(clamped); + if let Some(laid) = self.doc.as_ref() { + let clamped: Vec<(NodeId, (f64, f64))> = self.scroll.iter().map(|(f, s)| (*f, clamp_scroll(*s, self.range(laid, *f)))).collect(); + self.scroll.extend(clamped); + } self.rev += 1; } + /// Lay the document out for the screen: `design` with the presented frame resized to `fit` + /// when the prototype is responsive (its constraints and auto layout respond). + fn relayout(&mut self) { + let mut d = self.design.clone(); + if let (Some(d), Some(size)) = (d.as_mut(), self.fit) { + figcraft_engine::cmd::proto::respond(d, self.current, size); + } + self.doc = d; + self.rev += 1; + } + + /// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames + /// keep their size). Lays out again only when it changes. + fn respond_to(&mut self, size: Option<(f64, f64)>) { + if self.fit != size { + self.fit = size; + self.relayout(); + } + } + /// The player's document with scroll offsets applied and the presented frame sized to the /// device's screen (cached per revision). fn display(&mut self) -> Option { @@ -479,6 +516,9 @@ impl Presenter { "viewport": doc.and_then(|d| viewport(d, self.current)).map(|v| [v.0, v.1]), "scale": self.view.scale.id(), "deviceFrame": self.view.device_frame, + "deviceColor": doc.and_then(|d| page_settings(d, self.current).device_color()).map(|c| c.name), + "responsive": self.fit.map(|f| [f.0, f.1]), + "frameSize": doc.and_then(|d| d.node(self.current)).map(|n| [n.width, n.height]), "pan": [pan.0, pan.1], "panRange": [self.pan_range.0, self.pan_range.1], "zoom": self.zoom, @@ -489,7 +529,7 @@ impl Presenter { fn snapshot(&self) -> Option { Some(Snapshot { current: self.current, - doc: self.doc.clone()?, + doc: self.design.clone()?, overlays: self.overlays.clone(), history: self.history.clone(), scroll: self.scroll.clone(), @@ -529,6 +569,7 @@ impl Presenter { self.pan = None; self.temp = None; self.current = frame; + self.relayout(); if let Some(s) = kept && let Some(doc) = self.doc.as_ref() { @@ -550,7 +591,8 @@ impl Presenter { self.pan = None; self.anim = None; self.temp = None; - self.doc = self.original.clone(); + self.design = self.original.clone(); + self.relayout(); self.arrive(now); } @@ -558,7 +600,8 @@ impl Presenter { let Some(t) = self.temp.take() else { return }; self.begin(t.transition.reversed(), t.dur, t.easing, now); self.current = t.snap.current; - self.doc = Some(t.snap.doc); + self.design = Some(t.snap.doc); + self.relayout(); self.overlays = t.snap.overlays; self.history = t.snap.history; self.scroll = t.snap.scroll; @@ -692,26 +735,30 @@ impl Presenter { return false; } self.begin(tr, dur, easing, now); - let Some(d) = self.doc.as_mut() else { return false }; + let Some(d) = self.design.as_mut() else { return false }; if let Some(n) = d.node_mut(inst) && let NodeKind::Instance { component, .. } = &mut n.kind { *component = *dest; } figcraft_engine::components::sync_instance(d, inst); - self.rev += 1; + self.relayout(); true } Action::SetVariable { variable, value, expr } => { - let Some(d) = self.doc.as_mut() else { return false }; + let Some(d) = self.design.as_mut() else { return false }; let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref()); - self.rev += u64::from(set); + if set { + self.relayout(); + } set } Action::SetVariableMode { collection, mode } => { - let Some(d) = self.doc.as_mut() else { return false }; + let Some(d) = self.design.as_mut() else { return false }; let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); - self.rev += u64::from(set); + if set { + self.relayout(); + } set } Action::Conditional { blocks } => { @@ -1268,8 +1315,8 @@ struct Layout { device: Option, /// The device is turned to landscape. landscape: bool, - /// The device's body is drawn around its screen. - body: bool, + /// The colour of the device's body, when it is drawn around its screen. + color: Option, overlays: Vec<(NodeId, Place, Rect)>, /// Overlays animating out (same order as `Presenter::closing`). closing: Vec<(NodeId, Place, Rect)>, @@ -1306,15 +1353,20 @@ fn body_outset(device: &str, size: Vec2, landscape: bool) -> (f32, f32, f32) { /// Place the presented frame in `area`: on its device's screen (the whole device sized by the /// view's scale option), else the frame itself. Fit and 100% keep the chrome's padding from the /// edges; Fit never goes above 100%. What doesn't fit the window pans (`Presenter::pan`). The -/// preview pane always fits. +/// preview pane always fits. A responsive prototype's frame is first resized to the device's +/// screen, or without a device to the whole area (shown at 100%). fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { + let settings = page_settings(p.doc.as_ref()?, p.current); + let (device, landscape) = (settings.device(), settings.landscape()); + let fills = settings.responsive && device.is_none(); + let whole_area = (f64::from(area.width().round()), f64::from(area.height().round())); + p.respond_to(settings.responsive.then(|| device.map_or(whole_area, |d| (d.width, d.height)))); let disp = p.display()?; let fb = disp.world_bounds(p.current)?; - let settings = page_settings(&disp, p.current); - let (device, landscape) = (settings.device(), settings.landscape()); let view = if chrome.pane { PresentView::default() } else { p.view }; - let body = device.filter(|_| view.device_frame); - let pad = if matches!(view.scale, PresentScale::Fit | PresentScale::Actual) { chrome.pad } else { 0.0 }; + // Custom sizes are just a screen: no body. + let body = device.filter(|d| view.device_frame && !d.colors.is_empty()); + let pad = if !fills && matches!(view.scale, PresentScale::Fit | PresentScale::Actual) { chrome.pad } else { 0.0 }; let avail = area.shrink(pad); let (sw, sh) = device.map_or((fb.width(), fb.height()), |d| (d.width, d.height)); // The screen at `scale`, the body's outsets around it and the whole size. @@ -1333,7 +1385,7 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { PresentScale::Actual => 1.0, }) }; - let mut scale = factor(vec2(sw as f32, sh as f32)); + let mut scale = if fills { 1.0 } else { factor(vec2(sw as f32, sh as f32)) }; // The body doesn't scale exactly with the screen (its edges have a minimum): size it once more. if body.is_some() { scale *= factor(outer(scale).3); @@ -1365,7 +1417,8 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { let overlays = p.overlays.iter().filter_map(place_overlay).collect(); let closing = p.closing.iter().filter_map(|(_, o)| place_overlay(o)).collect(); let clip = if device.is_some() { screen } else { area }; - Some(Layout { disp, scale, place, screen, clip, device, landscape, body: body.is_some(), overlays, closing }) + let color = body.and(settings.device_color()); + Some(Layout { disp, scale, place, screen, clip, device, landscape, color, overlays, closing }) } /// The keys that present the current frame full-window. @@ -1396,6 +1449,8 @@ enum BarAction { Present, /// The full-window presenter's options menu (handled in the bar). Options, + /// Turn responsive presentation on or off (saved in the file: `proto.settings`). + Responsive(bool), } /// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. @@ -1415,6 +1470,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } // The options carry over to the next presentation. app.ui.present_view = pres.view; + if let Some(BarAction::Responsive(on)) = action { + app.run("proto.settings", json!({"responsive": on})); + } if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { return; } @@ -1500,38 +1558,58 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) { } } -/// Body of the presentation device behind the screen `s` (drawn in code: rounded slabs). Turned -/// to landscape (a quarter turn counter-clockwise), a phone's left side is at the bottom. -fn device_body(p: &egui::Painter, device: &str, s: Rect, landscape: bool) { - let body = Color32::from_rgb(0x1C, 0x1C, 0x1E); - let edge = Color32::from_rgb(0x5A, 0x5A, 0x5E); +/// The black glass around a device's screen. +const GLASS: Color32 = Color32::from_rgb(0x16, 0x16, 0x18); + +/// A device colour lighter (`k` > 0, towards white) or darker (`k` < 0, towards black). +fn shade([r, g, b]: [u8; 3], k: f32) -> Color32 { + let k = k.clamp(-1.0, 1.0); + let f = |c: u8| { + let c = f32::from(c); + (if k >= 0.0 { c + (255.0 - c) * k } else { c * (1.0 + k) }).round().clamp(0.0, 255.0) as u8 + }; + Color32::from_rgb(f(r), f(g), f(b)) +} + +/// The width of the coloured band round a device's glass (upright screen width `w`, bezel `b`). +fn rim(w: f32, b: f32) -> f32 { + (w * 0.016).clamp(1.5, (b * 0.5).max(1.5)) +} + +/// Body of the presentation device behind the screen `s` (drawn in code: rounded slabs in the +/// device's colour with black glass round the screen). Turned to landscape (a quarter turn +/// counter-clockwise), a phone's left side is at the bottom. +fn device_body(p: &egui::Painter, device: &str, s: Rect, landscape: bool, color: DeviceColor) { + let (body, edge, metal) = (shade(color.rgb, 0.0), shade(color.rgb, -0.3), shade(color.rgb, 0.12)); let w = upright_w(s.size(), landscape); let (b, screen_r) = bezel(device, w); + let band = rim(w, b); + let radius = |r: f32| CornerRadius::same(r.clamp(0.0, 255.0) as u8); match device { "MacBook Air" => { + // The lid's coloured edge round black glass, then the base: a thin slab wider than + // the lid, with a finger notch. let lid = s.expand(b); - p.rect_filled(lid, CornerRadius::same((b * 1.2).min(255.0) as u8), body); - p.rect_stroke(lid, CornerRadius::same((b * 1.2).min(255.0) as u8), Stroke::new(1.0, edge), StrokeKind::Outside); - // The base: a thin slab wider than the lid, with a finger notch. + p.rect_filled(lid, radius(b * 1.2), body); + p.rect_filled(lid.shrink(band), radius(b * 1.2 - band), GLASS); let base = Rect::from_min_max(pos2(lid.min.x - w * 0.08, lid.max.y), pos2(lid.max.x + w * 0.08, lid.max.y + (w * 0.022).max(6.0))); - p.rect_filled(base, CornerRadius { nw: 2, ne: 2, sw: 10, se: 10 }, Color32::from_rgb(0xB8, 0xB9, 0xBD)); + p.rect_filled(base, CornerRadius { nw: 2, ne: 2, sw: 10, se: 10 }, metal); let notch = Rect::from_center_size(pos2(base.center().x, base.min.y + 1.5), vec2(w * 0.12, 3.0)); - p.rect_filled(notch, CornerRadius::same(2), Color32::from_rgb(0x8E, 0x8E, 0x93)); + p.rect_filled(notch, CornerRadius::same(2), edge); } "Desktop" => { let screen = s.expand(b); p.rect_filled(screen, CornerRadius::same(8), body); - p.rect_stroke(screen, CornerRadius::same(8), Stroke::new(1.0, edge), StrokeKind::Outside); + p.rect_filled(screen.shrink(band), radius(8.0 - band), GLASS); let neck = Rect::from_min_max(pos2(screen.center().x - w * 0.06, screen.max.y), pos2(screen.center().x + w * 0.06, screen.max.y + w * 0.08)); - p.rect_filled(neck, CornerRadius::ZERO, Color32::from_rgb(0xB8, 0xB9, 0xBD)); + p.rect_filled(neck, CornerRadius::ZERO, metal); let foot = Rect::from_min_max(pos2(neck.min.x - w * 0.06, neck.max.y), pos2(neck.max.x + w * 0.06, neck.max.y + 6.0)); - p.rect_filled(foot, CornerRadius::same(3), Color32::from_rgb(0xA0, 0xA1, 0xA6)); + p.rect_filled(foot, CornerRadius::same(3), edge); } _ => { // Phones and tablets: a rounded slab (phones with side buttons). let slab = s.expand(b); - let r = CornerRadius::same((screen_r + b).min(255.0) as u8); if !device.starts_with("iPad") { // (on the left side, from the top, length) as fractions of the upright side. for (left, f0, h) in [(true, 0.18, 0.06), (true, 0.27, 0.1), (false, 0.24, 0.13)] { @@ -1545,23 +1623,24 @@ fn device_body(p: &egui::Painter, device: &str, s: Rect, landscape: bool) { p.rect_filled(btn, CornerRadius::same(1), edge); } } - p.rect_filled(slab, r, body); - p.rect_stroke(slab, r, Stroke::new(1.5, edge), StrokeKind::Inside); + p.rect_filled(slab, radius(screen_r + b), body); + p.rect_stroke(slab, radius(screen_r + b), Stroke::new(1.0, edge), StrokeKind::Inside); + // The glass reaches round the screen's square corners (masked by `device_glass`). + let glass = b - band; + p.rect_filled(slab.shrink(band), radius((screen_r + glass).min(glass * 3.4)), GLASS); } } } -/// What sits on top of the screen: rounded screen corners (the body colour masks the frame's -/// square corners) and a phone's camera pill (at the left when turned to landscape). +/// What sits on top of the screen: rounded screen corners (glass masks the frame's square +/// corners) and a phone's camera pill (at the left when turned to landscape). fn device_glass(p: &egui::Painter, device: &str, s: Rect, landscape: bool) { let w = upright_w(s.size(), landscape); - let (b, screen_r) = bezel(device, w); + let (_, screen_r) = bezel(device, w); if screen_r <= 0.0 { return; } - // An outside stroke along the rounded screen covers the frame's square corners. - let cover = (screen_r * 0.45 + 1.0).min(b); - p.rect_stroke(s, CornerRadius::same(screen_r.min(255.0) as u8), Stroke::new(cover, Color32::from_rgb(0x1C, 0x1C, 0x1E)), StrokeKind::Outside); + p.add(corner_masks(s, screen_r.min(s.width() / 2.0).min(s.height() / 2.0), GLASS)); if device.starts_with("iPhone") { let pill = if landscape { Rect::from_center_size(pos2(s.min.x + w * 0.045, s.center().y), vec2(w * 0.085, w * 0.3)) @@ -1572,6 +1651,35 @@ fn device_glass(p: &egui::Painter, device: &str, s: Rect, landscape: bool) { } } +/// Fill what lies between each corner of `s` and its rounding of radius `r` (fans of triangles +/// from the corner to the arc). +fn corner_masks(s: Rect, r: f32, color: Color32) -> egui::Shape { + const SEGMENTS: usize = 12; + if !(r.is_finite() && r > 0.0) { + return egui::Shape::Noop; + } + let mut mesh = egui::Mesh::default(); + // (corner, centre of its arc, angle the arc starts at). + let corners = [ + (s.left_top(), s.left_top() + vec2(r, r), std::f32::consts::PI), + (s.right_top(), s.right_top() + vec2(-r, r), std::f32::consts::FRAC_PI_2 * 3.0), + (s.right_bottom(), s.right_bottom() + vec2(-r, -r), 0.0), + (s.left_bottom(), s.left_bottom() + vec2(r, -r), std::f32::consts::FRAC_PI_2), + ]; + for (corner, centre, start) in corners { + let base = mesh.vertices.len() as u32; + mesh.colored_vertex(corner, color); + for k in 0..=SEGMENTS { + let a = start + std::f32::consts::FRAC_PI_2 * k as f32 / SEGMENTS as f32; + mesh.colored_vertex(centre + r * vec2(a.cos(), a.sin()), color); + } + for k in 0..SEGMENTS as u32 { + mesh.add_triangle(base, base + 1 + k, base + 2 + k); + } + } + egui::Shape::mesh(mesh) +} + /// One frame of the player in `rect`: the top bar, then the prototype (on its device) below. /// Returns what the bar asked for (restarting is done here). fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) -> Option { @@ -1617,14 +1725,15 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) } let Some(l) = layout(p, area, chrome) else { return action }; let painter = ui.painter_at(area); + let body = l.device.zip(l.color); if l.device.is_some() { - if let Some(d) = l.device.filter(|_| l.body) { - device_body(&painter, d.name, l.screen, l.landscape); + if let Some((d, c)) = body { + device_body(&painter, d.name, l.screen, l.landscape, c); } painter.rect_filled(l.screen, CornerRadius::ZERO, bg); } draw(p, &painter, &ctx, &l, now); - if let Some(d) = l.device.filter(|_| l.body) { + if let Some((d, _)) = body { device_glass(&painter, d.name, l.screen, l.landscape); } // Timers: wake up for the soonest pending "after delay". @@ -1666,7 +1775,8 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) Some(NodeKind::Page { flows, .. }) => flows.clone(), _ => vec![], }; - let device = page_settings(doc, p.current).device().is_some(); + let settings = page_settings(doc, p.current); + let device = settings.device(); let mut action = None; // Right: the buttons, right to left. let buttons: &[(BarAction, &str, Icon)] = if chrome.pane { @@ -1692,7 +1802,8 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) ui.painter().text(br.center(), Align2::CENTER_CENTER, *label, regular(11.5), text); } if *act == BarAction::Options { - egui::Popup::menu(&resp).width(220.0).show(|ui| options_menu(p, ui, device, now)); + let menu = egui::Popup::menu(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now)); + action = action.or(menu.and_then(|m| m.inner)); } else if resp.clicked() { action = Some(*act); } @@ -1700,22 +1811,32 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) // Left: the flow picker and the frame's name, clipped before the buttons. let left = ui.painter().with_clip_rect(Rect::from_min_max(bar.min, pos2(x, bar.max.y))); let mut lx = bar.min.x + if chrome.pane { 8.0 } else { 12.0 }; - let flow_name = flows.iter().find(|f| f.start == p.start).map(|f| f.name.clone()); + // The flow playing: its name on the picker, its description on hover; the menu lists each + // flow with its description. + let playing = flows.iter().find(|f| f.start == p.start); if !flows.is_empty() { - let label = format!("{} ▾", flow_name.unwrap_or_else(|| "Flows".into())); + let label = format!("{} ▾", playing.map_or("Flows", |f| f.name.as_str())); + let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or("Flows").to_string(); let galley = left.layout_no_wrap(label, semibold(11.5), text); let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); - let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).on_hover_text("Flows"); + let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).on_hover_text(tip); if resp.hovered() { left.rect_filled(fr, CornerRadius::same(5), hover); } left.galley(pos2(fr.min.x + 8.0, fr.center().y - galley.size().y / 2.0), galley, text); - egui::Popup::menu(&resp).width(200.0).show(|ui| { + egui::Popup::menu(&resp).width(240.0).show(|ui| { + let t = Tokens::get(ui.ctx()); for f in &flows { - if crate::right::menu_item(ui, &f.name, "") { + if crate::right::menu_check(ui, f.start == p.start, &f.name, "") { p.restart(f.start, now); ui.close(); } + let d = f.description.trim(); + if !d.is_empty() { + egui::Frame::NONE.inner_margin(egui::Margin { left: 26, right: 8, top: 0, bottom: 4 }).show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(d).font(regular(10.5)).color(t.text_secondary)).wrap()); + }); + } } }); lx = fr.max.x + 8.0; @@ -1726,22 +1847,43 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) action } -/// The full-window presenter's options menu: how the prototype is sized (Z cycles them), the -/// device's body, restart (R). -fn options_menu(p: &mut Presenter, ui: &mut Ui, device: bool, now: f64) { +/// The full-window presenter's options menu: how the prototype is sized (Z cycles them), whether +/// the frame is responsive (a file setting: the frame takes the window's size, or on a device the +/// screen's, instead of being scaled or shown at a fixed size), the device's body, restart (R). +fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option, responsive: bool, now: f64) -> Option { use crate::right::menu_check; - for s in PresentScale::options(device) { - if menu_check(ui, p.view.scale == *s, s.label(device), "") { + let mut action = None; + let on_device = device.is_some(); + // Without a device, responsive is one of the sizes: the frame fills the window at 100%. + if !on_device && menu_check(ui, responsive, "Responsive", "") { + action = Some(BarAction::Responsive(true)); + ui.close(); + } + for s in PresentScale::options(on_device) { + if menu_check(ui, p.view.scale == *s && (on_device || !responsive), s.label(on_device), "") { p.set_scale(*s); + if !on_device && responsive { + action = Some(BarAction::Responsive(false)); + } ui.close(); } } - if menu_check(ui, false, "Next size option", "Z") { - p.set_scale(p.view.scale.next(device)); + if (on_device || !responsive) && menu_check(ui, false, "Next size option", "Z") { + p.set_scale(p.view.scale.next(on_device)); ui.close(); } ui.separator(); - if device && menu_check(ui, p.view.device_frame, "Show device frame", "") { + // On a device: the frame takes the screen's size, or keeps its own (clipped or scrolling). + if on_device { + for (on, label) in [(true, "Responsive"), (false, "Fixed size")] { + if menu_check(ui, responsive == on, label, "") { + action = (responsive != on).then_some(BarAction::Responsive(on)); + ui.close(); + } + } + ui.separator(); + } + if device.is_some_and(|d| !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, "Show device frame", "") { p.view.device_frame = !p.view.device_frame; ui.close(); } @@ -1749,6 +1891,7 @@ fn options_menu(p: &mut Presenter, ui: &mut Ui, device: bool, now: f64) { p.restart(p.start, now); ui.close(); } + action } /// Scrolling frames under the screen point `q`, innermost first: those of `root` (in the display @@ -2009,7 +2152,8 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke consume_key(ctx, *key); return; } - egui::Key::Z if !pane => { + // (A responsive frame filling the window has no size options.) + egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => { p.set_scale(p.view.scale.next(l.device.is_some())); consume_key(ctx, *key); return; diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 39ae314..983ea93 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -340,6 +340,19 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } for f in &flows { flow_row(app, ui, f); + // Its description under it (in full on hover). + let d = f.description.trim(); + if !d.is_empty() { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::hover()); + // Lined up with the flow's name (after the play button). + let indent = PAD + 26.0; + let shown = widgets::elide(ui, d, w - indent - PAD); + ui.painter().text(pos2(r.min.x + indent, r.min.y + 1.0), Align2::LEFT_TOP, &shown, regular(10.5), t.text_secondary); + if shown != d { + resp.on_hover_text(d); + } + } } } @@ -352,26 +365,53 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { _ => proto::PrototypeSettings::default(), }; let bg = settings.background; + let model = settings.model(); // Phones and tablets turn: a toggle beside the device picker. - let rotates = settings.model().is_some_and(|d| d.rotates); + let rotates = model.is_some_and(|d| d.rotates); let landscape = settings.landscape(); widgets::sublabels(ui, "Device", None); row(ui, 32.0, |ui| { let w = ui.available_width() - if rotates { 32.0 } else { 0.0 }; let resp = widgets::dropdown(ui, &settings.device, w, None); egui::Popup::menu(&resp).show(|ui| { - for d in std::iter::once("None").chain(proto::PrototypeSettings::DEVICES.iter().map(|d| d.name)) { + let mut pick = None; + if crate::right::menu_check(ui, settings.device == "None", "None", "") { + pick = Some("None"); + } + ui.separator(); + for d in proto::PrototypeSettings::DEVICES.iter().map(|d| d.name) { if crate::right::menu_check(ui, d == settings.device, d, "") { - app.run("proto.settings", json!({"device": d})); - ui.close(); + pick = Some(d); } } + ui.separator(); + let custom = proto::PrototypeSettings::CUSTOM_DEVICE; + if crate::right::menu_check(ui, settings.device == custom, custom, "") { + pick = Some(custom); + } + if let Some(d) = pick { + app.run("proto.settings", json!({"device": d})); + ui.close(); + } }); let tip = if landscape { "Rotate to portrait" } else { "Rotate to landscape" }; if rotates && icons::button(ui, Icon::Rotate, 24.0, landscape, tip).clicked() { app.run("proto.settings", json!({"rotation": if landscape { "portrait" } else { "landscape" }})); } }); + if let Some(m) = model.filter(|m| !m.colors.is_empty()) { + device_colors(app, ui, m.colors, settings.device_color()); + } + if settings.device == proto::PrototypeSettings::CUSTOM_DEVICE { + custom_size(app, ui, model); + } + row(ui, 32.0, |ui| { + let clicked = widgets::checkbox(ui, settings.responsive, "Responsive"); + ui.interact(ui.min_rect(), Id::new("proto_responsive_tip"), Sense::hover()).on_hover_text(RESPONSIVE_TIP); + if clicked { + app.run("proto.settings", json!({"responsive": !settings.responsive})); + } + }); widgets::sublabels(ui, "Background", None); row(ui, 32.0, |ui| { widgets::swatch(ui, &Paint::solid(bg), 16.0); @@ -383,6 +423,62 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { }); } +/// What "Responsive" does, on hover. +const RESPONSIVE_TIP: &str = "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond"; + +/// A device colour as a swatch paint. +fn color_paint(c: proto::DeviceColor) -> Paint { + let [r, g, b] = c.rgb; + Paint::solid(figcraft_color::Color::from_rgb8_const(r, g, b)) +} + +/// The colours the device comes in: its swatch and a picker (each colour with its swatch). +fn device_colors(app: &mut FigApp, ui: &mut Ui, colors: &[proto::DeviceColor], current: Option) { + widgets::sublabels(ui, "Color", None); + row(ui, 32.0, |ui| { + if let Some(c) = current { + widgets::swatch(ui, &color_paint(c), 16.0); + } + let w = ui.available_width(); + let resp = widgets::dropdown(ui, current.map_or("", |c| c.name), w, None); + egui::Popup::menu(&resp).show(|ui| { + for c in colors { + let on = current.is_some_and(|x| x.name == c.name); + let label = format!("{}{}", if on { "✓ " } else { " " }, c.name); + let paint = color_paint(*c); + let picked = crate::right::menu_item_with(ui, &label, "", |p, r, _| { + widgets::paint_swatch(p, Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(12.0, 12.0)), &paint); + }); + if picked { + app.run("proto.settings", json!({"color": c.name})); + ui.close(); + } + } + }); + }); +} + +/// The custom size's screen: width and height fields and a button that swaps them. +fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { + let Some(d) = model else { return }; + let col = widgets::col_width(ui.available_width()); + widgets::sublabels(ui, "Size", None); + row(ui, 32.0, |ui| { + for (key, lead, v) in [("width", "W", d.width), ("height", "H", d.height)] { + if let Some(FieldEdit::Set(x) | FieldEdit::Scrub(x, true)) = + widgets::number_field(ui, Id::new(("proto_custom", key)), Lead::Text(lead), Some(v), "", col, 1.0) + && let Some(x) = proto::PrototypeSettings::custom_side(x) + && x != v + { + app.run("proto.settings", json!({ key: x })); + } + } + if icons::button(ui, Icon::Rotate, 24.0, false, "Swap width and height").clicked() && d.width != d.height { + app.run("proto.settings", json!({"width": d.height, "height": d.width})); + } + }); +} + fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); diff --git a/crates/ui-egui/src/proto_responsive_tests.rs b/crates/ui-egui/src/proto_responsive_tests.rs new file mode 100644 index 0000000..fc9ccda --- /dev/null +++ b/crates/ui-egui/src/proto_responsive_tests.rs @@ -0,0 +1,172 @@ +//! Responsive presentation in the player (the frame takes the device's screen or the window and +//! lays out again, live), device colours and custom sizes in the presenter and the Prototype +//! panel, and library collections in the Design panel's variable modes. + +use egui::{Key, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::present_tests::{Ui, id, setup}; + +fn presenter(u: &Ui) -> Value { + u.app.inspect()["presenter"].clone() +} + +fn size(v: &Value) -> (f64, f64) { + (v[0].as_f64().unwrap(), v[1].as_f64().unwrap()) +} + +/// A 600×400 frame "Wide" with a button pinned to its right edge, and a 300×200 frame "Small". +fn wide(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap(); + let w = id(&ex(s, "node.create", json!({"type": "frame", "name": "Wide", "x": 0, "y": 0, "width": 600, "height": 400}))); + let button = id(&ex(s, "node.create", json!({"type": "rectangle", "parent": w, "x": 500, "y": 10, "width": 90, "height": 30}))); + ex(s, "node.set", json!({"id": button, "props": {"constraints": {"horizontal": "right", "vertical": "top"}}})); + let small = id(&ex(s, "node.create", json!({"type": "frame", "name": "Small", "x": 700, "y": 0, "width": 300, "height": 200}))); + (w, button, small) +} + +#[test] +fn responsive_frames_take_the_devices_screen_live() { + let mut s = Session::new(); + s.execute("proto.settings", &json!({"device": "iPhone 16", "color": "Pink"})).unwrap(); + let (w, button, small) = wide(&mut s); + let mut u = Ui::new(s); + u.run("proto.present", json!({"start": w})); + let p = presenter(&u); + assert_eq!((p["responsive"].clone(), p["deviceColor"].clone()), (Value::Null, json!("Pink"))); + assert_eq!(size(&p["frameSize"]), (600.0, 400.0), "fixed size: the frame as designed"); + // Turned on while presenting: the frame takes the screen, and its layers respond. + u.run("proto.settings", json!({"responsive": true})); + let p = presenter(&u); + assert_eq!(size(&p["responsive"]), (393.0, 852.0)); + assert_eq!(size(&p["frameSize"]), (393.0, 852.0)); + assert_eq!(size(&p["viewport"]), (393.0, 852.0)); + assert_eq!(u.app.session.doc().unwrap().node(w).unwrap().width, 600.0, "the file keeps its frame"); + // Turned to landscape: the frame follows the screen. + u.run("proto.settings", json!({"rotation": "landscape"})); + assert_eq!(size(&presenter(&u)["frameSize"]), (852.0, 393.0)); + // Edits show live and are laid out again; the button stays pinned to the right. + u.run("node.set", json!({"id": button, "props": {"width": 40}})); + let laid = presenter(&u); + assert_eq!(size(&laid["frameSize"]), (852.0, 393.0)); + // The next frame responds too. + u.key(pos2(720.0, 470.0), Key::ArrowRight); + let p = presenter(&u); + assert_eq!(p["current"], json!(small)); + assert_eq!(size(&p["frameSize"]), (852.0, 393.0)); + // Off again: back to the frames as designed. + u.run("proto.settings", json!({"responsive": false})); + assert_eq!(size(&presenter(&u)["frameSize"]), (300.0, 200.0)); +} + +#[test] +fn without_a_device_a_responsive_frame_fills_the_window_at_100_percent() { + let mut s = Session::new(); + let (w, ..) = wide(&mut s); + s.execute("proto.settings", &json!({"responsive": true})).unwrap(); + let mut u = Ui::new(s); + u.run("proto.present", json!({"start": w})); + let p = presenter(&u); + // The window is 1440×900; the bar takes 40. + assert_eq!(size(&p["frameSize"]), (1440.0, 860.0), "{p}"); + assert_eq!(p["zoom"], json!(1.0)); + assert_eq!(size(&p["panRange"]), (0.0, 0.0)); + // Z has nothing to cycle: the size options don't apply. + u.key(pos2(720.0, 470.0), Key::Z); + assert_eq!(presenter(&u)["scale"], "fit"); + // The preview pane (after the presenter closes): the frame takes the pane's area. + u.key(pos2(720.0, 470.0), Key::Escape); + u.run("proto.preview", json!({"start": w})); + let pane = u.pane(); + let (pw, ph) = size(&u.preview()["frameSize"]); + assert!((pw - f64::from(pane.width())).abs() <= 4.0 && ph < f64::from(pane.height()) && ph > 100.0, "{pw}×{ph} in {pane:?}"); +} + +#[test] +fn custom_sizes_present_as_a_bare_screen() { + let (mut u, a, ..) = setup(); + u.run("proto.settings", json!({"width": 500, "height": 700})); + u.run("proto.present", json!({"start": a})); + let p = presenter(&u); + assert_eq!(p["device"], "Custom size"); + assert_eq!(p["deviceColor"], Value::Null, "no body"); + assert_eq!(size(&p["viewport"]), (393.0, 700.0), "the 393-wide frame clipped to the screen's height"); + assert_eq!(p["scrollRange"][1], json!(1300.0)); + // Responsive: the frame becomes the screen; its content scrolls through what's below. + u.run("proto.settings", json!({"responsive": true})); + assert_eq!(size(&presenter(&u)["frameSize"]), (500.0, 700.0)); +} + +#[test] +fn prototype_panel_shows_colours_custom_sizes_and_responsive() { + let (mut u, ..) = setup(); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": []})); + for settings in [json!({"color": "Teal"}), json!({"device": "MacBook Air"}), json!({"width": 640, "height": 480}), json!({"responsive": true})] { + u.run("proto.settings", settings); + u.frames(2, Vec::new); + } + // Each drew; the last settings stand. + let doc = u.app.session.doc().unwrap(); + let p = crate::present::page_settings(doc, doc.top_frames(u.app.session.page().unwrap())[0]); + assert_eq!((p.device.as_str(), p.custom_size, p.responsive), ("Custom size", [640.0, 480.0], true)); +} + +/// A library file with a "Brand" collection (Light/Dark) saved under the temp dir. +fn brand_library(tag: &str) -> (String, String) { + let dir = std::env::temp_dir().join(format!("figcraft-libmodes-{}-{tag}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("brand.figcraft").to_string_lossy().into_owned(); + let mut lib = Session::new(); + let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap(); + ex(&mut lib, "file.rename", json!({"name": "Brand kit"})); + ex(&mut lib, "variable.createCollection", json!({"name": "Brand", "modes": ["Light", "Dark"]})); + ex( + &mut lib, + "variable.create", + json!({"collection": "Brand", "name": "ink", "type": "color", "values": {"Light": "#000000", "Dark": "#ffffff"}}), + ); + let lid = ex(&mut lib, "library.publish", json!({}))["id"].as_str().unwrap().to_string(); + ex(&mut lib, "file.saveAs", json!({"path": path})); + (path, lid) +} + +#[test] +fn library_collections_show_in_the_mode_picker_under_their_library() { + let (path, lid) = brand_library("picker"); + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("library.add", &json!({"path": path})).unwrap(); + let mut u = Ui::new(s); + let rows = |u: &Ui| -> Vec<(String, Option, bool)> { + let doc = u.app.session.doc().unwrap(); + let libs = crate::variable_ui::libraries(&u.app, doc); + crate::variable_ui::mode_rows(doc, &libs) + .iter() + .map(|r| (r.coll.name.clone(), r.library.map(|l| l.1.to_string()), r.local.is_some())) + .collect() + }; + let lib_name = u.app.session.doc().unwrap().libraries.enabled[0].name.clone(); + assert_eq!(rows(&u), [("Theme".into(), None, true), ("Brand".into(), Some(lib_name.clone()), false)]); + // The Design panel draws the section for the selected frame. + u.run("select.set", json!({"ids": [f]})); + u.frames(2, Vec::new); + // Picking a library mode copies the collection in; the row then holds the copy's modes. + u.run("library.setVariableMode", json!({"library": lid, "collection": "Brand", "mode": "Dark", "ids": [f]})); + assert_eq!(rows(&u), [("Theme".into(), None, true), ("Brand".into(), Some(lib_name.clone()), true)]); + let doc = u.app.session.doc().unwrap(); + let local = doc.libraries.collections[0].local.clone(); + assert_eq!(doc.node(f).unwrap().var_modes.len(), 1); + assert!(doc.node(f).unwrap().var_modes.contains_key(&local)); + u.frames(2, Vec::new); + // The library unlinked: the copy stays listed under the library's name, set like this file's. + u.run("library.remove", json!({"id": lid})); + let r = rows(&u); + assert_eq!(r.len(), 2, "{r:?}"); + assert!(r[1].2 && r[1].0 == "Brand", "{r:?}"); + u.frames(2, Vec::new); + let _ = std::fs::remove_file(&path); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 83b6d27..1e2d862 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -679,10 +679,15 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { /// The variable modes of the selected frames (sections, components, instances): a row per /// collection with more than one mode, Auto (the mode the layer inherits from its parent) or a mode -/// of its own (`variable.setMode`). Returns whether it showed. +/// of its own (`variable.setMode`). Collections from enabled libraries follow under their +/// library's name (`library.setVariableMode` copies the collection in). Returns whether it showed. fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool { - let colls: Vec<&Collection> = doc.variables.collections.iter().filter(|c| c.modes.len() > 1).collect(); - if colls.is_empty() || !sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) { + if !sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) { + return false; + } + let libs = crate::variable_ui::libraries(app, doc); + let rows = crate::variable_ui::mode_rows(doc, &libs); + if rows.is_empty() { return false; } let t = Tokens::get(ui.ctx()); @@ -692,23 +697,39 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round()); // Auto resolves from the parent of the first selected layer. let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default(); - for c in colls { + let mut group = None; + for r in &rows { + let c = r.coll; + // Library collections under their library's name. + if r.library != group { + group = r.library; + if let Some((_, lib)) = r.library { + widgets::sublabels(ui, lib, None); + } + } let name_of = |m: &str| c.modes.iter().find(|x| x.id == m).map_or("", |x| x.name.as_str()); // Each layer's own mode (None: Auto); None overall when they differ. - let own = common(doc, sel, |n| n.var_modes.get(&c.id).cloned()); - let inherited = parent_modes.get(&c.id).or(c.default_mode()).map_or("", |m| name_of(m)); + let own = match r.local { + Some(l) => common(doc, sel, |n| n.var_modes.get(l).cloned()), + None => Some(None), + }; + let inherited = r.local.and_then(|l| parent_modes.get(l)).or(c.default_mode()).map_or("", |m| name_of(m)); let auto = format!("Auto ({inherited})"); let label = match &own { Some(Some(m)) => name_of(m).to_string(), Some(None) => auto.clone(), None => "Mixed".into(), }; + let hover = match r.library { + Some((_, lib)) => format!("{} (from {lib})", c.name), + None => c.name.clone(), + }; row(ui, 32.0, |ui| { - let (r, _) = ui.allocate_exact_size(vec2(name_w, FIELD_H), Sense::hover()); + let (rect, _) = ui.allocate_exact_size(vec2(name_w, FIELD_H), Sense::hover()); let name = widgets::elide(ui, &c.name, name_w); - ui.painter().text(pos2(r.min.x, r.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); - if name != c.name { - ui.interact(r, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&c.name); + ui.painter().text(pos2(rect.min.x, rect.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); + if name != c.name || r.library.is_some() { + ui.interact(rect, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); } let resp = widgets::elided_dropdown(ui, &label, pick_w); egui::Popup::menu(&resp).width(180.0).show(|ui| { @@ -723,7 +744,7 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) } } if let Some(mode) = pick { - app.run("variable.setMode", json!({"ids": sel, "collection": c.id, "mode": mode})); + set_variable_mode(app, sel, r, mode); ui.close(); } }); @@ -733,6 +754,20 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) true } +/// Set `sel`'s mode of the collection of `row` (`mode` null: Auto). A library collection goes +/// through `library.setVariableMode` (copying it in); Auto on one never used here is already so. +fn set_variable_mode(app: &mut FigApp, sel: &[NodeId], row: &crate::variable_ui::ModeRow, mode: Value) { + match (row.library, row.key, row.local) { + (Some((lib, _)), Some(key), _) if !mode.is_null() => { + app.run("library.setVariableMode", json!({"library": lib, "collection": key, "ids": sel, "mode": mode})); + } + (_, _, Some(local)) => { + app.run("variable.setMode", json!({"ids": sel, "collection": local, "mode": mode})); + } + _ => {} + } +} + /// While a range of text is selected, the Fill section edits that range's colour. fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: usize, end: usize) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index d4d0a1c..7eac2a6 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -95,6 +95,51 @@ pub fn groups<'a>( out } +/// A collection the Design panel's mode picker lists: this file's, or one an enabled library +/// offers (or offered: a copy whose library isn't read any more). +#[derive(Clone, Debug, PartialEq)] +pub struct ModeRow<'a> { + /// The collection with its modes (a library's copy once it is used here). + pub coll: &'a Collection, + /// The library's (id, name); None for this file's collections. + pub library: Option<(&'a str, &'a str)>, + /// The collection's id in the library, when the library is read (modes are set through + /// `library.setVariableMode`, which copies the collection in). + pub key: Option<&'a str>, + /// The id frames' modes are kept under: this file's collection or a library collection's copy + /// (None until a library collection is used here). + pub local: Option<&'a str>, +} + +/// The collections with more than one mode the mode picker lists: this file's first, then each +/// enabled library's published ones (their copies once used), then copies of collections from +/// libraries that aren't read. +pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc]) -> Vec> { + let several = |c: &Collection| c.modes.len() > 1; + let mut out: Vec> = + library::local_collections(doc).filter(|c| several(c)).map(|c| ModeRow { coll: c, library: None, key: None, local: Some(&c.id) }).collect(); + let copy = |lib: &str, key: &str| doc.libraries.collections.iter().find(|r| r.library == lib && r.key == key); + for lib in libs { + let (id, name) = (lib.manifest.id.as_str(), library::library_name(doc, &lib.manifest.id)); + for c in library::published_collections(lib) { + let local = copy(id, &c.id).map(|r| r.local.as_str()); + let coll = local.and_then(|l| doc.variables.collection(l)).unwrap_or(c); + if several(coll) { + out.push(ModeRow { coll, library: Some((id, name)), key: Some(&c.id), local }); + } + } + } + for r in &doc.libraries.collections { + if out.iter().any(|x| x.local == Some(r.local.as_str())) { + continue; + } + if let Some(c) = doc.variables.collection(&r.local).filter(|c| several(c)) { + out.push(ModeRow { coll: c, library: Some((&r.library, library::library_name(doc, &r.library))), key: None, local: Some(&r.local) }); + } + } + out +} + /// A picker row: the variable's swatch (colours) or icon, its name and (other types) its value in /// the default modes; true when clicked. pub fn variable_row(ui: &mut Ui, vars: &Variables, v: &Variable, width: f32) -> bool { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d6c6fd3..4c2ed92 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -66,8 +66,10 @@ UI state: panels and tabs, current tool, canvas rect, dialogs, view, timings (`f the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its `name`, `start`, `history`, open `overlays`, the screen's `scroll` offset and `scrollRange`, the -`device` (and whether it is turned to `landscape`) and the `viewport` of the frame its screen shows, -the size option (`scale`: `fit|fitWidth|fill|actual`, `deviceFrame`), the `zoom` it shows at, how +`device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame +its screen shows, the presented frame's `frameSize` and, for a responsive prototype, the screen it +was resized to (`responsive`: `[w, h]`, else `null`), the size option (`scale`: +`fit|fitWidth|fill|actual`, `deviceFrame`), the `zoom` it shows at, how far the view is panned (`pan`, `panRange`) and whether a scroll is still coasting (`flinging`) (`null` when nothing plays). `presentOptions` holds the options the next presentation starts with. diff --git a/docs/mcp.md b/docs/mcp.md index 7911215..5a9abdc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -177,11 +177,17 @@ a variable) or `{op: {op, args}}` with `add` (text joins when a string is involv `multiply`, `divide`, `equals`, `notEqual`, `less`, `lessOrEqual`, `greater`, `greaterOrEqual`, `and`, `or`, `not` and `negate`. Flows `proto.flow.add` / `rename` / `remove`, and the device, its rotation and the background `proto.settings {device?, background?, -rotation?}` (`None`, `iPhone 16`, `iPhone 16 Pro Max`, `Android Compact`, `iPad mini`, `MacBook -Air`, `Desktop`; `rotation` `portrait` or `landscape`, which turns phones and tablets). On a device the -presented frame is sized to the device's screen: what doesn't fit is clipped, or scrolls when the -frame's `overflow` allows it, while `fixed` layers stay on screen (pinned to the edges their -constraints name). `proto.present {start?}` plays the prototype full-window and `proto.preview +rotation?, color?, width?, height?, responsive?}` (`None`, `iPhone 16`, `iPhone 16 Pro Max`, +`Android Compact`, `iPad mini`, `MacBook Air`, `Desktop` or `Custom size`; `rotation` `portrait` or +`landscape`, which turns phones and tablets; `color` one of the device's colours by name, e.g. +`Black`, `White`, `Pink`, `Teal`, `Blue` for the iPhone 16 (an error lists them), `""` for the +default; `width` / `height` set the custom size's screen, 1–10000 points, and pick `Custom size`, +which is a bare screen without a body). On a device the presented frame is sized to the device's +screen: what doesn't fit is clipped, or scrolls when the frame's `overflow` allows it, while `fixed` +layers stay on screen (pinned to the edges their constraints name). With `responsive: true` (saved +in the file) the presented frame is instead resized to the device's screen, or without a device to +the presenter's window at 100%, and its constraints and auto layout respond as when it is resized +in the editor (an axis it hugs keeps hugging its content); the file's frames don't change. `proto.present {start?}` plays the prototype full-window and `proto.preview {start?}` in a resizable pane over the canvas that follows edits live; `proto.closePreview` closes it. `proto.presentOptions {scale?, deviceFrame?}` sets how the full-window presenter sizes the prototype (`fit` scales it down to fit, `fitWidth` fills the width, `fill` covers the window, @@ -189,6 +195,9 @@ prototype (`fit` scales it down to fit, `fitWidth` fills the width, `fill` cover kept for later presentations (in the presenter Z cycles them and R restarts). The player's state is in `inspect_ui` (`presenter`, `preview`: screen, scroll, `scale`, `zoom`, `pan`) when attached, and `ui.set {presentScroll: [x, y]}` scrolls its screen (see [control-protocol.md](control-protocol.md)). +The presenter's Options menu offers Responsive next to the size options (without a device) or +Responsive / Fixed size (on one), the same `proto.settings {responsive}`; its flow picker lists each +flow with its description. ### Local libraries @@ -221,7 +230,9 @@ read library files return an error). variable in (linked, read-only, with the variables it aliases and its collection) and binds it like `variable.bind`; `library.aliasVariable {library, alias, variable, mode?}` makes a local variable's value an alias of a library variable; `library.setVariableMode {library, collection, - mode, ids?}` sets a library collection's mode on frames (the page when no ids are given); + mode, ids?}` sets a library collection's mode on frames (the page when no ids are given; the +Design panel's Variable modes section lists enabled libraries' collections under the library's +name and sets them this way); `library.importVariable {library, variable}` only copies it (then `variable.bind`, `variable.set {value: {alias}}`). The copies render without the library file. Editing the copies (library components, styles, variables or collections) is refused; edit the library file and publish.