Merge upstream/main into feat/text-direction-1830

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-05 16:41:34 -05:00
co-authored by Claude Fable 5.1
35 changed files with 1259 additions and 72 deletions
Generated
+10
View File
@@ -1157,6 +1157,15 @@ dependencies = [
"zip 9.1.1",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2121,6 +2130,7 @@ name = "figcraft-web"
version = "0.15.0"
dependencies = [
"base64 0.22.1",
"craft-web-host",
"eframe",
"egui",
"figcraft-collab-net",
+5 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -19,6 +19,8 @@ figcraft-format = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
base64 = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -30,4 +32,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage", "Event", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+143
View File
@@ -0,0 +1,143 @@
//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages
//! and the two transports are `craft-web-host`'s (storytold/craft-libs): the same contract as
//! the other Craft apps' bridges. This module is FigCraft's part of it.
//!
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document) uses DOM
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present.
//! - `figcraft:ready` (to the host) once the app is running and listening; send a file now.
//! - `figcraft:open` (from the host): `{ name, bytes }`. It opens exactly as a file picked with
//! File › Open or dropped on the window would (the app's own open queue, with its "Opening as
//! a new file" card). A payload over the browser's own open limit
//! ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before its bytes are copied,
//! and anything malformed is ignored. FigCraft reads whole files, so it says `file: false` in
//! `ready` and a host sends `bytes`.
//! - `figcraft:save` (to the host): `{ name, saveAs, bytes }` for every file the app hands its
//! download service; in host mode nothing reaches the browser's downloads. `saveAs` says whether
//! the file becomes the document's file ([`crate::hostmode::save_as`]):
//! - A Save of the file the host opened, under its own name: `false`.
//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
//! under a name the host didn't open — Save As, or a Save of a document the host never sent
//! (an untitled one): `true`. It becomes the document's file; the next Save sends its name
//! with `false`. Names compare without case.
//! - Everything else is a copy or export that never becomes the document: `false` — rendered
//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
//! sidecar's `.json` extension doesn't make it a document).
//! - `figcraft:dirty` whenever unsaved changes appear or go away, `figcraft:failed` when the app
//! couldn't start.
//!
//! In host mode the app starts with no document (the home screen) and the host sends the file to
//! edit; `?author=` names the user for comments and board notes.
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use figcraft_ui_egui::FigApp;
/// The app id in the host messages (`figcraft:ready`).
const APP: &str = "figcraft";
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):
/// `(name, bytes)`.
pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
/// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
/// Save under (`saveAs: false`) is decided against.
host_file: Rc<RefCell<Option<String>>>,
}
impl Bridge {
/// The bridge for this page, or `None` when FigCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), host_file: Rc::default() })
}
/// Hear the files the host sends, and queue each one on `opens` for the app's own open path
/// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file
/// opens like a dropped one).
pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
let host_file = self.host_file.clone();
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&opens, &ctx, &host_file, &name, &data);
}
});
}
/// `?author=`: the host's name for the user signs comments and board notes.
pub fn set_author(app: &mut FigApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.user_name = author;
}
}
pub fn post_ready(&self) {
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.host.post_failed(error, None);
}
/// The document file the host opened or the last Save As made, if any.
pub fn host_file(&self) -> Option<String> {
self.host_file.borrow().clone()
}
/// Hand a file the app would otherwise download to the host; `false` when nothing was
/// delivered. The `saveAs` rule is this module's documentation's.
pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {
let name = file_name(path);
let host_file = self.host_file.borrow().clone();
let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
let sent = self.host.post_save(&name, bytes, None, save_as).is_ok();
// After a Save As hand-over the host has the new file: the next Save of it sends its name
// with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
if sent && save_as {
*self.host_file.borrow_mut() = Some(name);
}
sent
}
/// Report unsaved changes when they appear or go away, not every frame (the crate does not
/// repeat a state it already reported).
pub fn set_dirty(&self, dirty: bool) {
self.host.post_dirty(dirty);
}
}
/// The file an open message carries, queued for the app's own open path, or nothing when the
/// payload isn't one (a `File` the app doesn't read, or over the open limit).
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, name: &str, data: &FileData) {
let name = crate::hostmode::open_name(name);
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; FigCraft reads bytes only; not opened");
return;
};
if figcraft_ui_egui::browser_can_read(&name, data.size()).is_err() {
log::warn!("host file {name} is too large to open in the browser; not opened");
return;
}
// The document's file is now the one the host sent: a Save of it under this name is
// `saveAs: false`.
*host_file.borrow_mut() = Some(name.clone());
opens.borrow_mut().push((name, array.to_vec()));
ctx.request_repaint();
}
/// The last component of `path`, or the whole of it when there is none.
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
+92
View File
@@ -0,0 +1,92 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::query`]): the name the bridge fills in when an open request has none, and whether
//! a handed-over file becomes the document's file. Which pages host the app is `craft-web-host`'s.
/// The file name used when an open request carries no usable one.
pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";
/// The extensions of files that can be a document's own file: the formats `file.save` and
/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export
/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,
/// variables).
const DOC_EXTS: &[&str] = &["figcraft", "json"];
/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a
/// document, so its extension alone can't tell the `saveAs` decision.
const VARIABLES_SIDECAR: &str = ".variables.json";
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
/// name and falls back to this).
const NAMELESS: &str = "document";
/// A usable file name for an open request: the cleaned name the crate hands over, or
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
pub fn open_name(name: &str) -> String {
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
}
/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
/// given the file the host opened (`None`: the host never sent one). The rule:
///
/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't
/// open — Save As, or a Save of a document the host never sent — is the document's new file:
/// `true`. Names compare without case.
/// - A Save of the host's own file under its name is not: `false`.
/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,
/// PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`
/// extension says: `false`.
pub fn save_as(name: &str, host_file: Option<&str>) -> bool {
if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {
return false;
}
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);
if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {
return false;
}
match host_file {
Some(host) => !host.eq_ignore_ascii_case(name),
None => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
assert_eq!(open_name("Poster.figcraft"), "Poster.figcraft");
assert_eq!(open_name("Poster 2.json"), "Poster 2.json");
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
}
#[test]
fn a_save_of_the_host_s_file_is_not_save_as() {
assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));
assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");
assert!(!save_as("design.json", Some("design.json")));
}
#[test]
fn a_new_document_file_is_save_as() {
assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));
assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");
assert!(save_as("Untitled.figcraft", None), "the host never sent a file");
assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");
}
#[test]
fn copies_and_exports_never_become_the_document_s_file() {
for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {
assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");
assert!(!save_as(name, None), "{name} is a copy even without a host file");
}
}
#[test]
fn the_variables_sidecar_is_a_copy_whatever_its_extension() {
assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));
assert!(!save_as("Poster.variables.json", None));
assert!(!save_as("design.variables.json", Some("design.variables.json")));
}
}
+13 -1
View File
@@ -19,9 +19,17 @@
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or
//! `data-host="page"` on the canvas (the page's own document) hands the page the app's files
//! instead of downloading them and reports unsaved changes (`host`);
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
//! host one. A page served over `https://` can only reach sessions on the same computer
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`
//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open
//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either
//! host mode.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
@@ -30,6 +38,10 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod kept;
#[cfg(target_arch = "wasm32")]
+74 -9
View File
@@ -32,6 +32,15 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing FigCraft, opens
// documents in it and stores what it saves (`host`); unframed there is no parent, so that URL
// on its own stays the normal app with downloads. `data-host="page"` on the canvas opts a
// page in instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on
// the canvas.
let bridge = crate::host::Bridge::connect(&canvas);
let app_bridge = bridge.clone();
// Files the host sends, on their way into the app's own open queue each frame.
let opens: crate::host::OpenQueue = std::rc::Rc::default();
let mut options = eframe::WebOptions::default();
// `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU
// adapter request returns nothing).
@@ -44,12 +53,15 @@ pub fn start() {
.start(
canvas,
options,
Box::new(|cc| {
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let mut session = Session::new();
// Host mode: the host sends the file to edit, so start with no document (the
// home screen), as the desktop app does without a file; otherwise the session
// starts with one new document.
let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
// The host names its own server: the default for sharing, codes and the team list.
if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) {
session.prefs.publish_server = server;
@@ -60,11 +72,18 @@ pub fn start() {
// whatever arrives on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));
fig.note_recovered(recovered);
if let Err(e) = crate::pasted::install(cc.egui_ctx.clone()) {
log::error!("couldn't listen for pasted images: {e:?}");
}
// `?author=`: the host's name for the user signs this visit's comments and board
// notes (nothing is stored in the browser, so nothing needs keeping).
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut fig, &author);
}
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
@@ -78,10 +97,21 @@ pub fn start() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
// The host's files arrive on the app's own open path (a dropped file's).
if let Some(bridge) = &app_bridge {
bridge.listen(opens.clone(), cc.egui_ctx.clone());
}
Ok(Box::new(WebShell { fig, host: app_bridge, opens }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -141,20 +171,45 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
struct WebShell {
fig: FigApp,
/// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).
host: Option<crate::host::Bridge>,
/// Files the host sent, waiting to join the app's own open queue.
opens: crate::host::OpenQueue,
}
impl eframe::App for WebShell {
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// Run the browser shell's eframe logic callback: hand the host's files to the app's open
/// queue, guard `background_tick` so hidden tabs still advance autosave, live and control
/// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
// Files the host sent join the app's own open queue (a dropped file's): the same
// "Opening as a new file" card, and `file.open` with the contents once read.
for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {
let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));
self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {
label: name.clone(),
params: serde_json::json!({ "name": name }),
command: "file.open",
target: None,
read: Some(read),
});
}
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
// The host hears when unsaved changes appear or go away (crate::host).
if let Some(bridge) = &self.host {
bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@@ -182,12 +237,15 @@ impl eframe::App for WebShell {
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text or image (a page can't read the clipboard when it likes).
/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard
/// through egui's output; pastes arrive as paste events with their text or image (a page can't
/// read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
/// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).
host: Option<crate::host::Bridge>,
}
impl Services for WebServices {
@@ -204,11 +262,15 @@ impl Services for WebServices {
fn downloads(&self) -> bool {
true
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
/// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file
/// goes to the host page instead ([`crate::host`]).
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
if crate::api::capture(name, bytes) {
return true;
}
if let Some(bridge) = &self.host {
return bridge.post_save(name, bytes);
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
@@ -217,6 +279,9 @@ impl Services for WebServices {
}
}
}
fn host_file(&self) -> Option<String> {
self.host.as_ref()?.host_file()
}
fn clipboard_set_text(&mut self, s: &str) {
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
}
+3
View File
@@ -101,3 +101,6 @@ sync_github_name = true
[people.aleksei-suholita]
real_name = "Oleksii Sukholita"
[people.helenkwok]
real_name = "Helen Kwok"
+41
View File
@@ -49,6 +49,47 @@ fn glass_values_are_clamped_and_reach_their_backdrop() {
assert_eq!(hidden.backdrop_radius(), 0.0);
}
#[test]
fn glass_frost_stays_within_the_reference_range() {
// A file from before the 0–100 range draws its frost at 100, and reaches no further.
let g = Glass { frost: 150.0, ..Glass::default() };
assert_eq!(g.sanitized().frost, Glass::MAX_FROST);
let e = Effect { kind: EffectKind::Glass(g), visible: true };
let capped = Effect { kind: EffectKind::Glass(Glass { frost: Glass::MAX_FROST, ..Glass::default() }), visible: true };
assert_eq!(e.backdrop_radius(), capped.backdrop_radius());
}
#[test]
fn glass_fields_take_bound_numbers_as_the_panel_shows_them() {
let mut g = Glass::default();
for (field, x) in [
("lightAngle", 30.0),
("lightIntensity", 25.0),
("refraction", 40.0),
("depth", 12.0),
("dispersion", 10.0),
("radius", 8.0),
("splay", 50.0),
] {
assert!(g.set_bound(field, x), "{field}");
}
assert_eq!((g.light_angle, g.light_intensity, g.refraction, g.depth, g.dispersion, g.frost, g.splay), (30.0, 0.25, 0.4, 12.0, 0.1, 8.0, 0.5));
// Out of range: clamped to the field's range.
for (field, x) in [("lightAngle", 270.0), ("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] {
assert!(g.set_bound(field, x), "{field}");
}
assert_eq!((g.light_angle, g.light_intensity, g.depth, g.frost, g.splay), (180.0, 1.0, 1.0, Glass::MAX_FROST, 0.0));
// A field glass doesn't have, or a value that isn't a number, changes nothing.
let before = g.clone();
assert!(!g.set_bound("spread", 3.0));
assert!(!g.set_bound("splay", f64::NAN));
assert!(!g.set_bound("depth", f64::INFINITY));
assert_eq!(g, before);
for field in Glass::BOUND_FIELDS {
assert!(Glass::default().set_bound(field, 1.0), "{field}");
}
}
#[test]
fn effect_colours() {
let mut e = Effect::noise();
+40 -4
View File
@@ -660,7 +660,7 @@ pub struct Glass {
pub depth: f64,
/// Colour splitting along the curved edge (0–1).
pub dispersion: f64,
/// Blur of the backdrop (a blur radius, document units).
/// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]).
pub frost: f64,
/// How widely the light spreads over the glass (0–1).
pub splay: f64,
@@ -673,6 +673,34 @@ impl Default for Glass {
}
impl Glass {
/// The most frost a glass takes, as the reference tool's Frost field (0–100).
pub const MAX_FROST: f64 = 100.0;
/// The fields a variable binds on a glass effect (`effects/<i>/<field>`), see [`Self::set_bound`].
pub const BOUND_FIELDS: [&'static str; 7] = ["lightAngle", "lightIntensity", "refraction", "depth", "dispersion", "radius", "splay"];
/// Sets `field` (one of [`Self::BOUND_FIELDS`]) from a bound number variable, read as the
/// Design panel's field shows it: intensity, refraction, dispersion and splay in percent, the
/// frost as `radius`, each clamped to the field's range. False for a field glass doesn't have
/// or a value that isn't finite (nothing changes).
pub fn set_bound(&mut self, field: &str, x: f64) -> bool {
if !x.is_finite() {
return false;
}
let pct = (x / 100.0).clamp(0.0, 1.0);
match field {
"lightAngle" => self.light_angle = x.clamp(-180.0, 180.0),
"lightIntensity" => self.light_intensity = pct,
"refraction" => self.refraction = pct,
"depth" => self.depth = x.clamp(1.0, 1000.0),
"dispersion" => self.dispersion = pct,
"radius" => self.frost = x.clamp(0.0, Self::MAX_FROST),
"splay" => self.splay = pct,
_ => return false,
}
true
}
/// The values clamped to their ranges (non-finite ones at their defaults).
pub fn sanitized(&self) -> Glass {
let d = Glass::default();
@@ -683,15 +711,23 @@ impl Glass {
refraction: ok(self.refraction, d.refraction, 0.0, 1.0),
depth: ok(self.depth, d.depth, 1.0, 1000.0),
dispersion: ok(self.dispersion, d.dispersion, 0.0, 1.0),
frost: ok(self.frost, d.frost, 0.0, 1000.0),
frost: ok(self.frost, d.frost, 0.0, Self::MAX_FROST),
splay: ok(self.splay, d.splay, 0.0, 1.0),
}
}
/// Furthest the curved edge moves the backdrop (document units).
/// How far `dispersion` splits the colour channels, as a share of the bend: at full dispersion
/// red moves up to this much further and blue as much less. Matched to the reference tool:
/// across a black-to-white edge in the bend, red and blue part about 4.7 px at 100% and 2.4 px
/// at 50% (at 2×, depth 20, refraction 80%).
pub const DISPERSION_SPREAD: f64 = 0.16;
/// Furthest the curved edge moves the backdrop (document units): at most `depth` × `refraction`
/// (all of it a bevel in from the edge), and red's dispersion steps up to
/// [`Self::DISPERSION_SPREAD`] × `dispersion` times that again.
pub fn max_bend(&self) -> f64 {
let g = self.sanitized();
g.refraction * g.depth * (1.0 + g.dispersion * 0.25)
g.refraction * g.depth * (1.0 + Self::DISPERSION_SPREAD * g.dispersion)
}
}
+1
View File
@@ -33,6 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
_ => None,
},
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
+35
View File
@@ -98,6 +98,41 @@ fn effect_moves_and_removals_reject_junk() {
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn glass(s: &Session, r: NodeId) -> figcraft_doc::Glass {
match s.doc().unwrap().node(r).unwrap().effects.first().map(|e| &e.kind) {
Some(EffectKind::Glass(g)) => g.clone(),
k => panic!("{k:?}"),
}
}
#[test]
fn glass_fields_take_number_variables() {
let (mut s, r) = setup();
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
// Percent fields read the variable as the Design panel shows them; `radius` is the frost.
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/splay", "variable": "large"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/radius", "variable": "small"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/depth", "variable": "large"}));
let g = glass(&s, r);
assert_eq!((g.splay, g.frost, g.depth), (0.3, 2.0, 30.0));
// A new value follows, clamped to the field's range (frost stops at 100).
ex(&mut s, "variable.set", json!({"variable": "small", "value": 150}));
ex(&mut s, "variable.set", json!({"variable": "large", "value": 75}));
let g = glass(&s, r);
assert_eq!((g.splay, g.frost, g.depth), (0.75, figcraft_doc::Glass::MAX_FROST, 75.0));
// A colour variable doesn't fit a glass field.
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/0/refraction", "variable": "brand"})).is_err());
// An effect style binds them too, and every layer using it follows.
let fx = ex_value(&mut s, "style.create", json!({"type": "effect", "name": "Pane", "from": r}))["id"].as_str().unwrap().to_string();
ex(&mut s, "style.bind", json!({"style": fx, "field": "effects/0/refraction", "variable": "large"}));
assert_eq!(glass(&s, r).refraction, 0.75);
assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread");
}
fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
+2 -1
View File
@@ -465,7 +465,8 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
}
EffectKind::Glass(g) => {
g.depth *= k;
g.frost *= k;
// From the frost it draws with (an older file's past 100 draws at 100).
g.frost = (g.sanitized().frost * k).min(figcraft_doc::Glass::MAX_FROST);
}
}
}
+5 -2
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"style.bind",
"Bind variable in style",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>`, a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
true,
bind_style
),
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.bind",
"Bind variable",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color|lightAngle|lightIntensity|refraction|depth|dispersion|splay> (glass: `radius` is its frost; intensity, refraction, dispersion and splay in percent)|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
true,
bind
),
@@ -1304,6 +1304,9 @@ pub(crate) fn set_effect_field(effects: &mut [Effect], i: usize, prop: &str, val
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0),
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0),
(EffectKind::Glass(g), field, Some(x), _) => {
g.set_bound(field, x);
}
_ => {}
}
}
+1 -1
View File
@@ -475,7 +475,7 @@ pub fn properties_with(doc: &Document, id: NodeId, fmt: ColorFormat) -> Vec<Sect
pct(g.refraction),
num(g.depth),
pct(g.dispersion),
num(g.frost),
num(g.sanitized().frost),
pct(g.splay)
),
),
+26
View File
@@ -95,6 +95,32 @@ fn scaling_scales_grain_and_glass() {
assert_eq!((g.refraction, nz.density), (d.refraction, 0.5));
}
#[test]
fn scaling_a_frost_from_an_older_file_starts_from_what_it_draws() {
// A frost of 150, saved before the 0–100 range, draws at 100: halved, it draws at 50.
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
s.execute("transform.scale", &json!({"ids": [r], "factor": 0.5})).unwrap();
let EffectKind::Glass(g) = &s.doc().unwrap().node(r).unwrap().effects[0].kind else { panic!() };
assert_eq!(g.frost, 50.0);
}
#[test]
fn inspect_shows_a_frost_from_an_older_file_as_it_draws() {
// A frost of 150, saved before the 0–100 range, draws at 100: Dev Mode says 100 as CSS does.
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
let doc = s.doc().unwrap();
let sections = crate::devinspect::properties(doc, r);
let glass = &sections.iter().find(|s| s.title == "Effects").unwrap().rows[0];
assert!(glass.value.contains(" frost 100 "), "{}", glass.value);
assert!(code_for(doc, r, CodeLang::Css).contains("backdrop-filter: blur(50px);"));
}
#[test]
fn inspect_and_css_show_the_new_effects() {
let mut s = Session::new();
+1 -1
View File
@@ -234,7 +234,7 @@ impl<'d> Exporter<'d> {
}
EffectKind::Glass(g) => {
self.warn("glass effects keep only their frost");
f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]);
f.extend([("type", e("GLASS")), ("radius", fl(g.sanitized().frost))]);
}
}
o(f)
+19
View File
@@ -1243,6 +1243,25 @@ fn local_samples_round_trip() {
}
}
#[test]
fn a_glass_frost_is_written_as_it_draws() {
// A frost saved before the 0–100 range (or not a number) is written at what it draws: 100, or
// the default.
for (frost, written) in [(12.0, 12.0), (500.0, Glass::MAX_FROST), (f64::NAN, Glass::default().frost)] {
let mut doc = Document::new("glass");
let page = doc.first_page().unwrap();
let r = doc.create(page, NodeKind::Rectangle, "Pane", 0.0, 0.0, 24.0, 24.0).unwrap();
doc.get_mut(r).unwrap().effects = vec![Effect { kind: EffectKind::Glass(Glass { frost, ..Glass::default() }), visible: true }];
let (bytes, _) = export(&doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let pane = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some("Pane")).unwrap();
let radius = pane.get("effects").unwrap().list()[0].get("radius").and_then(Kv::f64).unwrap();
assert_eq!(radius, written, "frost {frost}");
}
}
#[test]
fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
let mut doc = Document::new("vd");
+175 -17
View File
@@ -1,9 +1,12 @@
//! The glass effect: the backdrop seen through a pane whose edges curve down like a lens.
//!
//! Within `depth` of the outline the surface curves: the backdrop there is bent towards the inside
//! (strongest at the very edge, `refraction` × `depth` far), each colour channel by a slightly
//! different amount (`dispersion`, a rainbow fringe), and the rim catches the light: brightest
//! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`.
//! Within `depth` of the outline the surface curves like a thick lens: the backdrop there is bent
//! towards the inside, most a [`BEVEL`] in from the edge (`refraction` × `depth` far), falling off
//! as the 3.5th power of the way out of the curve and gone `depth` further in, with a fifth as
//! much on the bevel itself ([`bend_at`]). Each colour channel bends by a different amount
//! (`dispersion`, a rainbow fringe: red furthest, blue least, each smeared along the bend), and the
//! rim catches the light: brightest where it faces `light_angle`, half as bright on the opposite
//! side, spread wider by `splay`.
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
//! distance transform of the shape's coverage.
//!
@@ -162,6 +165,70 @@ fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)>
.collect()
}
/// The bevel along a glass's edge (document units): where the bend eases off before the edge.
pub(crate) const BEVEL: f64 = 1.0;
/// How sharply the bend falls off going in: the 3.5th power of the way out of the curve.
const PROFILE_EXP: f32 = 3.5;
/// The furthest the backdrop moves, as a share of `depth`.
const MAX_BEND: f32 = 0.999;
/// The bend on the bevel itself, as a share of [`MAX_BEND`] × `depth` × `refraction`.
const RIM_BEND: f32 = 0.2;
/// Steps of the dispersion window: each channel is sampled this many times along the bend, weighted
/// by a triangle (the two end steps weigh nothing and are skipped).
const DISPERSION_STEPS: usize = 8;
/// `x` from `a` to `b` as 0 to 1, clamped (a span under 1e-5 counts as 1e-5).
fn lin(a: f32, b: f32, x: f32) -> f32 {
((x - a) / (b - a).max(1e-5)).clamp(0.0, 1.0)
}
/// How far the backdrop moves at `x` pixels in from the edge, for a glass `depth` and [`BEVEL`]
/// `bevel` deep (pixels) and `refraction` (0–1): a thick lens's profile, the most a bevel in from
/// the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further
/// in, and a fifth as much on the bevel itself, eased between the two over its outer 70%.
pub(crate) fn bend_at(x: f32, depth: f32, bevel: f32, refraction: f32) -> f32 {
// Past the curve (and the bevel's easing) nothing bends: the flat inside of the pane.
if x >= depth.max(1e-4) + bevel {
return 0.0;
}
let sd = -x;
let t = lin(-depth - bevel, -bevel, sd);
let most = MAX_BEND * depth;
let core = (t.powf(PROFILE_EXP) * depth * refraction).clamp(-most, most);
let rim = refraction * RIM_BEND * most;
let u = lin(-bevel - 1e-4, -0.3 * bevel, sd);
core + (rim - core) * u
}
/// The backdrop seen at pixel coordinates (`x`, `y`) moved by (`dx`, `dy`), its channels split by
/// `k` (dispersion, 0–1): red moves up to `k` further, blue up to `k` less, each smeared along the
/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when `k` is too small to show.
fn dispersed(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] {
let at = |s: f32| sample(frosted, x + dx * s, y + dy * s);
if k < 0.001 {
let s = at(1.0);
return [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]];
}
let last = (DISPERSION_STEPS - 1) as f32;
let (mut acc, mut sum) = ([0.0f32; 4], 0.0f32);
for i in 1..DISPERSION_STEPS - 1 {
let t = i as f32 / last - 0.5;
let w = 1.0 - (2.0 * t).abs();
let (r, g, b) = (at(1.0 + (t + 0.5) * k), at(1.0 + t * k), at(1.0 + (t - 0.5) * k));
acc[0] += r[0] * w;
acc[1] += g[1] * w;
acc[2] += b[2] * w;
acc[3] += r[3].max(g[3]).max(b[3]) * w;
sum += w;
}
let a = acc[3] / sum;
[(acc[0] / sum).min(a), (acc[1] / sum).min(a), (acc[2] / sum).min(a), a]
}
/// Bilinear sample of premultiplied `p` at pixel coordinates `(x, y)` (centres at +0.5), each
/// channel 0..=255; transparent outside.
fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
@@ -411,20 +478,20 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
let g = g.sanitized();
let depth = (g.depth * scale).clamp(1.0, 1024.0);
let bevel = (BEVEL * scale).min(1024.0);
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
let reach = light_reach(band, g.light_intensity);
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
// smoothed from (2r + 1 further).
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
// exact: the bend's (within `depth` + `bevel`), the light's (within `reach`) and those the
// normals are smoothed from (2r + 1 further).
let m = (depth + bevel).max(reach).ceil() as u32 + 4 * r as u32 + 4;
let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?;
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
let light = (ang.sin() as f32, -ang.cos() as f32);
let bend_max = (g.refraction * depth) as f32;
let fringe = (g.dispersion * 0.25) as f32;
let (refraction, dispersion, bevel) = (g.refraction as f32, (g.dispersion * Glass::DISPERSION_SPREAD) as f32, bevel as f32);
let power = (6.0 - 5.0 * g.splay) as f32;
let (band, reach) = (band as f32, reach as f32);
// The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light
@@ -441,19 +508,15 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
if c <= 0.0 {
continue;
}
let t = (d / depth).min(1.0);
let bend = bend_max * (1.0 - t) * (1.0 - t);
let bend = bend_at(d, depth, bevel, refraction);
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
let base = if bend == 0.0 {
// Nothing bends (past `depth`, or no refraction): the three channels' samples are
// at the same point, so one sample.
// Nothing bends (past `depth` + `bevel`, or no refraction): the channels' samples
// are at the same point, so one sample.
let s = sample(&frosted, bx, by);
[s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]
} else {
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
[rs[0].min(a), gs[1].min(a), bs[2].min(a), a]
dispersed(&frosted, bx, by, inward.0 * bend, inward.1 * bend, dispersion)
};
// The rim catches the light where it faces it, and half as much opposite. It fades out
// by `reach` and is none past it, wherever the layer is cut.
@@ -479,6 +542,101 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
mod tests {
use super::*;
/// The bend at depth 20, refraction 0.8 and a 1-unit bevel, x units in from the edge: the
/// reference profile's values.
#[test]
fn the_bend_follows_the_thick_lens_profile() {
let table = [
(0.10, 3.1968),
(0.25, 3.1968),
(0.50, 6.8543),
(0.75, 11.4263),
(1.00, 15.9982),
(2.0, 13.3707),
(4.0, 9.0591),
(6.0, 5.8457),
(8.0, 3.5426),
(10.0, 1.9742),
(16.0, 0.1250),
(20.0, 0.0004),
(21.0, 0.0),
(40.0, 0.0),
];
for (x, want) in table {
let got = bend_at(x, 20.0, 1.0, 0.8);
assert!((got - want).abs() < 1e-3, "x {x}: {got} vs {want}");
}
// No refraction, no bend; never more than 0.999 × depth, never outwards.
assert_eq!(bend_at(0.5, 20.0, 1.0, 0.0), 0.0);
for i in 0..400 {
let x = i as f32 * 0.1;
let b = bend_at(x, 20.0, 1.0, 1.0);
assert!((0.0..=0.999 * 20.0).contains(&b), "x {x}: {b}");
}
// A depth thinner than the bevel (zoomed far in) still gives numbers.
assert!(bend_at(0.5, 1.0, 8.0, 1.0).is_finite());
// `Glass::max_bend`, which sizes the backdrop read around a pane, bounds the whole bend.
for refraction in [0.3, 0.8, 1.0] {
let most = (0..800).map(|i| bend_at(i as f32 * 0.05, 20.0, 1.0, refraction as f32)).fold(0.0f32, f32::max);
let bound = Glass { refraction, depth: 20.0, dispersion: 0.0, ..Glass::default() }.max_bend();
assert!(f64::from(most) <= bound + 1e-4, "refraction {refraction}: {most} past {bound}");
}
}
/// Dispersion off is one sample at the moved point; on, the channels split along the move, red
/// further and blue less, and a flat backdrop stays flat.
#[test]
fn dispersion_splits_the_channels_along_the_bend() {
// A 40 × 1 ramp: red, green and blue all rise with x.
let mut p = Pixmap::new(40, 1).unwrap();
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
let v = (i * 6) as u8;
px.copy_from_slice(&[v, v, v, 255]);
}
let one = sample(&p, 10.5 + 8.0, 0.5);
let off = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 0.0);
assert_eq!(off, [one[0], one[1], one[2], one[3]]);
let on = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 1.0);
assert!(on[0] > on[1] && on[1] > on[2], "{on:?}");
assert!((on[1] - one[1]).abs() < 1e-3, "green stays centred on the move: {on:?} vs {one:?}");
let mut flat = Pixmap::new(40, 1).unwrap();
flat.fill(tiny_skia::Color::from_rgba8(90, 120, 200, 255));
let f = dispersed(&flat, 10.5, 0.5, 8.0, 0.0, 1.0);
assert!(f.iter().zip([90.0, 120.0, 200.0, 255.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{f:?}");
// One white pixel on black: only the weighted steps that reach it pick it up, red and green
// from opposite ends of their windows (the same amount), blue none.
let mut impulse = Pixmap::new(40, 1).unwrap();
impulse.fill(tiny_skia::Color::BLACK);
impulse.data_mut()[20 * 4..21 * 4].copy_from_slice(&[255, 255, 255, 255]);
let spread = dispersed(&impulse, 10.5, 0.5, 8.0, 0.0, 1.0);
assert!((spread[0] - 33.392857).abs() < 1e-3 && (spread[1] - 33.392857).abs() < 1e-3, "{spread:?}");
assert_eq!(spread[2], 0.0);
}
/// At full dispersion red's furthest step stays within `Glass::max_bend` of the pixel, all
/// along the bend, so the backdrop read around a pane covers it: one white pixel further out
/// than that is never seen.
#[test]
fn full_dispersion_reads_no_further_than_max_bend() {
let bound = Glass { refraction: 1.0, depth: 20.0, dispersion: 1.0, ..Glass::default() }.max_bend() as f32;
let k = Glass::DISPERSION_SPREAD as f32;
let mut furthest = 0usize;
for i in 0..100 {
let bend = bend_at(i as f32 * 0.25, 20.0, 1.0, 1.0);
for at in 0..40 {
let mut impulse = Pixmap::new(40, 1).unwrap();
impulse.fill(tiny_skia::Color::BLACK);
impulse.data_mut()[at * 4..at * 4 + 4].copy_from_slice(&[255, 255, 255, 255]);
let s = dispersed(&impulse, 0.5, 0.5, bend, 0.0, k);
if s[0].max(s[1]).max(s[2]) > 0.0 {
furthest = furthest.max(at);
}
}
}
// A sample `o` pixels along picks up the pixel `at` while |o − at| < 1.
assert!(furthest > 0 && (furthest as f32) < bound + 1.0, "pixel {furthest} seen, past {bound}");
}
#[test]
fn distance_of_a_square() {
let mut p = Pixmap::new(21, 21).unwrap();
+20 -6
View File
@@ -6,12 +6,7 @@ use super::{
impl FigApp {
pub fn save_as_dialog(&mut self) {
if self.services.downloads() {
if let Some(r) = self.run("file.saveAs", json!({"data": true}))
&& self.download(r["name"].as_str().unwrap_or_default(), &r)
{
self.run("file.markSaved", json!({}));
dialogs::file_saved(self, &r);
}
self.save_download(None);
return;
}
let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return };
@@ -43,6 +38,17 @@ impl FigApp {
});
}
/// Save by handing the file over as a download. `keep`: the name a Save of a host-opened
/// document keeps; `None` is a Save As, under the document's own name.
fn save_download(&mut self, keep: Option<&str>) {
if let Some(r) = self.run("file.saveAs", json!({"data": true}))
&& self.download(keep.unwrap_or_else(|| r["name"].as_str().unwrap_or_default()), &r)
{
self.run("file.markSaved", json!({}));
dialogs::file_saved(self, &r);
}
}
pub fn save(&mut self) {
// A branch tab saves its main file.
let tab = figcraft_engine::branches::file_tab(&self.session);
@@ -57,6 +63,9 @@ impl FigApp {
if self.run("file.save", json!({})).is_some() {
self.toast(i18n::tr("Saved"));
}
} else if let Some(name) = self.services.host_file().filter(|n| is_document_file(n)) {
// A host-opened document is saved as the file the host gave: the host updates that file.
self.save_download(Some(&name));
} else {
self.save_as_dialog();
}
@@ -299,3 +308,8 @@ impl FigApp {
failed
}
}
/// A `.figcraft` file name: the document format a Save writes (a `.fig` is an export copy).
fn is_document_file(name: &str) -> bool {
Path::new(name).extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("figcraft"))
}
+32
View File
@@ -185,6 +185,33 @@ fn drag_id() -> Id {
Id::unique("figcraft_handle_drag")
}
/// Whether a gradient handle or stop on the canvas is being dragged, the press that starts it
/// included: those are the open colour picker's own controls, so the picker stays through the drag
/// instead of taking the press for one outside it (#1852).
pub fn gradient_dragging(ctx: &egui::Context) -> bool {
matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Gradient { .. }))
}
/// Whether the document has a gesture open: a handle drag edits only inside its own.
fn gesture_open(app: &FigApp) -> bool {
app.session.ds().is_ok_and(|d| d.interaction.is_some())
}
/// A handle drag whose button went up wherever the pointer was (off the canvas, over a panel, the
/// colour picker or a ruler, outside the window) ends as a release on the handle would: the edit
/// commits now, instead of waiting, uncommitted, to move the handle to wherever the pointer next
/// presses. So does one whose gesture Escape or Undo closed under the held button: it stops there
/// rather than editing on with no gesture open, an undo step a frame. True when a drag ended.
pub fn end_released_drag(app: &mut FigApp, ctx: &egui::Context) -> bool {
let stashed = ctx.data(|d| d.get_temp::<Drag>(drag_id())).is_some();
if !stashed || (ctx.input(|i| i.pointer.primary_down()) && gesture_open(app)) {
return false;
}
let _ = app.session.commit_interaction();
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
true
}
/// The gradient being edited (single node, picker open on a gradient paint).
fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color::Gradient)> {
let Some(PickerTarget::Paint { fills, index, ids }) = &app.ui.picker else { return None };
@@ -516,6 +543,11 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
});
let dragging: Option<Drag> = ctx.data(|d| d.get_temp(drag_id()));
if let Some(drag) = dragging {
// Escape or Undo closed the gesture under the held button: the drag is over.
if !gesture_open(app) {
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
return true;
}
if down {
apply(app, &drag, pos, shift);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
+8
View File
@@ -183,6 +183,8 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
// So is a handle drag.
crate::handles::end_released_drag(app, &ctx);
return true;
};
// A press on the canvas gives the keyboard back to it (arrows nudge again).
@@ -230,6 +232,12 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
}
return true;
}
// A handle drag whose button went up ends first, wherever on the canvas the pointer is: over a
// ruler or a comment pin, which take the pointer below, or over something drawn above the
// canvas (the colour picker, a card). The handle had the press, so it has the release too.
if crate::handles::end_released_drag(app, &ctx) {
return false;
}
if !app.canvas.dragging && (resp.hovered() || app.ui.comments.dragging()) && crate::comments::input(app, &ctx, pos) {
return false;
}
@@ -215,3 +215,160 @@ fn a_press_in_the_gradient_variable_picker_keeps_the_colour_picker_open() {
assert!(u.app.ui.picker.is_some(), "the child variable picker is part of the interaction");
u.button(at, false);
}
#[test]
fn dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open() {
// #1852: the handles belong to the picker, so the press that starts a drag is not a press
// outside it: the picker and the handles stay, the drag is one undo step, and a press elsewhere
// on the layer still closes it.
let mut s = Session::new();
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
u.app.open_picker(target.clone(), None);
for _ in 0..5 {
u.frame(vec![]);
}
assert_eq!(u.app.ui.picker, Some(target.clone()));
let depth = u.app.session.undo_depth().0;
let from_handle = |u: &Ui| match &u.app.session.doc().unwrap().node(a).unwrap().fills[0].kind {
figcraft_color::PaintKind::Gradient(g) => g.from,
_ => panic!("the fill is a gradient"),
};
assert_eq!(from_handle(&u), (0.5, 0.0), "a linear gradient starts at the top middle");
// Drag the start handle from the top middle of the layer to its left middle.
let from = u.app.to_screen(200.0, 100.0);
let to = u.app.to_screen(100.0, 200.0);
u.button(from, true);
assert_eq!(u.app.ui.picker, Some(target.clone()), "the press on the handle keeps the picker open");
for _ in 0..2 {
u.frame(vec![egui::Event::PointerMoved(to)]);
}
assert_eq!(u.app.ui.picker, Some(target.clone()), "and so does the drag");
let (x, y) = from_handle(&u);
assert!(x.abs() < 0.02 && (y - 0.5).abs() < 0.02, "the handle followed the pointer: ({x}, {y})");
u.button(to, false);
u.frame(vec![]);
assert_eq!(u.app.ui.picker, Some(target.clone()), "released, the picker is still open");
assert_eq!(u.app.session.undo_depth().0, depth + 1, "the drag is one undo step");
// A press on the layer away from its handles is still a press outside the picker.
let body = u.app.to_screen(250.0, 250.0);
u.button(body, true);
u.button(body, false);
u.frame(vec![]);
assert!(u.app.ui.picker.is_none(), "a plain canvas press closes it");
}
#[test]
fn a_gradient_drag_released_off_the_canvas_ends_there() {
// A handle drag whose button goes up over the sidebar or over the picker itself ends there:
// the edit commits, the picker stays, and the next press on the canvas is a plain press (it
// closes the picker) rather than a move of the handle to wherever it landed.
for over in ["sidebar", "picker"] {
let mut s = Session::new();
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
u.app.open_picker(target.clone(), None);
for _ in 0..5 {
u.frame(vec![]);
}
let depth = u.app.session.undo_depth().0;
let from_handle = |u: &Ui| match &u.app.session.doc().unwrap().node(a).unwrap().fills[0].kind {
figcraft_color::PaintKind::Gradient(g) => g.from,
_ => panic!("the fill is a gradient"),
};
u.button(u.app.to_screen(200.0, 100.0), true);
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]);
assert!(crate::handles::gradient_dragging(&u.ctx), "{over}: the handle is being dragged");
let spot = match over {
"sidebar" => u.app.ui.right_panel_rect.unwrap().center(),
_ => u.app.ui.picker_popover.as_ref().unwrap().rect.center(),
};
if over == "picker" {
assert!(u.app.canvas.rect.contains(spot), "the picker lies over the canvas");
} else {
assert!(!u.app.canvas.rect.contains(spot), "the sidebar is off the canvas");
}
u.button(spot, false);
u.frame(vec![]);
assert!(!crate::handles::gradient_dragging(&u.ctx), "{over}: the release ended the drag");
assert_eq!(u.app.ui.picker, Some(target.clone()), "{over}: the picker is still open");
assert_eq!(u.app.session.undo_depth().0, depth + 1, "{over}: the drag is one undo step");
let moved = from_handle(&u);
assert!(moved.0.abs() < 0.02 && (moved.1 - 0.5).abs() < 0.02, "{over}: the handle stayed where it was dragged: {moved:?}");
// The next press on the layer is a plain one: the handle stays put and the picker closes.
let body = u.app.to_screen(250.0, 250.0);
u.button(body, true);
u.frame(vec![]);
assert_eq!(from_handle(&u), moved, "{over}: a later press doesn't move the handle");
assert!(u.app.ui.picker.is_none(), "{over}: a later press closes the picker as any outside press does");
u.button(body, false);
}
}
/// A rectangle with a linear gradient fill, selected, its picker open and the canvas settled; a
/// second layer drawn after it leaves an undo step that isn't the gradient's. Rulers on or off.
fn gradient_ui(rulers: bool) -> Ui {
let mut s = Session::new();
if rulers {
s.execute("view.rulers", &json!({"on": true})).unwrap();
}
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 20, "height": 20})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None);
for _ in 0..5 {
u.frame(vec![]);
}
u
}
#[test]
fn a_gradient_drag_released_over_a_ruler_ends_there() {
// The rulers take the pointer on the canvas before the handles do: the release still ends the
// drag, as one undo step, instead of leaving it waiting for the next press.
let mut u = gradient_ui(true);
let depth = u.app.session.undo_depth().0;
u.button(u.app.to_screen(200.0, 100.0), true);
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]);
assert!(crate::handles::gradient_dragging(&u.ctx), "the handle is being dragged");
let r = u.app.canvas.rect;
let ruler = egui::Pos2::new(r.min.x + 5.0, r.center().y);
assert!(!u.app.ui.picker_popover.as_ref().unwrap().rect.contains(ruler), "the ruler is not under the picker");
u.button(ruler, false);
assert!(!crate::handles::gradient_dragging(&u.ctx), "the release over the ruler ended the drag");
assert_eq!(u.app.session.undo_depth().0, depth + 1, "the drag is one undo step");
}
#[test]
fn a_gradient_drag_whose_gesture_closes_under_the_held_button_stops_editing() {
// Escape (the picker closes and commits) or Undo ends the gesture while the button is still
// down: moving on edits nothing more, rather than adding an undo step every frame.
for close in ["escape", "undo"] {
let mut u = gradient_ui(false);
u.button(u.app.to_screen(200.0, 100.0), true);
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]);
assert!(crate::handles::gradient_dragging(&u.ctx), "{close}: the handle is being dragged");
if close == "escape" {
crate::colorpicker::close(&mut u.app);
} else {
assert!(u.app.session.undo().unwrap(), "{close}: there is a step to undo");
}
let depth = u.app.session.undo_depth().0;
let doc = u.app.session.doc().unwrap().clone();
for k in 1..=4 {
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0 + 20.0 * f64::from(k), 200.0))]);
}
assert!(!crate::handles::gradient_dragging(&u.ctx), "{close}: the drag is over");
assert_eq!(u.app.session.undo_depth().0, depth, "{close}: no undo step a frame");
assert!(*u.app.session.doc().unwrap() == doc, "{close}: the gradient stays where the gesture left it");
u.button(u.app.to_screen(180.0, 200.0), false);
}
}
+28 -9
View File
@@ -247,6 +247,8 @@ const SORT_ARROW_W: f32 = 12.0;
/// Padding inside each cell, on each side.
const CELL_PAD: f32 = 6.0;
const NAME_MIN_W: f32 = 110.0;
/// The narrowest a credits column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
@@ -341,13 +343,17 @@ fn column_width<'a>(ui: &egui::Ui, header: &str, cells: impl Iterator<Item = &'a
}
/// A credits table: columns of `widths`, the `None` one taking what the others leave (at least
/// `flex_min`). The header stays put while the rows scroll, within `height` (header included). Too
/// narrow for every column, it scrolls sideways rather than clip one.
/// [`NAME_MIN_W`]). The header stays put while the rows scroll, within `height` (header included). Too
/// narrow for every column, it scrolls sideways rather than clip one. Every
/// column edge can be dragged, down to [`MIN_COLUMN`] (the `None` column to [`NAME_MIN_W`]): widening
/// a column grows the table, as in a spreadsheet, and only then does it scroll sideways. When the
/// window, a column's width or `flex_text` (the width the `None` column's text needs) changes, the
/// columns go back to this fit, dropping any dragged widths.
fn credits_table(
ui: &mut egui::Ui,
id: &str,
widths: &[Option<f32>],
flex_min: f32,
flex_text: f32,
height: f32,
header: impl FnOnce(egui_extras::TableRow<'_, '_>),
body: impl FnOnce(egui_extras::TableBody<'_>),
@@ -358,21 +364,31 @@ fn credits_table(
// The table puts a row gap under its header.
let body_h = (height - TABLE_HEADER_H - ui.spacing().item_spacing.y).max(TABLE_ROW_H);
egui::ScrollArea::horizontal().id_salt((id, "h")).auto_shrink([false, true]).show(ui, |ui| {
let flex = (ui.available_width() - fixed).max(flex_min);
let flex = (ui.available_width() - fixed).max(NAME_MIN_W);
// No gaps between cells: their padding spaces them, and the header rule runs unbroken.
ui.spacing_mut().item_spacing.x = 0.0;
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. Scrolling can nudge the available width, so this compares the clip rect, which scrolling
// never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (widths.to_vec(), flex_text, ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<Option<f32>>, f32, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut b = egui_extras::TableBuilder::new(ui)
.id_salt(id)
.striped(true)
.resizable(false)
.resizable(true)
.auto_shrink([false, false])
.min_scrolled_height(body_h)
.max_scroll_height(body_h)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center));
if refit {
b.reset();
}
for w in widths {
b = b.column(match w {
Some(w) => egui_extras::Column::exact(*w),
None => egui_extras::Column::exact(flex).clip(true),
Some(w) => egui_extras::Column::initial(*w).at_least(MIN_COLUMN).clip(true),
None => egui_extras::Column::initial(flex).at_least(NAME_MIN_W).clip(true),
});
}
b.header(TABLE_HEADER_H, header).body(body);
@@ -395,11 +411,13 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, height: f32) {
let t = Tokens::get(ui.ctx());
let names = v.names;
let widths = contributor_widths(ui, list);
let shown: Vec<String> = list.iter().map(|c| c.name(names)).collect();
let name_text = column_width(ui, SortKey::Name.label().1, shown.iter().map(String::as_str));
credits_table(
ui,
"credits_table",
&widths,
NAME_MIN_W,
name_text,
height,
|mut header| {
for k in SortKey::ALL {
@@ -522,12 +540,13 @@ fn models_in(ui: &mut egui::Ui, models: &[Model], total_commits: u64) {
let total = total_commits.max(models.iter().map(|m| m.commits).max().unwrap_or(0));
let rows: Vec<[String; 6]> = models.iter().map(|m| model_cells(m, total)).collect();
let widths = model_widths(ui, &rows);
let model_text = column_width(ui, tr(MODEL_HEADS[1]), rows.iter().filter_map(|r| r.get(1)).map(String::as_str));
let numeric = |i: usize| i >= 3;
credits_table(
ui,
"credits_models",
&widths,
NAME_MIN_W,
model_text,
table_height(ui, rows.len()),
|mut header| {
for (i, h) in MODEL_HEADS.iter().enumerate() {
@@ -267,3 +267,122 @@ fn the_about_window_draws_every_tab() {
assert_eq!(app.ui.about_tab, tab);
}
}
const WINDOW: f32 = 640.5;
const WIDTHS: [Option<f32>; 3] = [None, Some(80.0), Some(80.0)];
const NAME: f32 = 120.0;
/// One frame of a table long enough to scroll, in a window `width` wide; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, widths: &[Option<f32>], name: f32, events: Vec<egui::Event>) -> Vec<Rect> {
let screen_rect = Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
credits_table(
ui,
"test",
widths,
name,
240.0,
|mut header| {
for _ in widths {
header.col(|ui| cells.push(ui.max_rect()));
}
},
|body| {
body.rows(TABLE_ROW_H, 40, |mut row| {
for _ in widths {
row.col(|ui| {
ui.label("x");
});
}
});
},
);
});
out.drop_without_applying_deltas();
cells
}
/// [`table_frame`] at the usual size.
fn settled_frame(ctx: &egui::Context) -> Vec<Rect> {
table_frame(ctx, WINDOW, &WIDTHS, NAME, Vec::new())
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<Rect>) {
let ctx = egui::Context::default();
settled_frame(&ctx);
let cells = settled_frame(&ctx);
(ctx, cells)
}
/// Drags a column's right edge (cells have no gap between them), then lets go.
fn drag_edge(ctx: &egui::Context, cell: Rect, by: f32) {
let start = egui::pos2(cell.right(), cell.center().y);
let halfway = start + vec2(by / 2.0, 0.0);
let end = start + vec2(by, 0.0);
let moved = egui::Event::PointerMoved;
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &WIDTHS, NAME, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
settled_frame(&ctx);
let after = settled_frame(&ctx);
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &WIDTHS, NAME + 80.0, Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[None, Some(200.0), Some(80.0)], NAME, Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
// The table fills the window, which the layout rounds to whole points.
assert!(wider[2].right() <= WINDOW.ceil(), "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &WIDTHS, NAME, Vec::new());
assert!(narrower[2].right() <= 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
settled_frame(&ctx);
let dragged = settled_frame(&ctx);
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
table_frame(&ctx, WINDOW, &WIDTHS, NAME, vec![egui::Event::PointerMoved(dragged[0].center())]);
let mut scrolled = false;
for delta in [-13.3, -27.77, -41.1, 30.6] {
let wheel = egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Point,
delta: vec2(delta, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
};
table_frame(&ctx, WINDOW, &WIDTHS, NAME, vec![wheel]);
for _ in 0..8 {
let cells = settled_frame(&ctx);
assert!((cells[0].width() - dragged[0].width()).abs() < 1.0, "scrolled by {delta}: {cells:?}");
scrolled |= cells[0].left() < dragged[0].left() - 1.0;
}
}
assert!(scrolled, "the wheel never moved the table sideways");
}
+2 -2
View File
@@ -667,11 +667,11 @@ fn the_glass_light_intensity_field_has_a_label() {
u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true));
u.frame(vec![]);
let shapes = u.frame_output(vec![]).shapes;
let lead = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
let lead = u.ctx.read_response(Id::unique(("fx", "lightIntensity", 0usize)).with("lead")).expect("the intensity field is shown").rect;
let labels: Vec<(String, bool)> =
painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect();
assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short");
let field = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("field")).expect("its value").rect;
let field = u.ctx.read_response(Id::unique(("fx", "lightIntensity", 0usize)).with("field")).expect("its value").rect;
assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against");
}
+60
View File
@@ -0,0 +1,60 @@
//! In a page host the browser hands files to the host: a Save of the document the host opened goes
//! out under the host's file name, a Save As under the document's own name.
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use serde_json::json;
use crate::{FigApp, Services};
type Saved = Arc<Mutex<Vec<String>>>;
/// Services that save by downloads, with a document file the host opened.
struct Host {
got: Saved,
file: Option<&'static str>,
}
impl Services for Host {
fn downloads(&self) -> bool {
true
}
fn download(&mut self, name: &str, _bytes: &[u8]) -> bool {
self.got.lock().unwrap().push(name.to_string());
true
}
fn host_file(&self) -> Option<String> {
self.file.map(str::to_string)
}
}
fn app(file: Option<&'static str>) -> (FigApp, Saved) {
let got = Saved::default();
let mut app = FigApp::new(Session::empty(), Box::new(Host { got: got.clone(), file }));
app.session.execute("file.new", &json!({"name": "Original"})).unwrap();
(app, got)
}
#[test]
fn save_keeps_the_name_the_host_opened() {
let (mut app, got) = app(Some("client.figcraft"));
app.save();
assert_eq!(got.lock().unwrap().as_slice(), ["client.figcraft"]);
}
#[test]
fn save_as_uses_the_document_s_own_name() {
let (mut app, got) = app(Some("client.figcraft"));
app.save_as_dialog();
assert_eq!(got.lock().unwrap().as_slice(), ["Original.figcraft"]);
}
#[test]
fn without_a_host_file_or_for_a_copy_format_save_is_a_save_as() {
for file in [None, Some("design.json"), Some("export.fig")] {
let (mut app, got) = app(file);
app.save();
assert_eq!(got.lock().unwrap().as_slice(), ["Original.figcraft"], "{file:?}");
}
}
+2
View File
@@ -124,6 +124,8 @@ mod home_recents_tests;
#[cfg(test)]
mod home_tests;
#[cfg(test)]
mod host_save_tests;
#[cfg(test)]
mod idle_tests;
#[cfg(test)]
mod ime_tests;
+26 -10
View File
@@ -108,7 +108,22 @@ pub(crate) fn effect_open(ctx: &egui::Context, i: usize) -> bool {
/// A number field of effect `i` that can be bound to a variable (`effects/<i>/<prop>`).
fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, lead: Lead, value: f64, width: f32) -> Option<FieldEdit> {
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, Some(value), "", width)
fx_field_in(app, ui, ids, i, prop, lead, Some(value), "", width)
}
/// [`fx_field`] showing `value` as given (a glass percentage) with `suffix` after it.
fn fx_field_in(
app: &mut FigApp,
ui: &mut Ui,
ids: &[NodeId],
i: usize,
prop: &str,
lead: Lead,
value: Option<f64>,
suffix: &str,
width: f32,
) -> Option<FieldEdit> {
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, value, suffix, width)
}
/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live
@@ -290,29 +305,30 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
}
})
};
// Each field takes a variable (`effects/<i>/<field>`), read as the field shows it:
// percentages for the 0–1 values, and the frost as `radius` (see `Glass::set_bound`).
let pct = |v: f64| Some((v * 100.0).round());
let unit = |v: f64| (v / 100.0).clamp(0.0, 1.0);
row(ui, 32.0, |ui| {
let ed =
widgets::number_field(ui, Id::unique(("fx_gl", i)), Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "lightAngle", Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half);
edit(app, ed, &|v| with(&|gl| gl.light_angle = v.clamp(-180.0, 180.0)));
let ed = widgets::number_field(ui, Id::unique(("fx_gi", i)), Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "lightIntensity", Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half);
edit(app, ed, &|v| with(&|gl| gl.light_intensity = unit(v)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::unique(("fx_gr", i)), Lead::Text(tr("Refraction")), pct(g.refraction), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "refraction", Lead::Text(tr("Refraction")), pct(g.refraction), "%", half);
edit(app, ed, &|v| with(&|gl| gl.refraction = unit(v)));
let ed = widgets::number_field(ui, Id::unique(("fx_gd", i)), Lead::Text(tr("Depth")), Some(g.depth), "", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "depth", Lead::Text(tr("Depth")), Some(g.depth), "", half);
edit(app, ed, &|v| with(&|gl| gl.depth = v.clamp(1.0, 1000.0)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::unique(("fx_gs", i)), Lead::Text(tr("Dispersion")), pct(g.dispersion), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "dispersion", Lead::Text(tr("Dispersion")), pct(g.dispersion), "%", half);
edit(app, ed, &|v| with(&|gl| gl.dispersion = unit(v)));
let ed = widgets::number_field(ui, Id::unique(("fx_gf", i)), Lead::Text(tr("Frost")), Some(g.frost), "", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.frost = v.clamp(0.0, 1000.0)));
let ed = fx_field_in(app, ui, ids, i, "radius", Lead::Text(tr("Frost")), Some(g.sanitized().frost), "", half);
edit(app, ed, &|v| with(&|gl| gl.frost = v.clamp(0.0, Glass::MAX_FROST)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::unique(("fx_gp", i)), Lead::Text(tr("Splay")), pct(g.splay), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "splay", Lead::Text(tr("Splay")), pct(g.splay), "%", half);
edit(app, ed, &|v| with(&|gl| gl.splay = unit(v)));
});
}
+5
View File
@@ -24,6 +24,11 @@ pub trait Services {
fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool {
false
}
/// The document file a host page opened, where the app runs inside one (the browser's host
/// mode): a Save of that document keeps this name instead of the document's own.
fn host_file(&self) -> Option<String> {
None
}
/// A folder to write several files into.
fn pick_folder(&mut self, _title: &str) -> DialogFuture {
Box::pin(std::future::ready(None))
+4 -1
View File
@@ -977,7 +977,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
// Sampling a colour from the canvas — the eyedropper this picker's own button started, or the
// colour it just sampled — is the press, not a press outside: neither may close the picker.
let picking = app.session.tool.pick_only || app.session.tool.picked.is_some();
pop = pop.close_on_outside(!picking);
// Nor may a press on the open gradient's handles or stops on the canvas: they are this picker's
// controls, and the canvas took the press before the picker drew (#1852).
let own = picking || crate::handles::gradient_dragging(ctx);
pop = pop.close_on_outside(!own);
// Only the opening control is held out: any other sidebar or canvas press dismisses it.
let mut held: Vec<Rect> = app.ui.picker_button.into_iter().collect();
held.extend(crate::variable_ui::picker_rect(ctx));
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -67,7 +67,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show |
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
| Extended collections | done | this file's collections (Extend collection on a collection's row; following its edits at once, chains included) and library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
| 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
File diff suppressed because one or more lines are too long
+4 -3
View File
File diff suppressed because one or more lines are too long
+98
View File
@@ -107,6 +107,104 @@ Local test: `python3 -m http.server 8767` inside the folder, then open http://lo
(`allow-popups` lets Help links open in a new tab).
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Host mode (`?host=parent`, or the page's own document)
A page can embed FigCraft so that it hands files over instead of downloading them and reports
unsaved changes. Two opt-in transports carry the same messages:
- **`?host=parent`** on the iframe's URL (`https://example.com/figcraft/?host=parent`): a
**same-origin** framing page talks to the app over `postMessage`. Opened outside an iframe, the
URL gives the normal app with downloads; with a parent on another origin there is no host mode
either (messages to another origin are dropped silently, so saves would be lost while the
document looked saved).
- **`data-host="page"`** (the exact value `page`) on the canvas: a page that loads the module into
its own document gets the same behaviour over DOM `CustomEvent`s on the canvas, with no iframe
(see below).
| Message | Direction | Fields |
|---|---|---|
| `figcraft:ready` | app → page | `version`, `appVersion`: the app is running and listening; send a file now |
| `figcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open (a payload over the browser open limit of 512 MB is refused; anything malformed is ignored) |
| `figcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file: `false` after Save of the file the page sent, under its own name; `true` for a document file (`.figcraft`, `.json`) under a name the page didn't send — Save As, or a Save of a document the page never sent — after which the next Save carries that name with `false`; `false` for copies and exports that don't become the document (rendered images, SVG, PDF, `.fig` copies, the variables exports) |
| `figcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
| `figcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |
Files reach the page through the app's download service, the path this web build saves by: Save As
(and a Save of a document with no file yet), the `.fig` copy, the export panel and its dialog,
Export frames as PDF, plugin output and the variables export (Variables panel ▸ Export…). In host
mode each arrives as `figcraft:save` instead of a download.
In host mode the app starts with no document (the home screen), so the page sends the file to edit
and guards leaving the page while changes are unsaved. `&author=` on the URL names the user for
comments and board notes.
### Same-document host mode
The host application loads the wasm-bindgen module itself and gives FigCraft one of its own
canvases, so there is no iframe and no `?host=parent`. It opts in with `data-host="page"` on that
canvas — the exact value `page` — and the canvas keeps the id the app looks up
(`id="figcraft_canvas"`):
```html
<canvas id="figcraft_canvas" data-host="page" style="width: 100%; height: 720px; display: block;"></canvas>
```
The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`. The
app dispatches the same messages it would post to a parent — `figcraft:ready`, `figcraft:save`,
`figcraft:dirty`, `figcraft:failed` — with the message fields (without the type) as the event's
`detail`, and it opens a document when the page dispatches a `figcraft:open` event with
`detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself (two editors on
one page can't hear each other).
Add the listeners before `init`: the app announces itself with `figcraft:ready` while `init` is
still running, and a file sent in reply would otherwise be missed.
```js
import init from "./figcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
const canvas = document.getElementById("figcraft_canvas");
canvas.addEventListener("figcraft:ready", async () => {
// The app is listening; hand it a document now, if you have one.
const bytes = await documentBytes(); // an ArrayBuffer or Uint8Array
canvas.dispatchEvent(new CustomEvent("figcraft:open", {
detail: { name: "Poster.figcraft", bytes },
}));
});
// Every file FigCraft would otherwise download arrives here.
canvas.addEventListener("figcraft:save", (event) => {
const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer
// Store the file under `name`; `saveAs` says whether it becomes the document's file.
// Report your own errors.
storeFile(name, bytes, saveAs);
});
canvas.addEventListener("figcraft:dirty", (event) => {
unsaved = event.detail.dirty; // warn before leaving, as you like
});
canvas.addEventListener("figcraft:failed", (event) => {
showError(event.detail.error); // the app couldn't start
});
await init({ module_or_path: "./figcraft-web-<hash>_bg.wasm" });
```
- Saves are not downloads in this mode: the `bytes` in `figcraft:save` hold the file, and the host
decides where it goes.
- `?author=` on the page's URL still names the user for comments and board notes.
- An unframed page that uses `?host=parent` keeps the normal app with downloads.
`data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is
present it takes precedence, so a same-origin framed page uses the parent bridge, while an
unframed or cross-origin page gets no bridge and keeps normal downloads.
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `figcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. FigCraft reads bytes, so a host sends `bytes`, not a `File`.
### Trust and limits
- **Who is trusted.** In page host mode the page and FigCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MB) and a larger file is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `figcraft:ready`, `figcraft:open`, `figcraft:save`, `figcraft:dirty` and `figcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
## Renderer selection and fallback flags
FigCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to