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:
parent
044cfef834
commit
9ddf1b3a4e
17
ROADMAP.md
17
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
23
docs/mcp.md
23
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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue