M5: responsive presentation, device colours and custom sizes, library modes (#66)

- Responsive presentation: `proto.settings {responsive}` (saved in the
  page's prototype settings). The player resizes the presented frame to the
  device's screen, or without a device to its whole area at 100%, and lays
  it out again exactly as an editor resize does (constraints, auto layout,
  text; an axis the frame hugs keeps hugging), live as the window, device or
  file change. The player keeps its design document and lays the screen out
  from it each time (`cmd::proto::respond`). In the presenter's Options menu
  (Responsive beside the size options, or Responsive / Fixed size on a
  device) and the Prototype panel.
- Device colours: each device model lists its colour variants
  (`proto.settings {color}`), drawn in code as the body's band round black
  glass; screen corners are masked exactly. Colour picker with swatches in
  the Prototype panel.
- Custom size: a bare screen of any size (`proto.settings {width, height}`
  picks it), W/H fields and a swap button in the Prototype panel, imported
  from REST `CUSTOM` devices.
- Library collections in the Design panel's Variable modes section, under
  their library's name (`library.setVariableMode`; copies whose library
  isn't read stay listed).
- Flow descriptions in the presenter's flow picker (and its hover) and under
  each flow in the Prototype panel.
- Tests in new modules: engine proto_responsive_tests, ui-egui
  proto_responsive_tests; REST device test updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 11:14:46 +07:00 committed by GitHub
parent 044cfef834
commit 9ddf1b3a4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1038 additions and 126 deletions

View File

@ -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.

View File

@ -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<f64> {
(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<Device> {
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<DeviceColor> {
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)

View File

@ -109,29 +109,9 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
]
}
/// `proto.settings`: everything is checked before anything changes.
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
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<Option<f64>> {
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"];

View File

@ -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;

View File

@ -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);
}

View File

@ -52,6 +52,12 @@ pub(super) fn page_settings(c: &Value) -> Option<PrototypeSettings> {
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<PrototypeSettings> {
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 {

View File

@ -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));

View File

@ -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;

View File

@ -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<Document>,
/// 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<Document>,
/// The player's document as presented: `design` with the presented frame resized to `fit`.
doc: Option<Document>,
/// 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<ScrollAnim>,
@ -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<Document> {
@ -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<Snapshot> {
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<Device>,
/// 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<DeviceColor>,
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<Layout> {
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<Layout> {
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<Layout> {
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<BarAction> {
@ -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<Device>, responsive: bool, now: f64) -> Option<BarAction> {
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;

View File

@ -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<proto::DeviceColor>) {
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<proto::Device>) {
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();

View File

@ -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<String>, 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);
}

View File

@ -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());

View File

@ -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<PublishedLibrary>]) -> Vec<ModeRow<'a>> {
let several = |c: &Collection| c.modes.len() > 1;
let mut out: Vec<ModeRow<'a>> =
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 {

View File

@ -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.

View File

@ -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.