Compare commits
8 Commits
main
...
feat/plugi
| Author | SHA1 | Date |
|---|---|---|
|
|
9ff853ef61 | |
|
|
ba55ff7f58 | |
|
|
1f9c606eb6 | |
|
|
71dabf7072 | |
|
|
f69dcfac9e | |
|
|
aeebf7260d | |
|
|
47d37167d8 | |
|
|
264aa2a703 |
21
ROADMAP.md
21
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (plugin API 4: widget text input, property menus, hover styles, timers and richer drawing, panel sliders and pictures; session restore: the app comes back the way it was left, numbers unchanged; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -25,7 +25,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Full target (ready for real work) | ~53% | ~265–516 h | multiplayer (42–84), localization (29–51), plugins (5–20), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
|
||||
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ native speakers' reviews of the six language drafts need people.
|
|||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
|
||||
| Ecosystem / plugins | 43% (own WASM plugins with panels, settings and widgets that take typing, menus, hover and timers; a self-hosted plugin directory) | 5–20 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
||||
## Features
|
||||
|
|
@ -89,7 +89,7 @@ native speakers' reviews of the six language drafts need people.
|
|||
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
|
||||
| Plugins & widgets | 70% | 42% | 5–20 | [plugins](docs/plugins.md) |
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
|
@ -131,6 +131,17 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-13** · Plugin API 4 (gap 6; older plugins run unchanged): a widget's texts are edited in place and
|
||||
committed to its plugin, a selected widget shows the property menu its plugin declares (a bar by the widget and
|
||||
the same items in the Design panel), parts have hover styles and tooltips the app shows without calling the
|
||||
plugin, and a widget can ask to be called back after a delay (one timer each; in a live session it runs for
|
||||
whoever started it last); widgets draw lines, vectors, gradients, shadows and text styles; panels have sliders,
|
||||
pictures, progress bars, dividers, sections, disabled controls and validation messages; `widget.setText`,
|
||||
`widget.menu`, `widget.menuAction` and `widget.fireTimer` for agents; typing, hover and timers work in a
|
||||
presented prototype; SDK builders and the Counter, Focus list and Dot grid examples. Plugins & widgets 70%.
|
||||
- **2026-10-13** · Session restore: the windows and tabs open when the app was left come back at the next start, each
|
||||
window where it was (or on a screen that is still there) with its panels, tabs, pages and views; kept with the
|
||||
preferences as it changes, guarded against a crash while restoring, off with a preference or `--fresh`
|
||||
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
|
||||
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
|
||||
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
|
||||
|
|
@ -305,6 +316,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Plugin API 4 (gap 6; plugins written for API 1–3 run unchanged, proved by their tests and by running modules built before it): a widget's texts can be edited in place with the text editor (caret, selection, input method; a maximum length and one line unless the plugin allows more) and are committed to the plugin as one undo step; a selected widget shows the property menu its plugin declares, as a bar by the widget on the canvas and as the same items in the Design panel (buttons, toggles, dropdowns, a row of colours, separators, links that open only on a click and only to http or https addresses); parts have hover styles (fill, stroke, opacity) and tooltips the app shows itself, never calling the plugin, never part of the document; a widget can ask to be called back after 100 ms to an hour (`widget_timer`, the one new host function): one timer per widget, kept with the open file, waking the window only when the next is due, a run of callbacks one undo step, and in a live session running for whoever started it last (a stamp on the layer decides, so two people's timers never take turns), never in a file you may only view; widgets draw lines, vectors from SVG path data, gradients, shadows, per-corner radii, per-side padding, children that fill their frame, text weights, alignment and truncation, and image fit modes, all validated and capped; panels have sliders (throttled while dragged), pictures, progress bars, dividers, folding sections, disabled controls and validation messages; `widget.setText`, `widget.menu`, `widget.menuAction` and `widget.fireTimer` drive all of it headless, and a presented prototype takes typing, hover and timers on its own copy; the SDK has builders (`ui::Node`, menu items, panel controls), the Counter example gained a menu, and Focus list (typed text, a timer, auto layout) and Dot grid (sliders and a generated preview picture) are new; `cargo xtask plugins` now also runs every built example in the engine. Left for widgets: opening a panel, components, styles and variables in their layers, drags, image files from the plugin's folder, timers while the file is closed. Plugins & widgets 60%/35% → 70%/42%, 8–26 h → 5–20 h; Ecosystem dimension 38% → 43%; weighted totals unchanged at this rounding; full parity ~268–522 h → ~265–516 h |
|
||||
| 2026-10-13 | minor | Session restore (M12): the app comes back the way it was left. Each window's place, size, maximized or full-screen state and screen, its panels, its tabs in order with the one in front, and per tab the saved file (or your online file, joined again as its owner through its record), the page and the view are kept with the preferences, written atomically whenever they change (at once, then at most once a second; nothing while idle), so a crash or a forced quit has them too. At the next start they open again: a window off every screen moves onto one, files that are gone are skipped with one message, a file or invite link given on the command line opens in front; unsaved documents stay the recovery copies' job (a normal quit keeps none). A mark written before restoring and cleared once the windows have settled stops a crash while restoring from repeating: the next run starts without it and the home screen offers Restore last session. Preferences ▸ Reopen windows and tabs from last time (`pref.restoreSession`), `--fresh`, `session.restore`; `window.list` reports each window's rectangle and state and `ui.inspect` the kept session. The saved-state reader is fuzzed (junk, cut-off, huge, wrong types). Several windows: positions are restored; the screen-reader tree in other windows stays blocked upstream. Headline numbers unchanged |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! The command line: `figcraft [--control <port>] [--fresh] [files…] [figcraft://join?…]`.
|
||||
//! `--fresh` starts without reopening the windows and tabs from last time. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
|
|
@ -8,6 +9,8 @@
|
|||
pub struct Args {
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
/// `--fresh`: start without restoring the last session.
|
||||
pub fresh: bool,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
|
|
@ -51,6 +54,8 @@ pub fn parse(args: &[String]) -> Args {
|
|||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
} else if a == "--fresh" {
|
||||
out.fresh = true;
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ fn args(a: &[&str]) -> Vec<String> {
|
|||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None, fresh: false });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
//! FigCraft desktop app.
|
||||
//!
|
||||
//! Usage: `figcraft [--control <port>] [files…] [figcraft://join?…]` (an invite link joins that
|
||||
//! live session; the app registers itself as the opener of such links: `collab.registerLinks`).
|
||||
//! Usage: `figcraft [--control <port>] [--fresh] [files…] [figcraft://join?…]` (an invite link
|
||||
//! joins that live session; the app registers itself as the opener of such links:
|
||||
//! `collab.registerLinks`). The windows and tabs from last time open again (Preferences ▸ Reopen
|
||||
//! windows and tabs from last time; `--fresh` starts without them), and files and links given
|
||||
//! here open in front of them.
|
||||
//!
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
|
|
@ -22,6 +25,8 @@ mod recovery_online_tests;
|
|||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod restore_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -40,10 +45,37 @@ struct App {
|
|||
title: String,
|
||||
/// Shared with the services, which write the copies after a panic.
|
||||
recovery: recovery::Shared,
|
||||
/// When (UI clock) the screens are looked up next.
|
||||
monitors_at: f64,
|
||||
}
|
||||
|
||||
/// How often the screens are looked up, in frames drawn anyway (seconds).
|
||||
const MONITORS_EVERY: f64 = 2.0;
|
||||
|
||||
/// The screens there are, `[x, y, width, height]` in the window's points.
|
||||
// shortcut: every screen is measured with this window's scale, so on screens of different
|
||||
// scales a window can come back a little off; upgrade when a per-screen scale is needed.
|
||||
fn monitors(frame: &eframe::Frame, pixels_per_point: f32) -> Vec<[f32; 4]> {
|
||||
let Some(window) = frame.winit_window().filter(|_| pixels_per_point.is_finite() && pixels_per_point > 0.0) else { return Vec::new() };
|
||||
let points = |px: f64| (px / f64::from(pixels_per_point)) as f32;
|
||||
window
|
||||
.available_monitors()
|
||||
.map(|m| {
|
||||
let (at, size) = (m.position(), m.size());
|
||||
[points(f64::from(at.x)), points(f64::from(at.y)), points(f64::from(size.width)), points(f64::from(size.height))]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Before the first frame draws: restored windows open on the screens there are.
|
||||
if now >= self.monitors_at {
|
||||
self.monitors_at = now + MONITORS_EVERY;
|
||||
let list = monitors(frame, ctx.pixels_per_point());
|
||||
with_app(|fig| fig.monitors = list);
|
||||
}
|
||||
{
|
||||
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if slot.is_none() {
|
||||
|
|
@ -66,7 +98,6 @@ impl eframe::App for App {
|
|||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.title = title;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
}
|
||||
|
|
@ -124,11 +155,6 @@ fn main() -> eframe::Result {
|
|||
c.request_repaint();
|
||||
}
|
||||
}))));
|
||||
for f in &args.files {
|
||||
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
|
|
@ -144,6 +170,15 @@ fn main() -> eframe::Result {
|
|||
r.adopt(recovered.waiting);
|
||||
}
|
||||
*recovery::lock(&recovery) = copies;
|
||||
// The windows and tabs from last time, then what this run was started with, in front.
|
||||
let config = services::config_dir();
|
||||
let saved_main = session.prefs.session.windows.first().cloned();
|
||||
let start = services::start_session(config.as_deref(), &mut session, args.fresh);
|
||||
for f in &args.files {
|
||||
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
// Plugins installed from a server's plugin directory live with the user's settings.
|
||||
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
|
||||
// Invite links open this app (Windows registers per user at run time; installed builds by
|
||||
|
|
@ -153,11 +188,16 @@ fn main() -> eframe::Result {
|
|||
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
|
||||
// tips and templates; later runs start in a new file.
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
if session.docs.is_empty() && !first_run {
|
||||
// The home screen offers the last session after a run that didn't get through restoring it;
|
||||
// a restored online file on its way from its server is this run's file.
|
||||
let offered = start == services::Start::Guarded;
|
||||
if session.docs.is_empty() && !first_run && !offered && session.restore.joining().next().is_none() {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.ui.home = false;
|
||||
fig.ui.home = offered;
|
||||
// What restoring asked of the interface (its windows, files that are gone).
|
||||
fig.handle_ui_requests();
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
|
|
@ -172,6 +212,13 @@ fn main() -> eframe::Result {
|
|||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
// The main window opens where it was (the first frame moves it onto a screen if none shows it).
|
||||
if let Some(w) = saved_main.filter(|_| start == services::Start::Restore) {
|
||||
if let Some([x, y, width, height]) = w.rect {
|
||||
viewport = viewport.with_position([x, y]).with_inner_size([width, height]);
|
||||
}
|
||||
viewport = viewport.with_maximized(w.maximized).with_fullscreen(w.fullscreen);
|
||||
}
|
||||
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
|
||||
// window is frameless and FigCraft draws the buttons and resize edges.
|
||||
if cfg!(target_os = "macos") {
|
||||
|
|
@ -186,5 +233,9 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery, monitors_at: 0.0 }))),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
//! Starting the app with the last session: the windows and tabs kept in the preferences file
|
||||
//! open again, with the restoring mark up; a mark left by a run that never got through restoring
|
||||
//! starts this one without them, on offer; the preference off and `--fresh` start without them
|
||||
//! too; a file given on the command line opens in front of them; a preferences file that is
|
||||
//! junk, cut off or huge never stops the app starting; and after a crash the unsaved documents
|
||||
//! come back from their recovery copies beside the restored tabs, while a normal quit keeps none.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::FigApp;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover};
|
||||
use crate::services::{Desktop, Start, mark_restoring, start_session};
|
||||
|
||||
fn root(name: &str) -> PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-session-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&r);
|
||||
std::fs::create_dir_all(&r).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
/// A run of the app with its preferences in `dir`: files `names` saved there and open, drawn
|
||||
/// until the session is kept, then gone without a word (a crash or a kill; a quit writes the
|
||||
/// same preferences). Returns the files' paths.
|
||||
fn left_open(dir: &Path, names: &[&str]) -> Vec<String> {
|
||||
let mut s = Session::empty();
|
||||
let mut paths = Vec::new();
|
||||
for n in names {
|
||||
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
paths.push(path);
|
||||
}
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)))));
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.ui(ui)).textures_delta.clear();
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// The next run's session, with the preferences in `dir` loaded.
|
||||
fn next(dir: &Path) -> Session {
|
||||
let svc = Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)));
|
||||
let mut s = Session::empty();
|
||||
s.prefs = svc.prefs.clone().expect("the preferences were kept");
|
||||
s
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<String> {
|
||||
s.docs.iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn marked(dir: &Path) -> bool {
|
||||
dir.join("restoring").exists()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tabs_left_open_come_back_and_a_command_line_file_opens_in_front() {
|
||||
let dir = root("restart");
|
||||
let paths = left_open(&dir, &["A", "B", "C"]);
|
||||
let extra = left_open(&root("restart-extra"), &["Extra"]).remove(0);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(s.prefs.session.tabs(), 3, "kept while the app ran, not only when it quits");
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
|
||||
assert!(marked(&dir), "the mark is up until the interface says the restore settled");
|
||||
assert_eq!((names(&s), s.ds().unwrap().doc.name.as_str()), (vec!["A".to_string(), "B".into(), "C".into()], "A"));
|
||||
assert_eq!(s.docs.iter().map(|d| d.path.clone().unwrap()).collect::<Vec<_>>(), paths);
|
||||
// What the app was started with opens in addition, in front.
|
||||
s.execute("file.open", &json!({"path": extra})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None)))));
|
||||
app.handle_ui_requests();
|
||||
let ctx = egui::Context::default();
|
||||
for i in 0..4 {
|
||||
let input = egui::RawInput { time: Some(f64::from(i)), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
|
||||
}
|
||||
assert_eq!(
|
||||
(names(&app.session), app.session.ds().unwrap().doc.name.as_str()),
|
||||
(vec!["A".to_string(), "B".into(), "C".into(), "Extra".into()], "Extra")
|
||||
);
|
||||
assert!(!marked(&dir), "settled: the mark is down");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_never_got_through_restoring_is_not_tried_again() {
|
||||
let dir = root("guard");
|
||||
left_open(&dir, &["A", "B"]);
|
||||
// The last run put the mark up and never took it down.
|
||||
mark_restoring(&dir, true);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Guarded);
|
||||
assert!(s.docs.is_empty() && !marked(&dir));
|
||||
assert_eq!(s.restore.last.as_ref().map(figcraft_engine::restore::SavedSession::tabs), Some(2), "on offer instead");
|
||||
// The home screen's button restores it, with the mark up meanwhile; a crash then is
|
||||
// guarded the same way.
|
||||
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None)))));
|
||||
assert!(app.restore_offered());
|
||||
app.restore_last();
|
||||
assert!(marked(&dir));
|
||||
assert_eq!(names(&app.session), ["A", "B"]);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Guarded);
|
||||
// The run after that one starts as any other.
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
|
||||
assert_eq!(names(&s), ["A", "B"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_off_and_fresh_start_without_restoring() {
|
||||
let given: Vec<String> = ["--fresh", "a.figcraft"].iter().map(|a| a.to_string()).collect();
|
||||
let args = crate::args::parse(&given);
|
||||
assert!(args.fresh && args.files == ["a.figcraft"] && !crate::args::parse(&given[1..]).fresh);
|
||||
let dir = root("off");
|
||||
left_open(&dir, &["A"]);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, true), Start::Fresh, "--fresh");
|
||||
assert!(s.docs.is_empty() && !marked(&dir));
|
||||
assert!(s.restore.last.is_some(), "`session.restore` still brings it back when asked");
|
||||
let mut s = next(&dir);
|
||||
s.execute("pref.restoreSession", &json!({"on": false})).unwrap();
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Fresh, "the preference");
|
||||
assert!(s.docs.is_empty() && !marked(&dir));
|
||||
s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(names(&s), ["A"]);
|
||||
// Nothing kept (a first run): nothing to restore, nothing offered.
|
||||
let mut s = Session::empty();
|
||||
assert_eq!(start_session(None, &mut s, false), Start::Fresh);
|
||||
assert!(s.restore.last.is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preferences_file_that_is_junk_never_stops_the_app_starting() {
|
||||
let dir = root("junk");
|
||||
let paths = left_open(&dir, &["A"]);
|
||||
let state = dir.join("state.json");
|
||||
let whole = std::fs::read_to_string(&state).unwrap();
|
||||
let load = || Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None))).prefs;
|
||||
// Cut off anywhere: the preferences are the defaults, or whole.
|
||||
for cut in (0..whole.len()).step_by((whole.len() / 60).max(1)).filter(|c| whole.is_char_boundary(*c)) {
|
||||
std::fs::write(&state, &whole[..cut]).unwrap();
|
||||
assert!(load().is_none_or(|p| p.session.tabs() <= 1));
|
||||
}
|
||||
// The session as anything at all: the other preferences are still read.
|
||||
let nan_rect = "[1e999, 0, 800, 600]";
|
||||
for session in
|
||||
["null", "7", "\"x\"", "[]", "{\"windows\": 4}", "{\"windows\": [[], 3, {\"tabs\": [{\"path\": 9}, 4], \"rect\": \"wide\"}]}", nan_rect]
|
||||
{
|
||||
std::fs::write(&state, format!("{{\"prefs\": {{\"theme\": \"dark\", \"session\": {session}}}}}")).unwrap();
|
||||
let mut s = Session::empty();
|
||||
if let Some(p) = load() {
|
||||
assert_eq!((p.theme.as_str(), p.session.tabs()), ("dark", 0), "{session}");
|
||||
s.prefs = p;
|
||||
}
|
||||
// (A window without tabs still comes back where it was.)
|
||||
assert_ne!(start_session(Some(&dir), &mut s, false), Start::Guarded, "{session}");
|
||||
assert!(s.docs.is_empty(), "{session}");
|
||||
mark_restoring(&dir, false);
|
||||
}
|
||||
// Wrong types beside usable tabs, absurd rectangles: what is usable comes back.
|
||||
let session = json!({"windows": [{"rect": [0, 0, 0, 0], "maximized": "no", "tabs": [{"path": paths[0], "page": "first", "view": [1, 2]}, {"path": 5}], "front": 99}]});
|
||||
std::fs::write(&state, json!({"prefs": {"session": session}}).to_string()).unwrap();
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(s.prefs.session.windows[0].rect, None);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
|
||||
assert_eq!(names(&s), ["A"]);
|
||||
mark_restoring(&dir, false);
|
||||
// Huge: thousands of windows and tabs are cut to the limits; a file too large to be ours
|
||||
// isn't read at all.
|
||||
let tabs: Vec<_> = (0..2000).map(|i| json!({"path": format!("{i}.figcraft")})).collect();
|
||||
let windows: Vec<_> = (0..100).map(|_| json!({"tabs": tabs})).collect();
|
||||
std::fs::write(&state, json!({"prefs": {"session": {"windows": windows}}}).to_string()).unwrap();
|
||||
let p = load().unwrap();
|
||||
assert_eq!(
|
||||
(p.session.windows.len(), p.session.windows[0].tabs.len()),
|
||||
(figcraft_engine::restore::MAX_WINDOWS, figcraft_engine::restore::MAX_TABS)
|
||||
);
|
||||
let file = std::fs::File::create(&state).unwrap();
|
||||
file.set_len(80 << 20).unwrap();
|
||||
drop(file);
|
||||
assert!(load().is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_a_crash_unsaved_documents_come_back_beside_the_restored_tabs_and_a_quit_keeps_none() {
|
||||
let dir = root("crash");
|
||||
let recovery = dir.join("Recovery");
|
||||
let paths = left_open(&dir, &["A"]);
|
||||
// The run had A open with a change that wasn't saved, and a document never saved.
|
||||
let mut s = next(&dir);
|
||||
s.execute("file.open", &json!({"path": paths[0]})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("file.new", &json!({"name": "Scratch"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let mut r = Recovery::start(&recovery).unwrap();
|
||||
r.write(&s, true);
|
||||
// Killed: the lock goes with the process. The next start gets both back, and the tab.
|
||||
drop(r);
|
||||
let mut killed = next(&dir);
|
||||
assert_eq!(recover(&recovery, &mut killed), 2);
|
||||
assert_eq!(start_session(Some(&dir), &mut killed, false), Start::Restore);
|
||||
assert_eq!(killed.docs.iter().filter(|d| d.is_dirty() && d.path.is_none()).count(), 2, "the recovered copies, unsaved");
|
||||
assert_eq!(killed.docs.iter().filter(|d| d.path.as_deref() == Some(paths[0].as_str()) && !d.is_dirty()).count(), 1, "the tab, as saved");
|
||||
mark_restoring(&dir, false);
|
||||
// A normal quit instead (Don't save was the answer, or there was nothing to ask): the
|
||||
// copies go, and the saved file is all that comes back.
|
||||
let mut r = Recovery::start(&recovery).unwrap();
|
||||
r.write(&s, true);
|
||||
r.finish();
|
||||
let mut quit = next(&dir);
|
||||
assert_eq!(recover(&recovery, &mut quit), 0);
|
||||
assert_eq!(start_session(Some(&dir), &mut quit, false), Start::Restore);
|
||||
assert_eq!((names(&quit), quit.ds().unwrap().is_dirty()), (vec!["A".to_string()], false));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -38,8 +38,13 @@ impl Desktop {
|
|||
|
||||
/// With the preferences kept in `dir`.
|
||||
pub fn load_from(dir: Option<PathBuf>, recovery: crate::recovery::Shared) -> Self {
|
||||
let state: Value =
|
||||
dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
|
||||
// A preferences file this large isn't one the app wrote: starting never waits on it.
|
||||
let read = |d: &PathBuf| {
|
||||
let path = d.join(STATE);
|
||||
std::fs::metadata(&path).ok().filter(|m| m.len() <= MAX_STATE)?;
|
||||
std::fs::read_to_string(path).ok()
|
||||
};
|
||||
let state: Value = dir.as_ref().and_then(read).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
|
||||
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
|
||||
// Recent files lived next to the preferences before they moved into them.
|
||||
if p.recent_files.is_empty()
|
||||
|
|
@ -131,12 +136,69 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
|||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// The preferences' file in the config folder.
|
||||
/// The preferences' file in the config folder, and the most it holds.
|
||||
const STATE: &str = "state.json";
|
||||
const MAX_STATE: u64 = 64 << 20;
|
||||
|
||||
/// The mark kept in the config folder while the last session is being restored: one left behind
|
||||
/// means the run that wrote it never got through restoring (it crashed, or was killed).
|
||||
const RESTORING: &str = "restoring";
|
||||
|
||||
/// Put up or take down the restoring mark in `dir`.
|
||||
pub fn mark_restoring(dir: &Path, on: bool) {
|
||||
let mark = dir.join(RESTORING);
|
||||
let done = if on { std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&mark, b"")) } else { std::fs::remove_file(&mark) };
|
||||
if let Err(e) = done
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
log::warn!("session restore: couldn't update {}: {e}", mark.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// How this run starts.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Start {
|
||||
/// The windows and tabs from last time open again.
|
||||
Restore,
|
||||
/// Without them: the preference is off, `--fresh`, or there were none.
|
||||
Fresh,
|
||||
/// Without them, on the home screen, which offers them: the last run didn't get through
|
||||
/// restoring.
|
||||
Guarded,
|
||||
}
|
||||
|
||||
/// Decide how to start and get the session ready for it: the last run's windows and tabs open
|
||||
/// again (`session.restore`, with the restoring mark up until the interface says it settled),
|
||||
/// or stay on offer (`session.restore` later, the home screen's button) when this run starts
|
||||
/// without them. A mark left by the last run means it never got through: this run doesn't try
|
||||
/// again by itself.
|
||||
pub fn start_session(dir: Option<&Path>, session: &mut Session, fresh: bool) -> Start {
|
||||
let crashed = dir.is_some_and(|d| d.join(RESTORING).exists());
|
||||
if let Some(d) = dir.filter(|_| crashed) {
|
||||
mark_restoring(d, false);
|
||||
}
|
||||
let saved = session.prefs.session.clone();
|
||||
if saved.windows.is_empty() {
|
||||
return Start::Fresh;
|
||||
}
|
||||
if crashed || fresh || !session.prefs.restore_session {
|
||||
session.restore.last = Some(saved);
|
||||
return if crashed { Start::Guarded } else { Start::Fresh };
|
||||
}
|
||||
if let Some(d) = dir {
|
||||
mark_restoring(d, true);
|
||||
}
|
||||
if let Err(e) = session.execute("session.restore", &json!({})) {
|
||||
log::error!("session restore: {e}");
|
||||
}
|
||||
Start::Restore
|
||||
}
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
// A normal quit: whatever was being restored, this run got through it.
|
||||
mark_restoring(&dir, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +254,11 @@ impl Services for Desktop {
|
|||
write_prefs(dir, prefs);
|
||||
}
|
||||
}
|
||||
fn restoring(&mut self, on: bool) {
|
||||
if let Some(dir) = &self.dir {
|
||||
mark_restoring(dir, on);
|
||||
}
|
||||
}
|
||||
fn emergency_save(&mut self, session: &Session) {
|
||||
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
|
||||
r.write(session, true);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
//! A branch's history says where it came from: it starts with an entry for its creation and gets
|
||||
//! one each time it is updated from main, on its tab or closed; the entries are versions like any
|
||||
//! other (viewed, restored, saved with the file) and stay out of the main file's history.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::branches::{CREATED, UPDATED};
|
||||
use crate::collab_tests::base;
|
||||
|
||||
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn layer_name(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// The base file with a branch "Explore" shown: (session, branch id, a rectangle).
|
||||
fn branched() -> (Session, String, NodeId) {
|
||||
let mut s = Session::with_document(base());
|
||||
let r = s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Rectangle)).unwrap().id;
|
||||
let id = ok(&mut s, "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string();
|
||||
(s, id, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_history_starts_with_its_creation_and_notes_each_update_from_main() {
|
||||
let (mut s, id, r) = branched();
|
||||
let original = layer_name(&s, r);
|
||||
assert_eq!(names(&mut s), [CREATED]);
|
||||
let created = ok(&mut s, "version.list", json!({}))[0].clone();
|
||||
assert!(
|
||||
created.get("auto").is_none() && created["time"].as_u64().is_some_and(|t| t > 0),
|
||||
"kept like a named version, never pruned as an autosave"
|
||||
);
|
||||
// Main moves on; the branch takes it: an entry of its own, the branch as it was right after.
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "On the branch"}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
assert!(names(&mut s).is_empty(), "the main file's history has none of it");
|
||||
ok(&mut s, "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10, "props": {"name": "From main"}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
assert_eq!(ok(&mut s, "branch.updateFromMain", json!({}))["updated"], true);
|
||||
assert_eq!(names(&mut s), [CREATED, UPDATED]);
|
||||
// Again, after a version saved by hand: in order.
|
||||
ok(&mut s, "file.saveVersion", json!({"name": "Mine"}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "shape.ellipse", json!({"x": 950, "y": 900, "width": 10, "height": 10}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
ok(&mut s, "branch.updateFromMain", json!({}));
|
||||
assert_eq!(names(&mut s), [CREATED, UPDATED, "Mine", UPDATED]);
|
||||
// They are versions like any other: the creation shows the branch as it began, and restoring
|
||||
// it is one undo step.
|
||||
let first = created["id"].clone();
|
||||
ok(&mut s, "version.restore", json!({"id": first}));
|
||||
assert_eq!(layer_name(&s, r), original);
|
||||
assert!(!s.doc().unwrap().nodes().any(|n| n.name == "From main"));
|
||||
ok(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(layer_name(&s, r), "On the branch");
|
||||
assert!(s.doc().unwrap().nodes().any(|n| n.name == "From main"));
|
||||
// The update's entry is the branch with main's change in it.
|
||||
let update = ok(&mut s, "version.list", json!({}))[1]["id"].clone();
|
||||
ok(&mut s, "version.preview", json!({"id": update}));
|
||||
assert!(s.doc().unwrap().nodes().any(|n| n.name == "From main") && layer_name(&s, r) == "On the branch");
|
||||
ok(&mut s, "version.exitPreview", json!({}));
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_branch_gets_its_entry_too_and_the_history_saves_with_the_file() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-branch-history-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("file.figcraft").to_string_lossy().to_string();
|
||||
let (mut s, id, _) = branched();
|
||||
// Its tab closes; main changes; the branch is updated from the main file's tab.
|
||||
ok(&mut s, "file.close", json!({"index": 1, "force": true}));
|
||||
ok(&mut s, "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10, "props": {"name": "From main"}}));
|
||||
assert_eq!(ok(&mut s, "branch.updateFromMain", json!({"id": id}))["updated"], true);
|
||||
assert!(names(&mut s).is_empty(), "nothing in the main file's history");
|
||||
ok(&mut s, "file.saveAs", json!({"path": path}));
|
||||
// Opened again from the file: the branch's history has both entries.
|
||||
let mut t = Session::empty();
|
||||
ok(&mut t, "file.open", json!({"path": path}));
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), [CREATED, UPDATED]);
|
||||
assert!(t.doc().unwrap().nodes().any(|n| n.name == "From main"));
|
||||
// Merging leaves them with the branch; main gets its own entry for the merge.
|
||||
ok(&mut t, "branch.merge", json!({}));
|
||||
assert_eq!(names(&mut t).last().map(String::as_str), Some("Merged “Explore”"));
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), [CREATED, UPDATED]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
|
@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) {
|
|||
fn versions_saved_on_a_branch_are_the_branchs_own() {
|
||||
let (mut s, _, r) = branched();
|
||||
let original = layer_name(&s, r);
|
||||
// A new branch's history starts empty: the main file's versions stay with it.
|
||||
assert!(names(&mut s).is_empty());
|
||||
// A new branch's history starts with its creation: the main file's versions stay with it.
|
||||
assert_eq!(names(&mut s), ["Branch created"]);
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}}));
|
||||
let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone();
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}}));
|
||||
assert_eq!(names(&mut s), ["Branch v1"]);
|
||||
assert_eq!(names(&mut s), ["Branch created", "Branch v1"]);
|
||||
// Viewing a branch version shows it read-only; going back shows the branch as it is.
|
||||
ok(&mut s, "version.preview", json!({"id": v}));
|
||||
assert_eq!(layer_name(&s, r), "Before");
|
||||
|
|
@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() {
|
|||
ok(&mut t, "file.open", json!({"path": path}));
|
||||
assert_eq!(names(&mut t), ["Main v1"]);
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
|
||||
assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]);
|
||||
// Merging adds a version to the main file's history; the branch keeps its own.
|
||||
ok(&mut t, "branch.merge", json!({}));
|
||||
assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]);
|
||||
assert_eq!(layer_name(&t, r), "On the branch, later");
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
|
||||
assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() {
|
|||
ok(&mut s, "branch.archive", json!({}));
|
||||
assert_eq!(names(&mut s), ["Main v1"]);
|
||||
ok(&mut s, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut s), ["Branch v1"]);
|
||||
assert_eq!(names(&mut s), ["Branch created", "Branch v1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -244,9 +244,18 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
versions: Vec::new(),
|
||||
});
|
||||
let index = open_tab(s, uid, &id)?;
|
||||
// The branch's own history starts with how it was made.
|
||||
if let Some(tab) = s.docs.get_mut(index) {
|
||||
crate::cmd::file::add_version(tab, CREATED, "", false);
|
||||
}
|
||||
Ok(json!({"id": id, "name": name, "index": index}))
|
||||
}
|
||||
|
||||
/// The entries a branch's version history gets by itself: when it was made, and each time it took
|
||||
/// main's changes (the branch as it was right after).
|
||||
pub const CREATED: &str = "Branch created";
|
||||
pub const UPDATED: &str = "Updated from main";
|
||||
|
||||
/// Open branch `id` of the file in the tab with uid `main` in a tab, or switch to its tab.
|
||||
fn open_tab(s: &mut Session, main: u64, id: &str) -> Result<usize> {
|
||||
if let Some(i) = tab_of(s, main, id) {
|
||||
|
|
@ -357,15 +366,23 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result<Value> {
|
|||
out["merged"] = json!(true);
|
||||
out["index"] = json!(s.active);
|
||||
} else {
|
||||
if let Some(t) = tab_of(s, main_uid, &id).and_then(|t| s.docs.get_mut(t)) {
|
||||
let tab = tab_of(s, main_uid, &id).and_then(|t| s.docs.get_mut(t));
|
||||
let open = tab.is_some();
|
||||
if let Some(t) = tab {
|
||||
show(t, doc.clone());
|
||||
// Undo can't step back past the new base: that would undo main's changes on the branch.
|
||||
t.history.undo.clear();
|
||||
t.history.redo.clear();
|
||||
// In the branch's history (its tab's, while it is open).
|
||||
crate::cmd::file::add_version(t, UPDATED, "", false);
|
||||
}
|
||||
if let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) {
|
||||
b.base = main;
|
||||
b.head = doc;
|
||||
b.head = doc.clone();
|
||||
if !open && b.versions.len() < figcraft_format::MAX_VERSIONS {
|
||||
let id = crate::cmd::file::unique_id(|id| b.versions.iter().any(|v| v.id == id));
|
||||
b.versions.push(figcraft_format::Version::new(id, UPDATED, "", crate::cmd::comment::now_secs(), doc));
|
||||
}
|
||||
}
|
||||
out["updated"] = json!(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -649,7 +649,7 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"files": files}))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
let variables = variables_param("file.open", p)?;
|
||||
// Already open: switch to it.
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ fn registry() -> &'static Registry {
|
|||
list.extend(crate::online::specs());
|
||||
list.extend(crate::invite_links::specs());
|
||||
list.extend(crate::templates::specs());
|
||||
list.extend(crate::restore::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
|
|
|||
|
|
@ -59,16 +59,36 @@ pub fn link(doc: &Document, id: NodeId, lang: CodeLang) -> Option<&CodeLink> {
|
|||
doc.node(owner(doc, id)?)?.code_links.iter().find(|l| l.language == lang.id())
|
||||
}
|
||||
|
||||
/// The import line of `link` ("" without an import path).
|
||||
pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
||||
let path = &link.import_path;
|
||||
/// What a link's import brings in: `Card` for the code component `Card.Header`.
|
||||
fn imported(link: &CodeLink) -> &str {
|
||||
link.name.split('.').next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The line importing `names` from `path`.
|
||||
fn import_of(path: &str, names: &[&str], lang: CodeLang) -> String {
|
||||
match lang {
|
||||
_ if path.is_empty() => String::new(),
|
||||
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
|
||||
CodeLang::React => format!("import {{ {} }} from {};", names.join(", "), js_string(path)),
|
||||
_ => format!("import {path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The import line of `link` ("" without an import path).
|
||||
pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
||||
if link.import_path.is_empty() { String::new() } else { import_of(&link.import_path, &[imported(link)], lang) }
|
||||
}
|
||||
|
||||
/// The import lines of `links` (the connected instances in a generated layer tree): one line per
|
||||
/// import path, in path order, each naming what it brings in once, in name order. "" when none
|
||||
/// has an import path.
|
||||
pub fn import_lines(links: &[CodeLink], lang: CodeLang) -> String {
|
||||
let mut by_path: std::collections::BTreeMap<&str, std::collections::BTreeSet<&str>> = std::collections::BTreeMap::new();
|
||||
for l in links.iter().filter(|l| !l.import_path.is_empty()) {
|
||||
by_path.entry(&l.import_path).or_default().insert(imported(l));
|
||||
}
|
||||
let lines: Vec<String> = by_path.iter().map(|(path, names)| import_of(path, &names.iter().copied().collect::<Vec<_>>(), lang)).collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Layer `id`'s code with `link`: the import line and the snippet (an instance's values, else the
|
||||
/// defaults). `None` when `id` isn't a component, set or instance.
|
||||
pub fn connected_code(doc: &Document, id: NodeId, lang: CodeLang, link: &CodeLink) -> Option<String> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
//! Import lines in generated layer trees: the code for a frame (or a component, or an instance
|
||||
//! of a component that isn't connected) leads with the imports of every connected instance
|
||||
//! written into it, one line per import path, names in order and once each, per language;
|
||||
//! instances that aren't connected, and connected ones the code doesn't reach, bring none.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use figcraft_doc::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
|
||||
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A component named `name` at `y`, connected in `lang` to `code_name` from `path` ("" for none).
|
||||
fn component(s: &mut Session, name: &str, y: f64, links: &[(&str, &str, &str)]) -> NodeId {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 600, "y": y, "width": 80, "height": 40, "name": name})));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
for (lang, path, code_name) in links {
|
||||
ex(s, "codeConnect.set", json!({"id": c, "language": lang, "importPath": path, "name": code_name, "template": format!("<{code_name} />")}));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn instance(s: &mut Session, component: NodeId, parent: NodeId, x: f64) -> NodeId {
|
||||
idv(&ex(s, "instance.create", json!({"component": component, "x": x, "y": 10, "parent": parent})))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_trees_code_leads_with_the_imports_of_its_connected_instances() {
|
||||
let mut s = Session::new();
|
||||
let tag = component(&mut s, "Tag", 0.0, &[("react", "@acme/ui", "Tag"), ("swiftui", "AcmeUI", "Tag"), ("compose", "com.acme.ui.Tag", "Tag")]);
|
||||
let avatar = component(&mut s, "Avatar", 60.0, &[("react", "@acme/ui", "Avatar"), ("swiftui", "AcmeUI", "Avatar")]);
|
||||
let icon = component(&mut s, "Icon", 120.0, &[("react", "@acme/icons", "Icons.Star"), ("compose", "com.acme.icons.Star", "Star")]);
|
||||
let local = component(&mut s, "Local", 180.0, &[("react", "", "Local")]);
|
||||
let plain = component(&mut s, "Plain", 240.0, &[]);
|
||||
let unused = component(&mut s, "Unused", 300.0, &[("react", "@acme/unused", "Unused")]);
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 200, "name": "Header"})));
|
||||
let group = idv(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 180, "height": 100, "name": "Actions", "parent": frame})));
|
||||
// Two tags, an icon, an avatar deeper down, and instances without a link or an import path.
|
||||
for (c, parent, x) in
|
||||
[(tag, frame, 0.0), (icon, frame, 90.0), (tag, frame, 180.0), (avatar, group, 0.0), (local, group, 90.0), (plain, frame, 270.0)]
|
||||
{
|
||||
instance(&mut s, c, parent, x);
|
||||
}
|
||||
let react = code(&mut s, frame, "react");
|
||||
let want = "import { Icons } from \"@acme/icons\";\nimport { Avatar, Tag } from \"@acme/ui\";\nimport \"./header.css\";\n\n";
|
||||
assert!(react.starts_with(want), "{react}");
|
||||
assert_eq!(react.matches("@acme/ui").count(), 1, "once, however many instances");
|
||||
assert_eq!((react.matches("<Tag />").count(), react.matches("<Avatar />").count(), react.matches("<Icons.Star />").count()), (2, 1, 1));
|
||||
assert!(!react.contains("@acme/unused") && !react.contains("import { Local }") && !react.contains("import { Plain }"));
|
||||
// Per language: a module or a package each, in order, once.
|
||||
let swift = code(&mut s, frame, "swiftui");
|
||||
assert!(swift.starts_with("import AcmeUI\n\n") && swift.matches("import ").count() == 1, "{swift}");
|
||||
let compose = code(&mut s, frame, "compose");
|
||||
assert!(compose.starts_with("import com.acme.icons.Star\nimport com.acme.ui.Tag\n\n") && compose.matches("import ").count() == 2, "{compose}");
|
||||
// The part of the tree asked for: only what is in it.
|
||||
let part = code(&mut s, group, "react");
|
||||
assert!(part.starts_with("import { Avatar } from \"@acme/ui\";\nimport \"./actions.css\";\n\n"), "{part}");
|
||||
// CSS and HTML have no imports.
|
||||
assert!(!code(&mut s, frame, "css").contains("import") && !code(&mut s, frame, "html").contains("@acme"));
|
||||
// A layer tree without connected instances is as it was.
|
||||
let bare = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 100, "height": 100, "name": "Bare"})));
|
||||
instance(&mut s, plain, bare, 0.0);
|
||||
assert!(code(&mut s, bare, "react").starts_with("import \"./bare.css\";\n\n"));
|
||||
assert!(!code(&mut s, bare, "swiftui").contains("import "));
|
||||
let _ = unused;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_components_definition_brings_the_imports_of_what_it_is_made_of() {
|
||||
let mut s = Session::new();
|
||||
let tag = component(&mut s, "Tag", 0.0, &[("react", "@acme/ui", "Tag")]);
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100, "name": "Card"})));
|
||||
instance(&mut s, tag, f, 10.0);
|
||||
let card = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
// The component, and an instance of it (its call, then the definition): the import leads both.
|
||||
let want = "import { Tag } from \"@acme/ui\";\n";
|
||||
assert!(code(&mut s, card, "react").starts_with(want));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300})));
|
||||
let of_instance = code(&mut s, i, "react");
|
||||
assert!(of_instance.starts_with(&format!("{want}\n<Card />")), "{of_instance}");
|
||||
assert_eq!(of_instance.matches("@acme/ui").count(), 1);
|
||||
// A connected layer's own code is its import and snippet, as before.
|
||||
let t = instance(&mut s, tag, f, 100.0);
|
||||
assert_eq!(code(&mut s, t, "react"), "import { Tag } from \"@acme/ui\";\n\n<Tag />\n");
|
||||
// Nothing is left collecting: the next layer's code starts clean.
|
||||
let empty = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 50, "height": 50, "name": "Empty"})));
|
||||
assert!(code(&mut s, empty, "react").starts_with("import \"./empty.css\";"));
|
||||
}
|
||||
|
|
@ -107,14 +107,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
)]
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The links of the connected instances written into the code [`code`] is generating
|
||||
/// (`None`: nothing is collecting them).
|
||||
static CONNECTED: std::cell::RefCell<Option<Vec<CodeLink>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Start (`Some`) or stop collecting the links of connected instances as their snippets are
|
||||
/// written; what was collected until now.
|
||||
fn collect_connected(next: Option<Vec<CodeLink>>) -> Vec<CodeLink> {
|
||||
CONNECTED.with(|c| c.try_borrow_mut().map(|mut c| std::mem::replace(&mut *c, next)).ok().flatten()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a
|
||||
/// function component).
|
||||
/// function component). The import lines of the connected instances in that code lead it.
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
|
||||
return code;
|
||||
}
|
||||
collect_connected(Some(Vec::new()));
|
||||
let code = generated(doc, id, lang);
|
||||
let imports = crate::code_connect::import_lines(&collect_connected(None), lang);
|
||||
match (imports.is_empty(), code.starts_with("import ")) {
|
||||
(true, _) => code,
|
||||
// With the imports the code has already.
|
||||
(false, true) => format!("{imports}\n{code}"),
|
||||
(false, false) => format!("{imports}\n\n{code}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`code`] without the import lines of connected instances.
|
||||
fn generated(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
|
||||
match (&n.kind, comp) {
|
||||
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
|
||||
|
|
@ -460,9 +485,18 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
|||
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
|
||||
let values = instance_values(doc, id, &c)?;
|
||||
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
|
||||
// A connected component: its real snippet. shortcut: inside a layer tree it brings no import
|
||||
// line; collect them at the top of the tree's code when people ask for it.
|
||||
// A connected component: its real snippet; its import goes to the top of the code it is in.
|
||||
if let Some(link) = crate::code_connect::link(doc, id, lang) {
|
||||
CONNECTED.with(|list| {
|
||||
if let Ok(mut list) = list.try_borrow_mut()
|
||||
&& let Some(list) = list.as_mut()
|
||||
{
|
||||
// Once each: a tree can hold thousands of instances of one component.
|
||||
if !list.contains(link) {
|
||||
list.push(link.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad));
|
||||
}
|
||||
let slots = slots_of(doc, id);
|
||||
|
|
|
|||
|
|
@ -547,7 +547,11 @@ fn branches_and_the_version_history() {
|
|||
// On the branch tab.
|
||||
// A branch keeps its own version history: a version saved on its tab is listed there only.
|
||||
ex(&mut s, "file.saveVersion", json!({"name": "On the branch"}));
|
||||
assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::<Vec<_>>(), [json!("On the branch")], "a branch's own history");
|
||||
assert_eq!(
|
||||
versions(&mut s).iter().map(|v| v["name"].clone()).collect::<Vec<_>>(),
|
||||
[json!("Branch created"), json!("On the branch")],
|
||||
"a branch's own history"
|
||||
);
|
||||
rect(&mut s, 900.0, 900.0, None);
|
||||
ex(&mut s, "doc.switch", json!({"index": 0}));
|
||||
let made_on_main = rect(&mut s, -500.0, 0.0, None);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub mod plugin_directory;
|
|||
pub mod plugins;
|
||||
pub mod proto_run;
|
||||
pub mod rename;
|
||||
pub mod restore;
|
||||
pub mod rotation_origin;
|
||||
pub mod shape_handles;
|
||||
pub mod shapebuilder;
|
||||
|
|
@ -328,6 +329,11 @@ pub struct DocState {
|
|||
pub guide: Option<(NodeId, usize)>,
|
||||
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
|
||||
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
|
||||
/// The widgets' pending timers (see [`widgets`]; not saved: closing the file cancels them).
|
||||
pub(crate) widget_timers: Vec<widgets::Timer>,
|
||||
/// The part of a widget shown in its hover style and the page point the pointer is at: `doc`
|
||||
/// shows that look as a live preview (`text_preview` keeps the document itself).
|
||||
pub(crate) widget_hover: Option<(NodeId, figcraft_geom::Point)>,
|
||||
}
|
||||
|
||||
/// A version shown on the canvas in place of the document: read-only until it's restored or the
|
||||
|
|
@ -383,6 +389,8 @@ impl DocState {
|
|||
focus: None,
|
||||
guide: None,
|
||||
rotation_origin: None,
|
||||
widget_timers: Vec::new(),
|
||||
widget_hover: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
st
|
||||
|
|
@ -414,6 +422,22 @@ impl DocState {
|
|||
&mut self.branches
|
||||
}
|
||||
|
||||
/// Take a widget part's hover look off (see [`widgets::set_hover`]): the document shows as it
|
||||
/// is. Returns the page point the pointer was at, to put the look back for.
|
||||
pub(crate) fn end_widget_hover(&mut self) -> Option<figcraft_geom::Point> {
|
||||
let (_, at) = self.widget_hover.take()?;
|
||||
if let Some(base) = self.text_preview.take() {
|
||||
self.doc = base;
|
||||
self.revision += 1;
|
||||
}
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// The document as shown, without a widget part's hover look: what a prototype of it plays.
|
||||
pub fn without_widget_hover(&self) -> &Arc<Document> {
|
||||
self.text_preview.as_ref().filter(|_| self.widget_hover.is_some()).unwrap_or(&self.doc)
|
||||
}
|
||||
|
||||
/// The document itself, not a version or live text preview shown in its place.
|
||||
pub fn document_itself(&self) -> &Arc<Document> {
|
||||
self.preview.as_ref().map(|p| &p.doc).or(self.text_preview.as_ref()).unwrap_or(&self.doc)
|
||||
|
|
@ -591,6 +615,10 @@ pub struct Prefs {
|
|||
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
|
||||
/// again).
|
||||
pub getting_started_dismissed: bool,
|
||||
/// Reopen windows and tabs from last time when the app starts (`pref.restoreSession`).
|
||||
pub restore_session: bool,
|
||||
/// The windows and tabs open now, kept as they change ([`restore`]).
|
||||
pub session: restore::SavedSession,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -616,6 +644,8 @@ impl Default for Prefs {
|
|||
recent_files: Vec::new(),
|
||||
online_files: Vec::new(),
|
||||
getting_started_dismissed: false,
|
||||
restore_session: true,
|
||||
session: restore::SavedSession::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -673,6 +703,8 @@ pub struct Session {
|
|||
pub live: live::Lives,
|
||||
/// Commands the UI runs without waiting on a server ([`Session::start_background`]).
|
||||
pub background: background::Background,
|
||||
/// Session restore: the last run's session while it is on offer, online files on their way.
|
||||
pub restore: restore::State,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -707,6 +739,7 @@ impl Session {
|
|||
plugins: plugins::Runtime::default(),
|
||||
live: live::Lives::default(),
|
||||
background: background::Background::default(),
|
||||
restore: restore::State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1064,6 +1097,12 @@ impl Session {
|
|||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the document, selection, page and history are put back.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
self.grouped_as(Some(label), f)
|
||||
}
|
||||
|
||||
/// Like [`grouped`](Self::grouped); without a `label` what `f` changes is no undo step of its
|
||||
/// own (it joins the step before it).
|
||||
pub(crate) fn grouped_as<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let ds = self.ds_mut()?;
|
||||
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
|
|
@ -1079,7 +1118,7 @@ impl Session {
|
|||
ds.selection = sel;
|
||||
ds.page = page;
|
||||
} else if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
self.record(uid, Some(label), before, sel, page);
|
||||
self.record(uid, label, before, sel, page);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -1347,6 +1386,8 @@ mod binding_lists_tests;
|
|||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_history_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_versions_tests;
|
||||
|
|
@ -1357,6 +1398,8 @@ mod canvas_fixes_tests;
|
|||
#[cfg(test)]
|
||||
mod code_connect_tests;
|
||||
#[cfg(test)]
|
||||
mod code_imports_tests;
|
||||
#[cfg(test)]
|
||||
mod collab_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod collab_live_tests;
|
||||
|
|
@ -1493,6 +1536,8 @@ mod proto_run_tests;
|
|||
#[cfg(test)]
|
||||
mod proto_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod restore_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_walk_tests;
|
||||
#[cfg(test)]
|
||||
mod rotated_guides_tests;
|
||||
|
|
@ -1576,3 +1621,5 @@ mod version_tests;
|
|||
mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_v4_tests;
|
||||
|
|
|
|||
|
|
@ -871,6 +871,8 @@ impl Session {
|
|||
/// stay where they can, and the undo history isn't touched.
|
||||
pub fn apply_remote(&mut self, uid: u64, doc: Arc<Document>) {
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
// A widget's hover look isn't the document: it goes (the next pointer move shows it again).
|
||||
ds.end_widget_hover();
|
||||
ds.doc = doc;
|
||||
if !ds.doc.pages.contains(&ds.page) {
|
||||
ds.page = ds.doc.first_page().unwrap_or(ds.page);
|
||||
|
|
@ -1320,6 +1322,8 @@ impl Session {
|
|||
fn live_deferred(&mut self, id: SessionId) {
|
||||
let Some(live) = self.live.sessions.get(&id).filter(|l| !l.deferred.is_empty() && l.syncing()) else { return };
|
||||
let Some(uid) = live.doc_uid else { return };
|
||||
// A widget's hover look is no reason to wait: it steps aside and comes back after.
|
||||
let hover = self.docs.iter_mut().find(|d| d.uid == uid).and_then(DocState::end_widget_hover);
|
||||
if self.docs.iter().find(|d| d.uid == uid).is_none_or(busy) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1343,6 +1347,9 @@ impl Session {
|
|||
}
|
||||
self.live.send(id, out);
|
||||
self.apply_remote(uid, doc);
|
||||
if self.tab() == Some(uid) {
|
||||
crate::widgets::rehover(self, hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the peer followed in session `id`: their viewport (and their page, in the session's
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ fn named(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
|
|||
s.online_named(cmd, file.trim(), string(p, "server").as_deref().map(str::trim))
|
||||
}
|
||||
|
||||
fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.openFile";
|
||||
let i = named(s, CMD, p)?;
|
||||
let Some(f) = s.prefs.online_files.get(i).cloned() else { return Err(bad(CMD, "no such file")) };
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::cmd::{CommandSpec, find_command};
|
|||
use crate::params::{get, num, string};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind};
|
||||
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, WidgetFit};
|
||||
|
||||
/// What one plugin may keep in its settings (keys and JSON values, in bytes), and its longest key.
|
||||
const MAX_SETTINGS_BYTES: usize = 64 * 1024;
|
||||
|
|
@ -286,9 +286,10 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
|
|||
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
|
||||
}
|
||||
|
||||
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
|
||||
pub(crate) fn limits(fuel: Option<f64>) -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
/// The sandbox limits for a run of the plugin with manifest `m` (the plugin API it was written
|
||||
/// for decides what it may use), with `fuel` lowering the instruction budget.
|
||||
pub(crate) fn limits(fuel: Option<f64>, m: &Manifest) -> Limits {
|
||||
let mut limits = Limits { api: m.api, ..Limits::default() };
|
||||
if let Some(f) = fuel.filter(|f| f.is_finite()) {
|
||||
limits.fuel = (f.max(1000.0) as u64).min(limits.fuel);
|
||||
}
|
||||
|
|
@ -394,7 +395,7 @@ pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fue
|
|||
let c = m.command(command).ok_or_else(|| bad(cmd, format!("{} has no command `{}`", m.name, command.unwrap_or_default())))?;
|
||||
let params = c.resolve_params(params).map_err(|e| bad(cmd, e))?;
|
||||
let wasm = module_of(s, &p)?;
|
||||
let limits = limits(fuel);
|
||||
let limits = limits(fuel, m);
|
||||
// The API the plugin was written for: a v1 plugin gets the input it always got.
|
||||
let input = json!({"api": m.api, "command": c.id, "params": params, "selection": s.selection()}).to_string();
|
||||
s.plugins.last = Some((m.id.clone(), c.id.clone(), params.clone()));
|
||||
|
|
@ -465,8 +466,11 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let c = open
|
||||
.panel
|
||||
.control(&control)
|
||||
.filter(|c| c.kind != ParamKind::Note)
|
||||
.filter(|c| c.is_field() || c.kind == ParamKind::Button)
|
||||
.ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?;
|
||||
if c.disabled {
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("`{control}` is disabled")));
|
||||
}
|
||||
let field = c.is_field();
|
||||
let value = if field { c.coerce(get(p, "value").unwrap_or(&Value::Null)).map_err(|e| bad(cmd, e))? } else { Value::Null };
|
||||
let mut open = s.plugins.panels.remove(at);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,398 @@
|
|||
//! Session restore (milestone M12): the app comes back the way it was left.
|
||||
//!
|
||||
//! The interface keeps a [`SavedSession`] with the preferences ([`Prefs::session`](crate::Prefs)),
|
||||
//! written whenever it changes: each window's place on screen and panels, its tabs in order and
|
||||
//! the one in front. A tab is a saved file (its path) or one of your online files (its server and
|
||||
//! id, never a token: it is joined again through the record, `collab.openFile`). Documents that
|
||||
//! were never saved aren't in it: unsaved work is the recovery copies' job.
|
||||
//!
|
||||
//! `session.restore` opens the tabs again and asks the interface to put them in their windows.
|
||||
//! Anything may be in a preferences file: [`SavedSession::read`] keeps what is usable, within
|
||||
//! limits, and never fails.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::{DocState, Result, Session, ViewState, bad, spec};
|
||||
|
||||
/// Most windows and most tabs in a window a saved session holds.
|
||||
pub const MAX_WINDOWS: usize = 16;
|
||||
pub const MAX_TABS: usize = 100;
|
||||
|
||||
/// A window's sides, in screen points: anything outside isn't a window anyone left.
|
||||
const SIDE: std::ops::RangeInclusive<f32> = 100.0..=16_384.0;
|
||||
const FAR: f32 = 1_000_000.0;
|
||||
|
||||
/// The windows the app had open, the main window first.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct SavedSession {
|
||||
pub windows: Vec<SavedWindow>,
|
||||
}
|
||||
|
||||
/// A window: where it was (the interface checks it against the screens there are now), its
|
||||
/// panels and its tabs.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedWindow {
|
||||
/// `[x, y, width, height]` while it was neither maximized nor full screen (screen points).
|
||||
pub rect: Option<[f32; 4]>,
|
||||
pub maximized: bool,
|
||||
pub fullscreen: bool,
|
||||
/// The screen it was on, `[x, y, width, height]`.
|
||||
pub monitor: Option<[f32; 4]>,
|
||||
pub tabs: Vec<SavedTab>,
|
||||
/// Which of `tabs` was in front.
|
||||
pub front: usize,
|
||||
/// The side panels' tabs (the interface's own names) and widths.
|
||||
pub left_tab: String,
|
||||
pub right_tab: String,
|
||||
pub left_collapsed: bool,
|
||||
pub panel_widths: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// A tab: a saved file, or one of your online files.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct SavedTab {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// An online file's server (`wss://host/`, never with a key) and id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file: Option<String>,
|
||||
/// The page shown (its place among the file's pages).
|
||||
pub page: usize,
|
||||
/// The view: the page point at the centre, and the zoom.
|
||||
pub view: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// `n` finite numbers.
|
||||
fn numbers<const N: usize>(v: Option<&Value>) -> Option<[f64; N]> {
|
||||
let list = v?.as_array().filter(|a| a.len() == N)?;
|
||||
let mut out = [0.0; N];
|
||||
for (slot, n) in out.iter_mut().zip(list) {
|
||||
*slot = n.as_f64().filter(|n| n.is_finite())?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A rectangle a window or a screen can have: on a sane part of the plane, neither empty nor huge.
|
||||
fn rect(v: Option<&Value>) -> Option<[f32; 4]> {
|
||||
let r = numbers::<4>(v)?.map(|n| n as f32);
|
||||
(r[0].abs() <= FAR && r[1].abs() <= FAR && SIDE.contains(&r[2]) && SIDE.contains(&r[3])).then_some(r)
|
||||
}
|
||||
|
||||
/// A short text without control characters (`None`: not one, or longer than `max`).
|
||||
fn text(v: Option<&Value>, max: usize) -> Option<String> {
|
||||
v?.as_str().filter(|s| !s.is_empty() && s.len() <= max && !s.contains(char::is_control)).map(str::to_string)
|
||||
}
|
||||
|
||||
impl SavedTab {
|
||||
fn read(v: &Value) -> Option<Self> {
|
||||
let path = text(v.get("path"), 4096);
|
||||
let server = text(v.get("server"), 512).filter(|s| s.starts_with("ws://") || s.starts_with("wss://"));
|
||||
let file = text(v.get("file"), 64).filter(|f| f.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
|
||||
let (server, file) = server.zip(file).unzip();
|
||||
if path.is_none() && file.is_none() {
|
||||
return None;
|
||||
}
|
||||
let page = v.get("page").and_then(Value::as_u64).map_or(0, |p| p.min(100_000) as usize);
|
||||
let far = |n: f64| n.clamp(-1e9, 1e9);
|
||||
let view = numbers::<3>(v.get("view")).map(|[x, y, z]| [far(x), far(y), z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM)]);
|
||||
Some(Self { path, server, file, page, view })
|
||||
}
|
||||
|
||||
/// The same file as `other`.
|
||||
fn is(&self, other: &SavedTab) -> bool {
|
||||
match (&self.path, &other.path) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
(None, None) => self.file == other.file && self.server == other.server,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's name, for a message.
|
||||
fn name(&self) -> String {
|
||||
let path =
|
||||
self.path.as_deref().map(|p| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |f| f.to_string_lossy().into_owned()));
|
||||
path.or_else(|| self.file.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl SavedWindow {
|
||||
fn read(v: &Value) -> Option<Self> {
|
||||
v.as_object()?;
|
||||
let flag = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut tabs: Vec<SavedTab> = Vec::new();
|
||||
let listed = v.get("tabs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
// The tab in front keeps its place when tabs before it are dropped.
|
||||
let asked = v.get("front").and_then(Value::as_u64).unwrap_or(0);
|
||||
let mut front = 0;
|
||||
for (i, tab) in listed.iter().take(4 * MAX_TABS).enumerate() {
|
||||
let Some(tab) = SavedTab::read(tab).filter(|t| tabs.len() < MAX_TABS && !tabs.iter().any(|o| o.is(t))) else { continue };
|
||||
if i as u64 <= asked {
|
||||
front = tabs.len();
|
||||
}
|
||||
tabs.push(tab);
|
||||
}
|
||||
Some(Self {
|
||||
rect: rect(v.get("rect")),
|
||||
maximized: flag("maximized"),
|
||||
fullscreen: flag("fullscreen"),
|
||||
monitor: rect(v.get("monitor")),
|
||||
tabs,
|
||||
front,
|
||||
left_tab: text(v.get("leftTab"), 32).unwrap_or_default(),
|
||||
right_tab: text(v.get("rightTab"), 32).unwrap_or_default(),
|
||||
left_collapsed: flag("leftCollapsed"),
|
||||
panel_widths: numbers::<2>(v.get("panelWidths")).map(|w| w.map(|w| (w as f32).clamp(0.0, 10_000.0))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SavedSession {
|
||||
/// What of `v` is a usable saved session (nothing, for anything else).
|
||||
pub fn read(v: &Value) -> Self {
|
||||
let listed = v.get("windows").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
Self { windows: listed.iter().take(4 * MAX_WINDOWS).filter_map(SavedWindow::read).take(MAX_WINDOWS).collect() }
|
||||
}
|
||||
|
||||
/// How many tabs it would open.
|
||||
pub fn tabs(&self) -> usize {
|
||||
self.windows.iter().map(|w| w.tabs.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SavedSession {
|
||||
/// Junk here never costs the other preferences.
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
Ok(Value::deserialize(d).map(|v| Self::read(&v)).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// One of your online files on its way back into a restored window.
|
||||
struct Pending {
|
||||
/// The live session joining it.
|
||||
session: u64,
|
||||
/// Which of the restored windows it belongs to, and whether it is the tab in front there.
|
||||
window: usize,
|
||||
front: bool,
|
||||
tab: SavedTab,
|
||||
/// The tab before it in its window (`None`: it leads).
|
||||
after: Option<u64>,
|
||||
}
|
||||
|
||||
/// A restored online file that arrived from its server ([`Session::restore_arrivals`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Arrival {
|
||||
/// The open document.
|
||||
pub uid: u64,
|
||||
/// Which of the restored windows it belongs to (as `session.restore` listed them).
|
||||
pub window: usize,
|
||||
/// It was the tab in front there.
|
||||
pub front: bool,
|
||||
}
|
||||
|
||||
/// A restored window for the interface to place: what was saved of it, its tabs that are open
|
||||
/// (documents, in order) and the one in front.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Placed {
|
||||
pub saved: SavedWindow,
|
||||
pub docs: Vec<u64>,
|
||||
pub front: Option<u64>,
|
||||
}
|
||||
|
||||
/// What a session knows about restoring.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// The session of the last run, when this run started without restoring it: what
|
||||
/// `session.restore` brings back (once).
|
||||
pub last: Option<SavedSession>,
|
||||
/// The windows `session.restore` opened the tabs of, until the interface places them, and
|
||||
/// how many files were gone.
|
||||
pub placing: Option<(Vec<Placed>, usize)>,
|
||||
pending: Vec<Pending>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// The online files still on their way, with the restored window each belongs to: a session
|
||||
/// saved meanwhile keeps them.
|
||||
pub fn joining(&self) -> impl Iterator<Item = (usize, &SavedTab)> {
|
||||
self.pending.iter().map(|p| (p.window, &p.tab))
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `tab`'s page and view in its document.
|
||||
fn show(ds: &mut DocState, tab: &SavedTab) {
|
||||
if let Some(page) = ds.doc.pages.get(tab.page).copied().filter(|p| *p != ds.page && !ds.doc.is_divider(*p)) {
|
||||
ds.page = page;
|
||||
ds.selection.clear();
|
||||
}
|
||||
if let Some([cx, cy, zoom]) = tab.view {
|
||||
(ds.view.cx, ds.view.cy, ds.view.zoom) = (cx, cy, zoom);
|
||||
// The interface fits a document to its window the first time it sizes its view (one
|
||||
// still exactly at the default size): this one has its view already.
|
||||
ds.view.vw += 1e-6;
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Open document `d` as a tab of a saved session (`None`: one that was never saved, or a
|
||||
/// branch's tab, which opens from its file).
|
||||
pub fn saved_tab(&self, d: &DocState) -> Option<SavedTab> {
|
||||
if d.branch_of.is_some() {
|
||||
return None;
|
||||
}
|
||||
let online = || self.online_of(d.uid).map(|(server, file, _)| (server, file));
|
||||
let (server, file) = if d.path.is_some() { (None, None) } else { online().unzip() };
|
||||
if d.path.is_none() && file.is_none() {
|
||||
return None;
|
||||
}
|
||||
let page = d.doc.pages.iter().position(|p| *p == d.page).unwrap_or(0);
|
||||
Some(SavedTab { path: d.path.clone(), server, file, page, view: Some([d.view.cx, d.view.cy, d.view.zoom]) })
|
||||
}
|
||||
|
||||
/// Move open document `uid` to just after document `after` (`None`: before every other).
|
||||
fn tab_after(&mut self, uid: u64, after: Option<u64>) {
|
||||
let active = self.docs.get(self.active).map(|d| d.uid);
|
||||
let Some(from) = self.docs.iter().position(|d| d.uid == uid) else { return };
|
||||
let d = self.docs.remove(from);
|
||||
let to = match after {
|
||||
Some(a) => self.docs.iter().position(|d| d.uid == a).map_or(self.docs.len(), |i| i + 1),
|
||||
None => 0,
|
||||
};
|
||||
self.docs.insert(to, d);
|
||||
self.active = self.docs.iter().position(|d| Some(d.uid) == active).unwrap_or(self.active);
|
||||
}
|
||||
|
||||
/// The restored online files that arrived from their servers since the last call, each in
|
||||
/// its place among the tabs with its page and view; one that couldn't be joined is skipped
|
||||
/// with a message. The interface puts them in their windows.
|
||||
pub fn restore_arrivals(&mut self) -> Vec<Arrival> {
|
||||
let mut out = Vec::new();
|
||||
for p in std::mem::take(&mut self.restore.pending) {
|
||||
let live = self.live.get(p.session).map(|l| (l.doc_uid, l.status.clone()));
|
||||
match live {
|
||||
Some((Some(uid), _)) if self.docs.iter().any(|d| d.uid == uid) => {
|
||||
self.tab_after(uid, p.after);
|
||||
if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) {
|
||||
show(ds, &p.tab);
|
||||
}
|
||||
// The tabs that follow it there come after it.
|
||||
for later in self.restore.pending.iter_mut().filter(|l| l.window == p.window && l.after == p.after) {
|
||||
later.after = Some(uid);
|
||||
}
|
||||
out.push(Arrival { uid, window: p.window, front: p.front });
|
||||
}
|
||||
Some((_, status)) if status.active() => self.restore.pending.push(p),
|
||||
gone => {
|
||||
let why = gone.map(|(_, status)| format!(": {}", status.label())).unwrap_or_default();
|
||||
self.message = Some(format!("{} couldn't be reopened from its server{why}", p.tab.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How `tab` came back.
|
||||
enum Opened {
|
||||
Doc(u64),
|
||||
Joining(u64),
|
||||
Missing,
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, tab: &SavedTab) -> Opened {
|
||||
let uid_at =
|
||||
|s: &Session, r: &Value| r.get("index").or(r.get("document")).and_then(Value::as_u64).and_then(|i| s.docs.get(i as usize)).map(|d| d.uid);
|
||||
if let Some(path) = &tab.path {
|
||||
return match crate::cmd::file::open(s, &json!({"path": path})) {
|
||||
Ok(r) => uid_at(s, &r).map_or(Opened::Missing, Opened::Doc),
|
||||
Err(e) => {
|
||||
log::info!("session restore: {e}");
|
||||
Opened::Missing
|
||||
}
|
||||
};
|
||||
}
|
||||
match crate::online::open_file(s, &json!({"file": tab.file, "server": tab.server})) {
|
||||
Ok(r) => match (uid_at(s, &r), r.get("session").and_then(Value::as_u64)) {
|
||||
(Some(uid), _) => Opened::Doc(uid),
|
||||
(None, Some(session)) => Opened::Joining(session),
|
||||
(None, None) => Opened::Missing,
|
||||
},
|
||||
Err(e) => {
|
||||
log::info!("session restore: {e}");
|
||||
Opened::Missing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "session.restore";
|
||||
let saved = match p.get("session") {
|
||||
None | Some(Value::Null) => s.restore.last.take().unwrap_or_else(|| s.prefs.session.clone()),
|
||||
Some(v) if v.is_object() => SavedSession::read(v),
|
||||
Some(_) => return Err(bad(CMD, "`session` must be a saved session: {windows: [{tabs: [{path} | {server, file}]}]}")),
|
||||
};
|
||||
let was_open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let (mut windows, mut missing, mut joining, mut opened) = (Vec::new(), Vec::new(), 0, 0);
|
||||
// The tabs take the saved order: each after the one placed before it.
|
||||
let (mut front_doc, mut last) = (None, None);
|
||||
for (k, w) in saved.windows.iter().enumerate() {
|
||||
let (mut docs, mut front) = (Vec::new(), None);
|
||||
for (i, tab) in w.tabs.iter().enumerate() {
|
||||
match open(s, tab) {
|
||||
// A file is a tab of one window: the first that lists it.
|
||||
Opened::Doc(uid) if docs.contains(&uid) || windows.iter().any(|w: &Placed| w.docs.contains(&uid)) => {}
|
||||
Opened::Doc(uid) => {
|
||||
if !was_open.contains(&uid) {
|
||||
opened += 1;
|
||||
if let Some(ds) = s.docs.iter_mut().find(|d| d.uid == uid) {
|
||||
show(ds, tab);
|
||||
}
|
||||
}
|
||||
s.tab_after(uid, last);
|
||||
docs.push(uid);
|
||||
last = Some(uid);
|
||||
if i == w.front {
|
||||
front = Some(uid);
|
||||
}
|
||||
}
|
||||
Opened::Joining(session) => {
|
||||
joining += 1;
|
||||
s.restore.pending.retain(|p| p.session != session);
|
||||
s.restore.pending.push(Pending { session, window: k, front: i == w.front, tab: tab.clone(), after: docs.last().copied() });
|
||||
}
|
||||
Opened::Missing => missing.push(tab.name()),
|
||||
}
|
||||
}
|
||||
front_doc = front_doc.or(front).or(docs.first().copied());
|
||||
windows.push(Placed { saved: w.clone(), docs, front });
|
||||
}
|
||||
if let Some(i) = front_doc.and_then(|u| s.docs.iter().position(|d| d.uid == u)) {
|
||||
s.active = i;
|
||||
}
|
||||
let out = json!({"windows": windows.len(), "opened": opened, "joining": joining, "missing": missing});
|
||||
if !windows.is_empty() {
|
||||
s.restore.placing = Some((windows, missing.len()));
|
||||
s.request_ui(CMD, json!({}));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"session.restore",
|
||||
"Restore last session",
|
||||
"{session? (a saved session, as the control channel's `ui.inspect` gives it under `session.saved`: {windows: [{tabs: [{path} | {server, file}, page?, view?: [cx, cy, zoom]], front?, rect?: [x, y, width, height], maximized?, fullscreen?}]}; default: the one kept with the preferences, or the last run's when this run started without it)} → {windows, opened (tabs opened now), joining (your online files on their way from their servers), missing: [names of files that are gone]}: opens the saved windows' tabs again in order, with their page and view (a file that is open already is used as it is), and the app puts them in their windows where they were (a window that would be off every screen moves onto one). Headless (CLI, MCP): the tabs only",
|
||||
false,
|
||||
restore
|
||||
),
|
||||
spec!("pref.restoreSession", "Reopen windows and tabs from last time", "{on?}", false, |s, p| {
|
||||
s.prefs.restore_session = crate::params::boolean(p, "on").unwrap_or(!s.prefs.restore_session);
|
||||
Ok(json!({"on": s.prefs.restore_session}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
//! Session restore: a saved session survives the preferences file (and junk in it never costs the
|
||||
//! other preferences, nor a panic); `session.restore` opens the tabs again in their order with
|
||||
//! their page and view and the one in front, skips files that are gone and says which, joins
|
||||
//! your online files again through their records (a scripted link) and puts each in its place
|
||||
//! when it arrives; windows keep their own tabs; the last run's session on offer is restored
|
||||
//! once; the preference toggles; junk parameters are refused or ignored.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, HostInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Role, SessionInfo, Shared, Status};
|
||||
use crate::restore::{Arrival, MAX_TABS, MAX_WINDOWS, SavedSession, SavedTab, SavedWindow};
|
||||
use crate::{Prefs, Session};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
|
||||
/// A folder of saved files named `names`, and their paths.
|
||||
fn files(test: &str, names: &[&str]) -> (std::path::PathBuf, Vec<String>) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-restore-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::empty();
|
||||
let paths = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("page.add", &json!({"name": "Second"})).unwrap();
|
||||
s.execute("page.select", &json!({"index": 0})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
(dir, paths)
|
||||
}
|
||||
|
||||
fn tab(path: &str) -> SavedTab {
|
||||
SavedTab { path: Some(path.into()), ..SavedTab::default() }
|
||||
}
|
||||
|
||||
fn window(tabs: Vec<SavedTab>, front: usize) -> SavedWindow {
|
||||
SavedWindow { tabs, front, ..SavedWindow::default() }
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<String> {
|
||||
s.docs.iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// A session with `saved` kept in its preferences, as a new run has it.
|
||||
fn run_with(saved: SavedSession) -> Session {
|
||||
let mut s = Session::empty();
|
||||
s.prefs.session = saved;
|
||||
s.prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_session_survives_the_preferences_file() {
|
||||
let saved = SavedSession {
|
||||
windows: vec![
|
||||
SavedWindow {
|
||||
rect: Some([10.0, 20.0, 1200.0, 800.0]),
|
||||
maximized: true,
|
||||
fullscreen: false,
|
||||
monitor: Some([0.0, 0.0, 1920.0, 1080.0]),
|
||||
tabs: vec![
|
||||
SavedTab { path: Some("C:/work/a.figcraft".into()), page: 2, view: Some([10.5, -4.0, 2.0]), ..SavedTab::default() },
|
||||
SavedTab { server: Some(SERVER.into()), file: Some("f1".into()), ..SavedTab::default() },
|
||||
],
|
||||
front: 1,
|
||||
left_tab: "assets".into(),
|
||||
right_tab: "inspect".into(),
|
||||
left_collapsed: true,
|
||||
panel_widths: Some([300.0, 280.0]),
|
||||
},
|
||||
window(vec![tab("/home/me/b.figcraft")], 0),
|
||||
],
|
||||
};
|
||||
let s = run_with(saved.clone());
|
||||
assert_eq!(s.prefs.session, saved);
|
||||
assert_eq!(s.prefs.session.tabs(), 3);
|
||||
assert!(s.prefs.restore_session, "on unless turned off");
|
||||
// No token is ever in it.
|
||||
assert!(!serde_json::to_string(&s.prefs.session).unwrap().contains("owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_in_a_saved_session_never_panics_or_costs_the_other_preferences() {
|
||||
let deep = (0..200).fold(json!(1), |v, _| json!([v]));
|
||||
let junk = [
|
||||
json!(null),
|
||||
json!(7),
|
||||
json!("windows"),
|
||||
json!([]),
|
||||
json!({"windows": 3}),
|
||||
json!({"windows": {"0": {}}}),
|
||||
json!({"windows": [null, 1, "w", [], {"tabs": "none"}, {"tabs": [null, 2, {}, {"path": 5}, {"path": ""}, {"file": "f"}, {"server": "http://x/", "file": "f"}]}]}),
|
||||
json!({"windows": [{"rect": [0, 0, 0, 0], "tabs": [{"path": "a"}]}, {"rect": [1e30, 0, 800, 600]}, {"rect": ["a", null, {}, []]}, {"rect": [0, 0, 1e9, 1e9]}, {"rect": [0, 0, 800]}]}),
|
||||
json!({"windows": [{"front": -4, "maximized": "yes", "fullscreen": 1, "leftTab": 9, "rightTab": "x".repeat(5000), "panelWidths": [1e300, -1e300], "tabs": [{"path": "a", "page": -1, "view": [0, 0]}, {"path": "b", "page": 1e99, "view": [1e308, -1e308, 0]}]}]}),
|
||||
json!({"windows": [{"tabs": [{"path": "a\u{0}b"}, {"path": "p".repeat(100_000)}, {"server": "wss://x/", "file": "bad id"}, {"server": "wss://x/", "file": "i".repeat(65)}]}]}),
|
||||
json!({"windows": deep}),
|
||||
];
|
||||
for j in &junk {
|
||||
let read = SavedSession::read(j);
|
||||
assert!(read.windows.len() <= MAX_WINDOWS);
|
||||
for w in &read.windows {
|
||||
assert!(w.tabs.is_empty() || w.front < w.tabs.len(), "{j}");
|
||||
assert!(w.rect.is_none_or(|r| r.iter().all(|n| n.is_finite()) && r[2] >= 100.0 && r[3] >= 100.0 && r[2] <= 16_384.0), "{j}");
|
||||
for t in &w.tabs {
|
||||
assert!(t.path.is_some() || (t.server.is_some() && t.file.is_some()), "{j}");
|
||||
assert!(t.view.is_none_or(|v| v.iter().all(|n| n.is_finite()) && v[2] > 0.0), "{j}");
|
||||
}
|
||||
}
|
||||
// The other preferences load whatever the session is.
|
||||
let prefs: Prefs = serde_json::from_value(json!({"theme": "dark", "session": j, "restore_session": false})).unwrap();
|
||||
assert_eq!((prefs.theme.as_str(), prefs.restore_session, &prefs.session), ("dark", false, &read));
|
||||
}
|
||||
// Zero-sized and absurd rectangles are dropped, their windows kept.
|
||||
let read = SavedSession::read(&junk[7]);
|
||||
assert_eq!(read.windows.len(), 5);
|
||||
assert!(read.windows.iter().all(|w| w.rect.is_none()));
|
||||
// Unusable tabs are skipped; the tab in front keeps its place among the ones kept.
|
||||
let read = SavedSession::read(&json!({"windows": [{"front": 3, "tabs": [{"path": "a"}, {}, {"path": "a"}, {"path": "b"}, {"path": "c"}]}]}));
|
||||
assert_eq!((read.windows[0].tabs.len(), read.windows[0].front), (3, 1));
|
||||
// Huge lists are cut to the limits.
|
||||
let many: Vec<Value> = (0..600).map(|i| json!({"path": format!("f{i}")})).collect();
|
||||
let windows: Vec<Value> = (0..200).map(|_| json!({"tabs": many})).collect();
|
||||
let read = SavedSession::read(&json!({"windows": windows}));
|
||||
assert_eq!((read.windows.len(), read.windows[0].tabs.len()), (MAX_WINDOWS, MAX_TABS));
|
||||
// A preferences file cut off anywhere is an error to read, never a panic.
|
||||
let whole = serde_json::to_string(&json!({"prefs": run_with(read).prefs})).unwrap();
|
||||
for cut in (0..whole.len().min(4000)).step_by(7).filter(|c| whole.is_char_boundary(*c)) {
|
||||
let _ = serde_json::from_str::<Value>(&whole[..cut]).map(|v| serde_json::from_value::<Prefs>(v["prefs"].clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_come_back_in_order_with_their_page_view_and_the_one_in_front() {
|
||||
let (dir, paths) = files("order", &["A", "B", "C"]);
|
||||
let saved = SavedSession {
|
||||
windows: vec![window(vec![tab(&paths[2]), SavedTab { page: 1, view: Some([40.0, -30.0, 3.0]), ..tab(&paths[0]) }, tab(&paths[1])], 1)],
|
||||
};
|
||||
let mut s = run_with(saved);
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"windows": 1, "opened": 3, "joining": 0, "missing": []}));
|
||||
assert_eq!(names(&s), ["C", "A", "B"]);
|
||||
let front = s.ds().unwrap();
|
||||
assert_eq!(front.doc.name, "A", "the tab in front");
|
||||
assert_eq!(front.doc.pages.iter().position(|p| *p == front.page), Some(1), "its page");
|
||||
assert_eq!((front.view.cx, front.view.cy, front.view.zoom), (40.0, -30.0, 3.0), "its view");
|
||||
assert!(!front.is_dirty());
|
||||
// What the interface places: the window with its documents and the one in front.
|
||||
let (placed, missing) = s.restore.placing.take().unwrap();
|
||||
assert_eq!((placed.len(), missing), (1, 0));
|
||||
assert_eq!(placed[0].docs, s.docs.iter().map(|d| d.uid).collect::<Vec<_>>());
|
||||
assert_eq!(placed[0].front, Some(s.docs[1].uid));
|
||||
assert!(s.ui_requests.iter().any(|u| u.kind == "session.restore"));
|
||||
// The open tabs as a saved session again.
|
||||
let tabs: Vec<SavedTab> = s.docs.iter().filter_map(|d| s.saved_tab(d)).collect();
|
||||
assert_eq!(tabs.iter().map(|t| t.path.clone().unwrap()).collect::<Vec<_>>(), [paths[2].clone(), paths[0].clone(), paths[1].clone()]);
|
||||
assert_eq!((tabs[1].page, tabs[1].view), (1, Some([40.0, -30.0, 3.0])));
|
||||
// Restoring again opens nothing twice; files open already are put in order.
|
||||
s.execute("doc.switch", &json!({"index": 2})).unwrap();
|
||||
let again = json!({"windows": [{"tabs": [{"path": paths[1]}, {"path": paths[0]}], "front": 0}]});
|
||||
let r = s.execute("session.restore", &json!({"session": again})).unwrap();
|
||||
assert_eq!((&r["opened"], names(&s)), (&json!(0), vec!["B".to_string(), "A".into(), "C".into()]));
|
||||
assert_eq!(s.ds().unwrap().doc.name, "B");
|
||||
// A document that was never saved isn't a tab of a saved session.
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.saved_tab(s.ds().unwrap()), None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_are_gone_are_skipped_and_named() {
|
||||
let (dir, paths) = files("missing", &["A", "B", "C"]);
|
||||
std::fs::remove_file(&paths[0]).unwrap();
|
||||
std::fs::write(&paths[2], b"not a file of ours").unwrap();
|
||||
let saved = SavedSession { windows: vec![window(paths.iter().map(|p| tab(p)).collect(), 0)] };
|
||||
let mut s = run_with(saved);
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"windows": 1, "opened": 1, "joining": 0, "missing": ["A.figcraft", "C.figcraft"]}));
|
||||
assert_eq!(names(&s), ["B"]);
|
||||
assert_eq!(s.restore.placing.as_ref().map(|p| p.1), Some(2), "the interface says how many");
|
||||
// The tab in front was gone: the first one that opened shows.
|
||||
assert_eq!(s.ds().unwrap().doc.name, "B");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_keep_their_own_tabs() {
|
||||
let (dir, paths) = files("windows", &["A", "B", "C"]);
|
||||
let saved = SavedSession {
|
||||
windows: vec![
|
||||
SavedWindow { rect: Some([0.0, 0.0, 1000.0, 700.0]), ..window(vec![tab(&paths[0])], 0) },
|
||||
// (A file listed in two windows is a tab of the first.)
|
||||
SavedWindow {
|
||||
rect: Some([200.0, 100.0, 900.0, 600.0]),
|
||||
maximized: true,
|
||||
..window(vec![tab(&paths[1]), tab(&paths[2]), tab(&paths[0])], 1)
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut s = run_with(saved.clone());
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!((&r["windows"], &r["opened"]), (&json!(2), &json!(3)));
|
||||
let (placed, _) = s.restore.placing.take().unwrap();
|
||||
let uid = |n: &str| s.docs.iter().find(|d| d.doc.name == n).unwrap().uid;
|
||||
assert_eq!((placed[0].docs.clone(), placed[0].front), (vec![uid("A")], Some(uid("A"))));
|
||||
assert_eq!((placed[1].docs.clone(), placed[1].front), (vec![uid("B"), uid("C")], Some(uid("C"))));
|
||||
assert_eq!(placed[1].saved, saved.windows[1]);
|
||||
assert_eq!(s.ds().unwrap().doc.name, "A", "the main window's tab in front is the session's");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_runs_session_on_offer_is_restored_once() {
|
||||
let (dir, paths) = files("offer", &["A", "B"]);
|
||||
// This run started without restoring: its own session is what the preferences hold now.
|
||||
let mut s = run_with(SavedSession { windows: vec![window(vec![tab(&paths[1])], 0)] });
|
||||
s.restore.last = Some(SavedSession { windows: vec![window(vec![tab(&paths[0])], 0)] });
|
||||
s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(names(&s), ["A"]);
|
||||
assert!(s.restore.last.is_none());
|
||||
s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(names(&s), ["B", "A"], "then the one kept with the preferences");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_toggles_and_is_kept() {
|
||||
let mut s = Session::empty();
|
||||
assert_eq!(s.execute("pref.restoreSession", &json!({"on": false})).unwrap(), json!({"on": false}));
|
||||
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
assert!(!prefs.restore_session);
|
||||
assert_eq!(s.execute("pref.restoreSession", &json!({})).unwrap(), json!({"on": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_parameters_are_refused_or_ignored() {
|
||||
let (dir, paths) = files("sweep", &["A"]);
|
||||
let mut s = run_with(SavedSession { windows: vec![window(vec![tab(&paths[0])], 0)] });
|
||||
for j in [json!(7), json!("all"), json!([1, 2]), json!(true)] {
|
||||
assert!(s.execute("session.restore", &json!({"session": j})).unwrap_err().to_string().contains("saved session"));
|
||||
assert!(s.execute("pref.restoreSession", &json!({"on": j})).is_ok());
|
||||
}
|
||||
assert!(s.docs.is_empty() && s.restore.placing.is_none());
|
||||
for j in [json!({}), json!({"windows": 5}), json!({"windows": [{"tabs": [{"path": 3}, {"path": "no/such/file.figcraft"}]}]})] {
|
||||
let r = s.execute("session.restore", &json!({"session": j})).unwrap();
|
||||
assert_eq!(r["opened"], 0, "{j}");
|
||||
}
|
||||
assert!(s.docs.is_empty());
|
||||
// Nothing saved: nothing to do, and nothing asked of the interface.
|
||||
let mut empty = Session::empty();
|
||||
assert_eq!(empty.execute("session.restore", &json!(null)).unwrap(), json!({"windows": 0, "opened": 0, "joining": 0, "missing": []}));
|
||||
assert!(empty.ui_requests.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// What the scripted server was asked to join and the events its link hands out next.
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
joined: Vec<(u64, String)>,
|
||||
events: Vec<(u64, Event)>,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
self.0.lock().unwrap().joined.push((session, link.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, _: u64, _: PublishOptions) -> Result<(), String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn hosting(&mut self, _: &str, _: &str, _: &str, _: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn share(&mut self, _: &str, _: &str, _: &str, _: &[String], _: bool) -> Result<Option<Shared>, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
std::mem::take(&mut self.0.lock().unwrap().events)
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_link(file: &str) -> String {
|
||||
format!("figcraft://join?server={SERVER}&file={file}&token=EditSecretEditSecret00")
|
||||
}
|
||||
|
||||
/// The record of online file `file`, as the preferences file holds it.
|
||||
fn record(file: &str) -> Value {
|
||||
json!({"server": SERVER, "file": file, "name": format!("Online {file}"), "owner": "OwnerSecretOwnerSecret", "edit": edit_link(file), "view": "", "created": 1, "opened": 1})
|
||||
}
|
||||
|
||||
/// The file of session `session` arrives from the scripted server.
|
||||
fn arrive(script: &Arc<Mutex<Script>>, session: u64, file: &str) {
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: format!("Online {file}"),
|
||||
pages: vec![],
|
||||
link: edit_link(file),
|
||||
url: String::new(),
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
};
|
||||
let (_, _, sync) = crate::live_tests::serve(&Session::new().doc().unwrap().clone());
|
||||
script.lock().unwrap().events.extend([(session, Event::Welcome { info, peers: vec![] }), (session, sync)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_files_are_joined_again_and_take_their_place_when_they_arrive() {
|
||||
let (dir, paths) = files("online", &["A", "B"]);
|
||||
let online =
|
||||
|file: &str| SavedTab { server: Some(SERVER.into()), file: Some(file.into()), page: 0, view: Some([5.0, 6.0, 1.5]), ..SavedTab::default() };
|
||||
let saved =
|
||||
SavedSession { windows: vec![window(vec![tab(&paths[0]), online("f1"), tab(&paths[1])], 1), window(vec![online("f2"), online("gone")], 0)] };
|
||||
let mut s = run_with(saved);
|
||||
s.prefs.online_files = serde_json::from_value::<Prefs>(json!({"online_files": [record("f1"), record("f2")]})).unwrap().online_files;
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
// The one no longer recorded on this computer counts as gone.
|
||||
assert_eq!(r, json!({"windows": 2, "opened": 2, "joining": 2, "missing": ["gone"]}));
|
||||
assert!(!r.to_string().contains("Secret"));
|
||||
let joined = script.lock().unwrap().joined.clone();
|
||||
assert_eq!(joined.iter().map(|j| j.1.clone()).collect::<Vec<_>>(), [edit_link("f1"), edit_link("f2")], "as their owner, with the recorded links");
|
||||
assert_eq!(names(&s), ["A", "B"]);
|
||||
// Until they arrive they are still tabs of their windows.
|
||||
let waiting: Vec<(usize, Option<String>)> = s.restore.joining().map(|(k, t)| (k, t.file.clone())).collect();
|
||||
assert_eq!(waiting, [(0, Some("f1".into())), (1, Some("f2".into()))]);
|
||||
assert!(s.restore_arrivals().is_empty());
|
||||
|
||||
// The first arrives: between A and B, in front of its window, with its view.
|
||||
arrive(&script, joined[0].0, "f1");
|
||||
s.live_poll();
|
||||
let arrived = s.restore_arrivals();
|
||||
let uid = s.docs.iter().find(|d| d.path.is_none()).unwrap().uid;
|
||||
assert_eq!(arrived, [Arrival { uid, window: 0, front: true }]);
|
||||
assert_eq!(s.docs.iter().map(|d| d.path.is_some()).collect::<Vec<_>>(), [true, false, true]);
|
||||
let ds = s.docs.iter().find(|d| d.uid == uid).unwrap();
|
||||
assert_eq!((ds.view.cx, ds.view.cy, ds.view.zoom), (5.0, 6.0, 1.5));
|
||||
assert_eq!(s.saved_tab(ds).map(|t| (t.server, t.file)), Some((Some(SERVER.into()), Some("f1".into()))), "a tab of the next saved session");
|
||||
assert_eq!(s.restore.joining().count(), 1);
|
||||
|
||||
// The other's server can't be reached: it is skipped, and the message says so.
|
||||
script.lock().unwrap().events.push((joined[1].0, Event::Status(Status::Failed { reason: "no answer from the server (timed out)".into() })));
|
||||
s.live_poll();
|
||||
assert!(s.restore_arrivals().is_empty());
|
||||
assert_eq!(s.restore.joining().count(), 0);
|
||||
assert_eq!(s.message.as_deref(), Some("f2 couldn't be reopened from its server: no answer from the server (timed out)"));
|
||||
assert_eq!(s.docs.len(), 3);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -32,6 +32,21 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -
|
|||
pub fn end(s: &mut Session) -> Result<()> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
|
||||
s.ds_mut()?.text_edit = None;
|
||||
// A widget's editable text isn't the user's to change: what was typed goes to the widget's
|
||||
// plugin, which redraws it (one undo step), and the layer itself is put back.
|
||||
if let Some(input) = crate::widgets::input_of(s.doc()?, te.id) {
|
||||
let typed = text_of(s, te.id)?.characters;
|
||||
s.cancel_interaction()?;
|
||||
let changed = text_of(s, te.id).is_ok_and(|was| was.characters != typed);
|
||||
let widget = crate::widgets::widget_of(s.doc()?, te.id);
|
||||
if changed && let Err(e) = s.execute("widget.setText", &serde_json::json!({"id": te.id, "input": input.name, "text": typed})) {
|
||||
s.message = Some(e.to_string());
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
ds.selection = widget.into_iter().collect();
|
||||
ds.revision += 1;
|
||||
return Ok(());
|
||||
}
|
||||
let empty = text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false);
|
||||
if empty {
|
||||
let doc = s.doc_mut()?;
|
||||
|
|
@ -88,13 +103,24 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
|||
.chars()
|
||||
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||
.collect();
|
||||
// A widget's editable text takes what its plugin allows: one line unless it's multiline, and
|
||||
// no more characters than its maximum.
|
||||
let input = s.ds()?.text_edit.as_ref().and_then(|te| crate::widgets::input_of(&s.ds().ok()?.doc, te.id));
|
||||
let text = match &input {
|
||||
Some(input) => {
|
||||
let te = s.ds()?.text_edit.clone();
|
||||
let (kept, replaced) = te.map_or((0, 0), |te| (text_of(s, te.id).map_or(0, |t| t.char_count()), range(&te).1 - range(&te).0));
|
||||
input.clean(&text).chars().take(input.max.saturating_sub(kept.saturating_sub(replaced))).collect()
|
||||
}
|
||||
None => text,
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
|
||||
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
|
||||
if input.is_none() && text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let start = line_start(&chars, a);
|
||||
let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect();
|
||||
|
|
@ -218,7 +244,12 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
|
|||
let t = text_of(s, te.id)?;
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let n = chars.len();
|
||||
if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
// In a widget's one-line text, Enter and Tab commit.
|
||||
let input = crate::widgets::input_of(s.doc()?, te.id);
|
||||
if matches!(key, "Enter" | "Tab") && input.as_ref().is_some_and(|i| !i.multiline || key == "Tab") {
|
||||
return end(s);
|
||||
}
|
||||
if input.is_none() && matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
return Ok(());
|
||||
}
|
||||
match key {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
|
|||
pub fn end(s: &mut Session) -> bool {
|
||||
let Ok(ds) = s.ds_mut() else { return false };
|
||||
let Some(base) = ds.text_preview.take() else { return false };
|
||||
ds.widget_hover = None;
|
||||
ds.doc = base;
|
||||
ds.revision += 1;
|
||||
true
|
||||
|
|
|
|||
|
|
@ -301,6 +301,8 @@ pub struct Overlay {
|
|||
/// while one is dragged (see [`crate::shape_handles`]).
|
||||
pub shape_hover: Option<ShapeHandle>,
|
||||
pub shape: Option<ShapeHint>,
|
||||
/// The tooltip of the widget part under the pointer.
|
||||
pub widget_tip: Option<String>,
|
||||
}
|
||||
|
||||
/// The Scale tool's live readout while dragging a handle.
|
||||
|
|
@ -978,6 +980,8 @@ impl Session {
|
|||
cursor = Cursor::Text;
|
||||
}
|
||||
let mut hover = None;
|
||||
// The widget part under the pointer that has a hover style, and the one with a tooltip.
|
||||
let (mut widget_part, mut widget_tip) = (None, None);
|
||||
let mut guides = Vec::new();
|
||||
let mut measure_box = None;
|
||||
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
|
||||
|
|
@ -991,9 +995,14 @@ impl Session {
|
|||
cursor = resize_cursor(&doc, &sel, h);
|
||||
} else {
|
||||
let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
|
||||
if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() {
|
||||
cursor = Cursor::Pointer;
|
||||
// A widget's parts: a hand over what's clickable, an I-beam over a text to edit.
|
||||
match leaf.and_then(|l| crate::widgets::part_at(&doc, l, ev.p())) {
|
||||
Some((_, crate::widgets::Part::Target(_))) => cursor = Cursor::Pointer,
|
||||
Some((_, crate::widgets::Part::Input(_))) => cursor = Cursor::Text,
|
||||
None => {}
|
||||
}
|
||||
widget_part = leaf.and_then(|l| crate::widgets::hover_at(&doc, l, ev.p())).map(|part| (part, ev.p()));
|
||||
widget_tip = leaf.and_then(|l| crate::widgets::tooltip_at(&doc, l, ev.p()));
|
||||
hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
|
||||
// Alt over a selected layer: a drag from here duplicates it.
|
||||
if ev.alt && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) {
|
||||
|
|
@ -1031,6 +1040,8 @@ impl Session {
|
|||
}
|
||||
}
|
||||
self.tool.overlay.guides = guides;
|
||||
self.tool.overlay.widget_tip = widget_tip;
|
||||
crate::widgets::set_hover(self, widget_part);
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.hover != hover {
|
||||
ds.hover = hover;
|
||||
|
|
@ -1779,7 +1790,7 @@ impl Session {
|
|||
// Quick clicks on a widget's clickable part are clicks, one by one.
|
||||
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
|
||||
&& !(ev.shift || ev.cmd || ev.alt)
|
||||
&& self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some()
|
||||
&& self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::part_at(&doc, hit, ev.p())).is_some()
|
||||
{
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.cancel_interaction()?;
|
||||
|
|
@ -1824,11 +1835,17 @@ impl Session {
|
|||
}
|
||||
|
||||
/// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is
|
||||
/// shown as a message).
|
||||
/// shown as a message); one on a text of it that can be edited starts editing it in place,
|
||||
/// all of it selected (committing calls the plugin back: see [`textedit::end`]).
|
||||
fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> {
|
||||
use crate::widgets::Part;
|
||||
let (doc, page) = (self.doc()?.clone(), self.page()?);
|
||||
let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) };
|
||||
if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) {
|
||||
let result = match self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::part_at(&doc, hit, p)) {
|
||||
Some((id, Part::Target(target))) => self.execute("widget.click", &serde_json::json!({"id": id, "target": target})).map(|_| ()),
|
||||
Some((_, Part::Input(text))) => textedit::begin(self, text, None, false),
|
||||
None => Ok(()),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
self.message = Some(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -7,28 +7,105 @@
|
|||
//! (deterministic: the state is all it remembers), as one undo step; the plugin hands back the new
|
||||
//! state and layers. A callback can only rebuild its own widget; other layers change only through
|
||||
//! the commands its manifest permits. See `docs/plugins.md`.
|
||||
//!
|
||||
//! Plugin API 4 adds more events, each a callback like a click: a text the user edited in place
|
||||
//! ([`Input`], `widget.setText`), an item of the widget's property menu (`widget.menuAction`) and a
|
||||
//! timer the widget asked for (`widget.fireTimer`; [`run_timers`] in the app). Hover styles and
|
||||
//! tooltips are FigCraft's to show ([`set_hover`]): the plugin isn't called for pointer moves.
|
||||
//! Everything read back from layer data is checked again: a file or a peer may hold anything.
|
||||
//!
|
||||
//! **Timers** are kept with the open document, not in it: closing the file cancels them, and they
|
||||
//! run only while the document is in front. When a callback asks for one, the widget's layer gets
|
||||
//! a stamp only this session knows, and the timer fires only while that stamp is still on the
|
||||
//! layer. So in a live session a widget's timer runs for whoever started it last and for nobody
|
||||
//! else (a later click by someone else replaces the stamp, which stops the earlier timer), two
|
||||
//! people's timers never take turns rewriting a widget, and undoing past the callback that
|
||||
//! started a timer stops it. A file you may only view runs no timers.
|
||||
|
||||
use figcraft_color::{ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData};
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES};
|
||||
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, Effect, EffectKind, Node, NodeId, NodeKind, Shadow, TextData, VectorNetwork};
|
||||
use figcraft_geom::{Affine, Point, Shape};
|
||||
use figcraft_plugin::widget::{GradientShape, MAX_IMAGE_BYTES, MAX_SIZE, MAX_STATE_BYTES, MAX_TEXT, WidgetHover, check_menu};
|
||||
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetFit, WidgetKind, WidgetNode, WidgetRender};
|
||||
pub use figcraft_plugin::{MenuItem, MenuKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::{id, num, string};
|
||||
use crate::params::{get, id, num, string};
|
||||
use crate::plugins::{InstalledPlugin, call_plugin, installed, limits, module_of, plugin_error};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Layer data FigCraft keeps for widgets starts with this; plugins can't write it.
|
||||
pub const DATA_PREFIX: &str = "figcraft.widget";
|
||||
/// On a widget's own layer: its plugin's id, and its state (JSON).
|
||||
/// On a widget's own layer: its plugin's id, its state (JSON), its property menu (JSON) and the
|
||||
/// stamp of the session whose timer for it is running.
|
||||
const PLUGIN: &str = "figcraft.widget";
|
||||
const STATE: &str = "figcraft.widget.state";
|
||||
/// On a layer inside a widget: the name its plugin gets when it's clicked.
|
||||
const MENU: &str = "figcraft.widget.menu";
|
||||
const TIMER: &str = "figcraft.widget.timer";
|
||||
/// On a layer inside a widget: the name its plugin gets when it's clicked, the editable text it
|
||||
/// is ([`Input`] as JSON), its hover style (JSON) and its tooltip.
|
||||
const TARGET: &str = "figcraft.widget.target";
|
||||
const INPUT: &str = "figcraft.widget.input";
|
||||
const HOVER: &str = "figcraft.widget.hover";
|
||||
const TOOLTIP: &str = "figcraft.widget.tooltip";
|
||||
/// Images in a widget are at most this many pixels on a side.
|
||||
const MAX_IMAGE_SIDE: u32 = 4096;
|
||||
/// The stored menu and hover style are read back only up to this size.
|
||||
const MAX_DATA_BYTES: usize = 64 * 1024;
|
||||
/// While the document is busy (a drag, text being typed) a due timer waits this long.
|
||||
const TIMER_RETRY: Duration = Duration::from_millis(250);
|
||||
|
||||
/// A text in a widget the user can edit in place: its name for the plugin, the most characters
|
||||
/// it takes and whether it may have several lines.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Input {
|
||||
pub name: String,
|
||||
pub max: usize,
|
||||
#[serde(default)]
|
||||
pub multiline: bool,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// `text` as this input takes it: no control characters, one line unless it's multiline, and
|
||||
/// no longer than its maximum.
|
||||
pub fn clean(&self, text: &str) -> String {
|
||||
let line_break = |c: char| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}');
|
||||
text.replace("\r\n", "\n")
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
c if line_break(c) && self.multiline => '\n',
|
||||
c if line_break(c) || c == '\t' => ' ',
|
||||
c => c,
|
||||
})
|
||||
.filter(|c| !c.is_control() || *c == '\n')
|
||||
.take(self.max.min(MAX_TEXT))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget's pending timer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Timer {
|
||||
widget: NodeId,
|
||||
due: Instant,
|
||||
/// What [`TIMER`] on the widget's layer must still say when it fires.
|
||||
stamp: String,
|
||||
}
|
||||
|
||||
/// What the pointer is over in a widget.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Part {
|
||||
/// A clickable part: its target.
|
||||
Target(String),
|
||||
/// An editable text: its layer.
|
||||
Input(NodeId),
|
||||
}
|
||||
|
||||
/// Whether `n` is a widget's own layer.
|
||||
pub fn is_widget(n: &Node) -> bool {
|
||||
|
|
@ -40,18 +117,102 @@ pub fn widget_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
|||
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(is_widget))
|
||||
}
|
||||
|
||||
/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget
|
||||
/// and its target. The layers inside a widget are locked, so the hit stops at the widget itself;
|
||||
/// the front-most target whose box holds the point wins.
|
||||
pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> {
|
||||
/// The visible layers of the widget `hit` is (or is in) whose box holds page point `p`, back to
|
||||
/// front, and the widget. The layers inside a widget are locked, so a hit stops at the widget.
|
||||
fn layers_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, Vec<&Node>)> {
|
||||
let w = widget_of(doc, hit)?;
|
||||
let target = doc
|
||||
.subtree(w)
|
||||
.into_iter()
|
||||
.filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p)))
|
||||
.filter_map(|i| doc.node(i).filter(|n| n.visible)?.plugin_data.get(TARGET).cloned())
|
||||
.last()?;
|
||||
Some((w, target))
|
||||
let at =
|
||||
doc.subtree(w).into_iter().filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p))).filter_map(|i| doc.node(i).filter(|n| n.visible));
|
||||
Some((w, at.collect()))
|
||||
}
|
||||
|
||||
/// The part of a widget at page point `p` that takes a click, given the layer a click there hits:
|
||||
/// the widget, and the front-most clickable part or editable text whose box holds the point.
|
||||
pub fn part_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, Part)> {
|
||||
let (w, layers) = layers_at(doc, hit, p)?;
|
||||
let part = layers.iter().rev().find_map(|n| match n.plugin_data.get(TARGET) {
|
||||
Some(t) => Some(Part::Target(t.clone())),
|
||||
None => input_of(doc, n.id).map(|_| Part::Input(n.id)),
|
||||
})?;
|
||||
Some((w, part))
|
||||
}
|
||||
|
||||
/// The clickable part of a widget at page point `p`: the widget and its target.
|
||||
pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> {
|
||||
match part_at(doc, hit, p)? {
|
||||
(w, Part::Target(t)) => Some((w, t)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The front-most layer with a hover style at page point `p` of the widget `hit` is (or is in).
|
||||
pub fn hover_at(doc: &Document, hit: NodeId, p: Point) -> Option<NodeId> {
|
||||
layers_at(doc, hit, p)?.1.iter().rev().find(|n| n.plugin_data.contains_key(HOVER)).map(|n| n.id)
|
||||
}
|
||||
|
||||
/// The tooltip of the front-most layer that has one at page point `p` of the widget `hit` is in.
|
||||
pub fn tooltip_at(doc: &Document, hit: NodeId, p: Point) -> Option<String> {
|
||||
let tip = layers_at(doc, hit, p)?.1.iter().rev().find_map(|n| n.plugin_data.get(TOOLTIP))?;
|
||||
Some(tip.chars().filter(|c| !c.is_control()).take(200).collect::<String>()).filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The editable text layer `id` is, if it is one (in a widget).
|
||||
pub fn input_of(doc: &Document, id: NodeId) -> Option<Input> {
|
||||
let n = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)))?;
|
||||
let mut input: Input = serde_json::from_str(n.plugin_data.get(INPUT).filter(|s| s.len() <= 1024)?).ok()?;
|
||||
let named =
|
||||
!input.name.is_empty() && input.name.len() <= 64 && input.name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
|
||||
input.max = input.max.clamp(1, MAX_TEXT);
|
||||
(named && widget_of(doc, id).is_some_and(|w| w != id)).then_some(input)
|
||||
}
|
||||
|
||||
/// `doc` with layer `part` in its hover style, if it has one.
|
||||
pub fn hovered(doc: &Document, part: NodeId) -> Option<Document> {
|
||||
let style = doc.node(part)?.plugin_data.get(HOVER).filter(|s| s.len() <= MAX_DATA_BYTES)?;
|
||||
let h: WidgetHover = serde_json::from_str(style).ok()?;
|
||||
let colour = |c: &Option<String>| c.as_deref().filter(|c| c.len() <= 9).and_then(Color::from_hex).map(Paint::solid);
|
||||
let mut look = doc.clone();
|
||||
let n = look.get_mut(part).ok()?;
|
||||
if let Some(fill) = colour(&h.fill) {
|
||||
n.fills = vec![fill];
|
||||
}
|
||||
if let Some(stroke) = colour(&h.stroke) {
|
||||
n.strokes = vec![stroke];
|
||||
n.stroke.weight = n.stroke.weight.max(1.0);
|
||||
}
|
||||
if let Some(o) = h.opacity.filter(|o| o.is_finite()) {
|
||||
n.opacity = o.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// Show the widget part `part` (with the page point the pointer is at) in its hover style, or no
|
||||
/// part. The look is a live preview: the document itself, its undo history and what a live
|
||||
/// session syncs stay as they are, and any command ends it.
|
||||
pub fn set_hover(s: &mut Session, part: Option<(NodeId, Point)>) {
|
||||
let Ok(ds) = s.ds_mut() else { return };
|
||||
if ds.widget_hover.map(|h| h.0) == part.map(|h| h.0) {
|
||||
ds.widget_hover = part;
|
||||
return;
|
||||
}
|
||||
ds.end_widget_hover();
|
||||
let Some((id, _)) = part else { return };
|
||||
if ds.interaction.is_some() || ds.text_edit.is_some() || ds.preview.is_some() || ds.text_preview.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(look) = hovered(&ds.doc, id) else { return };
|
||||
ds.text_preview = Some(std::mem::replace(&mut ds.doc, Arc::new(look)));
|
||||
ds.widget_hover = part;
|
||||
ds.revision += 1;
|
||||
}
|
||||
|
||||
/// Put the hover look back after a change that wasn't the pointer's (a timer's callback, an edit
|
||||
/// from a live session): on what is now under page point `at`, where the pointer still is.
|
||||
pub(crate) fn rehover(s: &mut Session, at: Option<Point>) {
|
||||
let (Some(p), Ok(ds)) = (at, s.ds()) else { return };
|
||||
let (doc, page) = (ds.doc.clone(), ds.page);
|
||||
let part = s.hit(&doc, page, p, 0.0).and_then(|hit| hover_at(&doc, hit, p));
|
||||
set_hover(s, part.map(|part| (part, p)));
|
||||
}
|
||||
|
||||
/// A widget's state (an empty object when it has none or it's unusable).
|
||||
|
|
@ -78,6 +239,100 @@ fn targets(doc: &Document, w: NodeId) -> Vec<String> {
|
|||
out
|
||||
}
|
||||
|
||||
/// The editable texts inside widget `w` (each name once), in layer order.
|
||||
fn inputs(doc: &Document, w: NodeId) -> Vec<(NodeId, Input)> {
|
||||
let mut out: Vec<(NodeId, Input)> = Vec::new();
|
||||
for (i, input) in doc.subtree(w).into_iter().filter_map(|i| Some((i, input_of(doc, i)?))) {
|
||||
if !out.iter().any(|(_, o)| o.name == input.name) {
|
||||
out.push((i, input));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Widget `w`'s property menu (empty when it has none or it's unusable): what a selected widget
|
||||
/// shows near it and in the Design panel.
|
||||
pub fn menu_of(doc: &Document, w: NodeId) -> Vec<MenuItem> {
|
||||
let stored = doc.node(w).filter(|n| is_widget(n)).and_then(|n| n.plugin_data.get(MENU)).filter(|s| s.len() <= MAX_DATA_BYTES);
|
||||
let mut menu: Vec<MenuItem> = stored.and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
|
||||
let fine = check_menu(&mut menu).is_ok() && menu.iter().all(|i| i.kind != MenuKind::Link || crate::dev_resources::check_url(&i.url).is_ok());
|
||||
if fine { menu } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// A stamp no other session makes: this process's start in nanoseconds and a count.
|
||||
fn stamp() -> String {
|
||||
static START: std::sync::OnceLock<u128> = std::sync::OnceLock::new();
|
||||
static COUNT: AtomicU64 = AtomicU64::new(1);
|
||||
let start = START.get_or_init(|| web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()));
|
||||
format!("{start:x}-{}", COUNT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Widget `w`'s pending timer in the active document: one this session started that nobody
|
||||
/// replaced since.
|
||||
fn timer_of(s: &Session, w: NodeId) -> Option<&Timer> {
|
||||
let ds = s.ds().ok()?;
|
||||
let on_layer = ds.doc.node(w).filter(|n| is_widget(n))?.plugin_data.get(TIMER)?;
|
||||
ds.widget_timers.iter().find(|t| t.widget == w && t.stamp == *on_layer)
|
||||
}
|
||||
|
||||
/// Start (or with 0 milliseconds, stop) widget `w`'s timer: at most one is pending.
|
||||
fn arm(s: &mut Session, w: NodeId, ms: u32) -> Result<()> {
|
||||
let ds = s.ds_mut()?;
|
||||
ds.widget_timers.retain(|t| t.widget != w);
|
||||
if ms > 0 {
|
||||
let stamp = stamp();
|
||||
ds.doc_mut().get_mut(w)?.plugin_data.insert(TIMER.into(), stamp.clone());
|
||||
ds.widget_timers.push(Timer { widget: w, due: Instant::now() + Duration::from_millis(u64::from(ms)), stamp });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long until the active document's next widget timer is due, in seconds (`None`: none is
|
||||
/// pending). The app sleeps until then; nothing else wakes it for timers.
|
||||
pub fn next_timer(s: &Session) -> Option<f64> {
|
||||
let ds = s.ds().ok()?;
|
||||
let now = Instant::now();
|
||||
ds.widget_timers.iter().map(|t| t.due.saturating_duration_since(now).as_secs_f64()).reduce(f64::min)
|
||||
}
|
||||
|
||||
/// Fire the active document's widget timers that are due: each calls its widget's plugin back
|
||||
/// (`widget.fireTimer`). Timers whose widget is gone, was drawn again by someone else or undone
|
||||
/// past are dropped; while the document is busy (a drag, text being typed) they wait; a document
|
||||
/// you can't edit runs none. Returns what went wrong, to show.
|
||||
pub fn run_timers(s: &mut Session) -> Vec<String> {
|
||||
let now = Instant::now();
|
||||
let Ok(ds) = s.ds() else { return Vec::new() };
|
||||
if !ds.widget_timers.iter().any(|t| t.due <= now) {
|
||||
return Vec::new();
|
||||
}
|
||||
if s.active_read_only().is_some() || ds.preview.is_some() {
|
||||
if let Ok(ds) = s.ds_mut() {
|
||||
ds.widget_timers.clear();
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
// The hover look steps aside for the callback and comes back where the pointer still is.
|
||||
let Ok(ds) = s.ds_mut() else { return Vec::new() };
|
||||
let hover = ds.end_widget_hover();
|
||||
if ds.interaction.is_some() || ds.text_edit.is_some() || ds.text_preview.is_some() {
|
||||
ds.widget_timers.iter_mut().filter(|t| t.due <= now).for_each(|t| t.due = now + TIMER_RETRY);
|
||||
return Vec::new();
|
||||
}
|
||||
let due: Vec<NodeId> = ds.widget_timers.iter().filter(|t| t.due <= now).map(|t| t.widget).collect();
|
||||
let mut errors = Vec::new();
|
||||
for w in due {
|
||||
if timer_of(s, w).is_none() {
|
||||
if let Ok(ds) = s.ds_mut() {
|
||||
ds.widget_timers.retain(|t| t.widget != w);
|
||||
}
|
||||
} else if let Err(e) = s.execute("widget.fireTimer", &json!({"id": w})) {
|
||||
errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
rehover(s, hover);
|
||||
errors
|
||||
}
|
||||
|
||||
/// Installed widget plugin `plugin`, its manifest checked again (the preferences may have been
|
||||
/// edited by hand).
|
||||
fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result<InstalledPlugin> {
|
||||
|
|
@ -89,22 +344,29 @@ fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result<InstalledPlugin
|
|||
Ok(p)
|
||||
}
|
||||
|
||||
/// Call widget `w`'s plugin `p` back (`target`: what was clicked; none when it's inserted) and
|
||||
/// build what it hands back. Part of the caller's undo step; a failure rolls the whole step back.
|
||||
fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, target: Option<&str>, fuel: Option<f64>) -> Result<RunOutput> {
|
||||
/// Call widget `w`'s plugin `p` back with `event` (what happened to it: a click's `target`, an
|
||||
/// edited text, a menu item, a timer; null when it's inserted) and build what it hands back.
|
||||
/// Part of the caller's undo step; a failure rolls the whole step back.
|
||||
fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, event: &Value, fuel: Option<f64>) -> Result<RunOutput> {
|
||||
let m = &p.manifest;
|
||||
// A file you may only view calls no plugin: nothing it drew could be kept.
|
||||
if let Some(why) = s.active_read_only() {
|
||||
return Err(EngineError::Disabled(format!("{}'s widget", m.name), why.into()));
|
||||
}
|
||||
let wasm = module_of(s, p)?;
|
||||
let event = target.map(|t| json!({"target": t}));
|
||||
let state = state_of(s.doc()?, w);
|
||||
let input =
|
||||
json!({"api": m.api, "command": "widget", "params": {}, "selection": s.selection(), "widget": {"id": w, "state": state, "event": event}});
|
||||
let mut instance = Instance::new(&wasm, &limits(fuel)).map_err(|e| plugin_error(&m.name, e))?;
|
||||
let mut instance = Instance::new(&wasm, &limits(fuel, m)).map_err(|e| plugin_error(&m.name, e))?;
|
||||
let mut out = call_plugin(s, m, &m.name, &mut instance, &input.to_string()).map_err(|(e, _)| e)?;
|
||||
match out.widget.take() {
|
||||
Some(r) => build(s, &m.id, w, &r)?,
|
||||
None if target.is_none() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))),
|
||||
None if event.is_null() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))),
|
||||
None => {}
|
||||
}
|
||||
if let Some(ms) = out.timer {
|
||||
arm(s, w, ms)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +384,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
|||
doc.remove(c)?;
|
||||
}
|
||||
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
|
||||
dress(cmd, doc, w, &r.root)?;
|
||||
let n = doc.get_mut(w)?;
|
||||
n.locked = false;
|
||||
if !r.root.name.is_empty() {
|
||||
|
|
@ -130,6 +393,12 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
|||
if let Some(state) = &r.state {
|
||||
n.plugin_data.insert(STATE.into(), Value::Object(state.clone()).to_string());
|
||||
}
|
||||
if let Some(menu) = &r.menu {
|
||||
for link in menu.iter().filter(|i| i.kind == MenuKind::Link) {
|
||||
crate::dev_resources::check_url(&link.url).map_err(|e| bad(cmd, format!("menu item `{}`: {e}", link.name)))?;
|
||||
}
|
||||
n.plugin_data.insert(MENU.into(), json!(menu).to_string());
|
||||
}
|
||||
for c in &r.root.children {
|
||||
add(doc, w, c, cmd)?;
|
||||
}
|
||||
|
|
@ -139,21 +408,40 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
|||
|
||||
/// Add layer `d` (and what it holds) inside `parent`, locked.
|
||||
fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<()> {
|
||||
let (mut width, mut height) = (d.width.unwrap_or(100.0), d.height.unwrap_or(100.0));
|
||||
let (kind, name) = match d.kind {
|
||||
WidgetKind::Frame => (NodeKind::Frame, "Frame"),
|
||||
WidgetKind::Text => (NodeKind::Text(TextData { characters: d.text.clone(), ..TextData::default() }), "Text"),
|
||||
WidgetKind::Rectangle => (NodeKind::Rectangle, "Rectangle"),
|
||||
WidgetKind::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"),
|
||||
WidgetKind::Image => (NodeKind::Rectangle, "Image"),
|
||||
WidgetKind::Line => {
|
||||
height = 0.0;
|
||||
(NodeKind::Line, "Line")
|
||||
}
|
||||
WidgetKind::Vector => {
|
||||
let mut path = crate::cmd::create::parse_path_data(&d.path).ok_or_else(|| bad(cmd, "a vector's `path` isn't valid SVG path data"))?;
|
||||
let bb = path.bounding_box();
|
||||
let finite = [bb.x0, bb.y0, bb.x1, bb.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIZE);
|
||||
if !finite || path.elements().is_empty() {
|
||||
return Err(bad(cmd, format!("a vector's `path` must stay within ±{MAX_SIZE}")));
|
||||
}
|
||||
// In its own box, scaled to the size asked for.
|
||||
let fit = |want: Option<f64>, have: f64| want.filter(|_| have > 1e-9).map_or(1.0, |w| w / have);
|
||||
path.apply_affine(Affine::scale_non_uniform(fit(d.width, bb.width()), fit(d.height, bb.height())) * Affine::translate((-bb.x0, -bb.y0)));
|
||||
(width, height) = (d.width.unwrap_or(bb.width()), d.height.unwrap_or(bb.height()));
|
||||
(NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Vector")
|
||||
}
|
||||
};
|
||||
// Cut after `maxLines`: a fixed box that many lines high unless a height is given.
|
||||
if let (WidgetKind::Text, Some(lines), None) = (d.kind, d.max_lines, d.height) {
|
||||
height = (lines * d.size.unwrap_or(14.0) * 1.3).ceil();
|
||||
}
|
||||
let name = if d.name.is_empty() { name.to_string() } else { d.name.clone() };
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(id, kind, name, d.width.unwrap_or(100.0), d.height.unwrap_or(100.0));
|
||||
let mut n = Node::new(id, kind, name, width, height);
|
||||
n.transform = Affine::translate((d.x.unwrap_or(0.0), d.y.unwrap_or(0.0)));
|
||||
n.locked = true;
|
||||
if !d.target.is_empty() {
|
||||
n.plugin_data.insert(TARGET.into(), d.target.clone());
|
||||
}
|
||||
if d.kind == WidgetKind::Image {
|
||||
use base64::Engine as _;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?;
|
||||
|
|
@ -166,13 +454,65 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
|
|||
}
|
||||
n.width = d.width.unwrap_or(f64::from(iw));
|
||||
n.height = d.height.unwrap_or(f64::from(ih));
|
||||
n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)];
|
||||
let mode = match d.fit {
|
||||
None | Some(WidgetFit::Fill) => ImageScaleMode::Fill,
|
||||
Some(WidgetFit::Fit) => ImageScaleMode::Fit,
|
||||
Some(WidgetFit::Crop) => ImageScaleMode::Crop,
|
||||
Some(WidgetFit::Tile) => ImageScaleMode::Tile,
|
||||
};
|
||||
n.fills = vec![Paint::image(hash, mode)];
|
||||
}
|
||||
doc.insert(n, parent, None)?;
|
||||
crate::cmd::props::apply(cmd, doc, id, &props(d, false))?;
|
||||
dress(cmd, doc, id, d)?;
|
||||
d.children.iter().try_for_each(|c| add(doc, id, c, cmd))
|
||||
}
|
||||
|
||||
/// What `node.set` has no plain property for, on built layer `id` (the widget itself too): its
|
||||
/// gradient and shadow, and the layer data that makes it a part (target, input, hover, tooltip).
|
||||
fn dress(cmd: &str, doc: &mut Document, id: NodeId, d: &WidgetNode) -> Result<()> {
|
||||
let colour = |c: &str| Color::from_hex(c).ok_or_else(|| bad(cmd, "a colour isn't hex"));
|
||||
let n = doc.get_mut(id)?;
|
||||
if let Some(g) = &d.gradient {
|
||||
let stops = g.stops.iter().map(|s| Ok(ColorStop { position: s.at as f32, color: colour(&s.color)? })).collect::<Result<Vec<_>>>()?;
|
||||
let mut gradient = match g.kind {
|
||||
GradientShape::Linear => {
|
||||
let (sin, cos) = g.angle.unwrap_or(90.0).to_radians().sin_cos();
|
||||
let mut l = Gradient::default_for(GradientKind::Linear, Color::BLACK);
|
||||
(l.from, l.to) = ((0.5 - cos / 2.0, 0.5 - sin / 2.0), (0.5 + cos / 2.0, 0.5 + sin / 2.0));
|
||||
l.square_linear_width(n.width, n.height);
|
||||
l
|
||||
}
|
||||
GradientShape::Radial => Gradient::default_for(GradientKind::Radial, Color::BLACK),
|
||||
};
|
||||
gradient.stops = stops;
|
||||
n.fills = vec![Paint::gradient(gradient)];
|
||||
}
|
||||
n.effects = match &d.shadow {
|
||||
Some(s) => {
|
||||
let color = s.color.as_deref().map(colour).transpose()?.unwrap_or(Shadow::default().color);
|
||||
let shadow = Shadow { color, offset: (s.x, s.y), radius: s.blur, spread: s.spread, ..Shadow::default() };
|
||||
vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }]
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let input = (!d.input.is_empty())
|
||||
.then(|| json!(Input { name: d.input.clone(), max: d.max_length.map_or(MAX_TEXT, |m| m as usize), multiline: d.multiline }).to_string());
|
||||
let data = [
|
||||
(TARGET, (!d.target.is_empty()).then(|| d.target.clone())),
|
||||
(INPUT, input),
|
||||
(HOVER, d.hover.as_ref().map(|h| json!(h).to_string())),
|
||||
(TOOLTIP, (!d.tooltip.is_empty()).then(|| d.tooltip.clone())),
|
||||
];
|
||||
for (key, value) in data {
|
||||
match value {
|
||||
Some(v) => n.plugin_data.insert(key.into(), v),
|
||||
None => n.plugin_data.remove(key),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `node.set` properties for described layer `d` (validated already); `root`: the widget itself,
|
||||
/// which keeps its place and size unless the description gives one.
|
||||
fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
|
||||
|
|
@ -186,23 +526,36 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
|
|||
set("fills", json!([c]));
|
||||
}
|
||||
set("fontSize", json!(d.size.unwrap_or(14.0)));
|
||||
if d.bold {
|
||||
set("fontStyle", json!("Bold"));
|
||||
match d.weight {
|
||||
Some(w) => set("fontWeight", json!(w)),
|
||||
None if d.bold => set("fontStyle", json!("Bold")),
|
||||
None => {}
|
||||
}
|
||||
// A text with a width wraps; one without grows with its text.
|
||||
set("textAutoResize", json!(if d.width.is_some() { "height" } else { "widthAndHeight" }));
|
||||
if let Some(a) = d.text_align {
|
||||
set("textAlign", json!(a));
|
||||
}
|
||||
// A text with a width wraps (and is cut after `maxLines`); one without grows with its text.
|
||||
let resize = match (d.width, d.max_lines) {
|
||||
(Some(_), Some(lines)) => {
|
||||
set("maxLines", json!(lines));
|
||||
"truncate"
|
||||
}
|
||||
(Some(_), None) => "height",
|
||||
(None, _) => "widthAndHeight",
|
||||
};
|
||||
set("textAutoResize", json!(resize));
|
||||
}
|
||||
WidgetKind::Image => {}
|
||||
_ => set("fills", json!(d.fill.iter().collect::<Vec<_>>())),
|
||||
}
|
||||
if let Some(c) = &d.stroke {
|
||||
if let Some(c) = d.stroke.as_deref().or((d.kind == WidgetKind::Line).then_some("#000000")) {
|
||||
set("strokes", json!([c]));
|
||||
set("strokeWeight", json!(d.stroke_weight.unwrap_or(1.0)));
|
||||
} else if root {
|
||||
set("strokes", json!([]));
|
||||
}
|
||||
if matches!(d.kind, WidgetKind::Frame | WidgetKind::Rectangle | WidgetKind::Image) {
|
||||
set("cornerRadius", json!(d.radius.unwrap_or(0.0)));
|
||||
set("cornerRadius", json!(d.radius.map_or([0.0; 4], figcraft_plugin::Sides::each)));
|
||||
}
|
||||
set("opacity", json!(d.opacity.unwrap_or(1.0)));
|
||||
if d.kind == WidgetKind::Frame {
|
||||
|
|
@ -210,7 +563,7 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
|
|||
Some(l) => {
|
||||
set("layout", json!(l));
|
||||
set("gap", json!(d.gap.unwrap_or(0.0)));
|
||||
set("padding", json!(d.padding.unwrap_or(0.0)));
|
||||
set("padding", json!(d.padding.map_or([0.0; 4], figcraft_plugin::Sides::each)));
|
||||
set("crossAlign", json!(d.align.map_or(json!("center"), |a| json!(a))));
|
||||
set("mainAlign", json!(d.justify.map_or(json!("min"), |a| json!(a))));
|
||||
set("sizingH", json!(if d.width.is_some() { "fixed" } else { "hug" }));
|
||||
|
|
@ -219,6 +572,12 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
|
|||
None => set("layout", Value::Null),
|
||||
}
|
||||
}
|
||||
// What fills is in an auto layout frame with room on that axis (the description was checked).
|
||||
for (k, fill) in [("sizingH", d.fill_width), ("sizingV", d.fill_height)] {
|
||||
if fill {
|
||||
set(k, json!("fill"));
|
||||
}
|
||||
}
|
||||
// A new layer got its size when it was made.
|
||||
if root {
|
||||
for (k, v) in [("width", d.width), ("height", d.height)] {
|
||||
|
|
@ -230,15 +589,31 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
|
|||
p
|
||||
}
|
||||
|
||||
/// What `widget.insert` and `widget.click` return.
|
||||
fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result<Value> {
|
||||
/// A widget as `widget.list` describes it, without where it is: its state, what can be done to
|
||||
/// it (targets to click, texts to edit, its property menu) and its pending timer.
|
||||
fn describe(s: &Session, w: NodeId) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let output = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
|
||||
let texts: Vec<Value> = inputs(doc, w)
|
||||
.into_iter()
|
||||
.map(|(i, input)| {
|
||||
let text = doc.node(i).and_then(|n| n.kind.text()).map(|t| t.characters.clone()).unwrap_or_default();
|
||||
json!({"name": input.name, "text": text, "maxLength": input.max, "multiline": input.multiline})
|
||||
})
|
||||
.collect();
|
||||
let timer = timer_of(s, w).map(|t| t.due.saturating_duration_since(Instant::now()).as_millis() as u64);
|
||||
Ok(
|
||||
json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets(doc, w), "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}),
|
||||
json!({"id": w, "plugin": doc.get(w)?.plugin_data.get(PLUGIN), "state": state_of(doc, w), "targets": targets(doc, w), "inputs": texts, "menu": menu_of(doc, w), "timer": timer}),
|
||||
)
|
||||
}
|
||||
|
||||
/// What the commands that call a widget's plugin back return.
|
||||
fn report(s: &Session, w: NodeId, out: RunOutput) -> Result<Value> {
|
||||
let mut v = describe(s, w)?;
|
||||
v["output"] = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
|
||||
(v["logs"], v["calls"], v["fuel"]) = (json!(out.logs), json!(out.calls), json!(out.fuel));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "widget.insert";
|
||||
let fuel = num(p, "fuel");
|
||||
|
|
@ -259,7 +634,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
n.fills.clear();
|
||||
n.plugin_data.insert(PLUGIN.into(), m.id.clone());
|
||||
doc.insert(n, page, None)?;
|
||||
let out = callback(s, &wp, w, None, fuel)?;
|
||||
let out = callback(s, &wp, w, &Value::Null, fuel)?;
|
||||
let ds = s.ds_mut()?;
|
||||
// Without a position, centred in the view on whole pixels.
|
||||
if x.is_none() && y.is_none() {
|
||||
|
|
@ -269,49 +644,159 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
ds.selection = vec![w];
|
||||
Ok((w, out))
|
||||
})?;
|
||||
report(s, w, m, out)
|
||||
report(s, w, out)
|
||||
}
|
||||
|
||||
/// The widget command `cmd` is about: the one its `id` is (or is in), else the one selected.
|
||||
fn widget_id(s: &Session, cmd: &str, p: &Value) -> Result<NodeId> {
|
||||
let doc = s.doc()?;
|
||||
let nid = match (id(p, "id"), s.selection().as_slice()) {
|
||||
(Some(nid), _) => nid,
|
||||
(None, [only]) => *only,
|
||||
(None, _) => return Err(bad(cmd, "`id` required (a widget, or a layer in one; without it, the one selected layer)")),
|
||||
};
|
||||
widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))
|
||||
}
|
||||
|
||||
/// The widget command `cmd` is about (see [`widget_id`]), and its plugin.
|
||||
fn widget_for(s: &Session, cmd: &str, p: &Value) -> Result<(NodeId, InstalledPlugin)> {
|
||||
let w = widget_id(s, cmd, p)?;
|
||||
let plugin = s.doc()?.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default();
|
||||
Ok((w, widget_plugin(s, cmd, &plugin)?))
|
||||
}
|
||||
|
||||
/// Call widget `w`'s plugin back with `event`, as the one undo step `label` (none: it joins the
|
||||
/// step before it), and report.
|
||||
fn fire(s: &mut Session, wp: &InstalledPlugin, w: NodeId, event: &Value, label: Option<&str>, fuel: Option<f64>) -> Result<Value> {
|
||||
let out = s.grouped_as(label, |s| callback(s, wp, w, event, fuel))?;
|
||||
report(s, w, out)
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "widget.click";
|
||||
let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?;
|
||||
let (w, wp) = widget_for(s, cmd, p)?;
|
||||
let target = string(p, "target").ok_or_else(|| bad(cmd, "`target` required (see widget.list)"))?;
|
||||
let doc = s.doc()?;
|
||||
let w = widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))?;
|
||||
let all = targets(doc, w);
|
||||
let all = targets(s.doc()?, w);
|
||||
if !all.contains(&target) {
|
||||
return Err(bad(cmd, format!("the widget has no `{target}` to click (it has: {})", all.join(", "))));
|
||||
}
|
||||
let plugin = doc.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default();
|
||||
let fuel = num(p, "fuel");
|
||||
let wp = widget_plugin(s, cmd, &plugin)?;
|
||||
let out = s.grouped(&wp.manifest.name, |s| callback(s, &wp, w, Some(&target), fuel))?;
|
||||
report(s, w, &wp.manifest, out)
|
||||
fire(s, &wp, w, &json!({"target": target}), Some(&wp.manifest.name), num(p, "fuel"))
|
||||
}
|
||||
|
||||
/// Click `target` of the widget `id` is (or is in) on `doc`, a document that isn't one of the
|
||||
/// session's: the prototype player's copy. The same callback as on the canvas, with the session's
|
||||
/// installed plugins and the same sandbox limits, but the widget's state and layers are the
|
||||
/// copy's, nothing reaches the session's documents and there is no undo step. Returns the
|
||||
/// document as the callback left it (on failure the caller keeps its copy as it was) and what
|
||||
/// `widget.click` returns.
|
||||
pub fn click_on(s: &Session, doc: Document, id: NodeId, target: &str) -> Result<(Document, Value)> {
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "widget.setText";
|
||||
let (w, wp) = widget_for(s, cmd, p)?;
|
||||
let name = string(p, "input").ok_or_else(|| bad(cmd, "`input` required (an editable text's name; see widget.list)"))?;
|
||||
let text = get(p, "text").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`text` required (the new text)"))?;
|
||||
let all = inputs(s.doc()?, w);
|
||||
let Some((_, input)) = all.iter().find(|(_, i)| i.name == name) else {
|
||||
let names: Vec<&str> = all.iter().map(|(_, i)| i.name.as_str()).collect();
|
||||
return Err(bad(cmd, format!("the widget has no text `{name}` to edit (it has: {})", names.join(", "))));
|
||||
};
|
||||
fire(s, &wp, w, &json!({"input": name, "text": input.clean(text)}), Some(&wp.manifest.name), num(p, "fuel"))
|
||||
}
|
||||
|
||||
fn menu(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let w = widget_id(s, "widget.menu", p)?;
|
||||
let doc = s.doc()?;
|
||||
Ok(json!({"id": w, "plugin": doc.get(w)?.plugin_data.get(PLUGIN), "items": menu_of(doc, w)}))
|
||||
}
|
||||
|
||||
fn menu_action(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "widget.menuAction";
|
||||
let (w, wp) = widget_for(s, cmd, p)?;
|
||||
let name = string(p, "item").ok_or_else(|| bad(cmd, "`item` required (a menu item's name; see widget.menu)"))?;
|
||||
let mut items = menu_of(s.doc()?, w);
|
||||
let Some(item) = items.iter_mut().find(|i| i.kind != MenuKind::Separator && i.name == name) else {
|
||||
let names: Vec<&str> = items.iter().filter(|i| i.kind != MenuKind::Separator).map(|i| i.name.as_str()).collect();
|
||||
return Err(bad(cmd, format!("the widget's menu has no `{name}` (it has: {})", names.join(", "))));
|
||||
};
|
||||
// A link is the app's to open (on a click only); the plugin isn't called.
|
||||
if item.kind == MenuKind::Link {
|
||||
let url = crate::dev_resources::check_url(&item.url).map_err(|e| bad(cmd, e))?;
|
||||
return Ok(json!({"id": w, "item": name, "url": url}));
|
||||
}
|
||||
let value = item.coerce(get(p, "value")).map_err(|e| bad(cmd, e))?;
|
||||
if !value.is_null() {
|
||||
item.value = Some(value.clone());
|
||||
}
|
||||
let (label, fuel) = (wp.manifest.name.clone(), num(p, "fuel"));
|
||||
let out = s.grouped(&label, |s| {
|
||||
// The menu shows the choice even when the plugin doesn't hand a new menu back.
|
||||
s.ds_mut()?.doc_mut().get_mut(w)?.plugin_data.insert(MENU.into(), json!(items).to_string());
|
||||
callback(s, &wp, w, &json!({"menu": name, "value": value}), fuel)
|
||||
})?;
|
||||
report(s, w, out)
|
||||
}
|
||||
|
||||
fn fire_timer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "widget.fireTimer";
|
||||
let (w, wp) = widget_for(s, cmd, p)?;
|
||||
if timer_of(s, w).is_none() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the widget has no timer pending (its plugin asks for one; see widget.list)".into()));
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
ds.widget_timers.retain(|t| t.widget != w);
|
||||
// A run of ticks is one undo step: a tick right after one joins it.
|
||||
let label = format!("{} timer", wp.manifest.name);
|
||||
let joins = s.undo_labels(1).first() == Some(&label);
|
||||
let r = fire(s, &wp, w, &json!({"timer": true}), (!joins).then_some(label.as_str()), num(p, "fuel"))?;
|
||||
if joins {
|
||||
s.ds_mut()?.history.redo.clear();
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Run widget command `cmd` (`widget.click`, `widget.setText`, `widget.menuAction` or
|
||||
/// `widget.fireTimer`) on `doc`, a document that isn't one of the session's: the prototype
|
||||
/// player's copy. The same callback as on the canvas, with the session's installed plugins and
|
||||
/// the same sandbox limits, but the widget's state and layers are the copy's, nothing reaches the
|
||||
/// session's documents and there is no undo step. The player keeps the clock for timers: the
|
||||
/// result's `timerChanged` says whether the callback started or stopped the widget's timer, and
|
||||
/// then `timer` is the delay it asked for in milliseconds (null: none); `widget.fireTimer` fires
|
||||
/// whenever the player says the timer is due.
|
||||
/// Returns the document as the callback left it (on failure the caller keeps its copy as it was)
|
||||
/// and what the command returns.
|
||||
pub fn run_on(s: &Session, doc: Document, cmd: &str, params: &Value) -> Result<(Document, Value)> {
|
||||
if !matches!(cmd, "widget.click" | "widget.setText" | "widget.menuAction" | "widget.fireTimer") {
|
||||
return Err(bad(cmd, "not a widget event"));
|
||||
}
|
||||
let mut scratch = Session::with_document(doc);
|
||||
scratch.prefs.plugins.clone_from(&s.prefs.plugins);
|
||||
scratch.plugins.modules.clone_from(&s.plugins.modules);
|
||||
let out = click(&mut scratch, &json!({"id": id, "target": target}))?;
|
||||
let ds = scratch.docs.pop().ok_or(crate::EngineError::NoDocument)?;
|
||||
Ok((std::sync::Arc::unwrap_or_clone(ds.doc), out))
|
||||
// The timer the player holds for the widget, as the layer's stamp tells: due for
|
||||
// `widget.fireTimer`, else there only to see whether the callback replaces it.
|
||||
let held = id(params, "id").and_then(|i| widget_of(scratch.doc().ok()?, i)).and_then(|w| {
|
||||
let ds = scratch.ds_mut().ok()?;
|
||||
let stamp = ds.doc.node(w)?.plugin_data.get(TIMER)?.clone();
|
||||
ds.widget_timers.push(Timer { widget: w, due: Instant::now(), stamp: stamp.clone() });
|
||||
Some((w, stamp))
|
||||
});
|
||||
let mut out = scratch.execute(cmd, params)?;
|
||||
let kept = held.is_some_and(|(w, stamp)| cmd != "widget.fireTimer" && timer_of(&scratch, w).is_some_and(|t| t.stamp == stamp));
|
||||
out["timerChanged"] = json!(!kept);
|
||||
if kept {
|
||||
out["timer"] = Value::Null;
|
||||
}
|
||||
let ds = scratch.docs.pop().ok_or(EngineError::NoDocument)?;
|
||||
Ok((Arc::unwrap_or_clone(ds.doc), out))
|
||||
}
|
||||
|
||||
/// Click `target` of the widget `id` is (or is in) on the prototype player's copy `doc` (see
|
||||
/// [`run_on`]).
|
||||
pub fn click_on(s: &Session, doc: Document, id: NodeId, target: &str) -> Result<(Document, Value)> {
|
||||
run_on(s, doc, "widget.click", &json!({"id": id, "target": target}))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let widgets: Vec<Value> = doc
|
||||
.ids()
|
||||
.filter(|i| doc.node(*i).is_some_and(is_widget))
|
||||
.filter_map(|i| {
|
||||
let n = doc.node(i).filter(|n| is_widget(n))?;
|
||||
let state = Value::Object(state_of(doc, i));
|
||||
Some(json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets(doc, i)}))
|
||||
let mut v = describe(s, i).ok()?;
|
||||
(v["name"], v["page"]) = (json!(doc.node(i)?.name), json!(doc.page_of(i)));
|
||||
Some(v)
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"widgets": widgets}))
|
||||
|
|
@ -322,17 +807,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"widget.insert",
|
||||
"Insert widget",
|
||||
"{plugin (a widget plugin's id), x?, y? (top-left; default: centred in the view), fuel?} the plugin draws it; one undo step → {id, plugin, state, targets, output, logs, calls, fuel}",
|
||||
"{plugin (a widget plugin's id), x?, y? (top-left; default: centred in the view), fuel?} the plugin draws it; one undo step → {id, plugin, state, targets, inputs, menu, timer, output, logs, calls, fuel}",
|
||||
false,
|
||||
insert
|
||||
),
|
||||
spec!(
|
||||
"widget.click",
|
||||
"Click widget",
|
||||
"{id (a widget or a layer in one), target (see widget.list), fuel?} calls the widget's plugin back, which redraws it; one undo step → like widget.insert",
|
||||
"{id? (a widget or a layer in one; default: the selected one), target (see widget.list), fuel?} calls the widget's plugin back, which redraws it; one undo step → like widget.insert",
|
||||
false,
|
||||
click
|
||||
),
|
||||
spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets}]}", false, list),
|
||||
spec!(
|
||||
"widget.setText",
|
||||
"Edit widget text",
|
||||
"{id? (a widget or a layer in one; default: the selected one), input (an editable text's name; see widget.list), text, fuel?} commits the text as editing it in place does (cut to its maximum length; one line unless it's multiline): the plugin is called back and redraws; one undo step → like widget.insert",
|
||||
false,
|
||||
set_text
|
||||
),
|
||||
spec!(
|
||||
"widget.menu",
|
||||
"Widget property menu",
|
||||
"{id? (a widget or a layer in one; default: the selected one)} → {id, plugin, items: [{type: action|toggle|dropdown|color|separator|link, name, label, value, options, url}]}",
|
||||
false,
|
||||
menu
|
||||
),
|
||||
spec!(
|
||||
"widget.menuAction",
|
||||
"Use widget menu item",
|
||||
"{id?, item (see widget.menu), value? (a toggle: true|false, default flipped; a dropdown or colour: one of its options), fuel?} calls the widget's plugin back; one undo step → like widget.insert. A link isn't opened: → {id, item, url}",
|
||||
false,
|
||||
menu_action
|
||||
),
|
||||
spec!(
|
||||
"widget.fireTimer",
|
||||
"Fire widget timer",
|
||||
"{id?, fuel?} fires the widget's pending timer now (for scripts and tests; the app fires it when it's due): the plugin is called back; a run of timer callbacks is one undo step → like widget.insert",
|
||||
false,
|
||||
fire_timer
|
||||
),
|
||||
spec!(
|
||||
"widget.list",
|
||||
"List widgets",
|
||||
"{} → {widgets: [{id, plugin, name, page, state, targets, inputs: [{name, text, maxLength, multiline}], menu, timer (milliseconds left, or null)}]}",
|
||||
false,
|
||||
list
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,15 @@ const REPLY: usize = 200_000;
|
|||
|
||||
/// Lays out a test plugin's strings and builds its module.
|
||||
#[derive(Default)]
|
||||
struct W {
|
||||
pub(crate) struct W {
|
||||
data: String,
|
||||
at: usize,
|
||||
/// More imports than the widget tests' four (WAT).
|
||||
pub(crate) imports: String,
|
||||
}
|
||||
|
||||
impl W {
|
||||
fn s(&mut self, text: &str) -> (usize, usize) {
|
||||
pub(crate) fn s(&mut self, text: &str) -> (usize, usize) {
|
||||
let a = 1000 + self.at;
|
||||
self.data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text));
|
||||
self.at += text.len();
|
||||
|
|
@ -32,7 +34,7 @@ impl W {
|
|||
}
|
||||
|
||||
/// Draw `v`; when it's refused, output why and fail.
|
||||
fn render(&mut self, v: &Value) -> String {
|
||||
pub(crate) fn render(&mut self, v: &Value) -> String {
|
||||
let (a, l) = self.s(&v.to_string());
|
||||
format!(
|
||||
"(if (call $render (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const {REPLY}) (call $reply_read (i32.const {REPLY}) (i32.const 50000))) (return (i32.const 1))))"
|
||||
|
|
@ -40,13 +42,13 @@ impl W {
|
|||
}
|
||||
|
||||
/// Whether the input contains `needle`.
|
||||
fn has(&mut self, needle: &str) -> String {
|
||||
pub(crate) fn has(&mut self, needle: &str) -> String {
|
||||
let (a, l) = self.s(needle);
|
||||
format!("(call $has (i32.const {a}) (i32.const {l}) (local.get $n))")
|
||||
}
|
||||
|
||||
/// Call `cmd`; a refusal becomes the output and the run stops (successfully).
|
||||
fn call(&mut self, cmd: &str, params: &Value) -> String {
|
||||
pub(crate) fn call(&mut self, cmd: &str, params: &Value) -> String {
|
||||
let ((a, l), (b, m)) = (self.s(cmd), self.s(¶ms.to_string()));
|
||||
format!(
|
||||
"(if (call $call (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m}))
|
||||
|
|
@ -55,13 +57,14 @@ impl W {
|
|||
}
|
||||
|
||||
/// The module: it reads and outputs its input, then runs `body`.
|
||||
fn build(self, body: &str) -> Vec<u8> {
|
||||
pub(crate) fn build(self, body: &str) -> Vec<u8> {
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "widget_render" (func $render (param i32 i32) (result i32)))
|
||||
{}
|
||||
(memory (export "memory") 4)
|
||||
{}
|
||||
(func $has (param $p i32) (param $l i32) (param $n i32) (result i32) (local $i i32) (local $j i32)
|
||||
|
|
@ -82,7 +85,7 @@ impl W {
|
|||
(call $set_output (i32.const {INPUT}) (local.get $n))
|
||||
{body}
|
||||
(i32.const 0)))"#,
|
||||
self.data
|
||||
self.imports, self.data
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
|
@ -108,7 +111,7 @@ fn tally(extra: impl FnOnce(&mut W) -> String) -> Vec<u8> {
|
|||
w.build(&format!("{pre} (if {inc} (then {two} (return (i32.const 0)))) (if {dec} (then {zero} (return (i32.const 0)))) {one}"))
|
||||
}
|
||||
|
||||
fn widget_manifest(id: &str, access: &str) -> Value {
|
||||
pub(crate) fn widget_manifest(id: &str, access: &str) -> Value {
|
||||
json!({"id": id, "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": access}})
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +131,7 @@ fn click(s: &mut Session, w: NodeId, target: &str) -> Value {
|
|||
ex(s, "widget.click", json!({"id": w, "target": target}))
|
||||
}
|
||||
|
||||
fn state(s: &mut Session, w: NodeId) -> Value {
|
||||
pub(crate) fn state(s: &mut Session, w: NodeId) -> Value {
|
||||
let list = ex(s, "widget.list", json!({}));
|
||||
list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).map(|x| x["state"].clone()).unwrap_or(Value::Null)
|
||||
}
|
||||
|
|
@ -139,7 +142,7 @@ fn label(s: &Session, w: NodeId) -> String {
|
|||
node(s, first).kind.text().unwrap().characters.clone()
|
||||
}
|
||||
|
||||
fn err(s: &mut Session, cmd: &str, p: Value) -> String {
|
||||
pub(crate) fn err(s: &mut Session, cmd: &str, p: Value) -> String {
|
||||
match s.execute(cmd, &p) {
|
||||
Ok(v) => panic!("{cmd} accepted {p}: {v}"),
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,787 @@
|
|||
//! Plugin API 4: widgets take text typed in place, items of their property menu and timers, each
|
||||
//! one callback and one undo step (a run of timer ticks is one); hover styles and tooltips show
|
||||
//! without the plugin or the undo history; lines, vectors, gradients, shadows, text styles and
|
||||
//! fit modes are built; panels get sliders, images, progress bars, dividers, sections, disabled
|
||||
//! controls and validation messages. Everything is kept through save, reload, duplicating and
|
||||
//! export, syncs in a live session (where a timer runs for whoever started it last), works on a
|
||||
//! prototype's copy, is refused for plugins written for API 1–3, and junk never panics.
|
||||
//! Test plugins are WAT (see `widgets_tests`): they output their input, then draw what it asks.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_collab::{Client, Outgoing, Server};
|
||||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||||
use figcraft_doc::{EffectKind, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::components::tests::{ex, idv, node};
|
||||
use crate::live::{Access, Event, Role};
|
||||
use crate::live_tests::{Script, info, push};
|
||||
use crate::plugins_tests::{install, undo_len};
|
||||
use crate::tools::{Cursor, PointerEvent, PointerKind};
|
||||
use crate::widgets::{next_timer, run_on, run_timers};
|
||||
use crate::widgets_tests::{W, err, state, widget_manifest};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
const CARD: &str = "org.test.card";
|
||||
const TIMER: &str = r#"(import "figcraft" "widget_timer" (func $timer (param i32) (result i32)))"#;
|
||||
/// A 1×1 PNG.
|
||||
const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
fn menu() -> Value {
|
||||
json!([
|
||||
{"type": "action", "name": "reset", "label": "Reset"},
|
||||
{"type": "toggle", "name": "pinned", "label": "Pinned"},
|
||||
{"type": "dropdown", "name": "size", "label": "Size", "options": ["S", "M", "L"], "value": "M"},
|
||||
{"type": "color", "name": "tint", "label": "Tint", "options": ["#ff0000", "#00FF00"], "value": "#00ff00"},
|
||||
{"type": "separator"},
|
||||
{"type": "link", "name": "docs", "label": "Docs", "url": "https://example.org/docs"}
|
||||
])
|
||||
}
|
||||
|
||||
/// A widget using everything API 4 added, in state `n`, labelled `label`.
|
||||
fn look(n: i64, label: &str) -> Value {
|
||||
json!({"state": {"n": n}, "menu": menu(), "root": {"type": "frame", "name": "Card", "layout": "vertical", "gap": 6, "padding": [4, 8, 12, 16],
|
||||
"radius": [1, 2, 3, 4], "width": 200, "align": "min",
|
||||
"gradient": {"type": "linear", "angle": 0, "stops": [{"at": 0, "color": "#ffffff"}, {"at": 1, "color": "#dddddd"}]},
|
||||
"shadow": {"x": 1, "y": 2, "blur": 6, "color": "#00000040"}, "children": [
|
||||
{"type": "text", "name": "Title", "text": label, "input": "title", "maxLength": 10, "width": 120, "weight": 700, "textAlign": "center",
|
||||
"maxLines": 2, "size": 12, "tooltip": "Rename", "hover": {"fill": "#FF0000"}},
|
||||
{"type": "text", "name": "Notes", "text": "notes", "input": "notes", "maxLength": 40, "multiline": true, "width": 120, "size": 12},
|
||||
{"type": "rectangle", "name": "Start", "target": "start", "height": 20, "fill": "#0D99FF", "fillWidth": true, "tooltip": "Start",
|
||||
"hover": {"fill": "#00FF00", "stroke": "#000000", "opacity": 0.5}},
|
||||
{"type": "ellipse", "name": "Stop", "target": "stop", "width": 20, "height": 20, "fill": "#F24822"},
|
||||
{"type": "line", "width": 50},
|
||||
{"type": "vector", "path": "M0 0 L10 0 L5 8 Z", "fill": "#111111", "width": 20},
|
||||
{"type": "image", "data": PIXEL, "width": 16, "height": 16, "fit": "tile"}
|
||||
]}})
|
||||
}
|
||||
|
||||
/// The test widget: it draws "new" (state 1) when inserted; `start` asks for a 100 ms timer and
|
||||
/// draws 2, `stop` cancels it without drawing, a timer draws 3 and asks again, text typed into
|
||||
/// `title` draws 4 (44 when it is "Hello ther") and into `notes` nothing, the menu's `reset`
|
||||
/// draws 0 and its other items draw nothing. `extra` runs first on every call.
|
||||
fn card(extra: impl FnOnce(&mut W) -> String) -> Vec<u8> {
|
||||
let mut w = W::default();
|
||||
w.imports = TIMER.into();
|
||||
let pre = extra(&mut w);
|
||||
let on = |w: &mut W, needle: &str, then: String| format!("(if {} (then {then} (return (i32.const 0))))", w.has(needle));
|
||||
let again = "(drop (call $timer (i32.const 100)))";
|
||||
let (tick, started, exact, typed, reset, new) = (
|
||||
w.render(&look(3, "tick")),
|
||||
w.render(&look(2, "started")),
|
||||
w.render(&look(44, "exact")),
|
||||
w.render(&look(4, "typed")),
|
||||
w.render(&look(0, "reset")),
|
||||
w.render(&look(1, "new")),
|
||||
);
|
||||
let body = [
|
||||
on(&mut w, r#""timer":true"#, format!("{tick} {again}")),
|
||||
on(&mut w, r#""target":"start""#, format!("{started} {again}")),
|
||||
on(&mut w, r#""target":"stop""#, "(drop (call $timer (i32.const 0)))".into()),
|
||||
on(&mut w, r#""text":"Hello ther""#, exact),
|
||||
on(&mut w, r#""input":"title""#, typed),
|
||||
on(&mut w, r#""input":"notes""#, String::new()),
|
||||
on(&mut w, r#""menu":"reset""#, reset),
|
||||
on(&mut w, r#""menu":""#, String::new()),
|
||||
]
|
||||
.join("\n");
|
||||
w.build(&format!("{pre} {body} {new}"))
|
||||
}
|
||||
|
||||
fn manifest(api: u32) -> Value {
|
||||
let mut m = widget_manifest(CARD, "read");
|
||||
m["api"] = json!(api);
|
||||
m
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(4), &card(|_| String::new()));
|
||||
s
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session) -> (NodeId, Value) {
|
||||
let r = ex(s, "widget.insert", json!({"plugin": CARD, "x": 40, "y": 40}));
|
||||
(idv(&r), r)
|
||||
}
|
||||
|
||||
/// The layer called `name` inside widget `w`.
|
||||
fn part(s: &Session, w: NodeId, name: &str) -> NodeId {
|
||||
node(s, w).children.iter().copied().find(|c| node(s, *c).name == name).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
fn text_of(s: &Session, id: NodeId) -> String {
|
||||
node(s, id).kind.text().unwrap().characters.clone()
|
||||
}
|
||||
|
||||
fn pointer(s: &mut Session, kind: PointerKind, id: NodeId) {
|
||||
let c = s.doc().unwrap().world_bounds(id).unwrap().center();
|
||||
s.pointer(&PointerEvent::at(kind, c.x, c.y, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
/// A pointer event over the layer called `name` of widget `w`.
|
||||
fn point(s: &mut Session, kind: PointerKind, w: NodeId, name: &str) {
|
||||
let id = part(s, w, name);
|
||||
pointer(s, kind, id);
|
||||
}
|
||||
|
||||
fn listed(s: &mut Session, w: NodeId) -> Value {
|
||||
let list = ex(s, "widget.list", json!({}));
|
||||
list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_typed_in_place_or_set_by_command_calls_the_plugin_back_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let (w, r) = insert(&mut s);
|
||||
assert_eq!(r["output"]["api"], json!(4));
|
||||
assert_eq!(
|
||||
r["inputs"],
|
||||
json!([{"name": "title", "text": "new", "maxLength": 10, "multiline": false}, {"name": "notes", "text": "notes", "maxLength": 40, "multiline": true}])
|
||||
);
|
||||
// By command: cut to the maximum, one line; the plugin gets the name and the text and redraws.
|
||||
let undo = undo_len(&s);
|
||||
let r = ex(&mut s, "widget.setText", json!({"id": w, "input": "title", "text": "A\tlong\nline of text"}));
|
||||
assert_eq!(r["output"]["widget"]["event"], json!({"input": "title", "text": "A long lin"}));
|
||||
assert_eq!((r["state"].clone(), undo_len(&s), text_of(&s, part(&s, w, "Title"))), (json!({"n": 4}), undo + 1, "typed".into()));
|
||||
let inside = part(&s, w, "Start");
|
||||
let r = ex(&mut s, "widget.setText", json!({"id": inside, "input": "notes", "text": "two\r\nlines\u{7}"}));
|
||||
assert_eq!(r["output"]["widget"]["event"], json!({"input": "notes", "text": "two\nlines"}), "a multiline text keeps its line breaks");
|
||||
assert_eq!(undo_len(&s), undo + 1, "a callback that changes nothing is no undo step");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((state(&mut s, w), text_of(&s, part(&s, w, "Title"))), (json!({"n": 1}), "new".into()));
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(state(&mut s, w), json!({"n": 4}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "nope", "text": "x"})).contains("it has: title, notes"));
|
||||
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "title"})).contains("`text` required"));
|
||||
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "title", "text": 5})).contains("`text` required"));
|
||||
|
||||
// On the canvas: an I-beam over the text, a click edits it in place with everything selected.
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let title = part(&s, w, "Title");
|
||||
pointer(&mut s, PointerKind::Move, title);
|
||||
assert_eq!(s.tool.overlay.cursor, Cursor::Text);
|
||||
let undo = undo_len(&s);
|
||||
pointer(&mut s, PointerKind::Down, title);
|
||||
pointer(&mut s, PointerKind::Up, title);
|
||||
let te = s.ds().unwrap().text_edit.clone().unwrap();
|
||||
assert_eq!((te.id, te.anchor, te.caret), (title, 0, 3));
|
||||
// Typing replaces it, shows at once, and stops at the maximum; Enter is no line break.
|
||||
crate::textedit::insert(&mut s, "Hello\nthere, world").unwrap();
|
||||
assert_eq!(text_of(&s, title), "Hello ther");
|
||||
crate::textedit::insert(&mut s, "!").unwrap();
|
||||
assert_eq!((text_of(&s, title), undo_len(&s)), ("Hello ther".into(), undo));
|
||||
crate::textedit::key(&mut s, "Enter", false, false, false).unwrap();
|
||||
// Committed: the plugin saw exactly that text and redrew; one undo step; the widget is selected.
|
||||
assert!(s.ds().unwrap().text_edit.is_none() && s.ds().unwrap().interaction.is_none());
|
||||
assert_eq!((state(&mut s, w), undo_len(&s), s.selection()), (json!({"n": 44}), undo + 1, vec![w]));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((state(&mut s, w), text_of(&s, part(&s, w, "Title"))), (json!({"n": 1}), "new".into()));
|
||||
|
||||
// Leaving it as it was calls nobody; a multiline text takes Enter and commits on Escape.
|
||||
let title = part(&s, w, "Title");
|
||||
pointer(&mut s, PointerKind::Down, title);
|
||||
pointer(&mut s, PointerKind::Up, title);
|
||||
crate::textedit::key(&mut s, "Escape", false, false, false).unwrap();
|
||||
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 1}), undo));
|
||||
let notes = part(&s, w, "Notes");
|
||||
crate::textedit::begin(&mut s, notes, None, false).unwrap();
|
||||
crate::textedit::insert(&mut s, "a").unwrap();
|
||||
crate::textedit::key(&mut s, "Enter", false, false, false).unwrap();
|
||||
crate::textedit::insert(&mut s, "- b").unwrap();
|
||||
assert_eq!(text_of(&s, notes), "a\n- b", "no lists in a widget's text");
|
||||
crate::textedit::key(&mut s, "Escape", false, false, false).unwrap();
|
||||
assert_eq!((text_of(&s, part(&s, w, "Notes")), undo_len(&s)), ("notes".into(), undo), "the plugin drew nothing: the layer is as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_property_menu_is_listed_and_its_items_call_the_plugin_back() {
|
||||
let mut s = session();
|
||||
let (w, r) = insert(&mut s);
|
||||
let inside = part(&s, w, "Start");
|
||||
let m = ex(&mut s, "widget.menu", json!({"id": inside}));
|
||||
assert_eq!((m["id"].clone(), m["plugin"].clone(), m["items"].clone()), (json!(w), json!(CARD), r["menu"].clone()));
|
||||
let items = m["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 6);
|
||||
assert_eq!(items[1], json!({"type": "toggle", "name": "pinned", "label": "Pinned", "value": false}));
|
||||
assert_eq!(items[3], json!({"type": "color", "name": "tint", "label": "Tint", "value": "#00FF00", "options": ["#FF0000", "#00FF00"]}));
|
||||
// An action: no value; the plugin redraws; one undo step.
|
||||
ex(&mut s, "widget.click", json!({"id": w, "target": "stop"}));
|
||||
let undo = undo_len(&s);
|
||||
let r = ex(&mut s, "widget.menuAction", json!({"id": w, "item": "reset", "value": "ignored"}));
|
||||
assert_eq!(r["output"]["widget"]["event"], json!({"menu": "reset", "value": null}));
|
||||
assert_eq!((r["state"].clone(), undo_len(&s)), (json!({"n": 0}), undo + 1));
|
||||
// A toggle flips unless told; a dropdown and a colour take one of their options. The menu
|
||||
// shows the choice though this plugin draws nothing for them.
|
||||
let value = |s: &mut Session, item: &str, v: Value| {
|
||||
let mut p = json!({"id": w, "item": item});
|
||||
if !v.is_null() {
|
||||
p["value"] = v;
|
||||
}
|
||||
ex(s, "widget.menuAction", p)["output"]["widget"]["event"]["value"].clone()
|
||||
};
|
||||
assert_eq!(value(&mut s, "pinned", Value::Null), json!(true));
|
||||
assert_eq!(value(&mut s, "pinned", Value::Null), json!(false));
|
||||
assert_eq!(value(&mut s, "pinned", json!(true)), json!(true));
|
||||
assert_eq!(value(&mut s, "size", json!("L")), json!("L"));
|
||||
assert_eq!(value(&mut s, "tint", json!("#ff0000")), json!("#FF0000"));
|
||||
let now = ex(&mut s, "widget.menu", json!({"id": w}))["items"].clone();
|
||||
assert_eq!((now[1]["value"].clone(), now[2]["value"].clone(), now[3]["value"].clone()), (json!(true), json!("L"), json!("#FF0000")));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(ex(&mut s, "widget.menu", json!({"id": w}))["items"][3]["value"], json!("#00FF00"), "choosing is an undo step");
|
||||
// A link is the app's to open: the plugin isn't called and nothing changes.
|
||||
let undo = undo_len(&s);
|
||||
assert_eq!(
|
||||
ex(&mut s, "widget.menuAction", json!({"id": w, "item": "docs"})),
|
||||
json!({"id": w, "item": "docs", "url": "https://example.org/docs"})
|
||||
);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
for (p, why) in [
|
||||
(json!({"id": w, "item": "nope"}), "it has: reset, pinned, size, tint, docs"),
|
||||
(json!({"id": w}), "`item` required"),
|
||||
(json!({"id": w, "item": "size", "value": "XL"}), "must be one of: S, M, L"),
|
||||
(json!({"id": w, "item": "size"}), "must be one of"),
|
||||
(json!({"id": w, "item": "tint", "value": "#123456"}), "must be one of"),
|
||||
(json!({"id": w, "item": "pinned", "value": "yes"}), "true or false"),
|
||||
] {
|
||||
assert!(err(&mut s, "widget.menuAction", p.clone()).contains(why), "{p}");
|
||||
}
|
||||
let r = crate::plugins_tests::rect(&mut s, 900.0);
|
||||
assert!(err(&mut s, "widget.menu", json!({"id": r})).contains("isn't a widget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_styles_and_tooltips_show_without_the_plugin_or_an_undo_step() {
|
||||
let mut s = session();
|
||||
let (w, _) = insert(&mut s);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let (start, stop) = (part(&s, w, "Start"), part(&s, w, "Stop"));
|
||||
let itself = s.ds().unwrap().doc.clone();
|
||||
let undo = undo_len(&s);
|
||||
let shown = |s: &Session| {
|
||||
let n = node(s, start);
|
||||
(n.fills[0].solid_color().unwrap().to_css_hex(), n.strokes.len(), n.opacity)
|
||||
};
|
||||
assert_eq!(shown(&s), ("#0D99FF".into(), 0, 1.0));
|
||||
pointer(&mut s, PointerKind::Move, start);
|
||||
assert_eq!(shown(&s), ("#00FF00".into(), 1, 0.5), "the hover style shows");
|
||||
assert_eq!((s.tool.overlay.cursor, s.tool.overlay.widget_tip.as_deref()), (Cursor::Pointer, Some("Start")));
|
||||
let ds = s.ds().unwrap();
|
||||
assert!(Arc::ptr_eq(ds.document_itself(), &itself) && undo_len(&s) == undo, "the document and its history are as they were");
|
||||
// The same part again changes nothing; another part, or none, puts it back.
|
||||
let revision = ds.revision;
|
||||
pointer(&mut s, PointerKind::Move, start);
|
||||
assert_eq!(s.ds().unwrap().revision, revision);
|
||||
pointer(&mut s, PointerKind::Move, stop);
|
||||
assert!(Arc::ptr_eq(&s.ds().unwrap().doc, &itself) && s.tool.overlay.widget_tip.is_none(), "a part without a hover style or a tooltip");
|
||||
assert_eq!(s.tool.overlay.cursor, Cursor::Pointer);
|
||||
// A text's hover colours it; any command ends the look, and a click still works.
|
||||
point(&mut s, PointerKind::Move, w, "Title");
|
||||
assert_eq!(node(&s, part(&s, w, "Title")).fills[0].solid_color().unwrap().to_css_hex(), "#FF0000");
|
||||
assert_eq!(s.tool.overlay.widget_tip.as_deref(), Some("Rename"));
|
||||
ex(&mut s, "widget.list", json!({}));
|
||||
assert!(Arc::ptr_eq(&s.ds().unwrap().doc, &itself));
|
||||
pointer(&mut s, PointerKind::Move, start);
|
||||
pointer(&mut s, PointerKind::Down, start);
|
||||
pointer(&mut s, PointerKind::Up, start);
|
||||
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 2}), undo + 1));
|
||||
assert!(s.ds().unwrap().text_preview.is_none());
|
||||
// Saving while a part is hovered saves the document itself.
|
||||
point(&mut s, PointerKind::Move, w, "Start");
|
||||
assert!(s.ds().unwrap().text_preview.is_some());
|
||||
let fill = |d: &figcraft_doc::Document| d.node(part(&s, w, "Start")).unwrap().fills[0].solid_color().unwrap().to_css_hex();
|
||||
assert_eq!(fill(s.ds().unwrap().document_itself()), "#0D99FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timer_calls_back_once_due_and_a_run_of_ticks_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
let (w, r) = insert(&mut s);
|
||||
assert_eq!((r["timer"].clone(), next_timer(&s)), (Value::Null, None));
|
||||
assert!(matches!(s.execute("widget.fireTimer", &json!({"id": w})), Err(EngineError::Disabled(..))), "no timer is pending");
|
||||
assert!(run_timers(&mut s).is_empty());
|
||||
// A callback asks for one: it is pending, and nothing happens before it is due.
|
||||
let r = ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
|
||||
let left = r["timer"].as_u64().unwrap();
|
||||
assert!(left <= 100 && next_timer(&s).is_some_and(|t| t <= 0.1), "{left}");
|
||||
assert_eq!(listed(&mut s, w)["timer"].as_u64().map(|t| t <= 100), Some(true));
|
||||
let undo = undo_len(&s);
|
||||
assert!(run_timers(&mut s).is_empty());
|
||||
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 2}), undo));
|
||||
// Due: the plugin is called with the timer, redraws and asks again.
|
||||
std::thread::sleep(std::time::Duration::from_millis(130));
|
||||
assert!(run_timers(&mut s).is_empty());
|
||||
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 3}), undo + 1));
|
||||
assert_eq!(s.undo_labels(1), vec!["Tally timer".to_string()]);
|
||||
assert!(next_timer(&s).is_some(), "it asked for the next one");
|
||||
// More ticks join that step (fired by command, as a script would).
|
||||
let r = ex(&mut s, "widget.fireTimer", json!({"id": w}));
|
||||
assert_eq!(r["output"]["widget"]["event"], json!({"timer": true}));
|
||||
ex(&mut s, "widget.fireTimer", json!({"id": w}));
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
// Undoing the ticks goes back to before the first; undoing the click that started the timer
|
||||
// stops it (the layer no longer carries this session's stamp).
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(state(&mut s, w), json!({"n": 2}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((state(&mut s, w), listed(&mut s, w)["timer"].clone()), (json!({"n": 1}), Value::Null));
|
||||
std::thread::sleep(std::time::Duration::from_millis(130));
|
||||
assert!(run_timers(&mut s).is_empty());
|
||||
assert_eq!((state(&mut s, w), next_timer(&s)), (json!({"n": 1}), None), "the dead timer is dropped, not fired");
|
||||
// Cancelling, and a busy document: the timer waits for a drag or typing to end.
|
||||
ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
|
||||
ex(&mut s, "widget.click", json!({"id": w, "target": "stop"}));
|
||||
assert_eq!(next_timer(&s), None);
|
||||
ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
|
||||
let notes = part(&s, w, "Notes");
|
||||
crate::textedit::begin(&mut s, notes, None, false).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(130));
|
||||
assert!(run_timers(&mut s).is_empty());
|
||||
assert_eq!(state(&mut s, w), json!({"n": 2}));
|
||||
assert!(next_timer(&s).is_some_and(|t| t > 0.1), "it tries again a little later");
|
||||
crate::textedit::key(&mut s, "Escape", false, false, false).unwrap();
|
||||
assert_eq!(ex(&mut s, "widget.fireTimer", json!({"id": w}))["state"], json!({"n": 3}));
|
||||
// A copy has no timer of its own; a deleted widget's goes with it.
|
||||
s.execute("select.set", &json!({"ids": [w]})).unwrap();
|
||||
ex(&mut s, "edit.duplicate", json!({}));
|
||||
let copy = s.selection()[0];
|
||||
assert!(listed(&mut s, copy)["timer"].is_null() && listed(&mut s, w)["timer"].is_u64());
|
||||
assert!(matches!(s.execute("widget.fireTimer", &json!({"id": copy})), Err(EngineError::Disabled(..))));
|
||||
ex(&mut s, "edit.delete", json!({"ids": [w]}));
|
||||
std::thread::sleep(std::time::Duration::from_millis(130));
|
||||
assert!(run_timers(&mut s).is_empty());
|
||||
assert_eq!(next_timer(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timers_are_bounded_and_need_api_4() {
|
||||
let ask = |ms: i64| {
|
||||
move |w: &mut W| {
|
||||
let _ = w;
|
||||
format!(
|
||||
"(if (call $timer (i32.const {ms})) (then (call $set_output (i32.const 200000) (call $reply_read (i32.const 200000) (i32.const 5000))) (return (i32.const 1))))"
|
||||
)
|
||||
}
|
||||
};
|
||||
for (ms, api, why) in [
|
||||
(99, 4, "100 to 3600000"),
|
||||
(3_600_001, 4, "100 to 3600000"),
|
||||
(-1, 4, "100 to 3600000"),
|
||||
(i64::from(i32::MIN), 4, "100 to 3600000"),
|
||||
(1000, 3, "need plugin API 4"),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(api), &card(ask(ms)));
|
||||
let e = err(&mut s, "widget.insert", json!({"plugin": CARD}));
|
||||
assert!(e.contains(why), "{ms}: {e}");
|
||||
assert_eq!(crate::plugins_tests::layer_count(&s), 0);
|
||||
}
|
||||
for ms in [100, 3_600_000, 0] {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(4), &card(ask(ms)));
|
||||
let r = ex(&mut s, "widget.insert", json!({"plugin": CARD}));
|
||||
assert_eq!(r["timer"].is_u64(), ms > 0, "{ms}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn richer_layers_are_built() {
|
||||
let mut s = session();
|
||||
let (w, r) = insert(&mut s);
|
||||
assert_eq!(r["targets"], json!(["start", "stop"]));
|
||||
let root = node(&s, w);
|
||||
let PaintKind::Gradient(g) = &root.fills[0].kind else { panic!("{:?}", root.fills) };
|
||||
assert_eq!((g.stops.len(), g.from, g.to), (2, (0.0, 0.5), (1.0, 0.5)), "0° runs left to right");
|
||||
let EffectKind::DropShadow(shadow) = &root.effects[0].kind else { panic!("{:?}", root.effects) };
|
||||
assert_eq!((shadow.offset, shadow.radius), ((1.0, 2.0), 6.0));
|
||||
assert_eq!(root.corners.radii, [1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(root.layout.as_ref().unwrap().padding, [4.0, 8.0, 12.0, 16.0]);
|
||||
let title = node(&s, part(&s, w, "Title"));
|
||||
let t = title.kind.text().unwrap();
|
||||
assert_eq!(
|
||||
(t.max_lines, format!("{:?}", t.align_h).to_lowercase(), format!("{:?}", t.auto_resize).to_lowercase()),
|
||||
(2, "center".into(), "truncate".into())
|
||||
);
|
||||
assert!(serde_json::to_string(t).unwrap().contains("Bold"), "weight 700 is bold");
|
||||
let start = node(&s, part(&s, w, "Start"));
|
||||
assert!((start.width - (200.0 - 8.0 - 16.0)).abs() < 1e-6, "it fills the frame's width: {}", start.width);
|
||||
let line = node(&s, part(&s, w, "Line"));
|
||||
assert!(matches!(line.kind, NodeKind::Line) && line.width == 50.0 && line.strokes.len() == 1);
|
||||
let vector = node(&s, part(&s, w, "Vector"));
|
||||
let NodeKind::Vector { network } = &vector.kind else { panic!("{:?}", vector.kind) };
|
||||
assert!(
|
||||
network.vertices.len() >= 3 && (vector.width - 20.0).abs() < 1e-6 && (vector.height - 8.0).abs() < 1e-6,
|
||||
"{} × {}",
|
||||
vector.width,
|
||||
vector.height
|
||||
);
|
||||
let PaintKind::Image(image) = &node(&s, part(&s, w, "Image")).fills[0].kind else { panic!() };
|
||||
assert_eq!(image.scale_mode, ImageScaleMode::Tile);
|
||||
assert!(node(&s, w).children.iter().all(|c| node(&s, *c).locked));
|
||||
// Drawn again, nothing piles up.
|
||||
ex(&mut s, "widget.menuAction", json!({"id": w, "item": "reset"}));
|
||||
assert_eq!((node(&s, w).effects.len(), node(&s, w).fills.len(), node(&s, w).children.len()), (1, 1, 7));
|
||||
// A path that isn't one, or that leaves the limits, is refused and nothing is left behind.
|
||||
for path in ["M0 0 L1e9 0", "M 0 0 X 5"] {
|
||||
let mut b = W::default();
|
||||
let body = b.render(&json!({"root": {"type": "frame", "children": [{"type": "vector", "path": path}]}}));
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(4), &b.build(&body));
|
||||
let e = err(&mut s, "widget.insert", json!({"plugin": CARD}));
|
||||
assert!(e.contains("path"), "{path}: {e}");
|
||||
assert_eq!(crate::plugins_tests::layer_count(&s), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_api_4_added_survives_save_reload_duplicating_and_export() {
|
||||
let mut s = session();
|
||||
let (w, _) = insert(&mut s);
|
||||
ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
|
||||
ex(&mut s, "widget.menuAction", json!({"id": w, "item": "size", "value": "S"}));
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
let mut t = session();
|
||||
t.ds_mut().unwrap().doc = Arc::new(back);
|
||||
let l = listed(&mut t, w);
|
||||
assert_eq!((l["menu"][2]["value"].clone(), l["inputs"].as_array().unwrap().len(), l["state"].clone()), (json!("S"), 2, json!({"n": 2})));
|
||||
assert!(l["timer"].is_null() && next_timer(&t).is_none(), "a reopened file has no timers");
|
||||
assert!(matches!(t.execute("widget.fireTimer", &json!({"id": w})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(ex(&mut t, "widget.setText", json!({"id": w, "input": "title", "text": "again"}))["state"], json!({"n": 4}), "and still works");
|
||||
point(&mut t, PointerKind::Move, w, "Start");
|
||||
assert_eq!(t.tool.overlay.widget_tip.as_deref(), Some("Start"));
|
||||
// A duplicate has its own menu values.
|
||||
s.execute("select.set", &json!({"ids": [w]})).unwrap();
|
||||
ex(&mut s, "edit.duplicate", json!({}));
|
||||
let copy = s.selection()[0];
|
||||
ex(&mut s, "widget.menuAction", json!({"id": copy, "item": "size", "value": "L"}));
|
||||
assert_eq!((listed(&mut s, copy)["menu"][2]["value"].clone(), listed(&mut s, w)["menu"][2]["value"].clone()), (json!("L"), json!("S")));
|
||||
// So has a pasted copy, with the texts, the parts and the menu of the one copied, and no timer.
|
||||
ex(&mut s, "edit.copy", json!({"ids": [w]}));
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
|
||||
let l = listed(&mut s, pasted);
|
||||
assert_eq!(
|
||||
(l["inputs"].as_array().unwrap().len(), l["targets"].clone(), l["menu"][2]["value"].clone(), l["timer"].clone()),
|
||||
(2, json!(["start", "stop"]), json!("S"), Value::Null)
|
||||
);
|
||||
assert_eq!(ex(&mut s, "widget.setText", json!({"id": pasted, "input": "title", "text": "pasted"}))["state"], json!({"n": 4}));
|
||||
assert_eq!(state(&mut s, w), json!({"n": 2}), "the original keeps its own state");
|
||||
let svg = crate::cmd::file::export_bytes(s.doc().unwrap(), &[w], figcraft_doc::ExportFormat::Svg, 1.0).unwrap();
|
||||
let svg = String::from_utf8(svg).unwrap().to_ascii_lowercase();
|
||||
assert!(svg.contains("lineargradient") && svg.contains("dddddd"), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_written_for_api_3_get_what_they_always_got() {
|
||||
// The same description under API 3: what API 4 added is ignored where API 3 ignored it.
|
||||
let plain = |mut v: Value| {
|
||||
let root = &mut v["root"];
|
||||
root["padding"] = json!(8);
|
||||
root["radius"] = json!(4);
|
||||
root["children"].as_array_mut().unwrap().retain(|c| c["type"] != "line" && c["type"] != "vector");
|
||||
v
|
||||
};
|
||||
let mut b = W::default();
|
||||
let body = b.render(&plain(look(1, "old")));
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(3), &b.build(&body));
|
||||
let (w, r) = insert(&mut s);
|
||||
assert_eq!(
|
||||
(r["inputs"].clone(), r["menu"].clone(), r["timer"].clone(), r["targets"].clone()),
|
||||
(json!([]), json!([]), Value::Null, json!(["start", "stop"]))
|
||||
);
|
||||
let root = node(&s, w);
|
||||
assert!(root.effects.is_empty() && root.fills.is_empty(), "no shadow, no gradient");
|
||||
let (title, start) = (node(&s, part(&s, w, "Title")), node(&s, part(&s, w, "Start")));
|
||||
assert!(title.plugin_data.is_empty() && start.plugin_data.len() == 1, "only the target: {:?}", start.plugin_data);
|
||||
assert_eq!((title.kind.text().unwrap().max_lines, start.width), (0, 100.0));
|
||||
point(&mut s, PointerKind::Move, w, "Start");
|
||||
assert!(s.ds().unwrap().text_preview.is_none() && s.tool.overlay.widget_tip.is_none());
|
||||
point(&mut s, PointerKind::Move, w, "Title");
|
||||
assert_eq!(s.tool.overlay.cursor, Cursor::Default);
|
||||
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "title", "text": "x"})).contains("no text `title`"));
|
||||
assert!(err(&mut s, "widget.menuAction", json!({"id": w, "item": "reset"})).contains("no `reset`"));
|
||||
// And refused where API 3 refused it: kinds and shapes it didn't have.
|
||||
for (root, why) in [
|
||||
(json!({"type": "frame", "children": [{"type": "line"}]}), "needs plugin API 4"),
|
||||
(json!({"type": "frame", "children": [{"type": "vector", "path": "M0 0 L1 1"}]}), "needs plugin API 4"),
|
||||
(json!({"type": "frame", "layout": "vertical", "padding": [1, 2, 3, 4]}), "need plugin API 4"),
|
||||
] {
|
||||
let mut b = W::default();
|
||||
let body = b.render(&json!({ "root": root }));
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(3), &b.build(&body));
|
||||
assert!(err(&mut s, "widget.insert", json!({"plugin": CARD})).contains(why), "{root}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_layer_data_and_parameters_never_panic() {
|
||||
let mut s = session();
|
||||
let (w, _) = insert(&mut s);
|
||||
// A file (or a peer) may hold anything in a widget's data: it is read as nothing.
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut noise = |n: usize| -> String {
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
char::from_u32((seed % 0x2FF) as u32).unwrap_or('\u{0}')
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let junk = [
|
||||
"null".to_string(),
|
||||
"[]".into(),
|
||||
"{".into(),
|
||||
r#"{"name": "", "max": 0}"#.into(),
|
||||
r#"{"name": "a b", "max": 1e308, "multiline": 3}"#.into(),
|
||||
r#"{"name": "title", "max": 18446744073709551615}"#.into(),
|
||||
r#"{"fill": 5, "stroke": [1], "opacity": "x"}"#.into(),
|
||||
r##"{"fill": "#12", "opacity": 1e308}"##.into(),
|
||||
r#"[{"type": "link", "name": "x", "url": "javascript:alert(1)"}]"#.into(),
|
||||
r#"[{"type": "dropdown", "name": "x"}]"#.into(),
|
||||
format!("[{}]", vec![r#"{"type": "action", "name": "a"}"#; 40].join(",")),
|
||||
"x".repeat(200_000),
|
||||
noise(300),
|
||||
noise(5000),
|
||||
];
|
||||
for j in &junk {
|
||||
let (title, start) = (part(&s, w, "Title"), part(&s, w, "Start"));
|
||||
let doc = s.ds_mut().unwrap().doc_mut();
|
||||
for (id, key) in
|
||||
[(title, "figcraft.widget.input"), (start, "figcraft.widget.hover"), (start, "figcraft.widget.tooltip"), (w, "figcraft.widget.menu")]
|
||||
{
|
||||
doc.get_mut(id).unwrap().plugin_data.insert(key.into(), j.clone());
|
||||
}
|
||||
doc.get_mut(w).unwrap().plugin_data.insert("figcraft.widget.timer".into(), j.clone());
|
||||
doc.get_mut(w).unwrap().plugin_data.insert("figcraft.widget.state".into(), j.clone());
|
||||
let l = listed(&mut s, w);
|
||||
assert_eq!((l["menu"].clone(), l["timer"].clone()), (json!([]), Value::Null), "{}", &j[..j.len().min(60)]);
|
||||
assert!(l["inputs"].as_array().unwrap().iter().all(|i| i["maxLength"].as_u64().is_some_and(|m| (1..=1000).contains(&m))));
|
||||
// Used as a pointer would: the text first (its junk says it isn't one), then a click that
|
||||
// draws the widget anew, then its text for real.
|
||||
for name in ["Title", "Start", "Title"] {
|
||||
for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] {
|
||||
point(&mut s, kind, w, name);
|
||||
}
|
||||
if s.ds().unwrap().text_edit.is_some() {
|
||||
crate::textedit::insert(&mut s, "typed").unwrap();
|
||||
crate::textedit::key(&mut s, "Enter", false, false, false).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(run_timers(&mut s).is_empty());
|
||||
}
|
||||
// The new commands with junk parameters: an error each, never a panic.
|
||||
let rows = [
|
||||
json!({}),
|
||||
json!(null),
|
||||
json!([1, 2]),
|
||||
json!({"id": "x", "input": 5, "text": [1], "item": {"a": 1}, "value": 1e308, "fuel": "NaN"}),
|
||||
json!({"id": w, "input": "\u{0}", "text": "\u{0}".repeat(5000), "item": "\u{202e}", "value": {"deep": [[[]]]}, "fuel": -1e308}),
|
||||
json!({"id": part(&s, w, "Title"), "input": "title", "text": "x".repeat(100_000), "item": "tint", "value": null, "fuel": 1}),
|
||||
json!({"id": 99999, "input": "title", "text": "", "item": "reset"}),
|
||||
json!({"id": -1, "target": [1], "input": null, "item": null}),
|
||||
];
|
||||
for cmd in ["widget.setText", "widget.menu", "widget.menuAction", "widget.fireTimer", "widget.click", "widget.list", "widget.insert"] {
|
||||
for p in &rows {
|
||||
if let Err(e) = s.execute(cmd, p) {
|
||||
assert!(!matches!(e, EngineError::Internal { .. }), "{cmd} {p}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A session in the room `server` keeps, with the card plugin: (session, its link's script, id).
|
||||
type Peer = (Session, Arc<Mutex<Script>>, u32);
|
||||
|
||||
fn join(server: &mut Server, access: Access) -> Peer {
|
||||
let (mut s, script) = crate::live_tests::session();
|
||||
install(&mut s, manifest(4), &card(|_| String::new()));
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Me", "") else { panic!("no hello") };
|
||||
let (id, outs) = server.join_as(h, access).unwrap();
|
||||
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, m @ ServerMsg::Welcome { .. }) = o { Some(m) } else { None }).unwrap();
|
||||
let mut me = info(Role::Guest, id);
|
||||
me.access = access;
|
||||
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, Event::Sync(welcome)]);
|
||||
s.live_poll();
|
||||
(s, script, id)
|
||||
}
|
||||
|
||||
/// Send what each peer did through the server until nobody has anything left to send.
|
||||
fn pump(server: &mut Server, peers: &mut [Peer]) {
|
||||
for _ in 0..8 {
|
||||
let mut sent = false;
|
||||
for i in 0..peers.len() {
|
||||
peers[i].0.live_sync();
|
||||
let (msgs, from) = (std::mem::take(&mut peers[i].1.lock().unwrap().msgs), peers[i].2);
|
||||
for out in msgs.into_iter().flat_map(|m| server.receive(from, m)) {
|
||||
sent = true;
|
||||
for (_, script, id) in peers.iter() {
|
||||
let m = match &out {
|
||||
Outgoing::To(to, m) if to == id => m,
|
||||
Outgoing::Others(but, m) if but != id => m,
|
||||
Outgoing::All(m) => m,
|
||||
_ => continue,
|
||||
};
|
||||
if !matches!(m, ServerMsg::Presence { .. }) {
|
||||
push(script, vec![Event::Sync(m.clone())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
peers.iter_mut().for_each(|p| {
|
||||
p.0.live_poll();
|
||||
});
|
||||
if !sent {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_a_live_session_updates_sync_and_a_timer_runs_for_whoever_started_it_last() {
|
||||
let mut server = Server::new(Session::new().doc().unwrap().clone(), "", figcraft_collab::Limits::default());
|
||||
let mut peers = [join(&mut server, Access::Edit), join(&mut server, Access::Edit), join(&mut server, Access::View)];
|
||||
let (a, b, viewer) = (0, 1, 2);
|
||||
let (w, _) = insert(&mut peers[a].0);
|
||||
pump(&mut server, &mut peers);
|
||||
assert!(peers.iter().all(|p| p.0.doc().unwrap().node(w).is_some()), "the widget reached everyone");
|
||||
// Typed text and a menu choice by one show for the other.
|
||||
ex(&mut peers[b].0, "widget.setText", json!({"id": w, "input": "title", "text": "Hi"}));
|
||||
pump(&mut server, &mut peers);
|
||||
ex(&mut peers[a].0, "widget.menuAction", json!({"id": w, "item": "size", "value": "L"}));
|
||||
pump(&mut server, &mut peers);
|
||||
for p in &mut peers {
|
||||
let l = listed(&mut p.0, w);
|
||||
assert_eq!((l["state"].clone(), l["menu"][2]["value"].clone()), (json!({"n": 4}), json!("L")));
|
||||
}
|
||||
// A starts the timer; B starts it after: only B's fires, and its tick reaches A.
|
||||
ex(&mut peers[a].0, "widget.click", json!({"id": w, "target": "start"}));
|
||||
pump(&mut server, &mut peers);
|
||||
assert!(next_timer(&peers[a].0).is_some() && next_timer(&peers[b].0).is_none(), "the timer is its starter's");
|
||||
ex(&mut peers[b].0, "widget.click", json!({"id": w, "target": "start"}));
|
||||
pump(&mut server, &mut peers);
|
||||
std::thread::sleep(std::time::Duration::from_millis(130));
|
||||
assert!(run_timers(&mut peers[a].0).is_empty());
|
||||
assert_eq!((state(&mut peers[a].0, w), next_timer(&peers[a].0)), (json!({"n": 2}), None), "A's was replaced: dropped without a callback");
|
||||
assert!(run_timers(&mut peers[b].0).is_empty());
|
||||
assert_eq!(state(&mut peers[b].0, w), json!({"n": 3}));
|
||||
pump(&mut server, &mut peers);
|
||||
assert_eq!(state(&mut peers[a].0, w), json!({"n": 3}));
|
||||
assert_eq!(server.document().node(w).unwrap().plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"n":3}"#));
|
||||
// The hover look never syncs, and an update arriving under it shows.
|
||||
point(&mut peers[a].0, PointerKind::Move, w, "Start");
|
||||
assert!(peers[a].0.ds().unwrap().text_preview.is_some());
|
||||
ex(&mut peers[b].0, "widget.menuAction", json!({"id": w, "item": "reset"}));
|
||||
pump(&mut server, &mut peers);
|
||||
assert_eq!(state(&mut peers[a].0, w), json!({"n": 0}));
|
||||
let start = part(&peers[b].0, w, "Start");
|
||||
assert_eq!(server.document().node(start).unwrap().fills[0].solid_color().unwrap().to_css_hex(), "#0D99FF");
|
||||
// Someone who may only view can't use the widget and runs no timers.
|
||||
let v = &mut peers[viewer].0;
|
||||
assert!(v.execute("widget.click", &json!({"id": w, "target": "start"})).is_err());
|
||||
assert!(v.execute("widget.setText", &json!({"id": w, "input": "title", "text": "no"})).is_err());
|
||||
assert!(run_timers(v).is_empty() && next_timer(v).is_none());
|
||||
assert_eq!(state(v, w), json!({"n": 0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prototypes_copy_takes_text_menu_items_and_timers_too() {
|
||||
let mut s = session();
|
||||
let (w, _) = insert(&mut s);
|
||||
let editor = s.doc().unwrap().clone();
|
||||
let undo = undo_len(&s);
|
||||
let (copy, out) = run_on(&s, editor.clone(), "widget.setText", &json!({"id": w, "input": "title", "text": "Hello there"})).unwrap();
|
||||
assert_eq!((out["state"].clone(), out["output"]["widget"]["event"]["text"].clone()), (json!({"n": 44}), json!("Hello ther")));
|
||||
let (copy, out) = run_on(&s, copy, "widget.menuAction", &json!({"id": w, "item": "reset"})).unwrap();
|
||||
assert_eq!(out["state"], json!({"n": 0}));
|
||||
// A callback that asks for a timer says so; the player keeps the clock and fires it.
|
||||
let (copy, out) = run_on(&s, copy, "widget.click", &json!({"id": w, "target": "start"})).unwrap();
|
||||
assert!(out["timer"].as_u64().is_some_and(|t| t <= 100) && out["timerChanged"] == json!(true), "{}", out["timer"]);
|
||||
// An event that leaves the timer alone says so; one that stops it, too.
|
||||
let (copy, out) = run_on(&s, copy, "widget.menuAction", &json!({"id": w, "item": "pinned"})).unwrap();
|
||||
assert_eq!((out["timerChanged"].clone(), out["timer"].clone()), (json!(false), Value::Null));
|
||||
let (copy, out) = run_on(&s, copy, "widget.fireTimer", &json!({"id": w})).unwrap();
|
||||
assert_eq!((out["state"].clone(), out["timer"].is_u64(), out["timerChanged"].clone()), (json!({"n": 3}), true, json!(true)));
|
||||
let (copy, out) = run_on(&s, copy, "widget.click", &json!({"id": w, "target": "stop"})).unwrap();
|
||||
assert_eq!((out["timerChanged"].clone(), out["timer"].clone()), (json!(true), Value::Null));
|
||||
assert_eq!(copy.node(w).unwrap().plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"n":3}"#));
|
||||
// The file, its history and its timers are as they were.
|
||||
assert_eq!((state(&mut s, w), undo_len(&s), next_timer(&s)), (json!({"n": 1}), undo, None));
|
||||
assert!(run_on(&s, copy.clone(), "node.delete", &json!({"ids": [w]})).is_err(), "only widget events");
|
||||
assert!(run_on(&s, copy, "widget.setText", &json!({"id": w, "input": "nope", "text": ""})).is_err());
|
||||
}
|
||||
|
||||
/// A plugin that shows panel `spec` on every call.
|
||||
fn panel_plugin(spec: &Value) -> Vec<u8> {
|
||||
let mut w = W::default();
|
||||
w.imports = r#"(import "figcraft" "ui_show" (func $ui_show (param i32 i32) (result i32)))"#.into();
|
||||
let (a, l) = w.s(&spec.to_string());
|
||||
w.build(&format!(
|
||||
"(if (call $ui_show (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const 200000) (call $reply_read (i32.const 200000) (i32.const 5000))) (return (i32.const 1))))"
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_take_sliders_images_progress_sections_and_disabled_controls() {
|
||||
let spec = json!({"title": "Dots", "controls": [
|
||||
{"type": "slider", "name": "count", "label": "Count", "min": 2, "max": 12, "step": 2, "value": 7},
|
||||
{"type": "slider", "name": "plain"},
|
||||
{"type": "image", "label": "Preview", "data": PIXEL, "height": 80, "fit": "fit"},
|
||||
{"type": "progress", "label": "Half", "value": 0.5},
|
||||
{"type": "divider"},
|
||||
{"type": "section", "label": "More", "value": false},
|
||||
{"type": "text", "name": "name", "error": "Give it a name"},
|
||||
{"type": "button", "name": "go", "label": "Go", "disabled": true},
|
||||
{"type": "button", "name": "close", "label": "Close"}
|
||||
]});
|
||||
let manifest = |api: u32| json!({"id": "org.test.panel", "name": "Panel", "main": "p.wasm", "api": api, "permissions": {"access": "read"}});
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(4), &panel_plugin(&spec));
|
||||
let r = ex(&mut s, "plugin.run", json!({"id": "org.test.panel"}));
|
||||
let c = &r["panel"]["controls"];
|
||||
assert_eq!(r["panel"]["values"], json!({"count": 8.0, "plain": 0.0, "name": ""}), "a slider's value sits on a step");
|
||||
assert_eq!((c[1]["min"].clone(), c[1]["max"].clone()), (json!(0.0), json!(100.0)));
|
||||
assert_eq!(
|
||||
(c[3]["default"].clone(), c[5]["default"].clone(), c[6]["error"].clone(), c[7]["disabled"].clone()),
|
||||
(json!(0.5), json!(false), json!("Give it a name"), json!(true))
|
||||
);
|
||||
// A slider's event is clamped and stepped; display controls and disabled ones can't be used.
|
||||
let r = ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "count", "value": 99}));
|
||||
assert_eq!(r["output"]["event"], json!({"control": "count", "value": 12.0}));
|
||||
let r = ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "count", "value": 4.9}));
|
||||
assert_eq!(r["output"]["event"]["value"], json!(4.0));
|
||||
assert!(err(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "go"})).contains("disabled"));
|
||||
assert!(err(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "count", "value": "x"})).contains("number"));
|
||||
ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "close"}));
|
||||
// Under API 2 the same panel is refused: it didn't know these controls.
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(2), &panel_plugin(&spec));
|
||||
assert!(err(&mut s, "plugin.run", json!({"id": "org.test.panel"})).contains("needs plugin API 4"));
|
||||
// And what API 4 added to old controls is ignored there, as unknown fields always were.
|
||||
let old = json!({"controls": [{"type": "number", "name": "n", "min": 0, "max": 10, "step": 5, "value": 3, "disabled": true, "error": "x"}]});
|
||||
install(&mut s, manifest(2), &panel_plugin(&old));
|
||||
let r = ex(&mut s, "plugin.run", json!({"id": "org.test.panel"}));
|
||||
assert_eq!(r["panel"]["controls"][0], json!({"name": "n", "type": "number", "default": 3.0, "min": 0.0, "max": 10.0}));
|
||||
// Junk panels are refused whole.
|
||||
for (controls, why) in [
|
||||
(json!([{"type": "slider", "name": "s", "step": 0}]), "bad step"),
|
||||
(json!([{"type": "slider", "name": "s", "min": 5, "max": 1}]), "bad bounds"),
|
||||
(json!([{"type": "image", "label": "No data"}]), "`data`"),
|
||||
(json!([{"type": "text", "name": "t", "data": "AAAA"}]), "`data`"),
|
||||
(json!([{"type": "image", "data": PIXEL, "height": 4000}]), "high"),
|
||||
(json!([{"type": "text", "name": "t", "error": "x".repeat(300)}]), "longer than"),
|
||||
(json!([{"type": "slider"}]), "parameter name"),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, manifest(4), &panel_plugin(&json!({ "controls": controls })));
|
||||
let e = err(&mut s, "plugin.run", json!({"id": "org.test.panel"}));
|
||||
assert!(e.contains(why), "{e}");
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ pub mod widget;
|
|||
|
||||
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand};
|
||||
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run};
|
||||
pub use widget::{WidgetKind, WidgetNode, WidgetRender};
|
||||
pub use widget::{MenuItem, MenuKind, Sides, WidgetFit, WidgetKind, WidgetNode, WidgetRender};
|
||||
|
||||
#[cfg(test)]
|
||||
mod bundle_tests;
|
||||
|
|
@ -20,4 +20,6 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod v2_tests;
|
||||
#[cfg(test)]
|
||||
mod v4_tests;
|
||||
#[cfg(test)]
|
||||
mod widget_tests;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use serde_json::{Map, Value};
|
|||
/// The manifest's file name.
|
||||
pub const MANIFEST_FILE: &str = "figcraft-plugin.json";
|
||||
/// The plugin API version this build implements (`api` in the manifest).
|
||||
pub const API_VERSION: u32 = 3;
|
||||
pub const API_VERSION: u32 = 4;
|
||||
/// Largest manifest read.
|
||||
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
|
||||
|
||||
|
|
@ -23,6 +23,11 @@ const MAX_ALLOW: usize = 256;
|
|||
/// Largest panel spec a plugin may show, and its most controls.
|
||||
pub const MAX_PANEL_BYTES: usize = 64 * 1024;
|
||||
const MAX_CONTROLS: usize = 64;
|
||||
/// API 4. Largest panel spec with images (base64 in it), and the images' decoded bytes together.
|
||||
pub const MAX_PANEL_BYTES_4: usize = 1024 * 1024;
|
||||
pub const MAX_PANEL_IMAGE_BYTES: usize = 512 * 1024;
|
||||
/// API 4. An image control's height in a panel.
|
||||
const IMAGE_HEIGHT: std::ops::RangeInclusive<f64> = 16.0..=400.0;
|
||||
/// Longest text parameter value.
|
||||
const MAX_TEXT_VALUE: usize = 10_000;
|
||||
|
||||
|
|
@ -43,7 +48,8 @@ pub struct Manifest {
|
|||
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
|
||||
pub main: String,
|
||||
/// The plugin API version the plugin was written for (1 when not given; panels and settings
|
||||
/// need 2, widgets 3).
|
||||
/// need 2, widgets 3; text input, property menus, hover, timers and richer drawing in widgets,
|
||||
/// and sliders, images and sections in panels need 4).
|
||||
#[serde(default = "api_version")]
|
||||
pub api: u32,
|
||||
#[serde(default)]
|
||||
|
|
@ -125,6 +131,22 @@ pub struct Param {
|
|||
pub min: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<f64>,
|
||||
/// API 4 from here on. A `slider`'s (or `number`'s) step.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub step: Option<f64>,
|
||||
/// Panels: shown greyed and can't be used.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub disabled: bool,
|
||||
/// Panels: a validation message shown under the field.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub error: String,
|
||||
/// Panels: an `image`'s bytes (PNG, JPEG…) as base64, how it fits its box and the box's height.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub data: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fit: Option<crate::widget::WidgetFit>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<f64>,
|
||||
}
|
||||
|
||||
/// The kind of value a parameter takes.
|
||||
|
|
@ -144,6 +166,29 @@ pub enum ParamKind {
|
|||
Button,
|
||||
/// Panels only: text (`label`, up to 1000 characters).
|
||||
Note,
|
||||
/// API 4. A number from `min` to `max` (0 to 100 when not given) in `step`s, dragged.
|
||||
Slider,
|
||||
/// API 4, panels only: a picture (`data`; `label` names it for screen readers).
|
||||
Image,
|
||||
/// API 4, panels only: a progress bar (`value` from 0 to 1).
|
||||
Progress,
|
||||
/// API 4, panels only: a line between controls.
|
||||
Divider,
|
||||
/// API 4, panels only: a heading (`label`) that folds the controls up to the next section or
|
||||
/// divider (`value`: whether it starts open; true when not given).
|
||||
Section,
|
||||
}
|
||||
|
||||
impl ParamKind {
|
||||
/// Added by plugin API 4.
|
||||
fn api_4(self) -> bool {
|
||||
matches!(self, ParamKind::Slider | ParamKind::Image | ParamKind::Progress | ParamKind::Divider | ParamKind::Section)
|
||||
}
|
||||
|
||||
/// Needs no `name`: it has no value and isn't used.
|
||||
fn nameless(self) -> bool {
|
||||
matches!(self, ParamKind::Note | ParamKind::Image | ParamKind::Progress | ParamKind::Divider | ParamKind::Section)
|
||||
}
|
||||
}
|
||||
|
||||
impl Param {
|
||||
|
|
@ -152,9 +197,9 @@ impl Param {
|
|||
if self.label.is_empty() { &self.name } else { &self.label }
|
||||
}
|
||||
|
||||
/// Whether it holds a value (not a button or a note).
|
||||
/// Whether it holds a value the user sets (not a button, a note or another display control).
|
||||
pub fn is_field(&self) -> bool {
|
||||
!matches!(self.kind, ParamKind::Button | ParamKind::Note)
|
||||
self.kind != ParamKind::Button && !self.kind.nameless()
|
||||
}
|
||||
|
||||
/// The value used when none is given.
|
||||
|
|
@ -164,11 +209,11 @@ impl Param {
|
|||
}
|
||||
match self.kind {
|
||||
ParamKind::Text => Value::String(String::new()),
|
||||
ParamKind::Number => serde_json::json!(self.clamp(0.0)),
|
||||
ParamKind::Number | ParamKind::Slider => serde_json::json!(self.clamp(0.0)),
|
||||
ParamKind::Boolean => Value::Bool(false),
|
||||
ParamKind::Select | ParamKind::List => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())),
|
||||
ParamKind::Color => Value::String("#000000".into()),
|
||||
ParamKind::Button | ParamKind::Note => Value::Null,
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,7 +227,7 @@ impl Param {
|
|||
Value::Number(n) => Ok(Value::String(n.to_string())),
|
||||
_ => Err(format!("`{name}` must be text")),
|
||||
},
|
||||
ParamKind::Number => {
|
||||
ParamKind::Number | ParamKind::Slider => {
|
||||
let n = match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(s) => s.trim().parse::<f64>().ok(),
|
||||
|
|
@ -203,16 +248,45 @@ impl Param {
|
|||
.and_then(figcraft_color::Color::from_hex)
|
||||
.map(|c| Value::String(c.to_css_hex()))
|
||||
.ok_or_else(|| format!("`{name}` must be a hex colour like #FF5A5F")),
|
||||
ParamKind::Button | ParamKind::Note => Ok(Value::Null),
|
||||
// What the control shows: a progress bar's fraction, whether a section starts open.
|
||||
ParamKind::Progress => Ok(serde_json::json!(v.as_f64().filter(|n| n.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0))),
|
||||
ParamKind::Section => Ok(Value::Bool(v.as_bool().unwrap_or(true))),
|
||||
_ => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read as the plugin APIs before 4 did: the kinds API 4 added are refused (they were unknown)
|
||||
/// and its fields dropped (they were ignored).
|
||||
fn as_api_3(&mut self) -> Result<(), String> {
|
||||
if self.kind.api_4() {
|
||||
return Err(format!("a {} control needs plugin API {}", format!("{:?}", self.kind).to_lowercase(), crate::widget::API_4));
|
||||
}
|
||||
(self.step, self.disabled, self.fit, self.height) = (None, false, None, None);
|
||||
self.error.clear();
|
||||
self.data.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a parameter or panel control: its name, label, options and bounds. A default that
|
||||
/// doesn't fit its kind is dropped.
|
||||
fn check(&mut self) -> Result<(), String> {
|
||||
if !(self.kind == ParamKind::Note && self.name.is_empty()) {
|
||||
if !(self.kind.nameless() && self.name.is_empty()) {
|
||||
check_id("parameter name", &self.name, MAX_NAME)?;
|
||||
}
|
||||
self.error = clean("error", &self.error, 200)?;
|
||||
if self.step.is_some_and(|s| !s.is_finite() || s <= 0.0) {
|
||||
return Err(format!("parameter `{}` has a bad step", self.name));
|
||||
}
|
||||
if self.kind == ParamKind::Slider {
|
||||
// A slider always has both ends.
|
||||
(self.min, self.max) = (self.min.or(Some(0.0)), self.max.or(Some(100.0)));
|
||||
}
|
||||
if (self.kind == ParamKind::Image) == self.data.trim().is_empty() {
|
||||
return Err(format!("`data` (base64) is for (and needed by) images (control `{}`)", self.name));
|
||||
}
|
||||
if self.height.is_some_and(|h| !IMAGE_HEIGHT.contains(&h)) {
|
||||
return Err(format!("an image is {}–{} high", IMAGE_HEIGHT.start(), IMAGE_HEIGHT.end()));
|
||||
}
|
||||
let max = if self.kind == ParamKind::Note { MAX_TEXT } else { MAX_LABEL };
|
||||
self.label = clean("parameter label", &self.label, max)?;
|
||||
if self.options.len() > MAX_OPTIONS {
|
||||
|
|
@ -234,9 +308,14 @@ impl Param {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `n` within the number's bounds.
|
||||
/// `n` within the number's bounds, on its nearest step (counted from `min`).
|
||||
fn clamp(&self, n: f64) -> f64 {
|
||||
n.max(self.min.unwrap_or(f64::MIN)).min(self.max.unwrap_or(f64::MAX))
|
||||
let (lo, hi) = (self.min.unwrap_or(f64::MIN), self.max.unwrap_or(f64::MAX));
|
||||
let n = match (self.step, self.min) {
|
||||
(Some(step), Some(from)) => from + ((n.max(lo).min(hi) - from) / step).round() * step,
|
||||
_ => n,
|
||||
};
|
||||
if n.is_finite() { n.max(lo).min(hi) } else { lo.max(0.0).min(hi) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -333,9 +412,12 @@ impl Manifest {
|
|||
return Err(format!("command `{}` has more than {MAX_PARAMS} parameters", c.id));
|
||||
}
|
||||
for p in &mut c.params {
|
||||
if self.api < crate::widget::API_4 {
|
||||
p.as_api_3()?;
|
||||
}
|
||||
p.check()?;
|
||||
if !p.is_field() {
|
||||
return Err(format!("parameter `{}`: buttons and notes only go in panels", p.name));
|
||||
return Err(format!("parameter `{}`: buttons, notes and other display controls only go in panels", p.name));
|
||||
}
|
||||
}
|
||||
if let Some(dup) = first_duplicate(c.params.iter().map(|p| p.name.as_str())) {
|
||||
|
|
@ -357,10 +439,13 @@ pub struct Panel {
|
|||
}
|
||||
|
||||
impl Panel {
|
||||
/// Parse and validate a panel spec (it comes from the plugin: untrusted).
|
||||
pub fn parse(text: &str) -> Result<Panel, String> {
|
||||
if text.len() > MAX_PANEL_BYTES {
|
||||
return Err(format!("the panel is larger than {} KB", MAX_PANEL_BYTES / 1024));
|
||||
/// Parse and validate a panel spec (it comes from the plugin: untrusted) from a plugin written
|
||||
/// for plugin API `api`.
|
||||
pub fn parse(text: &str, api: u32) -> Result<Panel, String> {
|
||||
let old = api < crate::widget::API_4;
|
||||
let max = if old { MAX_PANEL_BYTES } else { MAX_PANEL_BYTES_4 };
|
||||
if text.len() > max {
|
||||
return Err(format!("the panel is larger than {} KB", max / 1024));
|
||||
}
|
||||
let mut p: Panel = serde_json::from_str(text).map_err(|e| format!("panel: {e}"))?;
|
||||
p.title = clean("title", &p.title, MAX_NAME).map_err(|e| format!("panel: {e}"))?;
|
||||
|
|
@ -368,8 +453,15 @@ impl Panel {
|
|||
return Err(format!("panel: more than {MAX_CONTROLS} controls"));
|
||||
}
|
||||
for c in &mut p.controls {
|
||||
if old {
|
||||
c.as_api_3().map_err(|e| format!("panel: {e}"))?;
|
||||
}
|
||||
c.check().map_err(|e| format!("panel: {e}"))?;
|
||||
}
|
||||
// Base64: four characters for three bytes (checked again when decoded).
|
||||
if p.controls.iter().map(|c| c.data.trim().len() / 4 * 3).sum::<usize>() > MAX_PANEL_IMAGE_BYTES + 3 {
|
||||
return Err(format!("panel: the images are larger than {} KB together", MAX_PANEL_IMAGE_BYTES / 1024));
|
||||
}
|
||||
if let Some(dup) = first_duplicate(p.controls.iter().map(|c| c.name.as_str()).filter(|n| !n.is_empty())) {
|
||||
return Err(format!("panel: two controls are called `{dup}`"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
//! memory cap, and sees the outside world only through the host functions below. There is no WASI:
|
||||
//! no file system, network, clock or randomness.
|
||||
//!
|
||||
//! # ABI (version 3; version 1 is the first seven imports, version 2 adds panels and settings)
|
||||
//! # ABI (version 4; version 1 is the first seven imports, version 2 adds panels and settings,
|
||||
//! version 3 widgets, version 4 widget timers and more in the panel and widget descriptions)
|
||||
//!
|
||||
//! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are
|
||||
//! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory.
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
//! | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> i32` | Stores JSON under the key. 1: refused, the reply says why. |
|
||||
//! | `setting_delete` | `(key_ptr, key_len)` | Forgets a setting. |
|
||||
//! | `widget_render` | `(ptr, len) -> i32` | Draws the widget being called back: a [`WidgetRender`] as JSON. 1: invalid, the reply says why. |
|
||||
//! | `widget_timer` | `(ms) -> i32` | API 4. Asks for the widget being called back to be called again in `ms` milliseconds (100 to 3,600,000; 0: no timer). 1: refused, the reply says why. |
|
||||
//!
|
||||
//! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes):
|
||||
//! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the
|
||||
|
|
@ -58,6 +60,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[
|
|||
("setting_set", 4, 1),
|
||||
("setting_delete", 2, 0),
|
||||
("widget_render", 2, 1),
|
||||
("widget_timer", 1, 1),
|
||||
];
|
||||
|
||||
/// What a run may use.
|
||||
|
|
@ -76,6 +79,9 @@ pub struct Limits {
|
|||
pub max_message: usize,
|
||||
/// Log lines kept per run.
|
||||
pub max_logs: usize,
|
||||
/// The plugin API the module was written for (its manifest's `api`): what later versions
|
||||
/// added is refused, and descriptions are read as that version read them.
|
||||
pub api: u32,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
|
|
@ -88,6 +94,7 @@ impl Default for Limits {
|
|||
max_calls: 100_000,
|
||||
max_message: 16 * 1024 * 1024,
|
||||
max_logs: 1000,
|
||||
api: crate::API_VERSION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,6 +159,8 @@ pub struct RunOutput {
|
|||
pub ui: Option<UiChange>,
|
||||
/// The last `widget_render` of the run.
|
||||
pub widget: Option<WidgetRender>,
|
||||
/// The last `widget_timer` of the run: the delay in milliseconds (0: no timer).
|
||||
pub timer: Option<u32>,
|
||||
}
|
||||
|
||||
/// A host call that needs the [`Host`]: the plugin is paused while the host answers it (see
|
||||
|
|
@ -480,7 +489,7 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
charge(&mut c)?;
|
||||
let spec = read_str(&mut c, ptr, len)?;
|
||||
let st = c.data_mut();
|
||||
Ok(match Panel::parse(&spec) {
|
||||
Ok(match Panel::parse(&spec, st.limits.api) {
|
||||
Ok(panel) => {
|
||||
st.out.ui = Some(UiChange::Show(panel));
|
||||
0
|
||||
|
|
@ -496,7 +505,7 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
charge(&mut c)?;
|
||||
let spec = read_str(&mut c, ptr, len)?;
|
||||
let st = c.data_mut();
|
||||
Ok(match WidgetRender::parse(&spec) {
|
||||
Ok(match WidgetRender::parse(&spec, st.limits.api) {
|
||||
Ok(w) => {
|
||||
st.out.widget = Some(w);
|
||||
0
|
||||
|
|
@ -508,6 +517,23 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
})
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "widget_timer", |mut c: Ctx<'_>, ms: i32| -> Result<i32, wasmi::Error> {
|
||||
use crate::widget::{API_4, MAX_TIMER_MS, MIN_TIMER_MS};
|
||||
charge(&mut c)?;
|
||||
let st = c.data_mut();
|
||||
let ms = u32::try_from(ms).ok().filter(|ms| *ms == 0 || (MIN_TIMER_MS..=MAX_TIMER_MS).contains(ms));
|
||||
let refusal = match ms {
|
||||
_ if st.limits.api < API_4 => format!("widget timers need plugin API {API_4}"),
|
||||
None => format!("a timer runs {MIN_TIMER_MS} to {MAX_TIMER_MS} milliseconds (0: no timer)"),
|
||||
Some(ms) => {
|
||||
st.out.timer = Some(ms);
|
||||
return Ok(0);
|
||||
}
|
||||
};
|
||||
st.set_reply(refusal.into_bytes());
|
||||
Ok(1)
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "ui_close", |mut c: Ctx<'_>| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
c.data_mut().out.ui = Some(UiChange::Close);
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ fn bad_manifests_are_refused() {
|
|||
json!({"main": "C:\\plugin.wasm"}),
|
||||
json!({"main": "plugin.js"}),
|
||||
json!({"main": "a//b.wasm"}),
|
||||
json!({"api": 4}),
|
||||
json!({"api": 5}),
|
||||
json!({"api": 0}),
|
||||
json!({"permissions": {"access": "root"}}),
|
||||
json!({"permissions": {"commands": ["*.x"]}}),
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ fn an_instance_keeps_its_memory_and_reports_panel_changes() {
|
|||
let mut inst = Instance::new(&m, &Limits::default()).unwrap();
|
||||
let mut host = Settings::default();
|
||||
let first = inst.call("{}", &mut host).unwrap();
|
||||
assert_eq!((first.output.as_str(), first.ui), ("1", Some(UiChange::Show(Panel::parse(PANEL).unwrap()))));
|
||||
assert_eq!((first.output.as_str(), first.ui), ("1", Some(UiChange::Show(Panel::parse(PANEL, 2).unwrap()))));
|
||||
let second = inst.call("{}", &mut host).unwrap();
|
||||
assert!(second.output.contains("panel:") && second.ui.is_none(), "{second:?}");
|
||||
let third = inst.call("{}", &mut host).unwrap();
|
||||
|
|
@ -124,6 +124,7 @@ fn panels_are_validated_and_keep_entered_values() {
|
|||
{"type": "text", "name": "t"},
|
||||
{"type": "button", "name": "go", "label": "Go"}
|
||||
]}"##,
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.control("accent").unwrap().default, Some(json!("#FF00AA")));
|
||||
|
|
@ -153,15 +154,16 @@ fn panels_are_validated_and_keep_entered_values() {
|
|||
&many,
|
||||
];
|
||||
for j in junk {
|
||||
assert!(Panel::parse(j).is_err(), "accepted {}", j.chars().take(60).collect::<String>());
|
||||
assert!(Panel::parse(j, 2).is_err(), "accepted {}", j.chars().take(60).collect::<String>());
|
||||
}
|
||||
let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(70_000));
|
||||
assert!(Panel::parse(&huge).unwrap_err().contains("larger"));
|
||||
assert!(Panel::parse(&huge, 2).unwrap_err().contains("larger"));
|
||||
let long_note = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1001));
|
||||
assert!(Panel::parse(&long_note).is_err());
|
||||
assert!(Panel::parse(&long_note, 2).is_err());
|
||||
// Junk values are dropped, not kept.
|
||||
let p = Panel::parse(
|
||||
r#"{"controls": [{"type": "color", "name": "c", "value": {"deep": [1, 2]}}, {"type": "boolean", "name": "b", "value": "yes"}]}"#,
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(json!(p.values(&Map::new())), json!({"c": "#000000", "b": false}));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
//! Plugin API 4: what it adds to widget descriptions and panels is validated and capped, read as
|
||||
//! before by plugins written for API 1–3 (ignored where they ignored it, refused where they
|
||||
//! refused it), `widget_timer` is bounded and needs API 4, and junk (rows of it, mutations of a
|
||||
//! good description, random bytes) is refused without a panic.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::widget::{MAX_MENU_ITEMS, MAX_PATH_BYTES, MAX_VECTORS, MenuKind, Sides};
|
||||
use crate::{Host, Instance, Limits, Manifest, Panel, ParamKind, WidgetKind, WidgetRender};
|
||||
|
||||
struct Quiet;
|
||||
|
||||
impl Host for Quiet {
|
||||
fn call(&mut self, _command: &str, _params: &str) -> Result<String, String> {
|
||||
Ok("{}".into())
|
||||
}
|
||||
fn selection(&mut self) -> String {
|
||||
"[]".into()
|
||||
}
|
||||
fn notify(&mut self, _message: &str) {}
|
||||
}
|
||||
|
||||
fn widget(v: &Value, api: u32) -> Result<WidgetRender, String> {
|
||||
WidgetRender::parse(&v.to_string(), api)
|
||||
}
|
||||
|
||||
/// A description using everything API 4 added.
|
||||
fn rich() -> Value {
|
||||
json!({"state": {"n": 1}, "menu": [
|
||||
{"type": "action", "name": "reset", "label": " Reset "},
|
||||
{"type": "toggle", "name": "pinned"},
|
||||
{"type": "dropdown", "name": "size", "options": ["S", "M"], "value": "XL"},
|
||||
{"type": "color", "name": "tint", "options": ["#f00", "00ff00"], "value": "#00FF00"},
|
||||
{"type": "separator"},
|
||||
{"type": "link", "name": "docs", "label": "Docs", "url": "https://example.org/a?b=c"}
|
||||
], "root": {"type": "frame", "layout": "vertical", "width": 200, "height": 300, "padding": [1, 2, 3, 4], "radius": [4, 3, 2, 1],
|
||||
"gradient": {"type": "radial", "stops": [{"at": 0, "color": "#fff"}, {"at": 1, "color": "#000000"}]},
|
||||
"shadow": {"y": 2, "blur": 8, "color": "#0004"}, "hover": {"opacity": 0.5}, "tooltip": " A card ", "children": [
|
||||
{"type": "text", "text": "Title", "input": "title", "maxLength": 20, "weight": 600, "textAlign": "right", "maxLines": 1, "width": 80,
|
||||
"hover": {"fill": "#f00", "stroke": "#00f"}},
|
||||
{"type": "text", "text": "Notes", "input": "notes", "multiline": true},
|
||||
{"type": "line", "width": 40, "stroke": "#ccc", "fillWidth": true},
|
||||
{"type": "vector", "path": "M0 0 L10 0 L5 8 Z", "fill": "#111", "fillHeight": true},
|
||||
{"type": "image", "data": "AAAA", "fit": "crop"}
|
||||
]}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_api_4_added_is_checked_and_normalised() {
|
||||
let w = widget(&rich(), 4).unwrap();
|
||||
let menu = w.menu.unwrap();
|
||||
assert_eq!(menu.len(), 6);
|
||||
assert_eq!((menu[0].title(), menu[1].title(), menu[1].value.clone()), ("Reset", "pinned", Some(json!(false))));
|
||||
assert_eq!(menu[2].value, Some(json!("S")), "a value that isn't an option: the first");
|
||||
assert_eq!((menu[3].options.clone(), menu[3].value.clone()), (vec!["#FF0000".to_string(), "#00FF00".into()], Some(json!("#00FF00"))));
|
||||
assert_eq!((menu[4].kind, menu[5].kind), (MenuKind::Separator, MenuKind::Link));
|
||||
let root = &w.root;
|
||||
assert_eq!((root.padding, root.radius), (Some(Sides::Each([1.0, 2.0, 3.0, 4.0])), Some(Sides::Each([4.0, 3.0, 2.0, 1.0]))));
|
||||
assert_eq!(root.gradient.as_ref().unwrap().stops[0].color, "#FFFFFF");
|
||||
assert_eq!((root.shadow.as_ref().unwrap().color.as_deref(), root.tooltip.as_str()), (Some("#00000044"), "A card"));
|
||||
assert_eq!(root.children[0].hover.as_ref().unwrap().fill.as_deref(), Some("#FF0000"));
|
||||
assert_eq!((root.children[2].kind, root.children[3].kind), (WidgetKind::Line, WidgetKind::Vector));
|
||||
// Menu values: an action has none, a toggle flips or takes a boolean, the rest one option.
|
||||
assert_eq!(menu[0].coerce(Some(&json!(5))), Ok(Value::Null));
|
||||
assert_eq!((menu[1].coerce(None), menu[1].coerce(Some(&json!(false)))), (Ok(json!(true)), Ok(json!(false))));
|
||||
assert!(menu[1].coerce(Some(&json!("on"))).is_err());
|
||||
assert_eq!((menu[2].coerce(Some(&json!("M"))), menu[3].coerce(Some(&json!("#ff0000")))), (Ok(json!("M")), Ok(json!("#FF0000"))));
|
||||
assert!(
|
||||
menu[2].coerce(Some(&json!("m"))).is_err() && menu[3].coerce(None).is_err() && menu[4].coerce(None).is_err() && menu[5].coerce(None).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_apis_read_descriptions_as_they_always_did() {
|
||||
// Fields API 3 didn't know are dropped (it ignored them); kinds and shapes it refused are refused.
|
||||
let mut old = rich();
|
||||
old["root"]["padding"] = json!(4);
|
||||
old["root"]["radius"] = json!(2);
|
||||
old["root"]["children"].as_array_mut().unwrap().retain(|c| c["type"] != "line" && c["type"] != "vector");
|
||||
let w = widget(&old, 3).unwrap();
|
||||
assert!(w.menu.is_none());
|
||||
let plain = widget(&json!({"state": {"n": 1}, "root": {"type": "frame", "layout": "vertical", "width": 200, "height": 300, "padding": 4, "radius": 2, "children": [
|
||||
{"type": "text", "text": "Title", "width": 80}, {"type": "text", "text": "Notes"}, {"type": "image", "data": "AAAA"}]}}), 3)
|
||||
.unwrap();
|
||||
assert_eq!(w, plain, "nothing API 4 added is left");
|
||||
for api in 1..=3 {
|
||||
assert!(widget(&rich(), api).unwrap_err().contains("plugin API 4"), "four paddings");
|
||||
assert!(
|
||||
widget(&json!({"root": {"type": "frame", "children": [{"type": "line"}]}}), api).unwrap_err().contains("a line layer needs plugin API 4")
|
||||
);
|
||||
assert!(widget(&json!({"root": {"type": "frame", "children": [{"type": "vector", "path": "M0 0"}]}}), api).is_err());
|
||||
// Junk in a field that's ignored is ignored too, as an unknown field was.
|
||||
assert!(widget(&json!({"root": {"type": "frame", "tooltip": "x".repeat(5000), "input": "a b"}, "menu": [{"type": "link"}]}), api).is_ok());
|
||||
}
|
||||
// Panels: the controls API 4 added are refused, its fields on old controls dropped.
|
||||
let spec = json!({"controls": [{"type": "number", "name": "n", "step": 5, "disabled": true, "error": "no", "value": 3}]}).to_string();
|
||||
let (old, new) = (Panel::parse(&spec, 2).unwrap(), Panel::parse(&spec, 4).unwrap());
|
||||
assert_eq!((old.controls[0].step, old.controls[0].disabled, old.controls[0].error.as_str()), (None, false, ""));
|
||||
assert_eq!(
|
||||
(new.controls[0].step, new.controls[0].disabled, new.controls[0].error.as_str(), new.controls[0].default.clone()),
|
||||
(Some(5.0), true, "no", Some(json!(3.0)))
|
||||
);
|
||||
for kind in ["slider", "image", "progress", "divider", "section"] {
|
||||
let spec = json!({"controls": [{"type": kind, "name": "x", "data": "AAAA"}]}).to_string();
|
||||
assert!(Panel::parse(&spec, 3).unwrap_err().contains("needs plugin API 4"), "{kind}");
|
||||
}
|
||||
assert!(Panel::parse(&format!(r#"{{"controls": [], "title": "{}"}}"#, " ".repeat(70_000)), 3).unwrap_err().contains("64 KB"));
|
||||
assert!(Panel::parse(&format!(r#"{{"controls": [], "title": "{}"}}"#, " ".repeat(70_000)), 4).is_ok(), "API 4 panels may carry images");
|
||||
// Manifests: a slider parameter needs API 4; display controls never go in a manifest.
|
||||
let manifest = |api: u32, kind: &str| {
|
||||
Manifest::from_value(
|
||||
&json!({"id": "p", "name": "P", "main": "p.wasm", "api": api, "commands": [{"id": "c", "label": "C", "params": [{"name": "x", "type": kind}]}]}),
|
||||
)
|
||||
};
|
||||
assert!(manifest(3, "slider").unwrap_err().contains("needs plugin API 4"));
|
||||
assert_eq!(manifest(4, "slider").unwrap().commands[0].params[0].default_value(), json!(0.0));
|
||||
assert!(manifest(4, "divider").unwrap_err().contains("only go in panels"));
|
||||
assert!(manifest(5, "text").is_err(), "a newer API than this build's is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_4_panel_controls_are_checked() {
|
||||
let p = Panel::parse(
|
||||
&json!({"controls": [
|
||||
{"type": "slider", "name": "a", "min": 1, "max": 2, "step": 0.25, "value": 1.6},
|
||||
{"type": "slider", "name": "b", "value": 250},
|
||||
{"type": "image", "label": "Pic", "data": "AAAA", "height": 64, "fit": "fill"},
|
||||
{"type": "progress", "value": 7}, {"type": "progress", "value": "x"},
|
||||
{"type": "divider"}, {"type": "section", "label": "More"}, {"type": "section", "label": "Shut", "value": false}
|
||||
]})
|
||||
.to_string(),
|
||||
4,
|
||||
)
|
||||
.unwrap();
|
||||
let c = &p.controls;
|
||||
assert_eq!((c[0].default.clone(), c[1].default.clone(), c[1].max), (Some(json!(1.5)), Some(json!(100.0)), Some(100.0)));
|
||||
assert_eq!((c[0].coerce(&json!(1.13)), c[0].coerce(&json!(-4)), c[0].coerce(&json!("1.9"))), (Ok(json!(1.25)), Ok(json!(1.0)), Ok(json!(2.0))));
|
||||
assert_eq!(
|
||||
(c[3].default.clone(), c[4].default.clone(), c[6].default.clone(), c[7].default.clone()),
|
||||
(Some(json!(1.0)), Some(json!(0.0)), None, Some(json!(false)))
|
||||
);
|
||||
assert!(c.iter().filter(|c| c.is_field()).map(|c| c.name.as_str()).eq(["a", "b"]), "only sliders hold a value here");
|
||||
assert_eq!(c[2].kind, ParamKind::Image);
|
||||
assert_eq!(p.values(&serde_json::Map::new()).len(), 2);
|
||||
let image = "A".repeat(400_000);
|
||||
for (controls, why) in [
|
||||
(json!([{"type": "slider", "name": "s", "step": 0}]), "bad step"),
|
||||
(json!([{"type": "slider", "name": "s", "step": -1}]), "bad step"),
|
||||
(json!([{"type": "slider", "name": "s", "min": 5, "max": 1}]), "bad bounds"),
|
||||
(json!([{"type": "slider"}]), "parameter name"),
|
||||
(json!([{"type": "image", "label": "No data"}]), "`data`"),
|
||||
(json!([{"type": "button", "name": "b", "data": "AAAA"}]), "`data`"),
|
||||
(json!([{"type": "image", "data": "AAAA", "height": 8}]), "high"),
|
||||
(json!([{"type": "image", "data": "AAAA", "fit": "stretch"}]), "unknown variant"),
|
||||
(json!([{"type": "image", "data": image}, {"type": "image", "data": image}]), "larger than 512 KB together"),
|
||||
(json!([{"type": "text", "name": "t", "error": "x".repeat(201)}]), "longer than"),
|
||||
(json!([{"type": "text", "name": "t", "error": "a\u{7}"}]), "control characters"),
|
||||
] {
|
||||
let e = Panel::parse(&json!({ "controls": controls }).to_string(), 4).unwrap_err();
|
||||
assert!(e.contains(why), "{e}");
|
||||
}
|
||||
// A step can't push a value outside its bounds or to infinity.
|
||||
let odd = Panel::parse(&json!({"controls": [{"type": "slider", "name": "s", "min": 0, "max": 10, "step": 7, "value": 9}, {"type": "number", "name": "n", "min": -1e308, "step": 1e-300, "value": 1e308}]}).to_string(), 4).unwrap();
|
||||
assert_eq!(odd.controls[0].default, Some(json!(7.0)));
|
||||
assert!(odd.controls[1].default.as_ref().and_then(Value::as_f64).is_some_and(f64::is_finite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_in_what_api_4_added_is_refused() {
|
||||
let child = |c: Value| json!({"root": {"type": "frame", "layout": "vertical", "children": [c]}});
|
||||
let root = |mut extra: Value| {
|
||||
extra["type"] = json!("frame");
|
||||
json!({ "root": extra })
|
||||
};
|
||||
let menu = |m: Value| json!({"root": {"type": "frame"}, "menu": m});
|
||||
let stops = |n: usize| (0..n).map(|i| json!({"at": i as f64 / n.max(2) as f64, "color": "#000"})).collect::<Vec<_>>();
|
||||
let junk = [
|
||||
root(json!({"padding": [1, 2, 3]})),
|
||||
root(json!({"padding": [1, 2, 3, -4]})),
|
||||
root(json!({"radius": [1, 2, 3, 1e9]})),
|
||||
root(json!({"radius": "8"})),
|
||||
root(json!({"gradient": {"stops": stops(1)}})),
|
||||
root(json!({"gradient": {"stops": stops(9)}})),
|
||||
root(json!({"gradient": {"type": "conic", "stops": stops(2)}})),
|
||||
root(json!({"gradient": {"angle": 720, "stops": stops(2)}})),
|
||||
root(json!({"gradient": {"stops": [{"at": 2, "color": "#000"}, {"at": 0, "color": "#000"}]}})),
|
||||
root(json!({"gradient": {"stops": [{"at": 0, "color": "red"}, {"at": 1, "color": "#000"}]}})),
|
||||
root(json!({"shadow": {"blur": -1}})),
|
||||
root(json!({"shadow": {"x": 1e9}})),
|
||||
root(json!({"shadow": {"color": "shadow"}})),
|
||||
root(json!({"hover": {"fill": "nope"}})),
|
||||
root(json!({"hover": {"opacity": 3}})),
|
||||
root(json!({"tooltip": "x".repeat(201)})),
|
||||
root(json!({"tooltip": "a\nb"})),
|
||||
root(json!({"input": "title"})),
|
||||
root(json!({"path": "M0 0"})),
|
||||
root(json!({"weight": 400})),
|
||||
root(json!({"fit": "fill"})),
|
||||
// Nothing can fill a frame that hugs it, one without auto layout, or the canvas.
|
||||
child(json!({"type": "rectangle", "fillWidth": true})),
|
||||
child(json!({"type": "frame", "layout": "horizontal", "width": 50, "children": [{"type": "rectangle", "fillHeight": true}]})),
|
||||
child(json!({"type": "frame", "width": 50, "height": 50, "children": [{"type": "rectangle", "fillWidth": true}]})),
|
||||
root(json!({"layout": "vertical", "width": 100, "fillWidth": true})),
|
||||
child(json!({"type": "text", "input": "a b"})),
|
||||
child(json!({"type": "text", "input": "t", "maxLength": 0})),
|
||||
child(json!({"type": "text", "input": "t", "maxLength": 1001})),
|
||||
child(json!({"type": "rectangle", "maxLength": 5})),
|
||||
child(json!({"type": "rectangle", "multiline": true})),
|
||||
child(json!({"type": "text", "weight": 50})),
|
||||
child(json!({"type": "text", "weight": 1000})),
|
||||
child(json!({"type": "text", "textAlign": "justify"})),
|
||||
child(json!({"type": "text", "maxLines": 0})),
|
||||
child(json!({"type": "text", "gradient": {"stops": stops(2)}})),
|
||||
child(json!({"type": "image", "data": "AAAA", "fit": "stretch"})),
|
||||
child(json!({"type": "image", "data": "AAAA", "gradient": {"stops": stops(2)}})),
|
||||
child(json!({"type": "vector"})),
|
||||
child(json!({"type": "vector", "path": " "})),
|
||||
child(json!({"type": "vector", "path": "M0 0 L1 1 <script>"})),
|
||||
child(json!({"type": "vector", "path": "M0 0 L1 1 \u{0}"})),
|
||||
child(json!({"type": "vector", "path": format!("M0 0 {}", "L1 1 ".repeat(MAX_PATH_BYTES / 5 + 1))})),
|
||||
child(json!({"type": "line", "path": "M0 0"})),
|
||||
child(json!({"type": "line", "text": "x"})),
|
||||
child(json!({"type": "line", "children": [{"type": "frame"}]})),
|
||||
json!({"root": {"type": "frame", "children": [{"type": "text", "input": "t"}, {"type": "frame", "children": [{"type": "text", "input": "t"}]}]}}),
|
||||
json!({"root": {"type": "frame", "children": vec![json!({"type": "vector", "path": "M0 0 L1 1"}); MAX_VECTORS + 1]}}),
|
||||
menu(json!({"type": "action"})),
|
||||
menu(json!([{"type": "action"}])),
|
||||
menu(json!([{"type": "action", "name": "a b"}])),
|
||||
menu(json!([{"type": "action", "name": "a"}, {"type": "toggle", "name": "a"}])),
|
||||
menu(json!([{"type": "action", "name": "a", "options": ["x"]}])),
|
||||
menu(json!([{"type": "action", "name": "a", "url": "https://example.org"}])),
|
||||
menu(json!([{"type": "action", "name": "a", "label": "x".repeat(81)}])),
|
||||
menu(json!([{"type": "dropdown", "name": "d"}])),
|
||||
menu(json!([{"type": "dropdown", "name": "d", "options": vec!["o"; 33]}])),
|
||||
menu(json!([{"type": "dropdown", "name": "d", "options": ["a\u{1}"]}])),
|
||||
menu(json!([{"type": "color", "name": "c", "options": ["red"]}])),
|
||||
menu(json!([{"type": "link", "name": "l"}])),
|
||||
menu(json!([{"type": "link", "name": "l", "url": "javascript:alert(1)"}])),
|
||||
menu(json!([{"type": "link", "name": "l", "url": "file:///etc/passwd"}])),
|
||||
menu(json!([{"type": "link", "name": "l", "url": "https://example.org/a b"}])),
|
||||
menu(json!([{"type": "link", "name": "l", "url": format!("https://example.org/{}", "a".repeat(2100))}])),
|
||||
menu(json!([{"type": "submenu", "name": "s"}])),
|
||||
menu(json!(vec![json!({"type": "separator"}); MAX_MENU_ITEMS + 1])),
|
||||
];
|
||||
for j in &junk {
|
||||
assert!(widget(j, 4).is_err(), "accepted {}", j.to_string().chars().take(120).collect::<String>());
|
||||
}
|
||||
assert!(widget(&menu(json!([])), 4).unwrap().menu.is_some_and(|m| m.is_empty()), "an empty menu removes it");
|
||||
// A frame that fills gives what's inside it room to fill too.
|
||||
let nested = json!({"root": {"type": "frame", "layout": "vertical", "width": 100, "children": [
|
||||
{"type": "frame", "layout": "horizontal", "fillWidth": true, "children": [{"type": "rectangle", "fillWidth": true}]}]}});
|
||||
assert!(widget(&nested, 4).is_ok());
|
||||
assert!(widget(&menu(json!(vec![json!({"type": "separator"}); MAX_MENU_ITEMS])), 4).is_ok());
|
||||
}
|
||||
|
||||
/// xorshift: the same "random" bytes every run.
|
||||
fn noise(seed: &mut u64) -> u64 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
*seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_and_random_descriptions_never_panic() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let panel = json!({"title": "P", "controls": [
|
||||
{"type": "slider", "name": "a", "min": 1, "max": 2, "step": 0.25, "value": 1.6, "error": "e", "disabled": true},
|
||||
{"type": "image", "label": "Pic", "data": "AAAA", "height": 64}, {"type": "progress", "value": 0.3}, {"type": "section", "label": "S"}]})
|
||||
.to_string();
|
||||
for good in [rich().to_string(), panel] {
|
||||
let bytes = good.as_bytes();
|
||||
for _ in 0..4000 {
|
||||
// Flip, drop or repeat a few bytes of a good description.
|
||||
let mut b = bytes.to_vec();
|
||||
for _ in 0..1 + noise(&mut seed) % 4 {
|
||||
let at = (noise(&mut seed) as usize) % b.len().max(1);
|
||||
match noise(&mut seed) % 3 {
|
||||
0 => b[at] = (noise(&mut seed) % 256) as u8,
|
||||
1 => drop(b.remove(at)),
|
||||
_ => b.insert(at, b[at]),
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
for api in [1, 3, 4, u32::MAX] {
|
||||
let _ = WidgetRender::parse(&text, api);
|
||||
let _ = Panel::parse(&text, api);
|
||||
}
|
||||
}
|
||||
}
|
||||
for len in [0usize, 1, 7, 64, 513, 4096] {
|
||||
for _ in 0..200 {
|
||||
let b: Vec<u8> = (0..len).map(|_| (noise(&mut seed) % 256) as u8).collect();
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let _ = (WidgetRender::parse(&text, 4), Panel::parse(&text, 4), Manifest::parse(&text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A module that asks for a timer of `ms` and outputs the status and the reply.
|
||||
fn timer_module(ms: i64) -> Vec<u8> {
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "widget_timer" (func $timer (param i32) (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "figcraft_run") (param $n i32) (result i32)
|
||||
(i32.store8 (i32.const 0) (i32.add (i32.const 48) (call $timer (i32.const {ms}))))
|
||||
(call $set_output (i32.const 0) (i32.add (i32.const 1) (call $reply_read (i32.const 1) (i32.const 200))))
|
||||
(i32.const 0)))"#
|
||||
);
|
||||
wat::parse_str(&wat).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_timer_is_bounded_charged_and_needs_api_4() {
|
||||
let run = |ms: i64, api: u32| Instance::new(&timer_module(ms), &Limits { api, ..Limits::default() }).unwrap().call("{}", &mut Quiet).unwrap();
|
||||
for ms in [100, 1000, 3_600_000] {
|
||||
let out = run(ms, 4);
|
||||
assert_eq!((out.timer, out.output.as_bytes()[0], out.calls), (Some(ms as u32), b'0', 1), "{ms}");
|
||||
}
|
||||
assert_eq!(run(0, 4).timer, Some(0), "0 cancels");
|
||||
let mut seed = 7u64;
|
||||
let junk =
|
||||
[99, 1, -1, 3_600_001, i64::from(i32::MAX), i64::from(i32::MIN)].into_iter().chain((0..300).map(|_| i64::from(noise(&mut seed) as i32)));
|
||||
for ms in junk.filter(|ms| *ms != 0 && !(100..=3_600_000).contains(ms)) {
|
||||
let out = run(ms, 4);
|
||||
assert!(out.timer.is_none() && out.output.starts_with("1a timer runs 100 to 3600000"), "{ms}: {}", out.output);
|
||||
}
|
||||
for api in 1..=3 {
|
||||
let out = run(1000, api);
|
||||
assert!(out.timer.is_none() && out.output.contains("need plugin API 4"), "{}", out.output);
|
||||
}
|
||||
// It costs what any host call costs: a plugin can't spin on it for free.
|
||||
let poor = Limits { fuel: 5_000, ..Limits::default() };
|
||||
assert!(Instance::new(&timer_module(1000), &poor).unwrap().call("{}", &mut Quiet).is_err());
|
||||
// A wrong signature is refused when the module is checked.
|
||||
let wrong = r#"(module (import "figcraft" "widget_timer" (func (param i32 i32) (result i32))) (memory (export "memory") 1)
|
||||
(func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#;
|
||||
assert!(crate::check_module(&wat::parse_str(wrong).unwrap(), &Limits::default()).is_err());
|
||||
}
|
||||
|
|
@ -2,6 +2,12 @@
|
|||
//! layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`]
|
||||
//! checks its size, node count, depth, numbers, strings, colours and image sizes before the engine
|
||||
//! sees it. The engine decides where the layers go (only inside the widget's own layer).
|
||||
//!
|
||||
//! ABI v4 adds to the description: lines and vectors, per-side padding and per-corner radii,
|
||||
//! gradients, shadows, children that fill their auto layout frame, text styles, image fit modes,
|
||||
//! editable texts (`input`), hover styles and tooltips, and the widget's property menu (`menu`).
|
||||
//! A plugin written for an older API gets none of it: [`WidgetRender::parse`] takes the manifest's
|
||||
//! `api` and reads the description as that version did.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
|
@ -17,13 +23,30 @@ const MAX_STATE_KEY: usize = 128;
|
|||
pub const MAX_NODES: usize = 256;
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// Longest text in a text layer.
|
||||
const MAX_TEXT: usize = 1000;
|
||||
pub const MAX_TEXT: usize = 1000;
|
||||
const MAX_NAME: usize = 64;
|
||||
/// Sizes, positions, gaps and paddings stay within this.
|
||||
const MAX_SIZE: f64 = 4000.0;
|
||||
pub const MAX_SIZE: f64 = 4000.0;
|
||||
/// Largest image (decoded bytes), and all images of one widget together.
|
||||
pub const MAX_IMAGE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_IMAGES_BYTES: usize = 2 * 1024 * 1024;
|
||||
/// API 4. Longest SVG path data of a vector layer, and the most vectors in one widget.
|
||||
pub const MAX_PATH_BYTES: usize = 8 * 1024;
|
||||
pub const MAX_VECTORS: usize = 64;
|
||||
/// API 4. Most colour stops in a gradient.
|
||||
const MAX_STOPS: usize = 8;
|
||||
/// API 4. Longest tooltip.
|
||||
const MAX_TOOLTIP: usize = 200;
|
||||
/// API 4. Most items in a widget's property menu, the options of one, and its longest link.
|
||||
pub const MAX_MENU_ITEMS: usize = 16;
|
||||
const MAX_MENU_OPTIONS: usize = 32;
|
||||
const MAX_URL: usize = 2048;
|
||||
const MAX_LABEL: usize = 80;
|
||||
/// API 4. A widget timer's shortest and longest delay, in milliseconds.
|
||||
pub const MIN_TIMER_MS: u32 = 100;
|
||||
pub const MAX_TIMER_MS: u32 = 60 * 60 * 1000;
|
||||
/// The first plugin API with what's marked "API 4" here.
|
||||
pub(crate) const API_4: u32 = 4;
|
||||
|
||||
/// What a widget is made of: FigCraft's own layer kinds.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -36,6 +59,10 @@ pub enum WidgetKind {
|
|||
Ellipse,
|
||||
/// A rectangle filled with an image (PNG, JPEG…) the plugin carries in its module.
|
||||
Image,
|
||||
/// API 4. A horizontal line `width` long, drawn with `stroke`.
|
||||
Line,
|
||||
/// API 4. A shape from SVG path data (`path`); its box is the path's.
|
||||
Vector,
|
||||
}
|
||||
|
||||
/// Auto layout direction of a frame.
|
||||
|
|
@ -55,6 +82,97 @@ pub enum WidgetAlign {
|
|||
Max,
|
||||
}
|
||||
|
||||
/// One number for every side or corner, or (API 4) four: padding as `[top, right, bottom,
|
||||
/// left]`, radii as `[top-left, top-right, bottom-right, bottom-left]`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Sides {
|
||||
All(f64),
|
||||
Each([f64; 4]),
|
||||
}
|
||||
|
||||
impl Sides {
|
||||
pub fn each(self) -> [f64; 4] {
|
||||
match self {
|
||||
Sides::All(v) => [v; 4],
|
||||
Sides::Each(v) => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API 4. A gradient fill: `linear` along `angle` (degrees; 0 runs left to right, 90 top to
|
||||
/// bottom) or `radial` from the centre.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidgetGradient {
|
||||
#[serde(rename = "type", default)]
|
||||
pub kind: GradientShape,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub angle: Option<f64>,
|
||||
pub stops: Vec<WidgetStop>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GradientShape {
|
||||
#[default]
|
||||
Linear,
|
||||
Radial,
|
||||
}
|
||||
|
||||
/// A gradient's colour (hex) at `at` (0–1 along it).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidgetStop {
|
||||
pub at: f64,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// API 4. A drop shadow.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidgetShadow {
|
||||
#[serde(default)]
|
||||
pub x: f64,
|
||||
#[serde(default)]
|
||||
pub y: f64,
|
||||
#[serde(default)]
|
||||
pub blur: f64,
|
||||
#[serde(default)]
|
||||
pub spread: f64,
|
||||
/// Hex; a quarter-opaque black when not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// API 4. What changes while the pointer is over a layer; FigCraft applies it itself (the plugin
|
||||
/// isn't called for pointer moves).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidgetHover {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fill: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// API 4. How a text's lines sit in its width.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WidgetTextAlign {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// API 4. How an image fills its box.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WidgetFit {
|
||||
Fill,
|
||||
Fit,
|
||||
Crop,
|
||||
Tile,
|
||||
}
|
||||
|
||||
/// One layer of a widget's description.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidgetNode {
|
||||
|
|
@ -82,16 +200,18 @@ pub struct WidgetNode {
|
|||
pub stroke: Option<String>,
|
||||
#[serde(rename = "strokeWeight", default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_weight: Option<f64>,
|
||||
/// One radius, or (API 4) four.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub radius: Option<f64>,
|
||||
pub radius: Option<Sides>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opacity: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<WidgetLayout>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gap: Option<f64>,
|
||||
/// One padding, or (API 4) four.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub padding: Option<f64>,
|
||||
pub padding: Option<Sides>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub align: Option<WidgetAlign>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -110,6 +230,155 @@ pub struct WidgetNode {
|
|||
pub data: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub children: Vec<WidgetNode>,
|
||||
/// API 4 from here on. A gradient instead of `fill`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gradient: Option<WidgetGradient>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shadow: Option<WidgetShadow>,
|
||||
/// In an auto layout frame: take the room that's left along that axis. The frame needs a size
|
||||
/// there (its own, or because it fills too): a frame can't hug what fills it.
|
||||
#[serde(rename = "fillWidth", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub fill_width: bool,
|
||||
#[serde(rename = "fillHeight", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub fill_height: bool,
|
||||
/// A text's font weight (100–900), alignment and the lines it shows at most (the rest is cut
|
||||
/// with an ellipsis).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub weight: Option<f64>,
|
||||
#[serde(rename = "textAlign", default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_align: Option<WidgetTextAlign>,
|
||||
#[serde(rename = "maxLines", default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_lines: Option<f64>,
|
||||
/// An image's fit (`fill` when not given).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fit: Option<WidgetFit>,
|
||||
/// A vector's SVG path data.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub path: String,
|
||||
/// A text the user can edit in place: committing calls the plugin back with this name and the
|
||||
/// new text (empty: not editable). At most `maxLength` characters, one line unless `multiline`.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub input: String,
|
||||
#[serde(rename = "maxLength", default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_length: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub multiline: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hover: Option<WidgetHover>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub tooltip: String,
|
||||
}
|
||||
|
||||
/// API 4. What an item of a widget's property menu is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MenuKind {
|
||||
/// A button.
|
||||
#[default]
|
||||
Action,
|
||||
/// On or off (`value`: a boolean).
|
||||
Toggle,
|
||||
/// One of `options` (`value`).
|
||||
Dropdown,
|
||||
/// One of the hex colours in `options` (`value`).
|
||||
Color,
|
||||
Separator,
|
||||
/// Opens `url` (http or https) when clicked; the plugin isn't called.
|
||||
Link,
|
||||
}
|
||||
|
||||
/// API 4. One item of a widget's property menu: choosing it calls the plugin back with its `name`
|
||||
/// (and its new value).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MenuItem {
|
||||
#[serde(rename = "type", default)]
|
||||
pub kind: MenuKind,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
/// The label shown: its own, else its name.
|
||||
pub fn title(&self) -> &str {
|
||||
if self.label.is_empty() { &self.name } else { &self.label }
|
||||
}
|
||||
|
||||
/// `v` as this item's new value (`None`: none given), or why it can't be: nothing for an
|
||||
/// action, a boolean for a toggle (none given: flipped), one of the options otherwise.
|
||||
pub fn coerce(&self, v: Option<&Value>) -> Result<Value, String> {
|
||||
let name = &self.name;
|
||||
match self.kind {
|
||||
MenuKind::Action => Ok(Value::Null),
|
||||
MenuKind::Toggle => match v {
|
||||
None | Some(Value::Null) => Ok(Value::Bool(!self.value.as_ref().and_then(Value::as_bool).unwrap_or(false))),
|
||||
Some(Value::Bool(b)) => Ok(Value::Bool(*b)),
|
||||
Some(_) => Err(format!("`{name}` takes true or false")),
|
||||
},
|
||||
MenuKind::Dropdown | MenuKind::Color => {
|
||||
let given = v.and_then(Value::as_str).unwrap_or_default();
|
||||
let same = |o: &&String| if self.kind == MenuKind::Color { o.eq_ignore_ascii_case(given) } else { *o == given };
|
||||
self.options
|
||||
.iter()
|
||||
.find(same)
|
||||
.map(|o| Value::String(o.clone()))
|
||||
.ok_or_else(|| format!("`{name}` must be one of: {}", self.options.join(", ")))
|
||||
}
|
||||
MenuKind::Separator | MenuKind::Link => Err("a separator or a link has no value".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&mut self) -> Result<(), String> {
|
||||
self.label = clean("menu label", &self.label, MAX_LABEL)?;
|
||||
if self.kind != MenuKind::Separator {
|
||||
check_id("menu item name", &self.name, MAX_NAME)?;
|
||||
}
|
||||
let name = &self.name;
|
||||
if self.options.len() > MAX_MENU_OPTIONS {
|
||||
return Err(format!("menu item `{name}` has more than {MAX_MENU_OPTIONS} options"));
|
||||
}
|
||||
let choice = matches!(self.kind, MenuKind::Dropdown | MenuKind::Color);
|
||||
if choice == self.options.is_empty() {
|
||||
return Err(format!("menu item `{name}`: `options` are for (and needed by) dropdowns and colours"));
|
||||
}
|
||||
for o in &mut self.options {
|
||||
*o = if self.kind == MenuKind::Color { hex("options", o)? } else { clean("option", o, MAX_LABEL)? };
|
||||
}
|
||||
self.value = match self.kind {
|
||||
MenuKind::Toggle => Some(Value::Bool(self.value.as_ref().and_then(Value::as_bool).unwrap_or(false))),
|
||||
// A value that isn't one of the options: the first of them.
|
||||
_ if choice => self.coerce(self.value.as_ref()).ok().or_else(|| self.options.first().cloned().map(Value::String)),
|
||||
_ => None,
|
||||
};
|
||||
if (self.kind == MenuKind::Link) == self.url.is_empty() {
|
||||
return Err(format!("menu item `{name}`: `url` is for (and needed by) links"));
|
||||
}
|
||||
let web = self.url.starts_with("https://") || self.url.starts_with("http://");
|
||||
if self.kind == MenuKind::Link && (!web || self.url.len() > MAX_URL || self.url.chars().any(|c| c.is_control() || c.is_whitespace())) {
|
||||
return Err(format!("menu item `{name}`: `url` must be an http or https address"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A property menu: its size, every item, names used once.
|
||||
pub fn check_menu(menu: &mut [MenuItem]) -> Result<(), String> {
|
||||
if menu.len() > MAX_MENU_ITEMS {
|
||||
return Err(format!("more than {MAX_MENU_ITEMS} menu items"));
|
||||
}
|
||||
menu.iter_mut().try_for_each(MenuItem::check)?;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
match menu.iter().filter(|i| i.kind != MenuKind::Separator).find(|i| !seen.insert(i.name.as_str())) {
|
||||
Some(dup) => Err(format!("two menu items are called `{}`", dup.name)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a widget callback hands back with `widget_render`: the widget's new state (`None`: keep
|
||||
|
|
@ -119,17 +388,24 @@ pub struct WidgetRender {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<Map<String, Value>>,
|
||||
pub root: WidgetNode,
|
||||
/// API 4. The widget's property menu (`None`: keep it; empty: none).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu: Option<Vec<MenuItem>>,
|
||||
}
|
||||
|
||||
/// What one description may still use while it's checked.
|
||||
#[derive(Default)]
|
||||
struct Budget {
|
||||
nodes: usize,
|
||||
image_bytes: usize,
|
||||
vectors: usize,
|
||||
/// The names of the editable texts so far (each names one text).
|
||||
inputs: Vec<String>,
|
||||
}
|
||||
|
||||
impl WidgetRender {
|
||||
/// Parse and validate a widget description.
|
||||
pub fn parse(text: &str) -> Result<WidgetRender, String> {
|
||||
/// Parse and validate a widget description from a plugin written for plugin API `api`.
|
||||
pub fn parse(text: &str, api: u32) -> Result<WidgetRender, String> {
|
||||
if text.len() > MAX_WIDGET_BYTES {
|
||||
return Err(format!("the widget is larger than {} MB", MAX_WIDGET_BYTES / (1024 * 1024)));
|
||||
}
|
||||
|
|
@ -140,7 +416,15 @@ impl WidgetRender {
|
|||
if w.root.kind != WidgetKind::Frame {
|
||||
return Err("widget: the root must be a frame".into());
|
||||
}
|
||||
w.root.check(0, &mut Budget { nodes: 0, image_bytes: 0 }).map_err(|e| format!("widget: {e}"))?;
|
||||
if api < API_4 {
|
||||
w.menu = None;
|
||||
} else if w.root.fill_width || w.root.fill_height {
|
||||
return Err("widget: the widget itself has no frame to fill".into());
|
||||
}
|
||||
if let Some(menu) = &mut w.menu {
|
||||
check_menu(menu).map_err(|e| format!("widget: {e}"))?;
|
||||
}
|
||||
w.root.check(0, api, &mut Budget::default()).map_err(|e| format!("widget: {e}"))?;
|
||||
Ok(w)
|
||||
}
|
||||
}
|
||||
|
|
@ -164,17 +448,130 @@ fn within(what: &str, v: Option<f64>, lo: f64, hi: f64) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `s` as `#RRGGBB(AA)`.
|
||||
fn hex(what: &str, s: &str) -> Result<String, String> {
|
||||
let parsed = Some(s).filter(|s| s.len() <= 9).and_then(figcraft_color::Color::from_hex);
|
||||
Ok(parsed.ok_or_else(|| format!("`{what}` must be a hex colour like #FF5A5F"))?.to_css_hex())
|
||||
}
|
||||
|
||||
/// A colour as `#RRGGBB(AA)`.
|
||||
fn colour(what: &str, c: &mut Option<String>) -> Result<(), String> {
|
||||
if let Some(s) = c {
|
||||
let parsed = Some(s.as_str()).filter(|s| s.len() <= 9).and_then(figcraft_color::Color::from_hex);
|
||||
*s = parsed.ok_or_else(|| format!("`{what}` must be a hex colour like #FF5A5F"))?.to_css_hex();
|
||||
*s = hex(what, s)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl WidgetNode {
|
||||
fn check(&mut self, depth: usize, budget: &mut Budget) -> Result<(), String> {
|
||||
/// Read as plugin API 3 did: what API 4 added is refused where API 3 refused it (new kinds,
|
||||
/// four paddings or radii) and dropped where API 3 ignored it (fields it didn't know).
|
||||
fn as_api_3(&mut self) -> Result<(), String> {
|
||||
if matches!(self.kind, WidgetKind::Line | WidgetKind::Vector) {
|
||||
return Err(format!("a {} layer needs plugin API {API_4}", format!("{:?}", self.kind).to_lowercase()));
|
||||
}
|
||||
if matches!(self.padding, Some(Sides::Each(_))) || matches!(self.radius, Some(Sides::Each(_))) {
|
||||
return Err(format!("four paddings or radii need plugin API {API_4}"));
|
||||
}
|
||||
let old = std::mem::take(self);
|
||||
*self = WidgetNode {
|
||||
kind: old.kind,
|
||||
name: old.name,
|
||||
target: old.target,
|
||||
x: old.x,
|
||||
y: old.y,
|
||||
width: old.width,
|
||||
height: old.height,
|
||||
fill: old.fill,
|
||||
stroke: old.stroke,
|
||||
stroke_weight: old.stroke_weight,
|
||||
radius: old.radius,
|
||||
opacity: old.opacity,
|
||||
layout: old.layout,
|
||||
gap: old.gap,
|
||||
padding: old.padding,
|
||||
align: old.align,
|
||||
justify: old.justify,
|
||||
text: old.text,
|
||||
size: old.size,
|
||||
color: old.color,
|
||||
bold: old.bold,
|
||||
data: old.data,
|
||||
children: old.children,
|
||||
..WidgetNode::default()
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What API 4 added to a layer, checked.
|
||||
fn check_api_4(&mut self, budget: &mut Budget) -> Result<(), String> {
|
||||
let kind = self.kind;
|
||||
let only = |what: &str, used: bool, allowed: bool| {
|
||||
if used && !allowed { Err(format!("`{what}` doesn't apply to a {kind:?} layer")) } else { Ok(()) }
|
||||
};
|
||||
let text = kind == WidgetKind::Text;
|
||||
only("gradient", self.gradient.is_some(), !matches!(kind, WidgetKind::Text | WidgetKind::Image | WidgetKind::Line))?;
|
||||
only("weight", self.weight.is_some() || self.text_align.is_some() || self.max_lines.is_some(), text)?;
|
||||
only("input", !self.input.is_empty() || self.max_length.is_some() || self.multiline, text)?;
|
||||
only("fit", self.fit.is_some(), kind == WidgetKind::Image)?;
|
||||
only("path", !self.path.is_empty(), kind == WidgetKind::Vector)?;
|
||||
within("weight", self.weight, 100.0, 900.0)?;
|
||||
within("maxLines", self.max_lines, 1.0, 100.0)?;
|
||||
within("maxLength", self.max_length, 1.0, MAX_TEXT as f64)?;
|
||||
if let Some(g) = &mut self.gradient {
|
||||
if g.stops.len() < 2 || g.stops.len() > MAX_STOPS {
|
||||
return Err(format!("a gradient has 2–{MAX_STOPS} stops"));
|
||||
}
|
||||
within("angle", g.angle, -360.0, 360.0)?;
|
||||
for s in &mut g.stops {
|
||||
within("at", Some(s.at), 0.0, 1.0)?;
|
||||
s.color = hex("color", &s.color)?;
|
||||
}
|
||||
}
|
||||
if let Some(s) = &mut self.shadow {
|
||||
for (what, v) in [("x", s.x), ("y", s.y), ("spread", s.spread)] {
|
||||
within(what, Some(v), -256.0, 256.0)?;
|
||||
}
|
||||
within("blur", Some(s.blur), 0.0, 256.0)?;
|
||||
colour("color", &mut s.color)?;
|
||||
}
|
||||
if let Some(h) = &mut self.hover {
|
||||
colour("fill", &mut h.fill)?;
|
||||
colour("stroke", &mut h.stroke)?;
|
||||
within("opacity", h.opacity, 0.0, 1.0)?;
|
||||
}
|
||||
self.tooltip = clean("tooltip", &self.tooltip, MAX_TOOLTIP)?;
|
||||
// What fills an axis needs room to fill: an auto layout frame with a size on that axis,
|
||||
// given or because it fills its own frame (the widget itself has no frame to fill).
|
||||
let room = |size: Option<f64>, fills: bool| self.layout.is_some() && (size.is_some() || fills);
|
||||
let (wide, high) = (room(self.width, self.fill_width), room(self.height, self.fill_height));
|
||||
if self.children.iter().any(|c| (c.fill_width && !wide) || (c.fill_height && !high)) {
|
||||
return Err("`fillWidth` and `fillHeight` need an auto layout frame with a size on that axis (or one that fills it too)".into());
|
||||
}
|
||||
if !self.input.is_empty() {
|
||||
check_id("input", &self.input, MAX_NAME)?;
|
||||
if budget.inputs.contains(&self.input) {
|
||||
return Err(format!("two texts are the input `{}`", self.input));
|
||||
}
|
||||
budget.inputs.push(self.input.clone());
|
||||
}
|
||||
if kind == WidgetKind::Vector {
|
||||
budget.vectors += 1;
|
||||
// Path data is numbers, command letters and separators; the engine parses it.
|
||||
let plain = |c: char| c.is_ascii_alphanumeric() || matches!(c, ' ' | ',' | '.' | '-' | '+' | '\n' | '\t');
|
||||
if self.path.trim().is_empty() || self.path.len() > MAX_PATH_BYTES || !self.path.chars().all(plain) {
|
||||
return Err(format!("a vector needs `path` (SVG path data) of at most {} KB", MAX_PATH_BYTES / 1024));
|
||||
}
|
||||
if budget.vectors > MAX_VECTORS {
|
||||
return Err(format!("more than {MAX_VECTORS} vectors"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check(&mut self, depth: usize, api: u32, budget: &mut Budget) -> Result<(), String> {
|
||||
if api < API_4 {
|
||||
self.as_api_3()?;
|
||||
}
|
||||
budget.nodes += 1;
|
||||
if budget.nodes > MAX_NODES {
|
||||
return Err(format!("more than {MAX_NODES} layers"));
|
||||
|
|
@ -189,9 +586,12 @@ impl WidgetNode {
|
|||
for (what, v) in [("x", self.x), ("y", self.y)] {
|
||||
within(what, v, -MAX_SIZE, MAX_SIZE)?;
|
||||
}
|
||||
for (what, v) in [("width", self.width), ("height", self.height), ("gap", self.gap), ("padding", self.padding), ("radius", self.radius)] {
|
||||
for (what, v) in [("width", self.width), ("height", self.height), ("gap", self.gap)] {
|
||||
within(what, v, 0.0, MAX_SIZE)?;
|
||||
}
|
||||
for (what, v) in [("padding", self.padding), ("radius", self.radius)] {
|
||||
v.map_or([None; 4], |s| s.each().map(Some)).into_iter().try_for_each(|v| within(what, v, 0.0, MAX_SIZE))?;
|
||||
}
|
||||
within("strokeWeight", self.stroke_weight, 0.0, 64.0)?;
|
||||
within("opacity", self.opacity, 0.0, 1.0)?;
|
||||
within("size", self.size, 1.0, 256.0)?;
|
||||
|
|
@ -210,6 +610,7 @@ impl WidgetNode {
|
|||
)?;
|
||||
only("text", !self.text.is_empty() || self.size.is_some() || self.color.is_some() || self.bold, kind == WidgetKind::Text)?;
|
||||
only("data", !self.data.is_empty(), kind == WidgetKind::Image)?;
|
||||
self.check_api_4(budget)?;
|
||||
if self.text.chars().count() > MAX_TEXT {
|
||||
return Err(format!("a text is longer than {MAX_TEXT} characters"));
|
||||
}
|
||||
|
|
@ -227,6 +628,6 @@ impl WidgetNode {
|
|||
return Err(format!("the images are larger than {} MB together", MAX_IMAGES_BYTES / (1024 * 1024)));
|
||||
}
|
||||
}
|
||||
self.children.iter_mut().try_for_each(|c| c.check(depth + 1, budget))
|
||||
self.children.iter_mut().try_for_each(|c| c.check(depth + 1, api, budget))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ impl Host for Quiet {
|
|||
}
|
||||
|
||||
fn parse(v: Value) -> Result<WidgetRender, String> {
|
||||
WidgetRender::parse(&v.to_string())
|
||||
WidgetRender::parse(&v.to_string(), 3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -73,8 +73,8 @@ fn junk_descriptions_are_refused() {
|
|||
assert!(parse(j.clone()).is_err(), "accepted {}", j.to_string().chars().take(80).collect::<String>());
|
||||
}
|
||||
assert!(parse(image(1000)).is_ok());
|
||||
assert!(WidgetRender::parse(&"[".repeat(100_000)).is_err(), "deep JSON fails cleanly");
|
||||
assert!(WidgetRender::parse(&" ".repeat(5 * 1024 * 1024)).is_err());
|
||||
assert!(WidgetRender::parse(&"[".repeat(100_000), 3).is_err(), "deep JSON fails cleanly");
|
||||
assert!(WidgetRender::parse(&" ".repeat(5 * 1024 * 1024), 3).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -110,7 +110,7 @@ fn widgets_need_api_3() {
|
|||
assert!(m(2).unwrap_err().contains("API 3"));
|
||||
let ok = m(3).unwrap();
|
||||
assert!(ok.widget && ok.commands.is_empty(), "a widget-only plugin has no menu command");
|
||||
assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 4})).is_err());
|
||||
assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 5})).is_err());
|
||||
let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap();
|
||||
assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1011,6 +1011,8 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Auf dem Server umbenennen…"
|
||||
"Rename page" = "Seite umbenennen"
|
||||
"Rename to" = "Umbenennen in"
|
||||
"Reopen its windows and tabs" = "Fenster und Tabs wieder öffnen"
|
||||
"Reopen windows and tabs from last time" = "Fenster und Tabs vom letzten Mal erneut öffnen"
|
||||
"Replace all" = "Alle ersetzen"
|
||||
"Replace mixed export settings" = "Gemischte Exporteinstellungen ersetzen"
|
||||
"Replace with" = "Ersetzen durch"
|
||||
|
|
@ -1037,6 +1039,7 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Responsiv"
|
||||
"Restart" = "Neu starten"
|
||||
"Restore" = "Wiederherstellen"
|
||||
"Restore last session" = "Letzte Sitzung wiederherstellen"
|
||||
"Restore main component" = "Hauptkomponente wiederherstellen"
|
||||
"Restore this version" = "Diese Version wiederherstellen"
|
||||
"Restored “{name}”" = "„{name}“ wiederhergestellt"
|
||||
|
|
@ -1271,6 +1274,7 @@ status = "draft — needs native review"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Diese Datei liegt auf {server} und ist nur für Sie zugänglich. Teilen Sie sie, um andere mit einem neuen Code hereinzulassen."
|
||||
"This frame is new on the branch." = "Dieser Rahmen ist im Branch neu."
|
||||
"This frame isn't on the branch." = "Dieser Rahmen ist nicht im Branch."
|
||||
"This picture can't be shown" = "Dieses Bild kann nicht angezeigt werden"
|
||||
"This server is open to the public: no key needed." = "Dieser Server ist öffentlich zugänglich: kein Schlüssel nötig."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Dieser Server braucht seinen Schlüssel: Fügen Sie der Adresse ?key=… hinzu (`figcraft-server key` gibt ihn auf dem Rechner des Servers aus)."
|
||||
"This server takes files only with its key: see Advanced." = "Dieser Server nimmt Dateien nur mit seinem Schlüssel an: siehe Erweitert."
|
||||
|
|
@ -1380,6 +1384,7 @@ status = "draft — needs native review"
|
|||
"White" = "Weiß"
|
||||
"Who runs it, its terms and how long it keeps files" = "Wer ihn betreibt, seine Bedingungen und wie lange er Dateien aufbewahrt"
|
||||
"Why it changed" = "Warum es sich geändert hat"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Breite"
|
||||
"Width point" = "Breitenpunkt"
|
||||
|
|
@ -1468,6 +1473,7 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} Konflikt zu lösen", other = "{n} Konflikte zu lösen" }
|
||||
"{n} days ago" = { one = "vor {n} Tag", other = "vor {n} Tagen" }
|
||||
"{n} files" = { one = "{n} Datei", other = "{n} Dateien" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} Datei konnte nicht wieder geöffnet werden", other = "{n} Dateien konnten nicht wieder geöffnet werden" }
|
||||
"{n} hours ago" = { one = "vor {n} Stunde", other = "vor {n} Stunden" }
|
||||
"{n} instances" = { one = "{n} Instanz", other = "{n} Instanzen" }
|
||||
"{n} layers" = { one = "{n} Ebene", other = "{n} Ebenen" }
|
||||
|
|
@ -1792,6 +1798,7 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Server für das Online-Teilen"
|
||||
"pref.reduceMotion" = "Bewegung reduzieren"
|
||||
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
|
||||
"pref.restoreSession" = "Fenster und Tabs vom letzten Mal erneut öffnen"
|
||||
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
|
||||
"pref.userName" = "Ihr Name in Kommentaren"
|
||||
"proto.action.add" = "Prototyp-Aktion hinzufügen"
|
||||
|
|
@ -1836,6 +1843,7 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Gleiche Kontur auswählen"
|
||||
"select.sameText" = "Gleiche Texteigenschaften auswählen"
|
||||
"select.set" = "Auswählen"
|
||||
"session.restore" = "Letzte Sitzung wiederherstellen"
|
||||
"shape.arrow" = "Pfeil"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Linie"
|
||||
|
|
@ -2054,8 +2062,12 @@ status = "draft — needs native review"
|
|||
"view.zoomOut" = "Verkleinern"
|
||||
"view.zoomSelection" = "Auf Auswahl zoomen"
|
||||
"widget.click" = "Widget anklicken"
|
||||
"widget.fireTimer" = "Widget-Timer auslösen"
|
||||
"widget.insert" = "Widget einfügen"
|
||||
"widget.list" = "Widgets auflisten"
|
||||
"widget.menu" = "Eigenschaftenmenü des Widgets"
|
||||
"widget.menuAction" = "Menüeintrag des Widgets verwenden"
|
||||
"widget.setText" = "Widget-Text bearbeiten"
|
||||
"window.focus" = "Fenster in den Vordergrund holen"
|
||||
"window.new" = "Neues Fenster"
|
||||
|
||||
|
|
|
|||
|
|
@ -1037,6 +1037,8 @@ name = "English"
|
|||
"Rename on the server…" = "Rename on the server…"
|
||||
"Rename page" = "Rename page"
|
||||
"Rename to" = "Rename to"
|
||||
"Reopen its windows and tabs" = "Reopen its windows and tabs"
|
||||
"Reopen windows and tabs from last time" = "Reopen windows and tabs from last time"
|
||||
"Replace all" = "Replace all"
|
||||
"Replace mixed export settings" = "Replace mixed export settings"
|
||||
"Replace with" = "Replace with"
|
||||
|
|
@ -1063,6 +1065,7 @@ name = "English"
|
|||
"Responsive" = "Responsive"
|
||||
"Restart" = "Restart"
|
||||
"Restore" = "Restore"
|
||||
"Restore last session" = "Restore last session"
|
||||
"Restore main component" = "Restore main component"
|
||||
"Restore this version" = "Restore this version"
|
||||
"Restored “{name}”" = "Restored “{name}”"
|
||||
|
|
@ -1307,6 +1310,7 @@ name = "English"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
|
||||
"This frame is new on the branch." = "This frame is new on the branch."
|
||||
"This frame isn't on the branch." = "This frame isn't on the branch."
|
||||
"This picture can't be shown" = "This picture can't be shown"
|
||||
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
|
||||
"This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced."
|
||||
|
|
@ -1421,6 +1425,7 @@ name = "English"
|
|||
"White" = "White"
|
||||
"Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files"
|
||||
"Why it changed" = "Why it changed"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Width"
|
||||
"Width point" = "Width point"
|
||||
|
|
@ -1510,6 +1515,7 @@ name = "English"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflict to resolve", other = "{n} conflicts to resolve" }
|
||||
"{n} days ago" = { one = "{n} day ago", other = "{n} days ago" }
|
||||
"{n} files" = { one = "{n} file", other = "{n} files" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} file couldn't be reopened", other = "{n} files couldn't be reopened" }
|
||||
"{n} hours ago" = { one = "{n} hour ago", other = "{n} hours ago" }
|
||||
"{n} instances" = { one = "{n} instance", other = "{n} instances" }
|
||||
"{n} layers" = { one = "{n} layer", other = "{n} layers" }
|
||||
|
|
|
|||
|
|
@ -1038,6 +1038,8 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Cambiar el nombre en el servidor…"
|
||||
"Rename page" = "Cambiar nombre de página"
|
||||
"Rename to" = "Nuevo nombre"
|
||||
"Reopen its windows and tabs" = "Reabre sus ventanas y pestañas"
|
||||
"Reopen windows and tabs from last time" = "Volver a abrir ventanas y pestañas de la última vez"
|
||||
"Replace all" = "Reemplazar todo"
|
||||
"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos"
|
||||
"Replace with" = "Reemplazar con"
|
||||
|
|
@ -1064,6 +1066,7 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Adaptable"
|
||||
"Restart" = "Reiniciar"
|
||||
"Restore" = "Restaurar"
|
||||
"Restore last session" = "Restaurar la última sesión"
|
||||
"Restore main component" = "Restaurar componente principal"
|
||||
"Restore this version" = "Restaurar esta versión"
|
||||
"Restored “{name}”" = "“{name}” restaurada"
|
||||
|
|
@ -1308,6 +1311,7 @@ status = "draft — needs native review"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
|
||||
"This frame is new on the branch." = "Este marco es nuevo en la rama."
|
||||
"This frame isn't on the branch." = "Este marco no está en la rama."
|
||||
"This picture can't be shown" = "No se puede mostrar esta imagen"
|
||||
"This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)."
|
||||
"This server takes files only with its key: see Advanced." = "Este servidor solo acepta archivos con su clave: mira Avanzado."
|
||||
|
|
@ -1422,6 +1426,7 @@ status = "draft — needs native review"
|
|||
"White" = "Blanco"
|
||||
"Who runs it, its terms and how long it keeps files" = "Quién lo gestiona, sus condiciones y cuánto tiempo conserva los archivos"
|
||||
"Why it changed" = "Por qué cambió"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Ancho"
|
||||
"Width point" = "Punto de ancho"
|
||||
|
|
@ -1511,6 +1516,7 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflicto por resolver", other = "{n} conflictos por resolver" }
|
||||
"{n} days ago" = { one = "hace {n} día", other = "hace {n} días" }
|
||||
"{n} files" = { one = "{n} archivo", other = "{n} archivos" }
|
||||
"{n} files couldn't be reopened" = { one = "No se pudo volver a abrir {n} archivo", other = "No se pudieron volver a abrir {n} archivos" }
|
||||
"{n} hours ago" = { one = "hace {n} hora", other = "hace {n} horas" }
|
||||
"{n} instances" = { one = "{n} instancia", other = "{n} instancias" }
|
||||
"{n} layers" = { one = "{n} capa", other = "{n} capas" }
|
||||
|
|
@ -1841,6 +1847,7 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Servidor para compartir en línea"
|
||||
"pref.reduceMotion" = "Reducir movimiento"
|
||||
"pref.renameDuplicates" = "Renombrar capas duplicadas"
|
||||
"pref.restoreSession" = "Volver a abrir ventanas y pestañas de la última vez"
|
||||
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
|
||||
"pref.userName" = "Tu nombre en los comentarios"
|
||||
"proto.action.add" = "Añadir acción de prototipo"
|
||||
|
|
@ -1885,6 +1892,7 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Seleccionar mismo trazo"
|
||||
"select.sameText" = "Seleccionar mismas propiedades de texto"
|
||||
"select.set" = "Seleccionar"
|
||||
"session.restore" = "Restaurar la última sesión"
|
||||
"shape.arrow" = "Flecha"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Línea"
|
||||
|
|
@ -2107,8 +2115,12 @@ status = "draft — needs native review"
|
|||
"view.zoomOut" = "Alejar"
|
||||
"view.zoomSelection" = "Zoom a la selección"
|
||||
"widget.click" = "Pulsar widget"
|
||||
"widget.fireTimer" = "Disparar temporizador del widget"
|
||||
"widget.insert" = "Insertar widget"
|
||||
"widget.list" = "Listar widgets"
|
||||
"widget.menu" = "Menú de propiedades del widget"
|
||||
"widget.menuAction" = "Usar elemento del menú del widget"
|
||||
"widget.setText" = "Editar texto del widget"
|
||||
"window.focus" = "Traer la ventana al frente"
|
||||
"window.new" = "Nueva ventana"
|
||||
"template.list" = "Listar plantillas"
|
||||
|
|
|
|||
|
|
@ -1011,6 +1011,8 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Renommer sur le serveur…"
|
||||
"Rename page" = "Renommer la page"
|
||||
"Rename to" = "Renommer en"
|
||||
"Reopen its windows and tabs" = "Rouvrir ses fenêtres et onglets"
|
||||
"Reopen windows and tabs from last time" = "Rouvrir les fenêtres et onglets de la dernière fois"
|
||||
"Replace all" = "Tout remplacer"
|
||||
"Replace mixed export settings" = "Remplacer les réglages d’exportation mixtes"
|
||||
"Replace with" = "Remplacer par"
|
||||
|
|
@ -1037,6 +1039,7 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Adaptatif"
|
||||
"Restart" = "Recommencer"
|
||||
"Restore" = "Restaurer"
|
||||
"Restore last session" = "Restaurer la dernière session"
|
||||
"Restore main component" = "Restaurer le composant principal"
|
||||
"Restore this version" = "Restaurer cette version"
|
||||
"Restored “{name}”" = "« {name} » restaurée"
|
||||
|
|
@ -1271,6 +1274,7 @@ status = "draft — needs native review"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Ce fichier est sur {server}, accessible à vous seul. Partagez-le pour laisser entrer d’autres personnes avec un nouveau code."
|
||||
"This frame is new on the branch." = "Ce cadre est nouveau dans la branche."
|
||||
"This frame isn't on the branch." = "Ce cadre n’est pas dans la branche."
|
||||
"This picture can't be shown" = "Impossible d’afficher cette image"
|
||||
"This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)."
|
||||
"This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé."
|
||||
|
|
@ -1380,6 +1384,7 @@ status = "draft — needs native review"
|
|||
"White" = "Blanc"
|
||||
"Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers"
|
||||
"Why it changed" = "Pourquoi cela a changé"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Largeur"
|
||||
"Width point" = "Point de largeur"
|
||||
|
|
@ -1468,6 +1473,7 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflit à résoudre", other = "{n} conflits à résoudre" }
|
||||
"{n} days ago" = { one = "il y a {n} jour", other = "il y a {n} jours" }
|
||||
"{n} files" = { one = "{n} fichier", other = "{n} fichiers" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} fichier n’a pas pu être rouvert", other = "{n} fichiers n’ont pas pu être rouverts" }
|
||||
"{n} hours ago" = { one = "il y a {n} heure", other = "il y a {n} heures" }
|
||||
"{n} instances" = { one = "{n} instance", other = "{n} instances" }
|
||||
"{n} layers" = { one = "{n} calque", other = "{n} calques" }
|
||||
|
|
@ -1792,6 +1798,7 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Serveur pour le partage en ligne"
|
||||
"pref.reduceMotion" = "Réduire les animations"
|
||||
"pref.renameDuplicates" = "Renommer les calques dupliqués"
|
||||
"pref.restoreSession" = "Rouvrir les fenêtres et onglets de la dernière fois"
|
||||
"pref.showDimensions" = "Afficher les dimensions sur les objets"
|
||||
"pref.userName" = "Votre nom dans les commentaires"
|
||||
"proto.action.add" = "Ajouter une action de prototype"
|
||||
|
|
@ -1836,6 +1843,7 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Sélectionner le même contour"
|
||||
"select.sameText" = "Sélectionner les mêmes propriétés de texte"
|
||||
"select.set" = "Sélectionner"
|
||||
"session.restore" = "Restaurer la dernière session"
|
||||
"shape.arrow" = "Flèche"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Ligne"
|
||||
|
|
@ -2054,8 +2062,12 @@ status = "draft — needs native review"
|
|||
"view.zoomOut" = "Zoom arrière"
|
||||
"view.zoomSelection" = "Zoom sur la sélection"
|
||||
"widget.click" = "Cliquer sur le widget"
|
||||
"widget.fireTimer" = "Déclencher le minuteur du widget"
|
||||
"widget.insert" = "Insérer un widget"
|
||||
"widget.list" = "Lister les widgets"
|
||||
"widget.menu" = "Menu de propriétés du widget"
|
||||
"widget.menuAction" = "Utiliser un élément du menu du widget"
|
||||
"widget.setText" = "Modifier le texte du widget"
|
||||
"window.focus" = "Mettre la fenêtre au premier plan"
|
||||
"window.new" = "Nouvelle fenêtre"
|
||||
|
||||
|
|
|
|||
|
|
@ -1013,6 +1013,8 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "サーバー上の名前を変更…"
|
||||
"Rename page" = "ページ名を変更"
|
||||
"Rename to" = "新しい名前"
|
||||
"Reopen its windows and tabs" = "ウィンドウとタブをもう一度開く"
|
||||
"Reopen windows and tabs from last time" = "前回のウィンドウとタブを再度開く"
|
||||
"Replace all" = "すべて置換"
|
||||
"Replace mixed export settings" = "混在した書き出し設定を置き換え"
|
||||
"Replace with" = "置換後の文字列"
|
||||
|
|
@ -1039,6 +1041,7 @@ status = "draft — needs native review"
|
|||
"Responsive" = "レスポンシブ"
|
||||
"Restart" = "再起動"
|
||||
"Restore" = "復元"
|
||||
"Restore last session" = "前回のセッションを復元"
|
||||
"Restore main component" = "メインコンポーネントを復元"
|
||||
"Restore this version" = "このバージョンを復元"
|
||||
"Restored “{name}”" = "「{name}」を復元済み"
|
||||
|
|
@ -1273,6 +1276,7 @@ status = "draft — needs native review"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "このファイルは {server} にあり、開けるのはあなただけ。共有すると、新しいコードで他の人が参加可能。"
|
||||
"This frame is new on the branch." = "このフレームはブランチで新規作成。"
|
||||
"This frame isn't on the branch." = "このフレームはブランチにない。"
|
||||
"This picture can't be shown" = "この画像は表示できません"
|
||||
"This server is open to the public: no key needed." = "このサーバーは一般公開されている:キーは不要。"
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "このサーバーにはキーが必要:アドレスに ?key=… を追加(サーバーのマシンで `figcraft-server key` を実行すると表示)。"
|
||||
"This server takes files only with its key: see Advanced." = "このサーバーはキーがないとファイルを受け付けない:「詳細設定」を参照。"
|
||||
|
|
@ -1382,6 +1386,7 @@ status = "draft — needs native review"
|
|||
"White" = "白"
|
||||
"Who runs it, its terms and how long it keeps files" = "運営者、利用規約、ファイルの保管期間"
|
||||
"Why it changed" = "変更理由"
|
||||
"Widget" = "ウィジェット"
|
||||
"Widgets" = "ウィジェット"
|
||||
"Width" = "幅"
|
||||
"Width point" = "幅のポイント"
|
||||
|
|
@ -1470,6 +1475,7 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { other = "解決が必要な競合 {n} 件" }
|
||||
"{n} days ago" = { other = "{n} 日前" }
|
||||
"{n} files" = { other = "{n} 個のファイル" }
|
||||
"{n} files couldn't be reopened" = { other = "{n} 個のファイルを再度開けませんでした" }
|
||||
"{n} hours ago" = { other = "{n} 時間前" }
|
||||
"{n} instances" = { other = "{n} 個のインスタンス" }
|
||||
"{n} layers" = { other = "{n} 個のレイヤー" }
|
||||
|
|
@ -1794,6 +1800,7 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "オンライン共有用のサーバー"
|
||||
"pref.reduceMotion" = "動きを減らす"
|
||||
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
|
||||
"pref.restoreSession" = "前回のウィンドウとタブを再度開く"
|
||||
"pref.showDimensions" = "オブジェクトに寸法を表示"
|
||||
"pref.userName" = "コメントに表示する名前"
|
||||
"proto.action.add" = "プロトタイプのアクションを追加"
|
||||
|
|
@ -1838,6 +1845,7 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "同じ線を選択"
|
||||
"select.sameText" = "同じテキストプロパティを選択"
|
||||
"select.set" = "選択"
|
||||
"session.restore" = "前回のセッションを復元"
|
||||
"shape.arrow" = "矢印"
|
||||
"shape.ellipse" = "楕円"
|
||||
"shape.line" = "線"
|
||||
|
|
@ -2056,8 +2064,12 @@ status = "draft — needs native review"
|
|||
"view.zoomOut" = "ズームアウト"
|
||||
"view.zoomSelection" = "選択範囲にズーム"
|
||||
"widget.click" = "ウィジェットをクリック"
|
||||
"widget.fireTimer" = "ウィジェットのタイマーを発火"
|
||||
"widget.insert" = "ウィジェットを挿入"
|
||||
"widget.list" = "ウィジェットを一覧表示"
|
||||
"widget.menu" = "ウィジェットのプロパティメニュー"
|
||||
"widget.menuAction" = "ウィジェットのメニュー項目を使用"
|
||||
"widget.setText" = "ウィジェットのテキストを編集"
|
||||
"window.focus" = "ウィンドウを最前面に表示"
|
||||
"window.new" = "新しいウィンドウ"
|
||||
|
||||
|
|
|
|||
|
|
@ -1012,6 +1012,8 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "서버에서 이름 바꾸기…"
|
||||
"Rename page" = "페이지 이름 바꾸기"
|
||||
"Rename to" = "새 이름"
|
||||
"Reopen its windows and tabs" = "창과 탭 다시 열기"
|
||||
"Reopen windows and tabs from last time" = "지난번 창과 탭 다시 열기"
|
||||
"Replace all" = "모두 바꾸기"
|
||||
"Replace mixed export settings" = "혼합된 내보내기 설정 바꾸기"
|
||||
"Replace with" = "바꿀 내용"
|
||||
|
|
@ -1038,6 +1040,7 @@ status = "draft — needs native review"
|
|||
"Responsive" = "반응형"
|
||||
"Restart" = "다시 시작"
|
||||
"Restore" = "복원"
|
||||
"Restore last session" = "마지막 세션 복원"
|
||||
"Restore main component" = "메인 컴포넌트 복원"
|
||||
"Restore this version" = "이 버전 복원"
|
||||
"Restored “{name}”" = "\"{name}\" 복원됨"
|
||||
|
|
@ -1272,6 +1275,7 @@ status = "draft — needs native review"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "이 파일은 {server}에 있으며 나만 열 수 있음. 공유하면 다른 사람들이 새 코드로 참여할 수 있음."
|
||||
"This frame is new on the branch." = "이 프레임은 브랜치에서 새로 만들어졌음."
|
||||
"This frame isn't on the branch." = "이 프레임은 브랜치에 없음."
|
||||
"This picture can't be shown" = "이 그림을 표시할 수 없음"
|
||||
"This server is open to the public: no key needed." = "이 서버는 누구에게나 열려 있음: 키가 필요 없음."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "이 서버는 키가 필요함: 주소에 ?key=…를 추가하세요(서버 컴퓨터에서 `figcraft-server key`로 확인)."
|
||||
"This server takes files only with its key: see Advanced." = "이 서버는 키가 있어야만 파일을 받음: 고급을 참고하세요."
|
||||
|
|
@ -1381,6 +1385,7 @@ status = "draft — needs native review"
|
|||
"White" = "흰색"
|
||||
"Who runs it, its terms and how long it keeps files" = "운영자, 약관, 파일 보관 기간"
|
||||
"Why it changed" = "변경 이유"
|
||||
"Widget" = "위젯"
|
||||
"Widgets" = "위젯"
|
||||
"Width" = "너비"
|
||||
"Width point" = "너비 점"
|
||||
|
|
@ -1469,6 +1474,7 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { other = "해결할 충돌 {n}개" }
|
||||
"{n} days ago" = { other = "{n}일 전" }
|
||||
"{n} files" = { other = "파일 {n}개" }
|
||||
"{n} files couldn't be reopened" = { other = "파일 {n}개를 다시 열 수 없음" }
|
||||
"{n} hours ago" = { other = "{n}시간 전" }
|
||||
"{n} instances" = { other = "인스턴스 {n}개" }
|
||||
"{n} layers" = { other = "레이어 {n}개" }
|
||||
|
|
@ -1793,6 +1799,7 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "온라인 공유용 서버"
|
||||
"pref.reduceMotion" = "동작 줄이기"
|
||||
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
|
||||
"pref.restoreSession" = "지난번 창과 탭 다시 열기"
|
||||
"pref.showDimensions" = "개체에 치수 표시"
|
||||
"pref.userName" = "댓글에 표시할 이름"
|
||||
"proto.action.add" = "프로토타입 액션 추가"
|
||||
|
|
@ -1837,6 +1844,7 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "같은 선 선택"
|
||||
"select.sameText" = "같은 텍스트 속성 선택"
|
||||
"select.set" = "선택"
|
||||
"session.restore" = "마지막 세션 복원"
|
||||
"shape.arrow" = "화살표"
|
||||
"shape.ellipse" = "타원"
|
||||
"shape.line" = "선"
|
||||
|
|
@ -2055,8 +2063,12 @@ status = "draft — needs native review"
|
|||
"view.zoomOut" = "축소"
|
||||
"view.zoomSelection" = "선택 항목으로 확대/축소"
|
||||
"widget.click" = "위젯 클릭"
|
||||
"widget.fireTimer" = "위젯 타이머 실행"
|
||||
"widget.insert" = "위젯 삽입"
|
||||
"widget.list" = "위젯 목록"
|
||||
"widget.menu" = "위젯 속성 메뉴"
|
||||
"widget.menuAction" = "위젯 메뉴 항목 사용"
|
||||
"widget.setText" = "위젯 텍스트 편집"
|
||||
"window.focus" = "창을 맨 앞으로 가져오기"
|
||||
"window.new" = "새 창"
|
||||
|
||||
|
|
|
|||
|
|
@ -1010,6 +1010,8 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Renomear no servidor…"
|
||||
"Rename page" = "Renomear página"
|
||||
"Rename to" = "Renomear para"
|
||||
"Reopen its windows and tabs" = "Reabra as janelas e abas"
|
||||
"Reopen windows and tabs from last time" = "Reabrir janelas e abas da última vez"
|
||||
"Replace all" = "Substituir tudo"
|
||||
"Replace mixed export settings" = "Substituir configurações de exportação mistas"
|
||||
"Replace with" = "Substituir por"
|
||||
|
|
@ -1036,6 +1038,7 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Responsivo"
|
||||
"Restart" = "Reiniciar"
|
||||
"Restore" = "Restaurar"
|
||||
"Restore last session" = "Restaurar a última sessão"
|
||||
"Restore main component" = "Restaurar componente principal"
|
||||
"Restore this version" = "Restaurar esta versão"
|
||||
"Restored “{name}”" = "“{name}” restaurada"
|
||||
|
|
@ -1270,6 +1273,7 @@ status = "draft — needs native review"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este arquivo está em {server}, aberto somente para você. Compartilhe-o para deixar outras pessoas entrarem com um novo código."
|
||||
"This frame is new on the branch." = "Este quadro é novo na branch."
|
||||
"This frame isn't on the branch." = "Este quadro não está na branch."
|
||||
"This picture can't be shown" = "Não é possível mostrar esta imagem"
|
||||
"This server is open to the public: no key needed." = "Este servidor é aberto ao público: não precisa de chave."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor precisa da própria chave: adicione ?key=… ao endereço (`figcraft-server key` a mostra na máquina do servidor)."
|
||||
"This server takes files only with its key: see Advanced." = "Este servidor só aceita arquivos com a própria chave: veja Avançado."
|
||||
|
|
@ -1379,6 +1383,7 @@ status = "draft — needs native review"
|
|||
"White" = "Branco"
|
||||
"Who runs it, its terms and how long it keeps files" = "Quem o mantém, os termos dele e por quanto tempo guarda os arquivos"
|
||||
"Why it changed" = "Por que mudou"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Largura"
|
||||
"Width point" = "Ponto de largura"
|
||||
|
|
@ -1467,6 +1472,7 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflito para resolver", other = "{n} conflitos para resolver" }
|
||||
"{n} days ago" = { one = "há {n} dia", other = "há {n} dias" }
|
||||
"{n} files" = { one = "{n} arquivo", other = "{n} arquivos" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} arquivo não pôde ser reaberto", other = "{n} arquivos não puderam ser reabertos" }
|
||||
"{n} hours ago" = { one = "há {n} hora", other = "há {n} horas" }
|
||||
"{n} instances" = { one = "{n} instância", other = "{n} instâncias" }
|
||||
"{n} layers" = { one = "{n} camada", other = "{n} camadas" }
|
||||
|
|
@ -1791,6 +1797,7 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Servidor para compartilhamento online"
|
||||
"pref.reduceMotion" = "Reduzir movimento"
|
||||
"pref.renameDuplicates" = "Renomear camadas duplicadas"
|
||||
"pref.restoreSession" = "Reabrir janelas e abas da última vez"
|
||||
"pref.showDimensions" = "Mostrar dimensões nos objetos"
|
||||
"pref.userName" = "Seu nome nos comentários"
|
||||
"proto.action.add" = "Adicionar ação de protótipo"
|
||||
|
|
@ -1835,6 +1842,7 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Selecionar mesmo traço"
|
||||
"select.sameText" = "Selecionar mesmas propriedades de texto"
|
||||
"select.set" = "Selecionar"
|
||||
"session.restore" = "Restaurar a última sessão"
|
||||
"shape.arrow" = "Seta"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Linha"
|
||||
|
|
@ -2053,8 +2061,12 @@ status = "draft — needs native review"
|
|||
"view.zoomOut" = "Diminuir zoom"
|
||||
"view.zoomSelection" = "Zoom na seleção"
|
||||
"widget.click" = "Clicar no widget"
|
||||
"widget.fireTimer" = "Disparar temporizador do widget"
|
||||
"widget.insert" = "Inserir widget"
|
||||
"widget.list" = "Listar widgets"
|
||||
"widget.menu" = "Menu de propriedades do widget"
|
||||
"widget.menuAction" = "Usar item do menu do widget"
|
||||
"widget.setText" = "Editar texto do widget"
|
||||
"window.focus" = "Trazer a janela para a frente"
|
||||
"window.new" = "Nova janela"
|
||||
|
||||
|
|
|
|||
|
|
@ -1013,6 +1013,8 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "在服务器上重命名…"
|
||||
"Rename page" = "重命名页面"
|
||||
"Rename to" = "重命名为"
|
||||
"Reopen its windows and tabs" = "重新打开其窗口和标签页"
|
||||
"Reopen windows and tabs from last time" = "重新打开上次的窗口与标签页"
|
||||
"Replace all" = "全部替换"
|
||||
"Replace mixed export settings" = "替换混合的导出设置"
|
||||
"Replace with" = "替换为"
|
||||
|
|
@ -1039,6 +1041,7 @@ status = "draft — needs native review"
|
|||
"Responsive" = "响应式"
|
||||
"Restart" = "重新开始"
|
||||
"Restore" = "恢复"
|
||||
"Restore last session" = "恢复上次会话"
|
||||
"Restore main component" = "恢复主组件"
|
||||
"Restore this version" = "恢复此版本"
|
||||
"Restored “{name}”" = "已恢复“{name}”"
|
||||
|
|
@ -1273,6 +1276,7 @@ status = "draft — needs native review"
|
|||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "此文件在 {server} 上,只有你能打开。分享后,其他人可以用新代码加入。"
|
||||
"This frame is new on the branch." = "此画框是分支上新增的。"
|
||||
"This frame isn't on the branch." = "此画框不在分支上。"
|
||||
"This picture can't be shown" = "无法显示此图片"
|
||||
"This server is open to the public: no key needed." = "此服务器对公众开放:无需密钥。"
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "此服务器需要密钥:请在地址后加上 ?key=…(在服务器所在的机器上运行 `figcraft-server key` 可显示)。"
|
||||
"This server takes files only with its key: see Advanced." = "此服务器只接受带密钥的文件:请查看“高级”。"
|
||||
|
|
@ -1382,6 +1386,7 @@ status = "draft — needs native review"
|
|||
"White" = "白"
|
||||
"Who runs it, its terms and how long it keeps files" = "谁在运营、其条款以及文件保留多久"
|
||||
"Why it changed" = "更改原因"
|
||||
"Widget" = "小组件"
|
||||
"Widgets" = "小组件"
|
||||
"Width" = "宽度"
|
||||
"Width point" = "宽度点"
|
||||
|
|
@ -1470,6 +1475,7 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { other = "{n} 个冲突待解决" }
|
||||
"{n} days ago" = { other = "{n} 天前" }
|
||||
"{n} files" = { other = "{n} 个文件" }
|
||||
"{n} files couldn't be reopened" = { other = "{n} 个文件无法重新打开" }
|
||||
"{n} hours ago" = { other = "{n} 小时前" }
|
||||
"{n} instances" = { other = "{n} 个实例" }
|
||||
"{n} layers" = { other = "{n} 个图层" }
|
||||
|
|
@ -1794,6 +1800,7 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "在线分享所用的服务器"
|
||||
"pref.reduceMotion" = "减少动态效果"
|
||||
"pref.renameDuplicates" = "重命名副本图层"
|
||||
"pref.restoreSession" = "重新打开上次的窗口与标签页"
|
||||
"pref.showDimensions" = "在对象上显示尺寸"
|
||||
"pref.userName" = "评论中显示的名字"
|
||||
"proto.action.add" = "添加原型操作"
|
||||
|
|
@ -1838,6 +1845,7 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "选择相同描边"
|
||||
"select.sameText" = "选择相同文本属性"
|
||||
"select.set" = "选择"
|
||||
"session.restore" = "恢复上次会话"
|
||||
"shape.arrow" = "箭头"
|
||||
"shape.ellipse" = "椭圆"
|
||||
"shape.line" = "直线"
|
||||
|
|
@ -2056,8 +2064,12 @@ status = "draft — needs native review"
|
|||
"view.zoomOut" = "缩小"
|
||||
"view.zoomSelection" = "缩放至所选项"
|
||||
"widget.click" = "点击小组件"
|
||||
"widget.fireTimer" = "触发小组件计时器"
|
||||
"widget.insert" = "插入小组件"
|
||||
"widget.list" = "列出小组件"
|
||||
"widget.menu" = "小组件属性菜单"
|
||||
"widget.menuAction" = "使用小组件菜单项"
|
||||
"widget.setText" = "编辑小组件文本"
|
||||
"window.focus" = "将窗口置于前台"
|
||||
"window.new" = "新建窗口"
|
||||
|
||||
|
|
|
|||
|
|
@ -891,6 +891,33 @@ pub fn number(resp: &Response, label: &str, value: Option<f64>, suffix: &str, em
|
|||
});
|
||||
}
|
||||
|
||||
/// A slider at `value` (none: mixed) within `range`.
|
||||
pub fn slider(resp: &Response, label: &str, value: Option<f64>, range: (f64, f64)) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::Slider);
|
||||
n.set_min_numeric_value(range.0);
|
||||
n.set_max_numeric_value(range.1);
|
||||
if let Some(v) = value {
|
||||
n.set_numeric_value(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A progress bar filled to `fraction` (0–1).
|
||||
pub fn progress(resp: &Response, label: &str, fraction: f64) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::ProgressIndicator);
|
||||
n.set_min_numeric_value(0.0);
|
||||
n.set_max_numeric_value(1.0);
|
||||
n.set_numeric_value(fraction);
|
||||
});
|
||||
}
|
||||
|
||||
/// A picture, named by what it shows.
|
||||
pub fn image(resp: &Response, label: &str) {
|
||||
node(resp, label, |n| n.set_role(Role::Image));
|
||||
}
|
||||
|
||||
/// A text field showing `value`.
|
||||
pub fn text(resp: &Response, label: &str, value: &str) {
|
||||
node(resp, label, |n| {
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
|||
crate::versions::preview_banner(app, &ctx, rect);
|
||||
crate::branch_ui::banner(app, &ctx, rect);
|
||||
crate::dev_mode::focus_bar(app, &ctx, rect);
|
||||
crate::widget_ui::menu_bar(app, ui, rect);
|
||||
crate::widget_ui::pointer(app, &resp);
|
||||
// The cursor: the tool's, unless something on the canvas with its own has the pointer.
|
||||
app.canvas.cursor = None;
|
||||
if let (true, Some(at)) = (own_cursor && resp.hovered(), ctx.input(|i| i.pointer.hover_pos())) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! - `engine.execute {command, params}` — run any command (no dialogs open)
|
||||
//! - `engine.commands {}` — every command with its params doc
|
||||
//! - `document.inspect {depth?, allPages?}` / `node.get {id, depth?}`
|
||||
//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings
|
||||
//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings, the kept session (`session`)
|
||||
//! - `ui.pointer {kind: down|move|up|click|double|hover|drag, x, y (page) | sx, sy (screen), to?, shift?, alt?, cmd?}`
|
||||
//! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas
|
||||
//! - `ui.type {text}` — type into the text being edited
|
||||
|
|
@ -18,8 +18,8 @@
|
|||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
|
||||
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
|
||||
//! windows (desktop)
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` (each window's files, rectangle
|
||||
//! and state) / `window.focus {id}` — several windows (desktop)
|
||||
//! - `app.quit {}`
|
||||
//!
|
||||
//! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
(tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"),
|
||||
(tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"),
|
||||
(tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"),
|
||||
(tr("Reopen windows and tabs from last time"), prefs.restore_session, "pref.restoreSession"),
|
||||
] {
|
||||
if crate::widgets::checkbox(ui, val, label) {
|
||||
toggles.push(cmd);
|
||||
|
|
|
|||
|
|
@ -208,11 +208,18 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b
|
|||
|
||||
fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
heading(ui, t, tr("Recents"));
|
||||
let (mut new_file, mut open) = (false, false);
|
||||
let (mut new_file, mut open, mut restore) = (false, false, false);
|
||||
ui.horizontal(|ui| {
|
||||
new_file = action_card(ui, t, Icon::Plus, tr("New design file"), tr("Start from a blank canvas"));
|
||||
open = action_card(ui, t, Icon::Page, tr("Open file"), tr("Open .figcraft, .svg or images"));
|
||||
// This run started without the last one's windows and tabs.
|
||||
if app.restore_offered() {
|
||||
restore = action_card(ui, t, Icon::WinRestore, tr("Restore last session"), tr("Reopen its windows and tabs"));
|
||||
}
|
||||
});
|
||||
if restore {
|
||||
app.restore_last();
|
||||
}
|
||||
if new_file {
|
||||
app.run("file.new", json!({}));
|
||||
app.ui.home = false;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const IDLE: Duration = Duration::from_secs(1);
|
|||
|
||||
pub(crate) struct Harness {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
pub(crate) app: FigApp,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ impl Harness {
|
|||
}
|
||||
|
||||
/// One frame with `events`, 1/60 s after the last; returns the repaint delay it asked for.
|
||||
fn frame(&mut self, events: Vec<Event>) -> Duration {
|
||||
pub(crate) fn frame(&mut self, events: Vec<Event>) -> Duration {
|
||||
self.time += 1.0 / 60.0;
|
||||
let input =
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
|
||||
|
|
@ -43,7 +43,7 @@ impl Harness {
|
|||
/// Draw frames with no input until the UI is idle: no render in flight and three frames in a
|
||||
/// row asking for no repaint sooner than [`IDLE`]. Returns the last frame's delay; fails with
|
||||
/// what kept asking for frames.
|
||||
fn settle(&mut self) -> Duration {
|
||||
pub(crate) fn settle(&mut self) -> Duration {
|
||||
let mut quiet = 0;
|
||||
for _ in 0..2000 {
|
||||
let delay = self.frame(vec![]);
|
||||
|
|
@ -56,7 +56,7 @@ impl Harness {
|
|||
panic!("still asking for frames after 2000 with no input: {:?}", self.ctx.repaint_causes());
|
||||
}
|
||||
|
||||
fn run(&mut self, cmd: &str, params: Value) -> Value {
|
||||
pub(crate) fn run(&mut self, cmd: &str, params: Value) -> Value {
|
||||
let r = self.app.session.execute(cmd, ¶ms).unwrap();
|
||||
self.app.handle_ui_requests();
|
||||
r
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pub mod proto_canvas;
|
|||
pub mod proto_editor;
|
||||
pub mod proto_panel;
|
||||
pub mod render_worker;
|
||||
pub mod restore;
|
||||
pub mod right;
|
||||
pub mod scale_panel;
|
||||
pub mod section_panel;
|
||||
|
|
@ -59,6 +60,7 @@ pub mod variable_ui;
|
|||
pub mod variables_panel;
|
||||
pub mod vector_panel;
|
||||
pub mod versions;
|
||||
pub mod widget_ui;
|
||||
pub mod widgets;
|
||||
pub mod windows;
|
||||
|
||||
|
|
@ -135,6 +137,8 @@ mod proto_reorder_tests;
|
|||
#[cfg(test)]
|
||||
mod proto_responsive_tests;
|
||||
#[cfg(test)]
|
||||
mod restore_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcut_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcuts_tests;
|
||||
|
|
@ -145,6 +149,8 @@ mod titlebar_tests;
|
|||
#[cfg(test)]
|
||||
mod vector_tail_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod widget_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod windows_qa_tests;
|
||||
#[cfg(test)]
|
||||
mod windows_tests;
|
||||
|
|
@ -189,6 +195,9 @@ pub trait Services {
|
|||
/// Keep the preferences now (the installed plugins or their settings changed), as the app
|
||||
/// does when it quits.
|
||||
fn save_prefs(&mut self, _prefs: &figcraft_engine::Prefs) {}
|
||||
/// A restore of the last session begins (`on`) or has settled: the app keeps a mark meanwhile,
|
||||
/// so a crash on the way starts the next run without restoring.
|
||||
fn restoring(&mut self, _on: bool) {}
|
||||
/// The OS or browser language as a tag ("es-ES"), for the "system" language preference.
|
||||
fn system_language(&self) -> Option<String> {
|
||||
None
|
||||
|
|
@ -495,6 +504,11 @@ pub struct FigApp {
|
|||
window_requests: Vec<figcraft_engine::UiRequest>,
|
||||
/// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results.
|
||||
waiting: Vec<(String, Done)>,
|
||||
/// The screens there are, `[x, y, width, height]` in screen points (the app says; none known
|
||||
/// in the browser and in tests): restored windows open on them.
|
||||
pub monitors: Vec<[f32; 4]>,
|
||||
/// Keeping and restoring the session.
|
||||
restore: restore::Restore,
|
||||
}
|
||||
|
||||
/// What to do with the result of a command run in the background.
|
||||
|
|
@ -522,6 +536,8 @@ impl FigApp {
|
|||
windows: windows::Windows::default(),
|
||||
window_requests: Vec::new(),
|
||||
waiting: Vec::new(),
|
||||
monitors: Vec::new(),
|
||||
restore: restore::Restore::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -735,7 +751,7 @@ impl FigApp {
|
|||
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
|
||||
"dialog.plugins" => self.ui.plugins_window = true,
|
||||
"dialog.cursorChat" => live::open_chat(self),
|
||||
"window.new" | "window.focus" => self.window_requests.push(r),
|
||||
"window.new" | "window.focus" | "session.restore" => self.window_requests.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -934,6 +950,8 @@ impl FigApp {
|
|||
}
|
||||
self.background_done(&ctx);
|
||||
}
|
||||
// Widget timers that are due call their plugins back; the next one wakes the window.
|
||||
widget_ui::tick(self, &ctx);
|
||||
windows::begin(self, &ctx);
|
||||
let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone());
|
||||
self.frame(ui);
|
||||
|
|
@ -942,6 +960,7 @@ impl FigApp {
|
|||
windows::repaint_others(self, &ctx);
|
||||
}
|
||||
windows::show(self, &ctx);
|
||||
restore::keep(self, &ctx);
|
||||
// Plugins installed, removed or set up are kept at once: a crash doesn't lose them.
|
||||
// So are the owner tokens of files put online.
|
||||
if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) {
|
||||
|
|
@ -1176,6 +1195,7 @@ impl FigApp {
|
|||
"live": live::inspect(self),
|
||||
"window": self.windows.current,
|
||||
"windows": windows::list(self),
|
||||
"session": self.inspect_session(),
|
||||
"tool": self.session.tool_info(),
|
||||
"cursor": self.canvas.cursor.map(cursors::Shown::name),
|
||||
"panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use egui::{Align2, Context, Id, RichText, Ui};
|
||||
use figcraft_engine::plugin_directory::{PluginEntry, installed_version, newer_version};
|
||||
use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind};
|
||||
use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind, WidgetFit};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
|
|
@ -56,6 +56,13 @@ pub struct PluginsWindow {
|
|||
|
||||
/// Width of the plugin windows' fields.
|
||||
const FIELD_W: f32 = 220.0;
|
||||
/// While a slider is dragged its plugin hears of it at most this often (seconds), and once more
|
||||
/// when the drag ends.
|
||||
const SLIDER_EVERY: f64 = 0.1;
|
||||
/// Width of the number field beside a slider.
|
||||
const SLIDER_FIELD_W: f32 = 52.0;
|
||||
/// A panel's picture is decoded only up to this many pixels on a side.
|
||||
const MAX_IMAGE_SIDE: u32 = 2048;
|
||||
/// How far apart plugin panels open, side by side (a panel and a gap).
|
||||
const PANEL_STEP: f32 = 372.0;
|
||||
|
||||
|
|
@ -552,21 +559,87 @@ fn form_window(app: &mut FigApp, ctx: &Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a control is drawn across the panel, not as a row of the label and field columns.
|
||||
fn full_width(kind: ParamKind) -> bool {
|
||||
matches!(kind, ParamKind::Section | ParamKind::Divider | ParamKind::Image | ParamKind::Progress)
|
||||
}
|
||||
|
||||
/// The label in the left column: checkboxes and buttons carry their own, a note has none.
|
||||
fn caption(p: &Param) -> &str {
|
||||
if matches!(p.kind, ParamKind::Boolean | ParamKind::Button | ParamKind::Note) { "" } else { p.title() }
|
||||
}
|
||||
|
||||
/// Parameters or panel controls in two columns (label, field); buttons in a row share one line,
|
||||
/// the first button filled. Returns what the user did to which control.
|
||||
/// the first button filled. Dividers, pictures and progress bars span the panel, and a section's
|
||||
/// heading folds the controls after it, up to the next section or divider. Returns what the user
|
||||
/// did to which control.
|
||||
fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Value>, t: &Tokens) -> Option<Used> {
|
||||
let mut changed = None;
|
||||
let mut first_button = true;
|
||||
egui::Grid::new(("plugin_controls", salt)).num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
for row in params.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) {
|
||||
// The label column is as wide as its widest label in every block of fields, so they line up.
|
||||
let label_w = |p: &Param| ui.painter().layout_no_wrap(caption(p).to_string(), regular(11.0), t.text_secondary).size().x;
|
||||
let caption_w = params.iter().filter(|p| !full_width(p.kind)).map(label_w).fold(0.0, f32::max);
|
||||
let mut open = true;
|
||||
for (i, block) in params.chunk_by(|a, b| !full_width(a.kind) && !full_width(b.kind)).enumerate() {
|
||||
let Some(p) = block.first() else { continue };
|
||||
match p.kind {
|
||||
ParamKind::Section => {
|
||||
let id = Id::new(("plugin_section", salt, i));
|
||||
let starts_open = p.default.as_ref().and_then(Value::as_bool).unwrap_or(true);
|
||||
open = ui.data(|d| d.get_temp(id)).unwrap_or(starts_open);
|
||||
if widgets::disclosure(ui, &p.label, open) {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(id, open));
|
||||
}
|
||||
}
|
||||
// A divider ends a section: it and what follows always show.
|
||||
ParamKind::Divider => {
|
||||
open = true;
|
||||
ui.add_space(2.0);
|
||||
widgets::separator(ui);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
_ if !open => {}
|
||||
ParamKind::Image => picture(ui, Id::new(("plugin_image", salt, i)), p, t),
|
||||
ParamKind::Progress => {
|
||||
if !p.label.is_empty() {
|
||||
widgets::label(ui, &p.label, t.text_secondary);
|
||||
}
|
||||
widgets::progress_bar(ui, p.title(), p.default.as_ref().and_then(Value::as_f64).unwrap_or(0.0), ui.available_width());
|
||||
}
|
||||
_ => {
|
||||
let hit = fields(ui, (salt, i), block, values, caption_w, &mut first_button, t);
|
||||
if hit.is_some() {
|
||||
changed = hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(if open || p.kind == ParamKind::Section { 8.0 } else { 0.0 });
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// A block of fields, notes and buttons in two columns. A disabled control is greyed and can't be
|
||||
/// used; a field's validation message shows under it.
|
||||
fn fields(
|
||||
ui: &mut Ui,
|
||||
salt: (&str, usize),
|
||||
block: &[Param],
|
||||
values: &Map<String, Value>,
|
||||
caption_w: f32,
|
||||
first_button: &mut bool,
|
||||
t: &Tokens,
|
||||
) -> Option<Used> {
|
||||
let mut changed = None;
|
||||
egui::Grid::new(("plugin_controls", salt)).num_columns(2).min_col_width(caption_w).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
for row in block.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) {
|
||||
let Some(p) = row.first() else { continue };
|
||||
// Checkboxes and buttons carry their own label; a list's sits by its first item.
|
||||
let caption = if matches!(p.kind, ParamKind::Boolean | ParamKind::Button | ParamKind::Note) { "" } else { p.title() };
|
||||
let caption = RichText::new(caption).font(regular(11.0)).color(t.text_secondary);
|
||||
let label = RichText::new(caption(p)).font(regular(11.0)).color(t.text_secondary);
|
||||
// A list's label sits by its first item.
|
||||
if p.kind == ParamKind::List {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| ui.label(caption));
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| ui.label(label));
|
||||
} else {
|
||||
ui.label(caption);
|
||||
ui.label(label);
|
||||
}
|
||||
let hit = match p.kind {
|
||||
ParamKind::Note => {
|
||||
|
|
@ -578,34 +651,131 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Valu
|
|||
}
|
||||
ParamKind::Button => {
|
||||
ui.horizontal(|ui| {
|
||||
let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(&mut first_button), true).clicked());
|
||||
let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(first_button), !b.disabled).clicked());
|
||||
pressed.last().map(|b| Used::Set(b.name.clone(), Value::Null))
|
||||
})
|
||||
.inner
|
||||
}
|
||||
_ => {
|
||||
let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value());
|
||||
field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t)
|
||||
let id = Id::new(("plugin_control", salt, &p.name));
|
||||
ui.add_enabled_ui(!p.disabled, |ui| field(ui, id, p, ¤t)).inner
|
||||
}
|
||||
};
|
||||
if hit.is_some() {
|
||||
changed = hit;
|
||||
}
|
||||
ui.end_row();
|
||||
if !p.error.is_empty() {
|
||||
ui.label("");
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(FIELD_W);
|
||||
ui.add(egui::Label::new(RichText::new(&p.error).font(regular(11.0)).color(t.danger)).wrap());
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// A panel's picture, fitted in the panel's width at its height and named for screen readers. It
|
||||
/// is decoded once, and again only when the plugin shows another.
|
||||
fn picture(ui: &mut Ui, id: Id, p: &Param, t: &Tokens) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
p.data.hash(&mut hasher);
|
||||
let key = hasher.finish();
|
||||
let kept = ui.data(|d| d.get_temp::<(u64, Option<egui::TextureHandle>)>(id)).filter(|(k, _)| *k == key);
|
||||
let (_, texture) = kept.unwrap_or_else(|| {
|
||||
let fresh = (key, decode(&p.data).map(|img| ui.ctx().load_texture("plugin-image", img, egui::TextureOptions::LINEAR)));
|
||||
ui.data_mut(|d| d.insert_temp(id, fresh.clone()));
|
||||
fresh
|
||||
});
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), p.height.unwrap_or(120.0) as f32), egui::Sense::hover());
|
||||
crate::a11y::image(&resp, p.title());
|
||||
let Some(texture) = texture else {
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.field);
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr("This picture can't be shown"), regular(11.0), t.text_secondary);
|
||||
return;
|
||||
};
|
||||
// `fit` shows all of it, `fill` stretches it to the box; `crop` and `tile` cover the box.
|
||||
let (size, mut uv) = (texture.size_vec2(), egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)));
|
||||
let scale = |cover: bool| {
|
||||
let (sx, sy) = (rect.width() / size.x.max(1.0), rect.height() / size.y.max(1.0));
|
||||
if cover { sx.max(sy) } else { sx.min(sy) }
|
||||
};
|
||||
let shown = match p.fit {
|
||||
None | Some(WidgetFit::Fit) => egui::Rect::from_center_size(rect.center(), size * scale(false)),
|
||||
Some(WidgetFit::Fill) => rect,
|
||||
Some(WidgetFit::Crop | WidgetFit::Tile) => {
|
||||
let covered = size * scale(true);
|
||||
uv = egui::Rect::from_center_size(egui::pos2(0.5, 0.5), egui::vec2(rect.width() / covered.x, rect.height() / covered.y));
|
||||
rect
|
||||
}
|
||||
};
|
||||
ui.painter().with_clip_rect(rect).image(texture.id(), shown, uv, egui::Color32::WHITE);
|
||||
}
|
||||
|
||||
/// A picture's pixels from its file's bytes as base64 (PNG, JPEG, BMP…), if it is one of at most
|
||||
/// [`MAX_IMAGE_SIDE`] pixels on a side.
|
||||
fn decode(data: &str) -> Option<egui::ColorImage> {
|
||||
use base64::Engine as _;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).ok()?;
|
||||
let reader = || image::ImageReader::new(std::io::Cursor::new(&bytes)).with_guessed_format().ok();
|
||||
let (w, h) = reader()?.into_dimensions().ok()?;
|
||||
if w == 0 || h == 0 || w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE {
|
||||
return None;
|
||||
}
|
||||
let rgba = reader()?.decode().ok()?.to_rgba8();
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba))
|
||||
}
|
||||
|
||||
/// Whether a slider that moved tells its plugin now: when the drag ends, and while it goes on at
|
||||
/// most every [`SLIDER_EVERY`] seconds (`last`: when the plugin last heard of this drag).
|
||||
pub(crate) fn slider_due(done: bool, now: f64, last: Option<f64>) -> bool {
|
||||
done || last.is_none_or(|l| now - l >= SLIDER_EVERY)
|
||||
}
|
||||
|
||||
/// A slider with its value in a number field beside it (which also takes the keyboard). While it
|
||||
/// is dragged it shows the dragged value at once and tells the plugin now and then.
|
||||
fn slider(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option<Value> {
|
||||
let name = p.title();
|
||||
// (the value being dragged, when the plugin last heard of the drag)
|
||||
let live = ui.data(|d| d.get_temp::<(f64, Option<f64>)>(id));
|
||||
let shown = live.map(|l| l.0).or(current.as_f64());
|
||||
let range = (p.min.unwrap_or(0.0), p.max.unwrap_or(100.0));
|
||||
ui.horizontal(|ui| {
|
||||
let (_, moved) = widgets::slider(ui, id, name, range, None, shown, FIELD_W - SLIDER_FIELD_W - 8.0);
|
||||
let typed = match widgets::number_field(ui, id.with("value"), Lead::Named(name), shown, "", SLIDER_FIELD_W, p.step.unwrap_or(1.0)) {
|
||||
Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) => p.coerce(&json!(v)).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let Some((v, done)) = moved else { return typed };
|
||||
let value = p.coerce(&json!(v)).ok()?;
|
||||
let (now, last) = (ui.input(|i| i.time), live.and_then(|l| l.1));
|
||||
// Nothing to tell when the plugin already has this value.
|
||||
let due = slider_due(done, now, last) && value.as_f64() != current.as_f64();
|
||||
let told = if due { Some(now) } else { last };
|
||||
ui.data_mut(|d| match done {
|
||||
true => d.remove::<(f64, Option<f64>)>(id),
|
||||
false => drop(d.insert_temp(id, (value.as_f64().unwrap_or(v), told))),
|
||||
});
|
||||
due.then_some(value)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// One parameter's field, named for screen readers: its new value when it changed.
|
||||
fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option<Used> {
|
||||
let name = p.title();
|
||||
let v = match p.kind {
|
||||
ParamKind::Text => widgets::text_field_named(ui, id, current.as_str().unwrap_or_default(), FIELD_W, name).map(Value::String),
|
||||
ParamKind::Number => match widgets::number_field(ui, id, Lead::Named(name), current.as_f64(), "", FIELD_W, 1.0)? {
|
||||
ParamKind::Number => match widgets::number_field(ui, id, Lead::Named(name), current.as_f64(), "", FIELD_W, p.step.unwrap_or(1.0))? {
|
||||
FieldEdit::Set(v) | FieldEdit::Scrub(v, true) => p.coerce(&json!(v)).ok(),
|
||||
FieldEdit::Scrub(_, false) => None,
|
||||
},
|
||||
ParamKind::Slider => slider(ui, id, p, current),
|
||||
ParamKind::Boolean => {
|
||||
let on = current.as_bool().unwrap_or(false);
|
||||
widgets::checkbox(ui, on, name).then_some(Value::Bool(!on))
|
||||
|
|
@ -651,7 +821,8 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option<Used> {
|
|||
}
|
||||
typed
|
||||
}
|
||||
ParamKind::Button | ParamKind::Note => None,
|
||||
// Buttons, notes and the controls that span the panel aren't fields.
|
||||
_ => None,
|
||||
};
|
||||
v.map(|v| Used::Set(p.name.clone(), v))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use figcraft_doc::proto::{
|
|||
Action, Device, DeviceColor, Direction, Easing, KeyMods, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
|
||||
};
|
||||
use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint};
|
||||
use figcraft_engine::widgets::Part;
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -311,8 +312,16 @@ pub struct Presenter {
|
|||
uses_pads: bool,
|
||||
/// The controller buttons held at the last poll (bit i: standard layout index i).
|
||||
pads_held: u32,
|
||||
/// Clicks on widgets' clickable parts (widget, target), for [`Presenter::click_widgets`].
|
||||
widget_clicks: Vec<(NodeId, String)>,
|
||||
/// What happened to widgets (a `widget.*` command and its parameters), for
|
||||
/// [`Presenter::run_widgets`].
|
||||
widget_events: Vec<(&'static str, Value)>,
|
||||
/// The widgets' pending timers on the player's clock: (widget, when it is due).
|
||||
widget_timers: Vec<(NodeId, f64)>,
|
||||
/// The widget part shown in its hover style, and the tooltip of the part under the pointer.
|
||||
widget_hover: Option<NodeId>,
|
||||
widget_tip: Option<String>,
|
||||
/// The widget text being typed into (its layer), and whether typing just started.
|
||||
widget_input: Option<(NodeId, bool)>,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
|
|
@ -372,7 +381,11 @@ impl Presenter {
|
|||
pads: Vec::new(),
|
||||
uses_pads: false,
|
||||
pads_held: 0,
|
||||
widget_clicks: Vec::new(),
|
||||
widget_events: Vec::new(),
|
||||
widget_timers: Vec::new(),
|
||||
widget_hover: None,
|
||||
widget_tip: None,
|
||||
widget_input: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -452,6 +465,7 @@ impl Presenter {
|
|||
let copy = Document::clone(doc);
|
||||
self.original = Some(copy.clone());
|
||||
self.design = Some(copy);
|
||||
self.forget_widgets();
|
||||
self.relayout();
|
||||
if first {
|
||||
self.arrived = now;
|
||||
|
|
@ -490,6 +504,10 @@ impl Presenter {
|
|||
}
|
||||
figcraft_engine::proto_run::play_interactions(d);
|
||||
raise_fixed(d);
|
||||
// The widget part under the pointer shows its hover style.
|
||||
if let Some(look) = self.widget_hover.and_then(|part| figcraft_engine::widgets::hovered(d, part)) {
|
||||
*d = look;
|
||||
}
|
||||
}
|
||||
self.uses_pads = d.as_ref().is_some_and(figcraft_doc::proto::uses_gamepad);
|
||||
self.doc = d;
|
||||
|
|
@ -673,10 +691,18 @@ impl Presenter {
|
|||
self.anim = None;
|
||||
self.temp = None;
|
||||
self.design = self.original.clone();
|
||||
self.forget_widgets();
|
||||
self.relayout();
|
||||
self.arrive(now);
|
||||
}
|
||||
|
||||
/// The prototype's copy of the file starts over: its widgets' timers, hover and typing go.
|
||||
fn forget_widgets(&mut self) {
|
||||
self.widget_events.clear();
|
||||
self.widget_timers.clear();
|
||||
(self.widget_hover, self.widget_tip, self.widget_input) = (None, None, None);
|
||||
}
|
||||
|
||||
fn revert(&mut self, now: f64) {
|
||||
let Some(t) = self.temp.take() else { return };
|
||||
self.begin(t.transition.reversed(), t.dur, t.easing, now);
|
||||
|
|
@ -739,15 +765,24 @@ impl Presenter {
|
|||
did
|
||||
}
|
||||
|
||||
/// Run the widget clicks the player took: each calls its plugin back on the player's copy (the
|
||||
/// prototype's own state of the widget), as on the canvas but without touching the file.
|
||||
/// Returns what went wrong, for a toast.
|
||||
pub fn click_widgets(&mut self, s: &figcraft_engine::Session) -> Option<String> {
|
||||
/// Run what happened to widgets in the player (clicks, text typed into them) and their timers
|
||||
/// that are due at `now` on the player's clock: each calls its plugin back on the player's
|
||||
/// copy (the prototype's own state of the widget), as on the canvas but without touching the
|
||||
/// file. Returns what went wrong, for a toast.
|
||||
pub fn run_widgets(&mut self, s: &figcraft_engine::Session, now: f64) -> Option<String> {
|
||||
let due = self.widget_timers.iter().filter(|t| t.1 <= now).map(|t| ("widget.fireTimer", json!({"id": t.0})));
|
||||
self.widget_events.extend(due.collect::<Vec<_>>());
|
||||
self.widget_timers.retain(|t| t.1 > now);
|
||||
let mut error = None;
|
||||
for (id, target) in std::mem::take(&mut self.widget_clicks) {
|
||||
for (cmd, params) in std::mem::take(&mut self.widget_events) {
|
||||
let Some(doc) = self.design.clone() else { break };
|
||||
match figcraft_engine::widgets::click_on(s, doc, id, &target) {
|
||||
Ok((d, _)) => {
|
||||
match figcraft_engine::widgets::run_on(s, doc, cmd, ¶ms) {
|
||||
Ok((d, out)) => {
|
||||
// The callback started or stopped the widget's timer: the player keeps the clock.
|
||||
if let (Some(w), true) = (out["id"].as_u64().map(NodeId), out["timerChanged"] == json!(true)) {
|
||||
self.widget_timers.retain(|t| t.0 != w);
|
||||
self.widget_timers.extend(out["timer"].as_f64().map(|ms| (w, now + ms / 1000.0)));
|
||||
}
|
||||
self.design = Some(d);
|
||||
self.relayout();
|
||||
}
|
||||
|
|
@ -1631,9 +1666,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
let now = ctx.input(|i| i.time);
|
||||
let Some(mut pres) = app.ui.present.take() else { return };
|
||||
if let Ok(ds) = app.session.ds() {
|
||||
pres.sync(&ds.doc, now);
|
||||
pres.sync(ds.without_widget_hover(), now);
|
||||
}
|
||||
poll_pads(app, &mut pres, &ctx);
|
||||
let in_widget_text = pres.widget_input.is_some();
|
||||
let mut action = None;
|
||||
// Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments.
|
||||
let typing = ctx.egui_wants_keyboard_input();
|
||||
|
|
@ -1679,7 +1715,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
if let Some(BarAction::Responsive(on)) = action {
|
||||
app.run("proto.settings", json!({"responsive": on}));
|
||||
}
|
||||
if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// Escape while typing into a widget's text ends the typing, not the presentation.
|
||||
if action == Some(BarAction::Close) || (!in_widget_text && ctx.input(|i| i.key_pressed(egui::Key::Escape))) {
|
||||
return;
|
||||
}
|
||||
app.ui.present = Some(pres);
|
||||
|
|
@ -1709,7 +1746,7 @@ fn finish(app: &mut FigApp, p: &mut Presenter) {
|
|||
for u in p.urls.drain(..) {
|
||||
app.services.open_url(&u);
|
||||
}
|
||||
if let Some(e) = p.click_widgets(&app.session) {
|
||||
if let Some(e) = p.run_widgets(&app.session, p.clock) {
|
||||
app.toast(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -1787,7 +1824,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) {
|
|||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let (doc, selected) = (ds.doc.clone(), selected_frame(ds));
|
||||
let (doc, selected) = (ds.without_widget_hover().clone(), selected_frame(ds));
|
||||
pane.player.sync(&doc, now);
|
||||
// Deselecting and coming back to the same frame keeps the preview where it got to.
|
||||
if selected.is_some() && selected != pane.selected {
|
||||
|
|
@ -2006,6 +2043,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
|
|||
painter.rect_filled(l.screen, CornerRadius::ZERO, bg);
|
||||
}
|
||||
draw(p, &painter, &ctx, &l, now);
|
||||
widget_extras(p, ui, &resp, &l);
|
||||
if p.view.inspect && !chrome.pane {
|
||||
inspect_hover(p, &painter, &ctx, &l, area);
|
||||
}
|
||||
|
|
@ -2023,9 +2061,45 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
|
|||
if let Some(due) = p.waiting.iter().map(|w| w.due - now).reduce(f64::min) {
|
||||
crate::repaint_in(&ctx, due.clamp(0.0, 60.0));
|
||||
}
|
||||
// Widget timers run on the player's clock: wake for the next one (it fires after the frame).
|
||||
if let Some(due) = p.widget_timers.iter().map(|t| t.1).reduce(f64::min) {
|
||||
crate::repaint_in(&ctx, due - now);
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// What widgets show over the player: the tooltip of the part under the pointer, and the field
|
||||
/// over a text being typed into (Enter or a click elsewhere commits it to the widget's plugin,
|
||||
/// Escape leaves it as it was).
|
||||
// shortcut: one line, and an emptied text is left as it was (the shared rename field); the
|
||||
// canvas editor takes several lines. Upgrade if prototypes need multiline widget text.
|
||||
fn widget_extras(p: &mut Presenter, ui: &mut Ui, area: &egui::Response, l: &Layout) {
|
||||
if let Some(tip) = p.widget_tip.as_deref().filter(|_| area.hovered()) {
|
||||
area.clone().on_hover_text_at_pointer(tip);
|
||||
}
|
||||
let Some((layer, start)) = p.widget_input else { return };
|
||||
// The field opens the frame after the click that asked for it: a field takes a click that
|
||||
// ends elsewhere in its first frame as one that takes the keyboard away from it.
|
||||
if start && ui.input(|i| i.pointer.any_released() || i.pointer.any_down()) {
|
||||
ui.ctx().request_repaint();
|
||||
return;
|
||||
}
|
||||
let (_, place, _) = l.active(p.current);
|
||||
let found =
|
||||
l.disp.world_bounds(layer).zip(l.disp.node(layer).and_then(|n| n.kind.text())).zip(figcraft_engine::widgets::input_of(&l.disp, layer));
|
||||
let Some(((bounds, text), input)) = found else {
|
||||
p.widget_input = None;
|
||||
return;
|
||||
};
|
||||
let rect = place.rect(bounds);
|
||||
let font = regular((rect.height() * 0.7).clamp(9.0, 32.0));
|
||||
let (typed, open) = widgets::rename_field(ui, Id::new(("present_widget_input", layer)), &text.characters, rect, font, start);
|
||||
if let Some(typed) = typed {
|
||||
p.widget_events.push(("widget.setText", json!({"id": layer, "input": input.name, "text": typed})));
|
||||
}
|
||||
p.widget_input = open.then_some((layer, false));
|
||||
}
|
||||
|
||||
/// Inspecting: the layer under the pointer becomes the inspected one; it is outlined.
|
||||
fn inspect_hover(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, area: Rect) {
|
||||
let (root, place, clip) = l.active(p.current);
|
||||
|
|
@ -2331,6 +2405,23 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
|
|||
if !hits.is_empty() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
// Widgets: the part under the pointer shows its hover style and its tooltip, with a hand over
|
||||
// what can be clicked and an I-beam over a text to type into. Their plugins aren't called.
|
||||
{
|
||||
use figcraft_engine::widgets::{hover_at, part_at, tooltip_at};
|
||||
let under = pos.filter(|_| over_area).and_then(|q| Some((layer_at(doc, root, &place, q)?, place.page(q))));
|
||||
match under.and_then(|(hit, at)| part_at(doc, hit, at)) {
|
||||
Some((_, Part::Target(_))) => ctx.set_cursor_icon(egui::CursorIcon::PointingHand),
|
||||
Some((_, Part::Input(_))) => ctx.set_cursor_icon(egui::CursorIcon::Text),
|
||||
None => {}
|
||||
}
|
||||
p.widget_tip = under.and_then(|(hit, at)| tooltip_at(doc, hit, at));
|
||||
let hover = under.and_then(|(hit, at)| hover_at(doc, hit, at));
|
||||
if hover != p.widget_hover {
|
||||
p.widget_hover = hover;
|
||||
p.relayout();
|
||||
}
|
||||
}
|
||||
// First (top-most) hit that has a matching trigger.
|
||||
let pick = |want: &dyn Fn(&Trigger) -> bool| -> Option<(NodeId, usize, Rect)> {
|
||||
hits.iter().find_map(|(id, r)| find_trigger(doc, *id, want).map(|k| (*id, k, *r)))
|
||||
|
|
@ -2461,12 +2552,18 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
|
|||
{
|
||||
return;
|
||||
}
|
||||
// A widget's clickable part calls its plugin back (after the frame, with the session).
|
||||
if let Some(q) = pos
|
||||
&& let Some(t) = layer_at(doc, root, &place, q).and_then(|hit| figcraft_engine::widgets::target_at(doc, hit, place.page(q)))
|
||||
{
|
||||
p.widget_clicks.push(t);
|
||||
return;
|
||||
// A widget's clickable part calls its plugin back (after the frame, with the session);
|
||||
// a text of it that can be edited is typed into in place.
|
||||
match pos.and_then(|q| layer_at(doc, root, &place, q).and_then(|hit| figcraft_engine::widgets::part_at(doc, hit, place.page(q)))) {
|
||||
Some((w, Part::Target(target))) => {
|
||||
p.widget_events.push(("widget.click", json!({"id": w, "target": target})));
|
||||
return;
|
||||
}
|
||||
Some((_, Part::Input(text))) => {
|
||||
p.widget_input = Some((text, true));
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// Clicking where nothing reacts flashes the hotspots.
|
||||
if p.view.hotspot_hints {
|
||||
|
|
@ -2675,6 +2772,9 @@ mod behaviour_tests;
|
|||
#[cfg(test)]
|
||||
#[path = "present_tail_tests.rs"]
|
||||
mod tail_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "present_widgets_tests.rs"]
|
||||
mod widgets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "present_delay_tests.rs"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
//! Widgets in a presented prototype (plugin API 4): the part under the pointer shows its hover
|
||||
//! style, a timer runs on the player's clock, and text is typed into a field over the widget's
|
||||
//! text, all on the prototype's own copy: the file, its history and its timers stay as they are.
|
||||
|
||||
use egui::{Event, Key, PointerButton, Pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::present_tests::Ui as Player;
|
||||
use crate::widget_ui_tests::{NOTE, fill, key, part, session, state};
|
||||
|
||||
#[test]
|
||||
fn a_presented_widget_takes_hover_typing_and_timers_on_its_own_copy() {
|
||||
let mut s = session();
|
||||
let a = NodeId(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300, "name": "A"})).unwrap()["id"].as_u64().unwrap());
|
||||
let w = NodeId(s.execute("widget.insert", &json!({"plugin": NOTE, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("node.reparent", &json!({"ids": [w], "parent": a})).unwrap();
|
||||
let mut u = Player::new(s);
|
||||
u.run("proto.present", json!({"start": a}));
|
||||
u.frames(2, Vec::new);
|
||||
let shown = |u: &Player, name: &str| {
|
||||
let p = u.app.ui.present.as_ref().unwrap();
|
||||
let (doc, (_, place, _)) = (p.doc.as_ref().unwrap(), p.shown.unwrap());
|
||||
(part(doc, w, name), place.rect(doc.world_bounds(part(doc, w, name)).unwrap()))
|
||||
};
|
||||
let player = |u: &Player| state(u.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(), w);
|
||||
let press = |u: &mut Player, name: &str| {
|
||||
let at: Pos2 = shown(u, name).1.center();
|
||||
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
u.frame(vec![Event::PointerMoved(at)]);
|
||||
u.frame(vec![b(true)]);
|
||||
u.frame(vec![b(false)]);
|
||||
u.frames(2, Vec::new);
|
||||
};
|
||||
// Hover: the part shows its style in the prototype; nobody is called.
|
||||
let (go, at) = shown(&u, "Go");
|
||||
u.frame(vec![Event::PointerMoved(at.center())]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(fill(u.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(), go), "#00FF00");
|
||||
assert_eq!(player(&u), r#"{"n":1}"#);
|
||||
u.frame(vec![Event::PointerMoved(at.center() + egui::vec2(0.0, 200.0))]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(fill(u.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(), go), "#0D99FF");
|
||||
// A timer runs on the player's clock: asked for in 100 ms, it fires a few frames later.
|
||||
press(&mut u, "Soon");
|
||||
assert_eq!(player(&u), r#"{"n":5}"#);
|
||||
u.frames(12, Vec::new);
|
||||
assert_eq!(player(&u), r#"{"n":3}"#, "the timer fired in the prototype");
|
||||
// Typing: a click on the text opens a field over it; Enter commits to the plugin.
|
||||
press(&mut u, "Title");
|
||||
u.frame(vec![Event::Text("Hey".into())]);
|
||||
u.frame(key(Key::Enter));
|
||||
u.frames(3, Vec::new);
|
||||
assert_eq!(player(&u), r#"{"n":4}"#, "the typed text reached the plugin");
|
||||
// The file, its history and its timers are as they were, and restarting starts over.
|
||||
let ds = u.app.session.ds().unwrap();
|
||||
assert_eq!((state(&ds.doc, w), ds.history.undo.len()), (r#"{"n":1}"#.to_string(), 3));
|
||||
assert!(figcraft_engine::widgets::next_timer(&u.app.session).is_none() && u.app.ui.toast.is_none(), "{:?}", u.app.ui.toast);
|
||||
press(&mut u, "Soon");
|
||||
u.frame(key(Key::R));
|
||||
u.frames(12, Vec::new);
|
||||
assert_eq!(player(&u), r#"{"n":1}"#, "restarting forgets the prototype's timers");
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
//! Session restore in the interface (the engine's [`figcraft_engine::restore`] has the tabs): each
|
||||
//! window's place on screen, state and panels are kept with the preferences as they change, and
|
||||
//! put back when `session.restore` opened the tabs again. A window comes back where it was only
|
||||
//! where a screen still shows it ([`place`]).
|
||||
|
||||
use egui::{Context, Pos2, Vec2, ViewportCommand, ViewportId};
|
||||
use figcraft_engine::restore::{Placed, SavedSession, SavedTab, SavedWindow};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{FigApp, LeftTab, RightTab, UiState};
|
||||
|
||||
/// How much of a window's top strip (where it is grabbed to move it) a screen must show for the
|
||||
/// window to count as on it: this high, and this wide (or the whole window, when narrower).
|
||||
const STRIP: f32 = 32.0;
|
||||
const HOLD: f32 = 120.0;
|
||||
|
||||
/// The seconds at most between two writes of a session that keeps changing, and how long after
|
||||
/// windows were put back a crash still counts as one during the restore.
|
||||
const WRITE_EVERY: f64 = 1.0;
|
||||
const SETTLED_AFTER: f64 = 1.0;
|
||||
|
||||
/// Where a window is: its outer position and inner size while it is neither maximized nor full
|
||||
/// screen (screen points), and those states.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Geometry {
|
||||
pub pos: Option<Pos2>,
|
||||
pub size: Vec2,
|
||||
pub maximized: bool,
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
impl Default for Geometry {
|
||||
fn default() -> Self {
|
||||
Self { pos: None, size: Vec2::new(1280.0, 800.0), maximized: false, fullscreen: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// Follow the window drawing now. A minimized window says nothing about where it is.
|
||||
pub(crate) fn track(&mut self, ctx: &Context) {
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
self.maximized = v.maximized.unwrap_or(self.maximized);
|
||||
self.fullscreen = v.fullscreen.unwrap_or(self.fullscreen);
|
||||
if v.minimized == Some(true) || self.maximized || self.fullscreen {
|
||||
return;
|
||||
}
|
||||
self.pos = v.outer_rect.map(|r| r.min).or(self.pos);
|
||||
self.size = v.inner_rect.map_or(self.size, |r| r.size());
|
||||
});
|
||||
}
|
||||
|
||||
fn rect(&self) -> Option<[f32; 4]> {
|
||||
self.pos.map(|p| [p.x, p.y, self.size.x, self.size.y])
|
||||
}
|
||||
|
||||
/// As saved, fitted to the screens there are now.
|
||||
fn of(saved: &SavedWindow, monitors: &[[f32; 4]]) -> Self {
|
||||
let rect = saved.rect.map(|r| place(r, saved.monitor, monitors));
|
||||
Self {
|
||||
pos: rect.map(|r| Pos2::new(r[0], r[1])),
|
||||
size: rect.map_or(Self::default().size, |r| Vec2::new(r[2], r[3])),
|
||||
maximized: saved.maximized,
|
||||
fullscreen: saved.fullscreen,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn json(&self, monitors: &[[f32; 4]]) -> Value {
|
||||
json!({"rect": self.rect(), "maximized": self.maximized, "fullscreen": self.fullscreen, "monitor": self.rect().and_then(|r| monitor_of(r, monitors))})
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of `[x, y, width, height]` rectangles `a` and `b` share: width and height.
|
||||
fn shared(a: [f32; 4], b: [f32; 4]) -> (f32, f32) {
|
||||
let side = |a0: f32, al: f32, b0: f32, bl: f32| ((a0 + al).min(b0 + bl) - a0.max(b0)).max(0.0);
|
||||
(side(a[0], a[2], b[0], b[2]), side(a[1], a[3], b[1], b[3]))
|
||||
}
|
||||
|
||||
/// The screen showing most of `rect`.
|
||||
pub fn monitor_of(rect: [f32; 4], monitors: &[[f32; 4]]) -> Option<[f32; 4]> {
|
||||
let area = |m: &[f32; 4]| {
|
||||
let (w, h) = shared(rect, *m);
|
||||
w * h
|
||||
};
|
||||
monitors.iter().copied().filter(|m| area(m) > 0.0).max_by(|a, b| area(a).total_cmp(&area(b)))
|
||||
}
|
||||
|
||||
/// Where a window saved at `rect` (`[x, y, width, height]`) on screen `monitor` opens on the
|
||||
/// screens there are now: where it was while a screen shows enough of its top strip to grab;
|
||||
/// otherwise moved onto its screen if that is still there, else the nearest one, and no larger
|
||||
/// than it. With no screens known it stays as saved.
|
||||
pub fn place(rect: [f32; 4], monitor: Option<[f32; 4]>, monitors: &[[f32; 4]]) -> [f32; 4] {
|
||||
let [x, y, w, h] = rect;
|
||||
let sane = |m: &&[f32; 4]| m.iter().all(|n| n.is_finite()) && m[2] >= 1.0 && m[3] >= 1.0;
|
||||
let monitors: Vec<[f32; 4]> = monitors.iter().filter(sane).copied().collect();
|
||||
if monitors.is_empty() || !rect.iter().all(|n| n.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let grabbable = |m: &[f32; 4]| {
|
||||
let (sw, sh) = shared([x, y, w, STRIP.min(h)], *m);
|
||||
sw >= HOLD.min(w) && sh >= (STRIP / 2.0).min(h)
|
||||
};
|
||||
if monitors.iter().any(grabbable) {
|
||||
return rect;
|
||||
}
|
||||
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
|
||||
let away = |m: &[f32; 4]| (m[0] + m[2] / 2.0 - cx).hypot(m[1] + m[3] / 2.0 - cy);
|
||||
let same = |m: &&[f32; 4]| monitor.is_some_and(|s| m.iter().zip(s).all(|(a, b)| (a - b).abs() < 1.0));
|
||||
let Some(m) = monitors.iter().find(same).or_else(|| monitors.iter().min_by(|a, b| away(a).total_cmp(&away(b)))) else { return rect };
|
||||
let (w, h) = (w.min(m[2]), h.min(m[3]));
|
||||
[x.clamp(m[0], m[0] + m[2] - w), y.clamp(m[1], m[1] + m[3] - h), w, h]
|
||||
}
|
||||
|
||||
fn left_id(t: LeftTab) -> &'static str {
|
||||
match t {
|
||||
LeftTab::File => "file",
|
||||
LeftTab::Assets => "assets",
|
||||
}
|
||||
}
|
||||
|
||||
fn right_id(t: RightTab) -> &'static str {
|
||||
match t {
|
||||
RightTab::Design => "design",
|
||||
RightTab::Prototype => "prototype",
|
||||
RightTab::Inspect => "inspect",
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a saved window's panels back into `ui`.
|
||||
fn panels(ui: &mut UiState, saved: &SavedWindow) {
|
||||
ui.left_tab = if saved.left_tab == "assets" { LeftTab::Assets } else { LeftTab::File };
|
||||
ui.right_tab = match saved.right_tab.as_str() {
|
||||
"prototype" => RightTab::Prototype,
|
||||
"inspect" => RightTab::Inspect,
|
||||
_ => RightTab::Design,
|
||||
};
|
||||
ui.left_collapsed = saved.left_collapsed;
|
||||
if let Some([left, right]) = saved.panel_widths {
|
||||
let within = |w: f32, range: std::ops::RangeInclusive<f32>| w.clamp(*range.start(), *range.end());
|
||||
(ui.left_width, ui.right_width) = (within(left, crate::LEFT_PANEL), within(right, crate::RIGHT_PANEL));
|
||||
}
|
||||
}
|
||||
|
||||
/// The windows and tabs open now (the main window draws: `app.ui` is its own).
|
||||
pub(crate) fn snapshot(app: &FigApp) -> SavedSession {
|
||||
let wins = &app.windows;
|
||||
// Online files still on their way back stay tabs of their windows (of the main window, when
|
||||
// theirs is gone).
|
||||
let joining = |id: u64| app.session.restore.joining().filter(move |(k, _)| wins.restored(*k) == id).map(|(_, tab)| tab.clone());
|
||||
let window = |id: u64, geo: &Geometry, ui: &UiState, active: Option<u64>| {
|
||||
let open = app.session.docs.iter().filter(|d| wins.of(d.uid) == id).filter_map(|d| Some((d.uid, app.session.saved_tab(d)?)));
|
||||
let (uids, mut tabs): (Vec<u64>, Vec<SavedTab>) = open.unzip();
|
||||
tabs.extend(joining(id));
|
||||
tabs.truncate(figcraft_engine::restore::MAX_TABS);
|
||||
SavedWindow {
|
||||
rect: geo.rect(),
|
||||
maximized: geo.maximized,
|
||||
fullscreen: geo.fullscreen,
|
||||
monitor: geo.rect().and_then(|r| monitor_of(r, &app.monitors)),
|
||||
front: uids.iter().position(|u| Some(*u) == active).unwrap_or(0),
|
||||
tabs,
|
||||
left_tab: left_id(ui.left_tab).into(),
|
||||
right_tab: right_id(ui.right_tab).into(),
|
||||
left_collapsed: ui.left_collapsed,
|
||||
panel_widths: Some([ui.left_width, ui.right_width]),
|
||||
}
|
||||
};
|
||||
let main = window(0, &wins.main, &app.ui, app.session.ds().ok().map(|d| d.uid));
|
||||
let others = wins.list.iter().map(|w| window(w.id, &w.geo, &w.pane.ui, w.active)).filter(|w| !w.tabs.is_empty());
|
||||
SavedSession { windows: std::iter::once(main).chain(others).take(figcraft_engine::restore::MAX_WINDOWS).collect() }
|
||||
}
|
||||
|
||||
/// After the main window drew: keep the session with the preferences when it changed (at once,
|
||||
/// then at most every [`WRITE_EVERY`] seconds while it keeps changing; nothing while it doesn't),
|
||||
/// and tell the app once a restore has settled.
|
||||
pub(crate) fn keep(app: &mut FigApp, ctx: &Context) {
|
||||
// The browser keeps no preferences: nothing to keep a session in.
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if let Some(at) = app.restore.settles {
|
||||
if now >= at {
|
||||
app.restore.settles = None;
|
||||
app.services.restoring(false);
|
||||
} else {
|
||||
crate::repaint_in(ctx, at - now);
|
||||
}
|
||||
}
|
||||
// A window on its way out isn't how the app was left.
|
||||
if app.ui.quit || app.session.restore.placing.is_some() {
|
||||
return;
|
||||
}
|
||||
let session = snapshot(app);
|
||||
if session != app.session.prefs.session {
|
||||
app.session.prefs.session = session;
|
||||
app.restore.unsaved = true;
|
||||
}
|
||||
if !app.restore.unsaved {
|
||||
return;
|
||||
}
|
||||
// Not while something is dragged (a panel's edge): the frame it is let go in writes.
|
||||
if ctx.input(|i| i.pointer.any_down()) {
|
||||
return;
|
||||
}
|
||||
if now < app.restore.next_write {
|
||||
crate::repaint_in(ctx, app.restore.next_write - now);
|
||||
return;
|
||||
}
|
||||
app.restore.unsaved = false;
|
||||
app.restore.next_write = now + WRITE_EVERY;
|
||||
app.services.save_prefs(&app.session.prefs);
|
||||
}
|
||||
|
||||
/// What the interface remembers between frames about keeping and restoring the session.
|
||||
#[derive(Default)]
|
||||
pub struct Restore {
|
||||
/// The saved session changed and isn't written yet; when (UI clock) it may be written next.
|
||||
unsaved: bool,
|
||||
next_write: f64,
|
||||
/// When (UI clock) the restore under way counts as done (the app's crash guard goes then).
|
||||
settles: Option<f64>,
|
||||
}
|
||||
|
||||
impl FigApp {
|
||||
/// Restore the session on offer (the home screen's button): the app's crash guard is up
|
||||
/// first, so a crash on the way starts the next run without restoring.
|
||||
pub fn restore_last(&mut self) {
|
||||
self.services.restoring(true);
|
||||
self.run("session.restore", json!({}));
|
||||
self.ui.home = !self.has_files();
|
||||
}
|
||||
|
||||
/// The last run's session is on offer (this run started without it).
|
||||
pub fn restore_offered(&self) -> bool {
|
||||
self.session.restore.last.as_ref().is_some_and(|s| s.tabs() > 0)
|
||||
}
|
||||
|
||||
/// The saved session and what restoring knows (`ui.inspect`'s `session`).
|
||||
pub(crate) fn inspect_session(&self) -> Value {
|
||||
json!({
|
||||
"saved": self.session.prefs.session,
|
||||
"restore": self.session.prefs.restore_session,
|
||||
"offered": self.restore_offered(),
|
||||
"joining": self.session.restore.joining().count(),
|
||||
"monitors": self.monitors,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The main window's frame begins: put the windows `session.restore` opened the tabs of where
|
||||
/// they were, and restored online files that arrived since into theirs.
|
||||
pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
||||
if let Some((placed, missing)) = app.session.restore.placing.take() {
|
||||
apply(app, ctx, placed);
|
||||
if missing > 0 {
|
||||
// With what is waiting to be said (documents recovered after a crash), not instead.
|
||||
let said = app
|
||||
.ui
|
||||
.toast
|
||||
.take()
|
||||
.filter(|(_, shown)| shown.is_none())
|
||||
.map(|(m, _)| {
|
||||
format!(
|
||||
"{m}
|
||||
"
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
app.toast(said + &crate::i18n::trn("{n} files couldn't be reopened", missing, &[]));
|
||||
}
|
||||
app.restore.settles = Some(ctx.input(|i| i.time) + SETTLED_AFTER);
|
||||
}
|
||||
if app.session.restore.joining().next().is_none() {
|
||||
return;
|
||||
}
|
||||
for a in app.session.restore_arrivals() {
|
||||
let id = app.windows.restored(a.window);
|
||||
crate::windows::adopt(app, id, &[a.uid], a.front.then_some(a.uid));
|
||||
ctx.request_repaint_of(crate::windows::viewport(id));
|
||||
}
|
||||
// One that couldn't be joined says so.
|
||||
if let Some(m) = app.session.message.take() {
|
||||
app.toast(m);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(app: &mut FigApp, ctx: &Context, placed: Vec<Placed>) {
|
||||
// One window (the browser, a screen reader running): every tab stays here.
|
||||
let one = ctx.embed_viewports() || crate::a11y::active(ctx);
|
||||
let mut any = false;
|
||||
// A file opened since (one the app was started with) stays in front.
|
||||
let active = app.session.ds().ok().map(|d| d.uid);
|
||||
let keep_front = !placed.iter().any(|p| active.is_some_and(|a| p.docs.contains(&a)));
|
||||
for w in &mut app.windows.list {
|
||||
w.restored = None;
|
||||
}
|
||||
for (k, p) in placed.iter().enumerate() {
|
||||
any |= !p.docs.is_empty();
|
||||
let geo = Geometry::of(&p.saved, &app.monitors);
|
||||
if k == 0 {
|
||||
let (root, now) = (ViewportId::ROOT, app.windows.main);
|
||||
let send = |cmd| ctx.send_viewport_cmd_to(root, cmd);
|
||||
// A window is placed while it is neither maximized nor full screen: it leaves a
|
||||
// state first and enters one last, and one it stays in keeps the place under it.
|
||||
if now.fullscreen && !geo.fullscreen {
|
||||
send(ViewportCommand::Fullscreen(false));
|
||||
}
|
||||
if now.maximized && !geo.maximized {
|
||||
send(ViewportCommand::Maximized(false));
|
||||
}
|
||||
let stays = (now.maximized && geo.maximized) || (now.fullscreen && geo.fullscreen);
|
||||
if let Some(pos) = geo.pos.filter(|_| !stays) {
|
||||
send(ViewportCommand::OuterPosition(pos));
|
||||
send(ViewportCommand::InnerSize(geo.size));
|
||||
}
|
||||
if geo.maximized && !now.maximized {
|
||||
send(ViewportCommand::Maximized(true));
|
||||
}
|
||||
if geo.fullscreen && !now.fullscreen {
|
||||
send(ViewportCommand::Fullscreen(true));
|
||||
}
|
||||
app.windows.main = Geometry { pos: geo.pos.or(app.windows.main.pos), ..geo };
|
||||
panels(&mut app.ui, &p.saved);
|
||||
app.session.prefs.panel_widths = (app.ui.left_width, app.ui.right_width);
|
||||
crate::windows::adopt(app, 0, &p.docs, p.front.filter(|_| !keep_front));
|
||||
} else if one || (p.docs.is_empty() && !app.session.restore.joining().any(|(w, _)| w == k)) {
|
||||
// Its tabs (none open yet) are the main window's.
|
||||
crate::windows::adopt(app, 0, &p.docs, None);
|
||||
} else {
|
||||
let mut ui = UiState { title_bar: app.ui.title_bar, ..UiState::default() };
|
||||
panels(&mut ui, &p.saved);
|
||||
let id = crate::windows::push(app, ui, geo);
|
||||
if let Some(w) = app.windows.list.last_mut() {
|
||||
w.restored = Some(k);
|
||||
}
|
||||
crate::windows::adopt(app, id, &p.docs, p.front);
|
||||
}
|
||||
}
|
||||
app.windows.focused = 0;
|
||||
if any {
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
//! Session restore in the interface, driven through egui headless: a window comes back where it
|
||||
//! was only where a screen shows it; the windows and tabs open now are kept with the preferences
|
||||
//! as they change (at once, then at most once a second, never while nothing changes) and come
|
||||
//! back in their windows with their panels, place and state; a window off every screen moves
|
||||
//! onto one; a build with one window keeps every tab; files that are gone get one message; a
|
||||
//! file opened since stays in front; the home screen offers the last session after a run that
|
||||
//! started without it, with the app's crash guard up while it is restored.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Pos2, RawInput, Rect, ViewportId, ViewportInfo, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::restore::{SavedSession, SavedTab, SavedWindow};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::restore::{monitor_of, place};
|
||||
use crate::{FigApp, RightTab, Services};
|
||||
|
||||
const A: [f32; 4] = [0.0, 0.0, 1920.0, 1080.0];
|
||||
const B: [f32; 4] = [1920.0, 0.0, 1280.0, 1024.0];
|
||||
|
||||
type Screen = [f32; 4];
|
||||
/// What the case is, the saved rectangle, the screen it was on, the screens now, where it opens.
|
||||
type Case = (&'static str, [f32; 4], Option<Screen>, &'static [Screen], [f32; 4]);
|
||||
|
||||
#[test]
|
||||
fn a_window_comes_back_where_a_screen_shows_it() {
|
||||
let nan = f32::NAN;
|
||||
let table: &[Case] = &[
|
||||
("on its screen", [100.0, 80.0, 1200.0, 800.0], Some(A), &[A, B], [100.0, 80.0, 1200.0, 800.0]),
|
||||
("across two screens", [1500.0, 50.0, 900.0, 700.0], Some(A), &[A, B], [1500.0, 50.0, 900.0, 700.0]),
|
||||
("on the second screen", [2000.0, 100.0, 1000.0, 700.0], Some(B), &[A, B], [2000.0, 100.0, 1000.0, 700.0]),
|
||||
("its screen is gone", [2000.0, 100.0, 1000.0, 700.0], Some(B), &[A], [920.0, 100.0, 1000.0, 700.0]),
|
||||
("far off every screen", [-5000.0, -5000.0, 800.0, 600.0], None, &[A, B], [0.0, 0.0, 800.0, 600.0]),
|
||||
("only a sliver shows", [1900.0, 100.0, 800.0, 600.0], Some(A), &[A], [1120.0, 100.0, 800.0, 600.0]),
|
||||
("its top strip is above the screen", [300.0, -200.0, 800.0, 600.0], Some(A), &[A], [300.0, 0.0, 800.0, 600.0]),
|
||||
("its top strip is below the screen", [300.0, 1075.0, 800.0, 600.0], Some(A), &[A], [300.0, 480.0, 800.0, 600.0]),
|
||||
("the bottom hangs off: it can still be grabbed", [300.0, 900.0, 800.0, 600.0], Some(A), &[A], [300.0, 900.0, 800.0, 600.0]),
|
||||
("larger than the screen", [5000.0, 5000.0, 4000.0, 3000.0], None, &[A], [0.0, 0.0, 1920.0, 1080.0]),
|
||||
("off screen: back to its own screen, not the nearest", [-900.0, 100.0, 800.0, 600.0], Some(B), &[A, B], [1920.0, 100.0, 800.0, 600.0]),
|
||||
(
|
||||
"off screen: the nearest when its own is gone",
|
||||
[3300.0, 100.0, 800.0, 600.0],
|
||||
Some([3200.0, 0.0, 1000.0, 1000.0]),
|
||||
&[A, B],
|
||||
[2400.0, 100.0, 800.0, 600.0],
|
||||
),
|
||||
("narrower than the strip to grab", [1850.0, 100.0, 100.0, 100.0], Some(A), &[A], [1820.0, 100.0, 100.0, 100.0]),
|
||||
("no screens known", [-5000.0, 0.0, 800.0, 600.0], None, &[], [-5000.0, 0.0, 800.0, 600.0]),
|
||||
(
|
||||
"screens that aren't",
|
||||
[-5000.0, 0.0, 800.0, 600.0],
|
||||
None,
|
||||
&[[0.0, 0.0, 0.0, 0.0], [f32::NAN, 0.0, 100.0, 100.0]],
|
||||
[-5000.0, 0.0, 800.0, 600.0],
|
||||
),
|
||||
];
|
||||
for (what, rect, monitor, monitors, want) in table {
|
||||
assert_eq!(place(*rect, *monitor, monitors), *want, "{what}");
|
||||
// Where it opens, a screen shows its top strip.
|
||||
if let Some(m) = monitor_of(*want, monitors) {
|
||||
assert!(want[1] >= m[1] - 0.5 && want[1] < m[1] + m[3], "{what}");
|
||||
}
|
||||
}
|
||||
assert!(place([nan, 0.0, 800.0, 600.0], None, &[A])[0].is_nan(), "never a panic on a rectangle that isn't one");
|
||||
assert_eq!(monitor_of([1500.0, 50.0, 900.0, 700.0], &[A, B]), Some(B), "the screen showing most of it");
|
||||
assert_eq!(monitor_of([-5000.0, 0.0, 800.0, 600.0], &[A, B]), None);
|
||||
}
|
||||
|
||||
/// What the app's services were asked: the sessions kept, and the restoring mark put up or down.
|
||||
#[derive(Default)]
|
||||
struct Asked {
|
||||
kept: Vec<SavedSession>,
|
||||
marks: Vec<bool>,
|
||||
}
|
||||
|
||||
struct Keeps(Arc<Mutex<Asked>>);
|
||||
impl Services for Keeps {
|
||||
fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) {
|
||||
self.0.lock().unwrap().kept.push(prefs.session.clone());
|
||||
}
|
||||
fn restoring(&mut self, on: bool) {
|
||||
self.0.lock().unwrap().marks.push(on);
|
||||
}
|
||||
}
|
||||
|
||||
/// The app with egui's context and clock, as the desktop app runs it (windows are native).
|
||||
struct Run {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
asked: Arc<Mutex<Asked>>,
|
||||
time: f64,
|
||||
/// Where the system says the main window is: `[x, y, width, height]`, maximized.
|
||||
main: Option<([f32; 4], bool)>,
|
||||
/// Where the main window was told to go and what state to take (the commands' names), in order.
|
||||
told: Vec<String>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn new(s: Session) -> Self {
|
||||
let asked = Arc::new(Mutex::new(Asked::default()));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
Self { ctx, app: FigApp::new(s, Box::new(Keeps(asked.clone()))), asked, time: 0.0, main: None, told: Vec::new() }
|
||||
}
|
||||
|
||||
/// One main-window frame `secs` later.
|
||||
fn frame(&mut self, secs: f64) {
|
||||
self.time += secs;
|
||||
let mut input = RawInput { time: Some(self.time), ..Default::default() };
|
||||
if let Some(([x, y, w, h], maximized)) = self.main {
|
||||
let rect = Rect::from_min_size(Pos2::new(x, y), vec2(w, h));
|
||||
let info = ViewportInfo { outer_rect: Some(rect), inner_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
|
||||
input.viewports.insert(ViewportId::ROOT, info);
|
||||
}
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
let commands = out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.as_slice()).unwrap_or_default();
|
||||
let name = |c: &egui::ViewportCommand| format!("{c:?}").split('(').next().unwrap_or_default().to_string();
|
||||
self.told.extend(commands.iter().map(name).filter(|c| ["OuterPosition", "InnerSize", "Maximized", "Fullscreen"].contains(&c.as_str())));
|
||||
}
|
||||
|
||||
/// Frames until the interface is up (the first one only installs the fonts).
|
||||
fn up(mut self) -> Self {
|
||||
for _ in 0..3 {
|
||||
self.frame(0.01);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn kept(&self) -> usize {
|
||||
self.asked.lock().unwrap().kept.len()
|
||||
}
|
||||
|
||||
fn windows(&self) -> Value {
|
||||
crate::windows::list(&self.app)
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder of saved files named `names`, and a session with them open.
|
||||
fn open_files(test: &str, names: &[&str]) -> (std::path::PathBuf, Session, Vec<String>) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-ui-restore-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::empty();
|
||||
let mut paths = Vec::new();
|
||||
for n in names {
|
||||
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
paths.push(path);
|
||||
}
|
||||
(dir, s, paths)
|
||||
}
|
||||
|
||||
/// The next run: nothing open, the preferences as they were kept.
|
||||
fn next_run(prefs: &figcraft_engine::Prefs) -> Run {
|
||||
let mut s = Session::empty();
|
||||
s.prefs = serde_json::from_value(serde_json::to_value(prefs).unwrap()).unwrap();
|
||||
Run::new(s)
|
||||
}
|
||||
|
||||
fn tab(path: &str) -> SavedTab {
|
||||
SavedTab { path: Some(path.into()), ..SavedTab::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_is_kept_as_it_changes_and_not_while_it_doesnt() {
|
||||
let (dir, mut s, paths) = open_files("kept", &["A", "B"]);
|
||||
s.execute("file.new", &json!({"name": "Scratch"})).unwrap();
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
let mut r = Run::new(s);
|
||||
r.main = Some(([40.0, 30.0, 1300.0, 820.0], false));
|
||||
r = r.up();
|
||||
assert_eq!(r.kept(), 1, "kept at once");
|
||||
let saved = r.app.session.prefs.session.clone();
|
||||
assert_eq!(saved.windows.len(), 1);
|
||||
let w = &saved.windows[0];
|
||||
assert_eq!(
|
||||
w.tabs.iter().map(|t| t.path.clone().unwrap()).collect::<Vec<_>>(),
|
||||
paths,
|
||||
"saved files only: unsaved work is the recovery copies' job"
|
||||
);
|
||||
assert_eq!((w.front, w.rect, w.maximized), (0, Some([40.0, 30.0, 1300.0, 820.0]), false));
|
||||
assert_eq!((w.left_tab.as_str(), w.right_tab.as_str(), w.panel_widths), ("file", "design", Some([240.0, 240.0])));
|
||||
// Nothing changes: nothing is written, and nothing asks for another frame to write.
|
||||
for _ in 0..30 {
|
||||
r.frame(0.5);
|
||||
}
|
||||
assert_eq!(r.kept(), 1);
|
||||
// The tab in front, the panel and the window's place change in quick succession: one more
|
||||
// write once a second has passed since the last, with all of it.
|
||||
r.frame(5.0);
|
||||
r.app.session.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.kept(), 2);
|
||||
r.app.ui.right_tab = RightTab::Inspect;
|
||||
r.frame(0.1);
|
||||
r.main = Some(([300.0, 200.0, 1000.0, 700.0], false));
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.kept(), 2, "not on every change");
|
||||
r.frame(1.0);
|
||||
assert_eq!(r.kept(), 3);
|
||||
let last = r.asked.lock().unwrap().kept.last().cloned().unwrap();
|
||||
assert_eq!(last, r.app.session.prefs.session);
|
||||
let w = &last.windows[0];
|
||||
assert_eq!((w.front, w.right_tab.as_str(), w.rect), (1, "inspect", Some([300.0, 200.0, 1000.0, 700.0])));
|
||||
// Maximized: the state is kept, and the place it goes back to.
|
||||
r.main = Some(([0.0, 0.0, 1920.0, 1080.0], true));
|
||||
r.frame(2.0);
|
||||
let w = r.app.session.prefs.session.windows[0].clone();
|
||||
assert_eq!((w.maximized, w.rect), (true, Some([300.0, 200.0, 1000.0, 700.0])));
|
||||
assert_eq!(crate::control::handle(&mut r.app, &r.ctx, "ui.inspect", &json!({})).unwrap()["session"]["saved"]["windows"][0]["maximized"], true);
|
||||
// Quitting: the window on its way out isn't how the app was left.
|
||||
let before = r.kept();
|
||||
r.app.ui.quit = true;
|
||||
r.app.session.execute("file.close", &json!({"index": 0, "force": true})).unwrap();
|
||||
r.frame(2.0);
|
||||
assert_eq!(r.kept(), before);
|
||||
assert_eq!(r.app.session.prefs.session.windows[0].tabs.len(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_and_tabs_come_back_as_they_were_left() {
|
||||
let (dir, s, _) = open_files("windows", &["A", "B", "C"]);
|
||||
let mut r = Run::new(s).up();
|
||||
// B and C move to a second window, which shows C; the main window is left on Dev Mode.
|
||||
let w = crate::control::handle(&mut r.app, &r.ctx, "window.new", &json!({"index": 1})).unwrap()["window"].as_u64().unwrap();
|
||||
r.frame(0.1);
|
||||
let c = r.app.session.docs[2].uid;
|
||||
crate::windows::adopt(&mut r.app, w, &[c], Some(c));
|
||||
r.app.ui.right_tab = RightTab::Inspect;
|
||||
// A is zoomed in on a corner.
|
||||
r.frame(0.1);
|
||||
(r.app.session.docs[0].view.cx, r.app.session.docs[0].view.zoom) = (77.0, 3.0);
|
||||
r.app.set_panel_widths(300.0, 320.0);
|
||||
r.main = Some(([10.0, 20.0, 1100.0, 760.0], false));
|
||||
r.frame(2.0);
|
||||
let mut prefs = r.app.session.prefs.clone();
|
||||
assert_eq!(prefs.session.windows.iter().map(|w| w.tabs.len()).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(prefs.session.windows[1].front, 1);
|
||||
// The second window was left maximized, over this place.
|
||||
prefs.session.windows[1].rect = Some([200.0, 150.0, 1000.0, 720.0]);
|
||||
prefs.session.windows[1].maximized = true;
|
||||
prefs.session.windows[1].right_tab = "prototype".into();
|
||||
let left = prefs.session.clone();
|
||||
|
||||
// The app quits and starts again.
|
||||
let mut r = next_run(&prefs);
|
||||
let out = r.app.run("session.restore", json!({})).unwrap();
|
||||
assert_eq!((&out["windows"], &out["opened"]), (&json!(2), &json!(3)));
|
||||
r = r.up();
|
||||
let list = r.windows();
|
||||
assert_eq!((&list[0]["files"], &list[0]["active"], &list[0]["rect"]), (&json!(["A"]), &json!("A"), &json!([10.0, 20.0, 1100.0, 760.0])));
|
||||
assert_eq!((&list[1]["files"], &list[1]["active"]), (&json!(["B", "C"]), &json!("C")));
|
||||
assert_eq!(
|
||||
(&list[1]["rect"], &list[1]["maximized"], &list[1]["fullscreen"]),
|
||||
(&json!([200.0, 150.0, 1000.0, 720.0]), &json!(true), &json!(false))
|
||||
);
|
||||
assert_eq!((r.app.ui.right_tab, r.app.ui.left_width, r.app.ui.right_width), (RightTab::Inspect, 300.0, 320.0), "the main window's panels");
|
||||
assert_eq!(r.app.windows.list[0].pane.ui.right_tab, RightTab::Prototype, "the other window's own");
|
||||
assert!(!r.app.ui.home && r.app.session.ds().unwrap().doc.name == "A");
|
||||
let view = &r.app.session.ds().unwrap().view;
|
||||
assert_eq!((view.cx, view.zoom), (77.0, 3.0), "drawn, and still where it was left (not fitted to the window again)");
|
||||
assert_eq!(r.app.session.prefs.session, left, "what is kept now is what was left");
|
||||
// The crash guard goes once the restore has settled, not before.
|
||||
assert_eq!(r.asked.lock().unwrap().marks, Vec::<bool>::new());
|
||||
r.frame(1.5);
|
||||
assert_eq!(r.asked.lock().unwrap().marks, [false]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_off_every_screen_moves_onto_one_and_one_window_keeps_every_tab() {
|
||||
let (dir, s, paths) = open_files("screens", &["A", "B"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.session = SavedSession {
|
||||
windows: vec![
|
||||
SavedWindow {
|
||||
rect: Some([-4000.0, 100.0, 1200.0, 800.0]),
|
||||
monitor: Some([-3840.0, 0.0, 1920.0, 1080.0]),
|
||||
tabs: vec![tab(&paths[0])],
|
||||
..SavedWindow::default()
|
||||
},
|
||||
SavedWindow { rect: Some([2500.0, 300.0, 5000.0, 4000.0]), tabs: vec![tab(&paths[1])], ..SavedWindow::default() },
|
||||
],
|
||||
};
|
||||
let mut r = next_run(&prefs);
|
||||
r.app.monitors = vec![A];
|
||||
r.app.run("session.restore", json!({}));
|
||||
r = r.up();
|
||||
let list = r.windows();
|
||||
assert_eq!((&list[0]["rect"], &list[0]["monitor"]), (&json!([0.0, 100.0, 1200.0, 800.0]), &json!(A)));
|
||||
assert_eq!((&list[1]["rect"], &list[1]["files"]), (&json!(A), &json!(["B"])));
|
||||
// A build with one window (the browser): the tabs of every window, in this one.
|
||||
let mut r = next_run(&prefs);
|
||||
r.ctx.set_embed_viewports(true);
|
||||
r.app.run("session.restore", json!({}));
|
||||
r = r.up();
|
||||
let list = r.windows();
|
||||
assert_eq!((list.as_array().map(Vec::len), &list[0]["files"]), (Some(1), &json!(["A", "B"])));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gone_files_get_one_message_and_a_file_opened_since_stays_in_front() {
|
||||
let (dir, s, paths) = open_files("gone", &["A", "B", "C", "D"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.session =
|
||||
SavedSession { windows: vec![SavedWindow { tabs: paths[..3].iter().map(|p| tab(p)).collect(), front: 1, ..SavedWindow::default() }] };
|
||||
std::fs::remove_file(&paths[0]).unwrap();
|
||||
std::fs::remove_file(&paths[2]).unwrap();
|
||||
let mut r = next_run(&prefs);
|
||||
r.app.run("session.restore", json!({}));
|
||||
// The file the app was started with opens after, in front; documents recovered after a
|
||||
// crash are still announced.
|
||||
r.app.session.execute("file.open", &json!({"path": paths[3]})).unwrap();
|
||||
r.app.note_recovered(1);
|
||||
r = r.up();
|
||||
let toast = r.app.ui.toast.clone().unwrap().0;
|
||||
assert!(toast.starts_with("Recovered an unsaved file") && toast.ends_with("\n2 files couldn't be reopened"), "{toast}");
|
||||
assert_eq!(r.windows()[0]["files"], json!(["B", "D"]));
|
||||
assert_eq!(r.app.session.ds().unwrap().doc.name, "D");
|
||||
// The files that are gone stay in Open recent (greyed there), and leave the kept session.
|
||||
assert!(r.app.session.prefs.recent_files.contains(&paths[0]));
|
||||
assert_eq!(r.app.session.prefs.session.windows[0].tabs.len(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_home_screen_offers_the_last_session_with_the_crash_guard_up() {
|
||||
let (dir, s, paths) = open_files("offer", &["A", "B"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.session = SavedSession { windows: vec![SavedWindow { tabs: vec![tab(&paths[0]), tab(&paths[1])], front: 1, ..SavedWindow::default() }] };
|
||||
// A run that starts without restoring: the last session is on offer, on the home screen.
|
||||
let mut r = next_run(&prefs);
|
||||
r.app.session.restore.last = Some(r.app.session.prefs.session.clone());
|
||||
r.app.ui.home = true;
|
||||
r = r.up();
|
||||
assert!(r.app.restore_offered());
|
||||
let inspect = r.app.inspect();
|
||||
assert_eq!((&inspect["session"]["offered"], &inspect["home"]), (&json!(true), &json!(true)));
|
||||
assert!(r.app.session.prefs.session.tabs() == 0, "this run's own session is what is kept now");
|
||||
// The button: the guard is up before anything opens, and down once it settled.
|
||||
r.app.restore_last();
|
||||
assert_eq!(r.asked.lock().unwrap().marks, [true]);
|
||||
r.frame(0.1);
|
||||
assert_eq!((r.windows()[0]["files"].clone(), r.windows()[0]["active"].clone()), (json!(["A", "B"]), json!("B")));
|
||||
assert!(!r.app.ui.home && !r.app.restore_offered());
|
||||
r.frame(1.5);
|
||||
assert_eq!(r.asked.lock().unwrap().marks, [true, false]);
|
||||
assert_eq!(r.app.session.prefs.session.tabs(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_left_maximized_comes_back_maximized_over_its_place() {
|
||||
let (dir, s, paths) = open_files("maximized", &["A"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
let rect = [200.0, 120.0, 1300.0, 850.0];
|
||||
prefs.session =
|
||||
SavedSession { windows: vec![SavedWindow { rect: Some(rect), maximized: true, tabs: vec![tab(&paths[0])], ..SavedWindow::default() }] };
|
||||
// The app starts: its window opens maximized (the app asked for that), and placing it again
|
||||
// would take it out of that state.
|
||||
let mut r = next_run(&prefs);
|
||||
r.main = Some(([0.0, 0.0, 1920.0, 1040.0], true));
|
||||
r.app.run("session.restore", json!({}));
|
||||
r = r.up();
|
||||
assert_eq!(r.told, Vec::<String>::new());
|
||||
let w = r.app.session.prefs.session.windows[0].clone();
|
||||
assert_eq!((w.maximized, w.rect), (true, Some(rect)), "the place under it is still the one to go back to");
|
||||
// Restored while the app runs, from a window that isn't maximized: placed first, then maximized.
|
||||
let mut r = next_run(&prefs);
|
||||
r.main = Some(([50.0, 50.0, 1000.0, 700.0], false));
|
||||
r = r.up();
|
||||
r.app.run("session.restore", json!({"session": serde_json::to_value(&prefs.session).unwrap()}));
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.told, ["OuterPosition", "InnerSize", "Maximized"]);
|
||||
// And back: a maximized window leaves that state before it is placed.
|
||||
let mut normal = prefs.session.clone();
|
||||
normal.windows[0].maximized = false;
|
||||
r.main = Some(([0.0, 0.0, 1920.0, 1040.0], true));
|
||||
r.frame(0.1);
|
||||
r.told.clear();
|
||||
r.app.run("session.restore", json!({"session": serde_json::to_value(&normal).unwrap()}));
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.told, ["Maximized", "OuterPosition", "InnerSize"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -342,6 +342,9 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
header(app, ui, &doc, &sel);
|
||||
widgets::separator(ui);
|
||||
if crate::widget_ui::design_section(app, ui, &doc, &sel) {
|
||||
widgets::separator(ui);
|
||||
}
|
||||
// Editing a vector with points selected, the points' fields stand in for the layer's position
|
||||
// and dimensions.
|
||||
let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty());
|
||||
|
|
|
|||
|
|
@ -768,36 +768,11 @@ fn round_axis(axis: &figcraft_text::Axis, v: f64) -> f64 {
|
|||
/// Returns the new value (None = double-click: back to the style's value) and whether the
|
||||
/// gesture ended.
|
||||
fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option<f64>, width: f32) -> Option<(Option<f64>, bool)> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
let resp = ui.interact(rect, id, Sense::click_and_drag());
|
||||
let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0));
|
||||
let span = (axis.max - axis.min).max(1e-9);
|
||||
let x_of = |v: f64| track.min.x + ((v - axis.min) / span).clamp(0.0, 1.0) as f32 * track.width();
|
||||
let p = ui.painter();
|
||||
p.rect_filled(track, CornerRadius::same(2), t.border);
|
||||
let dx = x_of(axis.default);
|
||||
p.line_segment([pos2(dx, track.min.y - 3.0), pos2(dx, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong));
|
||||
if let Some(v) = value {
|
||||
let x = x_of(v);
|
||||
p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent);
|
||||
p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE);
|
||||
let ring = if resp.hovered() || resp.dragged() { t.accent } else { t.border_strong };
|
||||
p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring));
|
||||
}
|
||||
let (resp, moved) = widgets::slider(ui, id, &axis.name, (axis.min, axis.max), Some(axis.default), value, width);
|
||||
if resp.double_clicked() {
|
||||
return Some((None, true));
|
||||
}
|
||||
if (resp.dragged() || resp.clicked() || resp.drag_started())
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0));
|
||||
return Some((Some(axis.min + f * span), resp.drag_stopped() || resp.clicked()));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
return value.map(|v| (Some(v), true));
|
||||
}
|
||||
None
|
||||
moved.map(|(v, done)| (Some(v), done))
|
||||
}
|
||||
|
||||
fn caption(ui: &mut Ui, s: &str) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
//! Widgets (the layers plugins draw) in the UI: the property menu of the selected widget, as a
|
||||
//! bar beside it on the canvas and as the same items in the Design panel; the tooltip of the part
|
||||
//! under the pointer; and the widgets' timers, which wake the window when the next one is due and
|
||||
//! at no other time. Everything acts through the engine's `widget.*` commands.
|
||||
|
||||
use egui::{Context, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::widgets::{self as engine, MenuItem, MenuKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::tr;
|
||||
use crate::right::{menu_item, menu_popup};
|
||||
use crate::theme::{ACCENT, Tokens};
|
||||
use crate::widgets::{self, FIELD_H, PAD};
|
||||
|
||||
/// How far the bar sits from the widget, and how much room the size label under a selection takes.
|
||||
const GAP: f32 = 12.0;
|
||||
const SIZE_LABEL: f32 = 24.0;
|
||||
/// Width of a dropdown in the menu, and the side of a colour.
|
||||
const DROPDOWN_W: f32 = 96.0;
|
||||
const SWATCH: f32 = 18.0;
|
||||
|
||||
/// Fire the widget timers that are due (their plugins are called back) and ask for the frame the
|
||||
/// next one is due at. Nothing here draws a frame sooner.
|
||||
pub fn tick(app: &mut FigApp, ctx: &Context) {
|
||||
// While a prototype is presented the file's timers wait: a callback would change the file,
|
||||
// and the prototype (which plays its own copy, with its own timers) would start over.
|
||||
if app.ui.present.is_some() || engine::next_timer(&app.session).is_none() {
|
||||
return;
|
||||
}
|
||||
let errors = engine::run_timers(&mut app.session);
|
||||
app.handle_ui_requests();
|
||||
if let Some(e) = errors.into_iter().next_back() {
|
||||
app.toast(e);
|
||||
}
|
||||
if let Some(secs) = engine::next_timer(&app.session) {
|
||||
crate::repaint_in(ctx, secs);
|
||||
}
|
||||
}
|
||||
|
||||
/// The one selected widget and its property menu, when it has one.
|
||||
fn selected(app: &FigApp) -> Option<(NodeId, Vec<MenuItem>)> {
|
||||
let ds = app.session.ds().ok()?;
|
||||
let [w] = ds.selection.as_slice() else { return None };
|
||||
let menu = engine::menu_of(ds.document_itself(), *w);
|
||||
(!menu.is_empty()).then_some((*w, menu))
|
||||
}
|
||||
|
||||
/// Choose item `name` of widget `w`'s menu: its plugin is called back; a link opens.
|
||||
fn choose(app: &mut FigApp, ctx: &Context, w: NodeId, name: &str, value: Value) {
|
||||
let Some(out) = app.run("widget.menuAction", json!({"id": w, "item": name, "value": value})) else { return };
|
||||
// Opened only on this click, and only what the engine checked is a web address.
|
||||
if let Some(url) = out["url"].as_str() {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's items in a row, each named for screen readers. Returns the item used and its new
|
||||
/// value (null for an action or a link).
|
||||
fn items(ui: &mut Ui, menu: &[MenuItem], t: &Tokens) -> Option<(String, Value)> {
|
||||
let mut used = None;
|
||||
for item in menu {
|
||||
let label = item.title();
|
||||
let value = item.value.as_ref();
|
||||
let picked = match item.kind {
|
||||
MenuKind::Action => widgets::button(ui, label, false, true).clicked().then_some(Value::Null),
|
||||
MenuKind::Link => {
|
||||
let resp = widgets::button(ui, &format!("{label} ↗"), false, true);
|
||||
crate::a11y::link(&resp, label);
|
||||
resp.on_hover_text(item.url.as_str()).clicked().then_some(Value::Null)
|
||||
}
|
||||
MenuKind::Toggle => {
|
||||
let on = value.and_then(Value::as_bool).unwrap_or(false);
|
||||
widgets::checkbox(ui, on, label).then_some(Value::Bool(!on))
|
||||
}
|
||||
MenuKind::Dropdown => {
|
||||
let shown = value.and_then(Value::as_str).unwrap_or_default();
|
||||
widgets::label(ui, label, t.text_secondary);
|
||||
let resp = widgets::dropdown(ui, shown, DROPDOWN_W, None);
|
||||
crate::a11y::combo(&resp, label, shown, crate::a11y::menu_open(&resp));
|
||||
let mut picked = None;
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for o in &item.options {
|
||||
if menu_item(ui, o, "") {
|
||||
picked = Some(json!(o));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
picked
|
||||
}
|
||||
MenuKind::Color => {
|
||||
widgets::label(ui, label, t.text_secondary);
|
||||
let mut picked = None;
|
||||
for hex in &item.options {
|
||||
let resp = widgets::swatch(ui, &Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)), SWATCH);
|
||||
crate::a11y::radio(&resp, &format!("{label}: {hex}"), value.and_then(Value::as_str) == Some(hex));
|
||||
if value.and_then(Value::as_str) == Some(hex) {
|
||||
ui.painter().rect_stroke(resp.rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.5, ACCENT), StrokeKind::Outside);
|
||||
}
|
||||
if resp.on_hover_text(hex.as_str()).clicked() {
|
||||
picked = Some(json!(hex));
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
MenuKind::Separator => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(1.0, FIELD_H), Sense::hover());
|
||||
ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.border));
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(v) = picked {
|
||||
used = Some((item.name.clone(), v));
|
||||
}
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
/// The selected widget's property menu as a bar on the canvas (`ui` is the canvas's: windows and
|
||||
/// popups lie over the bar): centred above the widget, or under it when there is no room above,
|
||||
/// and kept inside the canvas. Hidden while the widget is dragged or its text is typed.
|
||||
pub fn menu_bar(app: &mut FigApp, ui: &mut Ui, canvas: Rect) {
|
||||
let Some((w, menu)) = selected(app) else { return };
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
if ds.interaction.is_some() || ds.text_edit.is_some() || ds.preview.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(b) = ds.doc.world_bounds(w) else { return };
|
||||
let on_screen = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
|
||||
if !canvas.intersects(on_screen) {
|
||||
return;
|
||||
}
|
||||
// Placed by the size it had in the last frame (drawn again when that changes).
|
||||
let id = Id::new("widget_menu");
|
||||
let size: egui::Vec2 = ui.data(|d| d.get_temp(id)).unwrap_or(vec2(320.0, FIELD_H + PAD));
|
||||
let within = canvas.shrink(8.0);
|
||||
let above = on_screen.min.y - GAP - size.y >= within.min.y;
|
||||
let y = if above { on_screen.min.y - GAP - size.y } else { on_screen.max.y + GAP + SIZE_LABEL };
|
||||
let x = (on_screen.center().x - size.x / 2.0).min(within.max.x - size.x).max(within.min.x);
|
||||
let at = Rect::from_min_size(pos2(x, y), size);
|
||||
// The bar takes the pointer: a press on it isn't one on the canvas under it.
|
||||
ui.interact(at, id.with("bar"), Sense::click_and_drag());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let room = Rect::from_min_max(at.min, pos2(within.max.x.max(at.max.x), at.max.y));
|
||||
let drawn = ui.scope_builder(egui::UiBuilder::new().id_salt(id).max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Min)), |ui| {
|
||||
crate::a11y::container(ui, egui::accesskit::Role::Toolbar, tr("Widget"));
|
||||
widgets::bar(ui, |ui| items(ui, &menu, &t))
|
||||
});
|
||||
if drawn.response.rect.size() != size {
|
||||
ui.data_mut(|d| d.insert_temp(id, drawn.response.rect.size()));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if let Some((name, value)) = drawn.inner {
|
||||
let ctx = ui.ctx().clone();
|
||||
choose(app, &ctx, w, &name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected widget's property menu in the Design panel (the same items as the bar's). Returns
|
||||
/// whether it drew anything.
|
||||
pub fn design_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool {
|
||||
let [w] = sel else { return false };
|
||||
let menu = engine::menu_of(doc, *w);
|
||||
if menu.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::section_header(ui, tr("Widget"), &[], false);
|
||||
// Buttons and toggles share rows (wrapping); a dropdown or a row of colours has its own,
|
||||
// with its label.
|
||||
let plain = |i: &MenuItem| matches!(i.kind, MenuKind::Action | MenuKind::Toggle | MenuKind::Link);
|
||||
let mut used = None;
|
||||
for group in menu.chunk_by(|a, b| plain(a) && plain(b)) {
|
||||
let hit = match group.first().map(|i| i.kind) {
|
||||
None | Some(MenuKind::Separator) => None,
|
||||
Some(MenuKind::Dropdown | MenuKind::Color) => {
|
||||
let mut hit = None;
|
||||
crate::right::row(ui, widgets::ROW_H, |ui| hit = items(ui, group, &t));
|
||||
hit
|
||||
}
|
||||
Some(_) => {
|
||||
let inset = ui.available_rect_before_wrap().shrink2(vec2(PAD, 0.0));
|
||||
let wrapped = ui.scope_builder(egui::UiBuilder::new().max_rect(inset), |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
|
||||
items(ui, group, &t)
|
||||
})
|
||||
.inner
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
wrapped.inner
|
||||
}
|
||||
};
|
||||
used = hit.or(used);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if let Some((name, value)) = used {
|
||||
let ctx = ui.ctx().clone();
|
||||
choose(app, &ctx, *w, &name, value);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// What the pointer over the canvas (its response) means for widgets: the tooltip of the part
|
||||
/// under it; and once it has left the canvas (for a panel, a window over it, the menu bar), no
|
||||
/// part is under it, so none keeps its hover style.
|
||||
pub fn pointer(app: &mut FigApp, canvas: &egui::Response) {
|
||||
if canvas.hovered() {
|
||||
if let Some(tip) = app.session.tool.overlay.widget_tip.as_deref() {
|
||||
canvas.clone().on_hover_text_at_pointer(tip);
|
||||
}
|
||||
} else if !canvas.ctx.input(|i| i.pointer.any_down()) {
|
||||
engine::set_hover(&mut app.session, None);
|
||||
app.session.tool.overlay.widget_tip = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
//! Plugin API 4 in the UI, driven headless: a widget's text is typed in place on the canvas and
|
||||
//! committed to its plugin; the property menu sits by the selected widget and in the Design panel,
|
||||
//! named for screen readers and reachable from the keyboard; a part's hover style and tooltip show
|
||||
//! without a callback; a pending timer wakes the window when it is due and at no other time; and a
|
||||
//! panel's slider tells its plugin now and then while dragged (a presented prototype's side is in
|
||||
//! `present_widgets_tests`). The test plugin is WAT: it draws what its input asks.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use egui::accesskit::{Node, Role};
|
||||
use egui::{Event, Key, Modifiers, Pos2, Rect};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::a11y::Region;
|
||||
use crate::idle_tests::Harness;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
pub(crate) const NOTE: &str = "org.test.note";
|
||||
|
||||
/// A module that, for the first of `cases` whose needle its input contains (an empty needle
|
||||
/// always does), draws the look (unless it is null) and asks for a timer (unless it is negative).
|
||||
fn module(imports: &str, cases: &[(&str, Value, i32)], call: &str) -> Vec<u8> {
|
||||
let lit = |s: &str| s.bytes().map(|b| format!("\\{b:02x}")).collect::<String>();
|
||||
let (mut data, mut at, mut body) = (String::new(), 1000usize, String::new());
|
||||
let mut put = |text: &str| {
|
||||
let a = at;
|
||||
data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text));
|
||||
at += text.len();
|
||||
(a, text.len())
|
||||
};
|
||||
for (needle, look, timer) in cases {
|
||||
let ((n, nl), (l, ll)) = (put(needle), put(&look.to_string()));
|
||||
let draw = if look.is_null() { String::new() } else { format!("(drop (call $draw (i32.const {l}) (i32.const {ll})))") };
|
||||
let timer = if *timer < 0 { String::new() } else { format!("(drop (call $timer (i32.const {timer})))") };
|
||||
body += &format!("(if (call $has (i32.const {n}) (i32.const {nl}) (local.get $n)) (then {draw} {timer} (return (i32.const 0))))\n");
|
||||
}
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
{imports}
|
||||
(memory (export "memory") 4)
|
||||
{data}
|
||||
(func $has (param $p i32) (param $l i32) (param $n i32) (result i32) (local $i i32) (local $j i32)
|
||||
(block $no (loop $outer
|
||||
(br_if $no (i32.gt_s (i32.add (local.get $i) (local.get $l)) (local.get $n)))
|
||||
(local.set $j (i32.const 0))
|
||||
(block $miss (loop $inner
|
||||
(if (i32.ge_s (local.get $j) (local.get $l)) (then (return (i32.const 1))))
|
||||
(br_if $miss (i32.ne (i32.load8_u (i32.add (i32.const 100000) (i32.add (local.get $i) (local.get $j))))
|
||||
(i32.load8_u (i32.add (local.get $p) (local.get $j)))))
|
||||
(local.set $j (i32.add (local.get $j) (i32.const 1)))
|
||||
(br $inner)))
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||
(br $outer)))
|
||||
(i32.const 0))
|
||||
(func (export "figcraft_run") (param $n i32) (result i32)
|
||||
(drop (call $reply_read (i32.const 100000) (local.get $n)))
|
||||
{call}
|
||||
{body}
|
||||
(i32.const 0)))"#
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
/// The note widget in state `n`: a title to edit, a part with a hover style and a tooltip, and a
|
||||
/// property menu.
|
||||
fn look(n: i64) -> Value {
|
||||
json!({"state": {"n": n}, "menu": [
|
||||
{"type": "action", "name": "reset", "label": "Start over"},
|
||||
{"type": "toggle", "name": "pinned", "label": "Pinned note"},
|
||||
{"type": "dropdown", "name": "size", "label": "Note size", "options": ["S", "M"], "value": "M"},
|
||||
{"type": "color", "name": "tint", "label": "Note tint", "options": ["#FF0000", "#00FF00"], "value": "#FF0000"},
|
||||
{"type": "separator"},
|
||||
{"type": "link", "name": "docs", "label": "Note docs", "url": "https://example.org/docs"}
|
||||
], "root": {"type": "frame", "name": "Note", "layout": "vertical", "gap": 8, "padding": 12, "fill": "#FFFFFF", "align": "min", "children": [
|
||||
{"type": "text", "name": "Title", "text": format!("Title {n}"), "input": "title", "maxLength": 12, "width": 160, "size": 14},
|
||||
{"type": "rectangle", "name": "Go", "target": "go", "width": 80, "height": 30, "fill": "#0D99FF", "hover": {"fill": "#00FF00"}, "tooltip": "Go on"},
|
||||
{"type": "rectangle", "name": "Soon", "target": "soon", "width": 80, "height": 30, "fill": "#F24822"}
|
||||
]}})
|
||||
}
|
||||
|
||||
/// A session with the note widget's plugin: inserted it is in state 1; `go` draws 2 and asks for
|
||||
/// a timer in a minute, `soon` draws 5 and asks for one in 100 ms, a timer draws 3, text typed
|
||||
/// into the title draws 4, the menu's `reset` draws 0 and its other items draw nothing.
|
||||
pub(crate) fn session() -> Session {
|
||||
let imports = r#"(import "figcraft" "widget_render" (func $draw (param i32 i32) (result i32)))
|
||||
(import "figcraft" "widget_timer" (func $timer (param i32) (result i32)))"#;
|
||||
let cases = [
|
||||
(r#""timer":true"#, look(3), -1),
|
||||
(r#""target":"go""#, look(2), 60_000),
|
||||
(r#""target":"soon""#, look(5), 100),
|
||||
(r#""input":"title""#, look(4), -1),
|
||||
(r#""menu":"reset""#, look(0), -1),
|
||||
(r#""menu":""#, Value::Null, -1),
|
||||
("", look(1), -1),
|
||||
];
|
||||
let manifest =
|
||||
json!({"id": NOTE, "name": "Note", "version": "1.0.0", "main": "w.wasm", "api": 4, "widget": true, "permissions": {"access": "read"}});
|
||||
let mut s = Session::new();
|
||||
let wasm = base64::engine::general_purpose::STANDARD.encode(module(imports, &cases, ""));
|
||||
s.execute("plugin.import", &json!({"manifest": manifest, "wasm": wasm})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session) -> NodeId {
|
||||
NodeId(s.execute("widget.insert", &json!({"plugin": NOTE, "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
pub(crate) fn state(doc: &Document, w: NodeId) -> String {
|
||||
doc.node(w).and_then(|n| n.plugin_data.get("figcraft.widget.state").cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The layer called `name` inside widget `w`.
|
||||
pub(crate) fn part(doc: &Document, w: NodeId, name: &str) -> NodeId {
|
||||
doc.subtree(w).into_iter().find(|i| doc.node(*i).is_some_and(|n| n.name == name)).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
/// Where the layer called `name` of widget `w` shows on the canvas.
|
||||
fn on_canvas(app: &crate::FigApp, w: NodeId, name: &str) -> Rect {
|
||||
let doc = app.session.doc().unwrap();
|
||||
let b = doc.world_bounds(if name.is_empty() { w } else { part(doc, w, name) }).unwrap();
|
||||
Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1))
|
||||
}
|
||||
|
||||
pub(crate) fn fill(doc: &Document, id: NodeId) -> String {
|
||||
doc.node(id).unwrap().fills[0].solid_color().unwrap().to_css_hex()
|
||||
}
|
||||
|
||||
pub(crate) fn key(k: Key) -> Vec<Event> {
|
||||
[true, false].map(|pressed| Event::Key { key: k, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }).to_vec()
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, at: Pos2) {
|
||||
u.button(at, true);
|
||||
u.button(at, false);
|
||||
}
|
||||
|
||||
/// The AccessKit tree a screen reader would get now.
|
||||
fn tree(u: &mut Ui) -> Vec<Node> {
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
fn bounds(n: &Node) -> Rect {
|
||||
let b = n.bounds().unwrap();
|
||||
Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_widgets_text_is_typed_in_place_on_the_canvas_and_committed_to_its_plugin() {
|
||||
let mut s = session();
|
||||
let w = insert(&mut s);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let undo = u.app.session.ds().unwrap().history.undo.len();
|
||||
let title = part(u.app.session.doc().unwrap(), w, "Title");
|
||||
let at = on_canvas(&u.app, w, "Title").center();
|
||||
click(&mut u, at);
|
||||
assert_eq!(u.app.session.ds().unwrap().text_edit.as_ref().map(|t| t.id), Some(title), "a click starts editing the text in place");
|
||||
// What is typed replaces the selected text and shows at once, up to the text's maximum.
|
||||
u.frame(vec![Event::Text("Hello, wide world".into())]);
|
||||
let shown = |u: &Ui| u.app.session.doc().unwrap().node(title).and_then(|n| n.kind.text()).map(|t| t.characters.clone());
|
||||
assert_eq!(shown(&u).as_deref(), Some("Hello, wide "));
|
||||
assert_eq!(state(u.app.session.doc().unwrap(), w), r#"{"n":1}"#, "the plugin hears nothing while it's typed");
|
||||
// Enter commits: the plugin is called back and redraws, as one undo step.
|
||||
u.frame(key(Key::Enter));
|
||||
u.frame(vec![]);
|
||||
let ds = u.app.session.ds().unwrap();
|
||||
assert!(ds.text_edit.is_none());
|
||||
assert_eq!((state(&ds.doc, w), ds.history.undo.len(), ds.selection.clone()), (r#"{"n":4}"#.to_string(), undo + 1, vec![w]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_property_menu_sits_by_the_selected_widget_and_in_the_design_panel() {
|
||||
let mut s = session();
|
||||
let w = insert(&mut s);
|
||||
let mut u = Ui::new(s);
|
||||
let nodes = tree(&mut u);
|
||||
// The panel keeps its place beside the canvas (a row that overflows would push it out).
|
||||
assert!((u.app.canvas.rect.max.x - 1200.0).abs() < 1.0, "{:?}", u.app.canvas.rect);
|
||||
let (canvas, widget) = (u.app.canvas.rect, on_canvas(&u.app, w, ""));
|
||||
// Every item is there twice, named: in the bar over the canvas and in the Design panel.
|
||||
let named = |role: Role, label: &str| nodes.iter().filter(|n| n.role() == role && n.label() == Some(label)).map(bounds).collect::<Vec<_>>();
|
||||
// The bar sits just above the widget, or just under it when the canvas has no room above.
|
||||
let above = widget.min.y - 68.0 >= canvas.min.y;
|
||||
for (role, label) in [
|
||||
(Role::Button, "Start over"),
|
||||
(Role::CheckBox, "Pinned note"),
|
||||
(Role::ComboBox, "Note size"),
|
||||
(Role::RadioButton, "Note tint: #FF0000"),
|
||||
(Role::Link, "Note docs"),
|
||||
] {
|
||||
let at = named(role, label);
|
||||
let (bar, panel): (Vec<&Rect>, Vec<&Rect>) = at.iter().partition(|r| canvas.contains_rect(**r));
|
||||
assert_eq!((bar.len(), panel.len()), (1, 1), "{label}: one over the canvas, one in the panel: {at:?} (canvas {canvas:?})");
|
||||
let gap = if above { widget.min.y - bar[0].max.y } else { bar[0].min.y - widget.max.y };
|
||||
assert!((0.0..60.0).contains(&gap), "{label} at {:?}, the widget at {widget:?} (above: {above})", bar[0]);
|
||||
assert!(panel[0].min.x >= canvas.max.x, "{label} in the Design panel: {:?}", panel[0]);
|
||||
}
|
||||
assert!(nodes.iter().any(|n| n.role() == Role::Toolbar && n.label() == Some("Widget")));
|
||||
let bar_reset = named(Role::Button, "Start over").into_iter().find(|r| canvas.contains_rect(*r)).unwrap();
|
||||
assert!((bar_reset.center().x - widget.center().x).abs() < 300.0, "over the widget's middle (or as near as the canvas allows)");
|
||||
// Using an item calls the plugin back; a toggle shows its new state.
|
||||
click(&mut u, bar_reset.center());
|
||||
assert_eq!(state(u.app.session.doc().unwrap(), w), r#"{"n":0}"#);
|
||||
let pinned = nodes.iter().filter(|n| n.label() == Some("Pinned note")).map(bounds).find(|r| !canvas.contains_rect(*r)).unwrap();
|
||||
click(&mut u, pinned.center());
|
||||
let menu = u.app.session.execute("widget.menu", &json!({"id": w})).unwrap();
|
||||
assert_eq!(menu["items"][1]["value"], json!(true), "the Design panel's toggle");
|
||||
// The Design panel's items are reached and used with the keyboard: F6 to the panel, then Tab,
|
||||
// and Enter on the action.
|
||||
u.app.session.execute("widget.click", &json!({"id": w, "target": "go"})).unwrap();
|
||||
for _ in 0..4 {
|
||||
if u.app.ui.nav.region == Region::Properties {
|
||||
break;
|
||||
}
|
||||
u.frame(key(Key::F6));
|
||||
}
|
||||
assert_eq!(u.app.ui.nav.region, Region::Properties);
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..60 {
|
||||
seen.push(crate::a11y::focused_name(&u.ctx));
|
||||
if seen.last().is_some_and(|n| n == "Start over") {
|
||||
u.frame(key(Key::Enter));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(state(u.app.session.doc().unwrap(), w), r#"{"n":0}"#, "used from the keyboard");
|
||||
}
|
||||
u.frame(key(Key::Tab));
|
||||
}
|
||||
for want in ["Start over", "Pinned note", "Note size", "Note docs"] {
|
||||
assert!(seen.iter().any(|n| n == want), "{want} not reached by Tab: {seen:?}");
|
||||
}
|
||||
// Deselected, there is no menu anywhere.
|
||||
u.app.session.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
assert!(!tree(&mut u).iter().any(|n| n.label() == Some("Start over")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parts_hover_style_and_tooltip_show_without_a_callback_or_more_frames() {
|
||||
let mut s = session();
|
||||
let w = insert(&mut s);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let mut h = Harness::new(s);
|
||||
h.settle();
|
||||
let go = part(h.app.session.doc().unwrap(), w, "Go");
|
||||
let at = on_canvas(&h.app, w, "Go").center();
|
||||
h.frame(vec![Event::PointerMoved(at)]);
|
||||
h.frame(vec![]);
|
||||
let ds = h.app.session.ds().unwrap();
|
||||
assert_eq!((fill(&ds.doc, go), fill(ds.document_itself(), go)), ("#00FF00".to_string(), "#0D99FF".to_string()), "shown, not in the document");
|
||||
assert_eq!((h.app.session.tool.overlay.widget_tip.as_deref(), ds.history.undo.len()), (Some("Go on"), 1));
|
||||
assert_eq!(state(&ds.doc, w), r#"{"n":1}"#, "its plugin wasn't called");
|
||||
// Resting there draws nothing more.
|
||||
h.assert_idle("a hovered widget part");
|
||||
assert_eq!(fill(&h.app.session.ds().unwrap().doc, go), "#00FF00");
|
||||
// Moving off puts it back, on the canvas and when the pointer leaves it for a panel.
|
||||
h.frame(vec![Event::PointerMoved(at + egui::vec2(0.0, 300.0))]);
|
||||
let ds = h.app.session.ds().unwrap();
|
||||
assert!(fill(&ds.doc, go) == "#0D99FF" && ds.text_preview.is_none() && h.app.session.tool.overlay.widget_tip.is_none());
|
||||
h.frame(vec![Event::PointerMoved(at)]);
|
||||
h.frame(vec![]);
|
||||
assert_eq!(fill(&h.app.session.ds().unwrap().doc, go), "#00FF00");
|
||||
h.frame(vec![Event::PointerMoved(egui::pos2(1300.0, 500.0))]);
|
||||
h.frame(vec![]);
|
||||
let ds = h.app.session.ds().unwrap();
|
||||
assert!(fill(&ds.doc, go) == "#0D99FF" && ds.text_preview.is_none() && h.app.session.tool.overlay.widget_tip.is_none(), "over the Design panel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_timer_wakes_the_window_when_it_is_due_and_at_no_other_time() {
|
||||
let mut s = session();
|
||||
let w = insert(&mut s);
|
||||
let mut h = Harness::new(s);
|
||||
// No timer: the next frame is the autosave's, minutes away.
|
||||
assert!(h.settle() > Duration::from_secs(60));
|
||||
// A timer a minute off: the window sleeps until then, and draws nothing meanwhile.
|
||||
h.run("widget.click", json!({"id": w, "target": "go"}));
|
||||
let wake = h.settle();
|
||||
assert!(wake > Duration::from_secs(30) && wake <= Duration::from_secs(60), "{wake:?}");
|
||||
h.assert_idle("a widget with a timer pending");
|
||||
assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":2}"#);
|
||||
// One due in 100 ms fires in the frame after that: its plugin is called back.
|
||||
h.run("widget.click", json!({"id": w, "target": "soon"}));
|
||||
assert!(h.frame(vec![]) <= Duration::from_millis(100));
|
||||
assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":5}"#, "not before it is due");
|
||||
std::thread::sleep(Duration::from_millis(130));
|
||||
h.frame(vec![]);
|
||||
assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":3}"#);
|
||||
// It asked for no other: the window is idle again, with nothing to wake for.
|
||||
assert!(h.settle() > Duration::from_secs(60));
|
||||
// While a prototype is presented the file's timers wait (the prototype plays its own copy).
|
||||
let a = h.run("frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 300}))["id"].clone();
|
||||
h.run("widget.click", json!({"id": w, "target": "soon"}));
|
||||
h.run("proto.present", json!({"start": a}));
|
||||
std::thread::sleep(Duration::from_millis(130));
|
||||
h.frame(vec![]);
|
||||
assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":5}"#);
|
||||
h.app.ui.present = None;
|
||||
h.frame(vec![]);
|
||||
assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":3}"#, "it fires once the presentation ends");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panels_slider_tells_its_plugin_now_and_then_while_it_is_dragged() {
|
||||
use crate::plugins_ui::slider_due;
|
||||
assert!(slider_due(false, 5.0, None) && slider_due(true, 5.0, Some(4.99)), "the first move and the last always tell");
|
||||
assert!(!slider_due(false, 5.05, Some(5.0)) && slider_due(false, 5.2, Some(5.0)));
|
||||
// A panel with a slider, a picture, a progress bar and a section; each event makes a rectangle.
|
||||
let spec = json!({"title": "Dots", "controls": [
|
||||
{"type": "slider", "name": "amount", "label": "Amount", "min": 0, "max": 100, "step": 1, "value": 0},
|
||||
{"type": "image", "label": "Preview", "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "height": 40},
|
||||
{"type": "progress", "label": "Half done", "value": 0.5},
|
||||
{"type": "divider"},
|
||||
{"type": "section", "label": "Fine tuning", "value": false},
|
||||
{"type": "text", "name": "hidden", "label": "Hidden", "error": "Needs a name"},
|
||||
{"type": "button", "name": "go", "label": "Go", "disabled": true},
|
||||
{"type": "divider"},
|
||||
{"type": "note", "label": "Always shown"}
|
||||
]});
|
||||
// Its strings: the command, its parameters, the needle that tells an event from the first run,
|
||||
// and the panel.
|
||||
let hex = |s: &str| s.bytes().map(|b| format!("\\{b:02x}")).collect::<String>();
|
||||
let (cmd, rect, needle, text) = ("shape.rectangle", r#"{"x":0,"y":0,"width":5,"height":5}"#, r#""event""#, spec.to_string());
|
||||
let imports = format!(
|
||||
r#"(import "figcraft" "ui_show" (func $show (param i32 i32) (result i32)))
|
||||
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
|
||||
(data (i32.const 200) "{}") (data (i32.const 300) "{}") (data (i32.const 400) "{}") (data (i32.const 50000) "{}")"#,
|
||||
hex(cmd),
|
||||
hex(rect),
|
||||
hex(needle),
|
||||
hex(&text)
|
||||
);
|
||||
let call = format!(
|
||||
"(if (i32.eqz (call $has (i32.const 400) (i32.const {}) (local.get $n))) (then (return (call $show (i32.const 50000) (i32.const {})))))
|
||||
(drop (call $call (i32.const 200) (i32.const {}) (i32.const 300) (i32.const {})))",
|
||||
needle.len(),
|
||||
text.len(),
|
||||
cmd.len(),
|
||||
rect.len()
|
||||
);
|
||||
let manifest = json!({"id": "org.test.dots", "name": "Dots", "main": "p.wasm", "api": 4, "permissions": {"access": "write"}});
|
||||
let mut s = Session::new();
|
||||
let wasm = base64::engine::general_purpose::STANDARD.encode(module(&imports, &[], &call));
|
||||
s.execute("plugin.import", &json!({"manifest": manifest, "wasm": wasm})).unwrap();
|
||||
s.execute("plugin.run", &json!({"id": "org.test.dots"})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let nodes = tree(&mut u);
|
||||
let find = |role: Role, label: &str| nodes.iter().find(|n| n.role() == role && n.label() == Some(label)).map(bounds);
|
||||
let track = find(Role::Slider, "Amount").expect("the slider is named");
|
||||
assert!(
|
||||
find(Role::Image, "Preview").is_some() && find(Role::ProgressIndicator, "Half done").is_some(),
|
||||
"the picture and the progress bar are named"
|
||||
);
|
||||
assert!(nodes.iter().all(|n| n.label() != Some("Hidden") && n.label() != Some("Go")), "a closed section hides its controls");
|
||||
assert!(nodes.iter().any(|n| n.label().or(n.value()) == Some("Always shown")), "a divider ends the section");
|
||||
let layers = |u: &Ui| {
|
||||
let ds = u.app.session.ds().unwrap();
|
||||
ds.doc.node(ds.page).unwrap().children.len()
|
||||
};
|
||||
// Dragged across in 40 frames (two thirds of a second): the plugin hears a handful of times,
|
||||
// not forty, the slider follows the pointer all the way, and the last value is told.
|
||||
let (from, to) = (Pos2::new(track.min.x + 8.0, track.center().y), Pos2::new(track.max.x - 8.0, track.center().y));
|
||||
u.button(from, true);
|
||||
for k in 1..=40 {
|
||||
u.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 40.0))]);
|
||||
}
|
||||
let during = layers(&u);
|
||||
assert!((3..=9).contains(&during), "told {during} times while dragging");
|
||||
u.button(to, false);
|
||||
u.frame(vec![]);
|
||||
let value = u.app.session.plugins.panel("org.test.dots").unwrap().values["amount"].as_f64().unwrap();
|
||||
assert!(value >= 95.0 && layers(&u) <= during + 1, "the plugin has the value the drag ended on: {value}");
|
||||
// Opening the section shows its controls: the validation message, and a button that can't be used.
|
||||
let more = nodes.iter().find(|n| n.label() == Some("Fine tuning")).map(bounds).expect("the section's heading");
|
||||
click(&mut u, more.center());
|
||||
let nodes = tree(&mut u);
|
||||
let labels: Vec<&str> = nodes.iter().filter_map(|n| n.label().or(n.value())).collect();
|
||||
assert!(labels.contains(&"Hidden") && labels.contains(&"Needs a name"), "the field and its message: {labels:?}");
|
||||
let go = nodes.iter().find(|n| n.label() == Some("Go")).map(bounds).unwrap();
|
||||
let before = layers(&u);
|
||||
click(&mut u, go.center());
|
||||
assert_eq!(layers(&u), before, "a disabled button calls nobody");
|
||||
}
|
||||
|
|
@ -750,30 +750,91 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, tip: &str) -> bool {
|
|||
/// A bar floating at the top centre of the `canvas` area (a version preview, the focus view):
|
||||
/// `add` lays out its contents in a row.
|
||||
pub fn canvas_banner<R>(ctx: &egui::Context, id: Id, canvas: Rect, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Area::new(id)
|
||||
.order(egui::Order::Foreground)
|
||||
.pivot(Align2::CENTER_TOP)
|
||||
.fixed_pos(pos2(canvas.center().x, canvas.min.y + 12.0))
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow })
|
||||
.inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 })
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner
|
||||
.show(ctx, |ui| bar(ui, add))
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A bar's box (the panel's fill, a border, a shadow) around `add`'s contents, laid out in a row.
|
||||
pub fn bar<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow })
|
||||
.inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 })
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A horizontal slider over `range`, named `name` for screen readers, with a mark at `tick` (a
|
||||
/// notable value). `value` None = mixed: no knob. Returns its response and, when it was moved,
|
||||
/// the new value and whether the gesture ended.
|
||||
pub fn slider(
|
||||
ui: &mut Ui,
|
||||
id: Id,
|
||||
name: &str,
|
||||
range: (f64, f64),
|
||||
tick: Option<f64>,
|
||||
value: Option<f64>,
|
||||
width: f32,
|
||||
) -> (Response, Option<(f64, bool)>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (min, max) = range;
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
let resp = ui.interact(rect, id, Sense::click_and_drag());
|
||||
a11y::slider(&resp, name, value, range);
|
||||
let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0));
|
||||
let span = (max - min).max(1e-9);
|
||||
let x_of = |v: f64| track.min.x + ((v - min) / span).clamp(0.0, 1.0) as f32 * track.width();
|
||||
let p = ui.painter();
|
||||
p.rect_filled(track, CornerRadius::same(2), t.border);
|
||||
if let Some(tick) = tick {
|
||||
let x = x_of(tick);
|
||||
p.line_segment([pos2(x, track.min.y - 3.0), pos2(x, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong));
|
||||
}
|
||||
if let Some(v) = value {
|
||||
let x = x_of(v);
|
||||
p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent);
|
||||
p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE);
|
||||
let ring = if resp.hovered() || resp.dragged() { t.accent } else { t.border_strong };
|
||||
p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring));
|
||||
}
|
||||
let moved = if (resp.dragged() || resp.clicked() || resp.drag_started())
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0));
|
||||
Some((min + f * span, resp.drag_stopped() || resp.clicked()))
|
||||
} else if resp.drag_stopped() {
|
||||
value.map(|v| (v, true))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(resp, moved)
|
||||
}
|
||||
|
||||
/// A progress bar `width` wide filled to `fraction` (0–1), named `name` for screen readers.
|
||||
pub fn progress_bar(ui: &mut Ui, name: &str, fraction: f64, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let fraction = if fraction.is_finite() { fraction.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 12.0), Sense::hover());
|
||||
a11y::progress(&resp, name, fraction);
|
||||
let track = Rect::from_center_size(rect.center(), vec2(width, 4.0));
|
||||
ui.painter().rect_filled(track, CornerRadius::same(2), t.border);
|
||||
let done = Rect::from_min_max(track.min, pos2(track.min.x + track.width() * fraction as f32, track.max.y));
|
||||
ui.painter().rect_filled(done, CornerRadius::same(2), t.accent);
|
||||
}
|
||||
|
||||
/// Where a dragged row will land: an accent line across `x0..x1` at `y`.
|
||||
pub fn insertion_line(painter: &egui::Painter, x0: f32, x1: f32, y: f32) {
|
||||
painter.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(2.0, crate::theme::ACCENT));
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use serde_json::{Value, json};
|
|||
use crate::canvas::CanvasState;
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::render_worker::Worker;
|
||||
use crate::restore::Geometry;
|
||||
use crate::{FigApp, TitleBar, UiState};
|
||||
|
||||
/// Another window: its tabs and the UI state it swaps into the app while it draws.
|
||||
|
|
@ -25,17 +26,19 @@ pub struct Window {
|
|||
/// Its open files (document uids; the tabs keep the session's order).
|
||||
pub docs: Vec<u64>,
|
||||
/// The file it shows.
|
||||
active: Option<u64>,
|
||||
pane: Pane,
|
||||
/// Where it opens and its size, then where it is (screen points).
|
||||
pos: Option<Pos2>,
|
||||
size: Vec2,
|
||||
pub(crate) active: Option<u64>,
|
||||
pub(crate) pane: Pane,
|
||||
/// Where it opens, then where it is.
|
||||
pub(crate) geo: Geometry,
|
||||
/// Which window of the restored session it is: online files still on their way open in it
|
||||
/// (and keep it open until they arrive, or can't).
|
||||
pub(crate) restored: Option<usize>,
|
||||
}
|
||||
|
||||
/// A window's own UI state.
|
||||
#[derive(Default)]
|
||||
struct Pane {
|
||||
ui: UiState,
|
||||
pub(crate) struct Pane {
|
||||
pub(crate) ui: UiState,
|
||||
canvas: CanvasState,
|
||||
worker: Option<Worker>,
|
||||
field_focus: bool,
|
||||
|
|
@ -50,6 +53,8 @@ pub struct Windows {
|
|||
pub current: u64,
|
||||
/// The window last in front: control requests without a `window` go to it.
|
||||
pub focused: u64,
|
||||
/// Where the main window is.
|
||||
pub(crate) main: Geometry,
|
||||
/// The main window's file while another window draws.
|
||||
main_active: Option<u64>,
|
||||
/// Windows to close (their files close with them), at the main window's next frame.
|
||||
|
|
@ -63,6 +68,12 @@ impl Windows {
|
|||
self.list.iter().find(|w| w.docs.contains(&uid)).map_or(0, |w| w.id)
|
||||
}
|
||||
|
||||
/// The window that is window `k` of the restored session (the main window when it is gone,
|
||||
/// or never opened).
|
||||
pub(crate) fn restored(&self, k: usize) -> u64 {
|
||||
self.list.iter().find(|w| w.restored == Some(k)).map_or(0, |w| w.id)
|
||||
}
|
||||
|
||||
pub fn exists(&self, id: u64) -> bool {
|
||||
id == 0 || self.list.iter().any(|w| w.id == id)
|
||||
}
|
||||
|
|
@ -197,36 +208,51 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option<usize>, path: Option<
|
|||
if let Some(why) = one {
|
||||
return Err(why.into());
|
||||
}
|
||||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| *u != uid);
|
||||
}
|
||||
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
|
||||
app.windows.last_id += 1;
|
||||
let id = app.windows.last_id;
|
||||
app.windows.list.push(Window {
|
||||
id,
|
||||
docs: vec![uid],
|
||||
active: Some(uid),
|
||||
pane: Pane {
|
||||
// A new window opens like the one it comes from: the same tabs and panel widths.
|
||||
ui: UiState {
|
||||
title_bar: app.ui.title_bar,
|
||||
right_tab: app.ui.right_tab,
|
||||
left_tab: app.ui.left_tab,
|
||||
left_width: app.ui.left_width,
|
||||
right_width: app.ui.right_width,
|
||||
..UiState::default()
|
||||
},
|
||||
..Pane::default()
|
||||
},
|
||||
// A new window opens like the one it comes from: the same tabs and panel widths.
|
||||
let ui = UiState {
|
||||
title_bar: app.ui.title_bar,
|
||||
right_tab: app.ui.right_tab,
|
||||
left_tab: app.ui.left_tab,
|
||||
left_width: app.ui.left_width,
|
||||
right_width: app.ui.right_width,
|
||||
..UiState::default()
|
||||
};
|
||||
let geo = Geometry {
|
||||
pos: at.or_else(|| outer.map(|r| r.min + Vec2::splat(32.0))),
|
||||
size: inner.map_or(Vec2::new(1280.0, 800.0), |r| r.size()),
|
||||
});
|
||||
size: inner.map_or(Geometry::default().size, |r| r.size()),
|
||||
..Geometry::default()
|
||||
};
|
||||
let id = push(app, ui, geo);
|
||||
adopt(app, id, &[uid], Some(uid));
|
||||
app.windows.focused = id;
|
||||
ctx.request_repaint_of(ViewportId::ROOT);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// A new window with its panels as in `ui`, at `geo`, without files yet ([`adopt`]). Returns its id.
|
||||
pub(crate) fn push(app: &mut FigApp, ui: UiState, geo: Geometry) -> u64 {
|
||||
app.windows.last_id += 1;
|
||||
let id = app.windows.last_id;
|
||||
app.windows.list.push(Window { id, docs: Vec::new(), active: None, pane: Pane { ui, ..Pane::default() }, geo, restored: None });
|
||||
id
|
||||
}
|
||||
|
||||
/// Open files `docs` become tabs of window `id` (0: the main window), which shows `front`.
|
||||
pub(crate) fn adopt(app: &mut FigApp, id: u64, docs: &[u64], front: Option<u64>) {
|
||||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| !docs.contains(u));
|
||||
if w.id == id {
|
||||
w.docs.extend(docs);
|
||||
w.active = front.or(w.active);
|
||||
}
|
||||
}
|
||||
// The main window draws now: it shows the session's file.
|
||||
if let Some(i) = front.filter(|_| id == 0).and_then(|u| app.session.docs.iter().position(|d| d.uid == u)) {
|
||||
app.session.active = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring window `id` to the front; control requests go to it from now on.
|
||||
pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String> {
|
||||
if !app.windows.exists(id) {
|
||||
|
|
@ -242,6 +268,11 @@ pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String>
|
|||
pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
|
||||
for r in std::mem::take(&mut app.window_requests) {
|
||||
let n = |k: &str| r.data.get(k).and_then(Value::as_u64);
|
||||
// The main window places restored windows when its frame begins.
|
||||
if r.kind == "session.restore" {
|
||||
ctx.request_repaint_of(ViewportId::ROOT);
|
||||
continue;
|
||||
}
|
||||
let res = if r.kind == "window.new" {
|
||||
open(app, ctx, n("index").map(|i| i as usize), r.data.get("path").and_then(Value::as_str), None).map(|_| ())
|
||||
} else {
|
||||
|
|
@ -253,7 +284,9 @@ pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every window: id, title, its files and the one it shows (`window.list`).
|
||||
/// Every window: id, title, its files and the one it shows, where it is on screen (`rect`:
|
||||
/// `[x, y, width, height]` while neither maximized nor full screen) and the screen it is on
|
||||
/// (`window.list`).
|
||||
pub fn list(app: &FigApp) -> Value {
|
||||
let active_of = |id: u64| -> Option<u64> {
|
||||
if id == app.windows.current {
|
||||
|
|
@ -269,14 +302,19 @@ pub fn list(app: &FigApp) -> Value {
|
|||
ids.map(|id| {
|
||||
let mine = |d: &&DocState| app.windows.of(d.uid) == id;
|
||||
let shown = app.session.docs.iter().filter(mine).find(|d| Some(d.uid) == active_of(id));
|
||||
json!({
|
||||
let geo = app.windows.list.iter().find(|w| w.id == id).map_or(&app.windows.main, |w| &w.geo);
|
||||
let mut out = json!({
|
||||
"id": id,
|
||||
"main": id == 0,
|
||||
"title": title_of(shown),
|
||||
"files": app.session.docs.iter().filter(mine).map(|d| d.doc.name.as_str()).collect::<Vec<_>>(),
|
||||
"active": shown.map(|d| d.doc.name.as_str()),
|
||||
"focused": id == app.windows.focused,
|
||||
})
|
||||
});
|
||||
if let (Some(out), Value::Object(geo)) = (out.as_object_mut(), geo.json(&app.monitors)) {
|
||||
out.extend(geo);
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
|
|
@ -338,6 +376,8 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) {
|
|||
/// (a window left with none closes) and the main window shows one of its own files.
|
||||
pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
||||
app.windows.current = 0;
|
||||
app.windows.main.track(ctx);
|
||||
crate::restore::begin(app, ctx);
|
||||
for id in std::mem::take(&mut app.windows.closing) {
|
||||
if id == 0 && app.windows.list.is_empty() {
|
||||
app.ui.quit = true;
|
||||
|
|
@ -357,10 +397,10 @@ pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
|||
// The main window takes over another window: its files, panels and place on screen.
|
||||
let mut w = app.windows.list.remove(0);
|
||||
exchange(app, &mut w.pane);
|
||||
if let Some(p) = w.pos {
|
||||
if let Some(p) = w.geo.pos {
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::OuterPosition(p));
|
||||
}
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::InnerSize(w.size));
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::InnerSize(w.geo.size));
|
||||
app.windows.focused = 0;
|
||||
show_own(app, w.active);
|
||||
}
|
||||
|
|
@ -368,7 +408,9 @@ pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
|||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| open.contains(u));
|
||||
}
|
||||
app.windows.list.retain(|w| !w.docs.is_empty());
|
||||
// A restored window whose online files are still on their way waits for them.
|
||||
let waiting: Vec<usize> = app.session.restore.joining().map(|(k, _)| k).collect();
|
||||
app.windows.list.retain(|w| !w.docs.is_empty() || w.restored.is_some_and(|k| waiting.contains(&k)));
|
||||
if !app.windows.exists(app.windows.focused) {
|
||||
app.windows.focused = 0;
|
||||
}
|
||||
|
|
@ -408,8 +450,14 @@ pub(crate) fn repaint_others(app: &FigApp, ctx: &Context) {
|
|||
pub(crate) fn show(app: &FigApp, ctx: &Context) {
|
||||
for w in &app.windows.list {
|
||||
let title = title_of(app.session.docs.iter().find(|d| Some(d.uid) == w.active));
|
||||
let mut b = ViewportBuilder::default().with_title(title).with_inner_size(w.size).with_min_inner_size([800.0, 500.0]).with_drag_and_drop(true);
|
||||
if let Some(p) = w.pos {
|
||||
let mut b = ViewportBuilder::default()
|
||||
.with_title(title)
|
||||
.with_inner_size(w.geo.size)
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_maximized(w.geo.maximized)
|
||||
.with_fullscreen(w.geo.fullscreen)
|
||||
.with_drag_and_drop(true);
|
||||
if let Some(p) = w.geo.pos {
|
||||
b = b.with_position(p);
|
||||
}
|
||||
b = match w.pane.ui.title_bar {
|
||||
|
|
@ -459,12 +507,10 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) {
|
|||
repaint_others(app, &ctx);
|
||||
}
|
||||
let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id);
|
||||
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
|
||||
exchange(app, &mut pane);
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) {
|
||||
w.active = shown.or(w.active);
|
||||
w.pos = outer.map(|r| r.min).or(w.pos);
|
||||
w.size = inner.map_or(w.size, |r| r.size());
|
||||
w.geo.track(&ctx);
|
||||
w.pane = pane;
|
||||
}
|
||||
app.windows.current = 0;
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ live sessions (`collab.*`: without a key on a server open to the public, with `c
|
|||
`collab.manageFile` for the owner token and `collab.report`) and use plugin directories
|
||||
(`plugin.directory.*`); the one-shot `run` can't (no network link).
|
||||
|
||||
The desktop app's own command line is `figcraft [--control PORT] [--fresh] [files…]
|
||||
[figcraft://join?…]`: files and an invite link open in front of the windows and tabs from last
|
||||
time, which open again by themselves (Preferences ▸ Reopen windows and tabs from last time);
|
||||
`--fresh` starts without them once, leaving the preference as it is (`session.restore` still
|
||||
brings them back in that run).
|
||||
|
||||
### `plugin publish <folder> --server ws://host:port/[?key=KEY]`
|
||||
|
||||
Publishes a plugin folder (`figcraft-plugin.json`, the module its `main` names, an optional `assets/`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (branch history entries for a branch's creation and updates from main; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
|
|
@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived), which starts with an entry for its creation and gets one each time it is updated from main; fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
|
||||
| Notifications, activity | yes | no | with sharing |
|
||||
|
||||
|
|
@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Branch history entries (gap 17): a branch's version history starts with Branch created (the branch as it began) and gets Updated from main each time it takes main's changes (the branch right after, also when its tab is closed); they are versions like any other (viewed, restored, saved with the file, never pruned as autosaves) and stay out of the main file's history. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Live sessions are per document (a fix from real use: after hosting or joining in one tab, a new or other file showed that session, and starting one there silently replaced it). Every open file has its own session or none; several run at once and keep syncing behind the tab in front; the Share popover, the people, the cursors, cursor chat, spotlight, follow and the read-only reason are the file in front's (each window its own); the `collab.*` commands act on the active file's session and `collab.peers` lists all of them (`sessions`); joining opens the session's file in its own tab without touching other sessions; closing a file ends its session only. One link carries the sessions, each under its own id (desktop, browser and tests). Percentages and hours unchanged |
|
||||
|
|
|
|||
|
|
@ -241,9 +241,12 @@ for menus, the Actions palette and `engine.execute`.
|
|||
|
||||
### `window.list {}`
|
||||
|
||||
Every window: `[{id, main, title, files, active, focused}]` (`files` are its tabs' names, `active`
|
||||
the one it shows, `focused` the window requests go to). `ui.inspect` reports it as `windows`, plus
|
||||
`window` (the window it ran in).
|
||||
Every window: `[{id, main, title, files, active, focused, rect, maximized, fullscreen, monitor}]`
|
||||
(`files` are its tabs' names, `active` the one it shows, `focused` the window requests go to;
|
||||
`rect` is `[x, y, width, height]` in screen points, its outer position and inner size while it is
|
||||
neither maximized nor full screen, so a maximized window reports the place it goes back to;
|
||||
`monitor` is the screen showing most of it, `[x, y, width, height]`, or `null` when none is known).
|
||||
`ui.inspect` reports it as `windows`, plus `window` (the window it ran in).
|
||||
|
||||
### `window.focus {id}`
|
||||
|
||||
|
|
@ -257,6 +260,26 @@ closing while others are open takes over one of them, and closing the last windo
|
|||
|
||||
Close the app (every window, without asking about unsaved changes).
|
||||
|
||||
### Session restore
|
||||
|
||||
The desktop app keeps its windows and tabs with the preferences as they change and opens them
|
||||
again at the next start (see [ui-parity.md](ui-parity.md)); `figcraft --fresh` starts without
|
||||
them. `ui.inspect` reports it as `session`:
|
||||
|
||||
- `saved`: the session kept now, `{windows: [{rect, maximized, fullscreen, monitor, tabs: [{path}
|
||||
| {server, file}, page, view: [cx, cy, zoom]], front, leftTab, rightTab, leftCollapsed,
|
||||
panelWidths}]}`, the main window first. Tabs are saved files and your online files (their
|
||||
server and id, never a token); documents never saved aren't in it.
|
||||
- `restore`: the preference (`pref.restoreSession {on?}`).
|
||||
- `offered`: this run started without the last run's session (the preference, `--fresh`, or the
|
||||
last run never got through restoring), and `session.restore` brings it back.
|
||||
- `joining`: restored online files still on their way from their servers.
|
||||
- `monitors`: the screens the app knows, `[[x, y, width, height]]`.
|
||||
|
||||
`engine.execute {command: "session.restore", params: {session?}}` restores the session on offer
|
||||
(else the one kept), or the saved session given: it returns `{windows, opened, joining, missing}`
|
||||
and the windows are placed at the main window's next frame (`window.list` shows them).
|
||||
|
||||
## Example session (shell)
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Dev Mode parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (import lines for connected instances in generated layer trees; dev resources links; inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
|
||||
**Breadth ~96%, ready ~72% (estimated).** The inspect surface is broad, components connect
|
||||
to the code components of a repository and layers carry dev resources links; left: import lines
|
||||
inside generated layer trees. Remaining: 0–1 h.
|
||||
to the code components of a repository and layers carry dev resources links; left: publishing
|
||||
mappings from the code repository with a CLI. Remaining: 0–1 h.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
|
|
@ -16,7 +16,7 @@ inside generated layer trees. Remaining: 0–1 h.
|
|||
| Ready for dev list, statuses, "Changed" after edits | done | |
|
||||
| Focus view (one frame, previous / next) | done | |
|
||||
| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version |
|
||||
| Code connect (map components to repository code) | done | per language (React, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
|
||||
| Code connect (map components to repository code) | done | per language (React, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. The code of a layer tree (a frame, a component, an instance of a component that isn't connected) leads with the import lines of every connected instance written into it: one line per import path, in path order, each name once (`import { Avatar, Tag } from "@acme/ui";`; SwiftUI and Compose: one `import` per module or package) |
|
||||
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations |
|
||||
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
|
||||
| Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them |
|
||||
|
|
@ -25,6 +25,7 @@ inside generated layer trees. Remaining: 0–1 h.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Import lines inside generated layer trees (gap 16): `inspect.code` and the Inspect panel put the imports of every connected instance in a layer tree at the top of its code, deduplicated and ordered, per language (React names from one path share a line); instances without a link or an import path bring none. Breadth and ready unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h |
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h |
|
||||
|
|
|
|||
61
docs/gaps.md
61
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (plugin API 4: widget text input, property menus, hover styles, timers and richer drawing, panel sliders and pictures; branch history entries; import lines inside generated layer trees; session restore; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
|
||||
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
|
||||
| 5 | Localization: English plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) |
|
||||
| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) |
|
||||
| 6 | Plugins: a self-hosted directory but no public catalogue; plugins written for other apps don't run | Ecosystem | 5–20 | [target-app-parity](target-app-parity.md) |
|
||||
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
|
||||
| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
|
||||
| 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
|
||||
|
|
@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) |
|
||||
| 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) |
|
||||
| 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) |
|
||||
| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) |
|
||||
| 16 | Dev Mode: publishing code mappings from the repository with a CLI; the editor extension is left to the MCP server (code connect, Dev Mode plugins, dev resources and import lines in layer trees landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) |
|
||||
| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) |
|
||||
| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) |
|
||||
|
|
@ -163,7 +163,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
[`localization-parity.md`](localization-parity.md); how to add one in
|
||||
[`translating.md`](translating.md).
|
||||
|
||||
### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run
|
||||
### 6. Plugins: a self-hosted directory but no public catalogue; plugins written for other apps don't run
|
||||
- **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in
|
||||
the browser) acting through the command registry, one undo step per run, manifest permissions,
|
||||
Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples
|
||||
|
|
@ -180,16 +180,29 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions
|
||||
confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their
|
||||
SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`.
|
||||
- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings
|
||||
and install counts, background update checks, the directory in the browser build; widgets take only clicks (no
|
||||
text input, property menus, hover or timers) and draw only frames, text, shapes and images;
|
||||
no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and
|
||||
don't run.
|
||||
Plugin API 4 (2026-10-13; older plugins run unchanged): a widget's texts are edited in place
|
||||
with the text editor and committed to its plugin, a selected widget shows the property menu its
|
||||
plugin declares (a bar by the widget and the same items in the Design panel: buttons, toggles,
|
||||
dropdowns, colours, links), parts have hover styles and tooltips the app shows without calling
|
||||
the plugin, and a widget can ask to be called back after 100 ms to an hour (one timer each; in a
|
||||
live session it runs for whoever started it last); widgets draw lines, vectors from path data,
|
||||
gradients, shadows, per-corner radii, per-side padding, children that fill, text weights,
|
||||
alignment and truncation and image fit modes; panels have sliders, pictures, progress bars,
|
||||
dividers, folding sections, disabled controls and validation messages; `widget.setText` /
|
||||
`widget.menu` / `widget.menuAction` / `widget.fireTimer` for agents; all of it works on a
|
||||
presented prototype's copy; the SDK has builders for it, and the Counter (menu), Focus list
|
||||
(typed text, a timer, auto layout) and Dot grid (sliders and a picture) examples use it.
|
||||
- **Missing:** a public catalogue and community files, publisher accounts and signatures, ratings
|
||||
and install counts, background update checks, the directory in the browser build; widgets can't
|
||||
open a panel, use components, styles or variables in their layers, take drags or name image
|
||||
files from the plugin's folder, and their timers stop while the file is closed or behind
|
||||
another tab; panels have no free layout or tabs. Plugins written for other apps are JavaScript
|
||||
against another plugin API and don't run.
|
||||
- **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks)
|
||||
can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better
|
||||
than Figma does.
|
||||
- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins
|
||||
needs a JS engine and an API shim: **owner decision needed**.
|
||||
- **Estimate:** 5–20 h for the missing pieces of FigCraft's own API. Running plugins written for
|
||||
other apps needs a JS engine and an API shim: **owner decision needed**.
|
||||
|
||||
### 7. CPU canvas where Figma uses the GPU
|
||||
- **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded,
|
||||
|
|
@ -321,9 +334,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
host, no spaces, capped) and never fetched: a link added without a name is named after its URL,
|
||||
and a link opens only when clicked. The Inspect tab lists, adds, edits and removes them;
|
||||
`get_node` / `inspect_document` return them as `devResources`.
|
||||
- **Missing:** import lines for connected instances inside a
|
||||
generated layer tree (only the selected layer's own import is shown); publishing mappings from
|
||||
the code repository with a CLI. An editor extension is out of scope: the MCP server already
|
||||
- **Landed (2026-10-13):** import lines inside generated layer trees: the code of a frame, a
|
||||
component or an unconnected instance leads with the imports of every connected instance written
|
||||
into it, one line per import path, ordered, each name once, per language.
|
||||
- **Missing:** publishing mappings from the code repository with a CLI. An editor extension is out of scope: the MCP server already
|
||||
serves design data, code and the connected snippets to agents and editors.
|
||||
- **Estimate:** 0–1 h.
|
||||
|
||||
|
|
@ -348,8 +362,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
conflict; only the same item changed both ways, an item removed on one side, or different items
|
||||
added on both sides conflict (as the whole list).
|
||||
- **Missing:** branches of a file in a live session are refused; other lists (text runs, export
|
||||
settings, guides…) still conflict as a whole; the branch history has no entries of its own for
|
||||
its creation or updates from main (only saved versions, autosaves and the merge on main).
|
||||
settings, guides…) still conflict as a whole.
|
||||
- **Landed (2026-10-13):** a branch's history has entries of its own: Branch created when it is
|
||||
made and Updated from main each time it takes main's changes, shown in the branch's version
|
||||
list with the versions saved there.
|
||||
- **Estimate:** 1–2 h.
|
||||
|
||||
### 18. Display P3 on screen (several windows landed)
|
||||
|
|
@ -357,9 +373,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
dragged off the strip; each window has its own tabs, panels and canvas and draws only when it
|
||||
needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and
|
||||
`window` on any control request.
|
||||
- **Landed (2026-10-13):** session restore: every window's place, size, state, screen, panels and
|
||||
tabs are kept with the preferences as they change and come back at the next start (off-screen
|
||||
windows move onto a screen; `session.restore`, `pref.restoreSession`, `--fresh`).
|
||||
- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on
|
||||
screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36
|
||||
gives only the main window one); window positions restored at the next start.
|
||||
screen; exports keep them). A screen-reader tree in windows other than the main one is blocked
|
||||
upstream (eframe 0.36 gives only the main window an accessibility adapter): nothing to do here
|
||||
until it adapts every window; meanwhile files stay tabs of the main window while a screen reader
|
||||
runs.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
### 19. AI features
|
||||
|
|
@ -394,6 +415,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Gap 6: plugin API 4 landed (widgets take text typed in place, a property menu, hover styles, tooltips and timers, and draw lines, vectors, gradients, shadows and text styles; panels have sliders, pictures, progress bars, dividers, sections, disabled controls and validation messages; commands for every interaction; three examples); 8–26 h → 5–20 h, retitled |
|
||||
| 2026-10-13 | minor | Gap 17: a branch's version history gets entries for its creation and each update from main; left: branches in live sessions and lists other than fills, strokes and effects merging whole; estimate and ranking unchanged |
|
||||
| 2026-10-13 | minor | Gap 16: import lines for connected instances inside generated layer trees landed (deduplicated and ordered per language); what is left is publishing mappings from the code repository with a CLI; estimate and ranking unchanged |
|
||||
| 2026-10-13 | minor | Gap 18: window places are restored at the next start (session restore: windows, tabs, pages, views and panels come back as they were left); what is left is wide-gamut colour on screen and the screen-reader tree in other windows, both blocked upstream; estimate and ranking unchanged |
|
||||
| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged |
|
||||
| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged |
|
||||
| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (window places restored at the next start; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
|
|
@ -18,7 +18,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
|
|||
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
|
||||
| Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — |
|
||||
| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |
|
||||
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
|
||||
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs (blocked upstream); each window's place, size, state and screen come back at the next start, and one whose screen is gone moves onto a screen there is; the presenter stays inside its window; one window in the browser | 1–2 |
|
||||
| Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA |
|
||||
| Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 |
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Several windows: places, sizes, maximized and full-screen states and screens are kept with the preferences and restored at the next start (a window off every screen moves onto one); numbers unchanged |
|
||||
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged |
|
||||
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
|
|
|
|||
52
docs/mcp.md
52
docs/mcp.md
|
|
@ -898,7 +898,8 @@ branches). Driven with `run_command`:
|
|||
- `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be
|
||||
opened, but not merged).
|
||||
|
||||
Each branch keeps its own version history: on a branch tab, `file.saveVersion`, `version.autosave`
|
||||
Each branch keeps its own version history, which starts with `Branch created` and gets `Updated
|
||||
from main` after each `branch.updateFromMain`: on a branch tab, `file.saveVersion`, `version.autosave`
|
||||
and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the
|
||||
branch's versions, which are saved in the file with the branch and stay with it when it is merged
|
||||
or archived. Branches aren't available in a live session.
|
||||
|
|
@ -922,6 +923,18 @@ opens one (a new file, the file at `path`, or open file `index` moved out of its
|
|||
focus a window before working on its files, and pass its id to `screenshot {window}` to see it.
|
||||
Headless (one session, no windows) and in the browser the two commands do nothing.
|
||||
|
||||
**Session restore.** The desktop app opens the windows and tabs it was left with at the next
|
||||
start. `run_command` with `session.restore {session?}` does it on demand: without `session` it
|
||||
restores the last run's session when this run started without it (else the one kept with the
|
||||
preferences); with one, `{windows: [{tabs: [{path} | {server, file}, page?, view?: [cx, cy,
|
||||
zoom]], front?, rect?, maximized?, fullscreen?}]}`, it opens those tabs in that order with their
|
||||
page and view. It returns `{windows, opened, joining (your online files on their way from their
|
||||
servers), missing (the names of files that are gone)}`; a file that is open already is used as it
|
||||
is. Headless there are no windows and no preferences file, so pass `session`: the tabs of every
|
||||
window open in the one session, in order, and the first window's front tab is the active document.
|
||||
`pref.restoreSession {on?}` turns reopening at start on or off (on by default). Attached,
|
||||
`inspect_ui` reports the kept session as `session.saved`.
|
||||
|
||||
### Live sessions
|
||||
|
||||
People (and agents) edit one file together and see each other's cursors, selections and
|
||||
|
|
@ -1128,16 +1141,40 @@ Plugin settings are kept per plugin with the preferences (in a headless session,
|
|||
```
|
||||
|
||||
Widgets (API 3, layers on the canvas a plugin draws) are driven the same way: `widget.insert
|
||||
{plugin, x?, y?}` puts one on the page (centred in the view without a position) and returns `{id,
|
||||
plugin, state, targets, output, logs, calls, fuel}`; `widget.click {id, target}` clicks one of its
|
||||
`targets` as a click on the canvas would (the plugin redraws it; one undo step; returns the same);
|
||||
`widget.list` lists every widget in the file with its plugin, page, state and targets. A widget's
|
||||
state is layer data, so it is saved, undone and synced with the file.
|
||||
{plugin, x?, y?}` puts one on the page (centred in the view without a position), selects it and
|
||||
returns `{id, plugin, state, targets, inputs, menu, timer, output, logs, calls, fuel}`;
|
||||
`widget.click {id?, target}` clicks one of its `targets` as a click on the canvas would (the plugin
|
||||
redraws it; one undo step; returns the same); `widget.list` lists every widget in the file with
|
||||
its plugin, page, state, targets, editable texts, property menu and pending timer. A widget's
|
||||
state is layer data, so it is saved, undone and synced with the file. Without an `id`, a widget
|
||||
command acts on the selected widget.
|
||||
|
||||
Everything a person can do to an API 4 widget has a command too, each a callback to its plugin
|
||||
and one undo step:
|
||||
|
||||
- `widget.setText {id?, input, text}` commits a text typed in place: `input` is one of the
|
||||
widget's `inputs` (`[{name, text, maxLength, multiline}]`); the text is cut to `maxLength` and
|
||||
put on one line unless the input is `multiline`.
|
||||
- `widget.menu {id?}` lists the property menu: `items: [{type, name, label, value, options, url}]`
|
||||
(`action`, `toggle`, `dropdown`, `color`, `separator`, `link`). `widget.menuAction {id?, item,
|
||||
value?}` uses one: no value for an action, `true` / `false` for a toggle (without it, flipped),
|
||||
one of `options` for a dropdown or a colour. A link is never opened from a command: it returns
|
||||
`{id, item, url}`.
|
||||
- `widget.fireTimer {id?}` fires the widget's pending timer (`timer` in `widget.list` is the
|
||||
milliseconds left, or null). A headless session has no clock: an agent fires timers itself. In
|
||||
the app they fire when due; a run of timer callbacks is one undo step.
|
||||
|
||||
Hover styles and tooltips are the app's to show and have no command (nothing is called for them).
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.counter"}}}}
|
||||
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"id":12,"target":"increment"}}}}
|
||||
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}}
|
||||
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.menuAction","params":{"id":12,"item":"step","value":"5"}}}}
|
||||
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.focus-list"}}}}
|
||||
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.setText","params":{"input":"title","text":"Release day"}}}}
|
||||
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"target":"focus"}}}}
|
||||
{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.fireTimer","params":{}}}}
|
||||
```
|
||||
|
||||
Plugins from a `figcraft-server`'s plugin directory ([`plugins.md`](plugins.md#plugin-directories)):
|
||||
|
|
@ -1180,7 +1217,8 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
|||
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
|
||||
properties). Then `inspect.code` on an instance or the component returns the import line and the
|
||||
snippet instead of generated code (`connected: true`), connected instances inside a generated
|
||||
layer tree are their snippet, and `inspect.componentCode` returns `connected: {importPath, name,
|
||||
layer tree are their snippet with their import lines at the top of the tree's code (one per
|
||||
import path, in order, each name once), and `inspect.componentCode` returns `connected: {importPath, name,
|
||||
template, import, code}` with the snippet as `usage`. Example: template `<Button
|
||||
variant="primary" icon="{Has icon ? "cart" : "none"}">{label}</Button>` with import path
|
||||
`@acme/ui` gives `import { Button } from "@acme/ui";` then `<Button variant="primary" icon="none">{"Buy
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> **Generated:** 2026-10-13 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (547 commands in the engine).
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (551 commands in the engine).
|
||||
|
||||
**211 / 211 menu items (100.0%)**
|
||||
|
||||
|
|
|
|||
260
docs/plugins.md
260
docs/plugins.md
|
|
@ -19,6 +19,10 @@ permissions its manifest asks for, and nothing more.
|
|||
- **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer
|
||||
(frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its
|
||||
parts calls the plugin back to redraw it from its state, which is kept in the document.
|
||||
- **More for widgets and panels (API 4).** A widget's texts can be edited in place, a selected
|
||||
widget shows a property menu the plugin declares, its parts can have a hover style and a
|
||||
tooltip, and it can ask to be called back after a delay. Widgets draw lines, vectors, gradients
|
||||
and shadows too, and panels get sliders, pictures, progress bars, dividers and sections.
|
||||
- **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and
|
||||
can narrow the commands it may call. FigCraft enforces both.
|
||||
- **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are
|
||||
|
|
@ -56,6 +60,10 @@ of the colour when a drag in the picker ends); its hex can also be typed. Pressi
|
|||
changing a field calls the plugin back with every field's value; what that callback does is one
|
||||
undo step (none when it changes nothing), and the plugin can update the panel or close it. The
|
||||
module stays loaded while its panel is open, so the plugin keeps its state between callbacks.
|
||||
With API 4 a panel also has sliders (a number field beside each takes the keyboard; while one is
|
||||
dragged the plugin hears of it at most ten times a second, and once more when the drag ends),
|
||||
pictures, progress bars, dividers and sections that fold the controls after them; a control can
|
||||
be greyed out, and a field can show a validation message under it.
|
||||
Closing the window ends the plugin's session. Each plugin has at most one panel, and the panels of
|
||||
different plugins open side by side (leftwards from the first, three to a row);
|
||||
running a plugin again replaces its own panel. An open panel costs nothing while nobody uses it (no
|
||||
|
|
@ -70,11 +78,47 @@ and its state (a small JSON object) in its own layer data, so a saved file, a co
|
|||
a live session carries the widget as it is, and a copy is a widget of its own. A widget whose
|
||||
plugin isn't installed stays as it was drawn; clicking it says the plugin is missing.
|
||||
|
||||
A widget written for API 4 can do more:
|
||||
|
||||
- **Text typed in place.** A text the plugin made editable shows an I-beam; a click edits it in
|
||||
place with FigCraft's text editor (caret, selection, the input method), all of it selected. What
|
||||
you type shows at once, stops at the text's maximum length, and stays on one line unless the
|
||||
text takes several. Enter (Escape or a click elsewhere too; in a text of several lines Enter
|
||||
starts a new line) commits: the plugin is called back with the text and redraws the widget, as
|
||||
one undo step. Leaving the text as it was calls nobody.
|
||||
- **A property menu.** A selected widget shows the menu its plugin declared as a bar beside it on
|
||||
the canvas (above it, or under it when there is no room), and the same items under **Widget** in
|
||||
the Design panel, where Tab reaches them: buttons, toggles, dropdowns, a row of colours to pick
|
||||
one from, separators and links. Using one calls the plugin back with the item's name and its new
|
||||
value, as one undo step. A link opens only when it is clicked, only if it is an `http` or `https`
|
||||
address, and its plugin isn't called.
|
||||
- **Hover styles and tooltips.** A part can change its fill, stroke or opacity while the pointer
|
||||
is over it, and show a tooltip. FigCraft does that itself: the plugin isn't called for pointer
|
||||
moves, and the look is never part of the document (it isn't saved, synced or undone).
|
||||
- **Timers.** A callback can ask for the widget to be called back after 100 ms to an hour. A
|
||||
widget has one timer at most; asking again replaces it. Timers are kept with the open file, not
|
||||
in it: they run while the file is open and in front (one that came due meanwhile fires when you
|
||||
come back to it), wait while you drag or type and while a prototype is presented, and are gone
|
||||
once the file is closed or the widget is deleted. A run of timer callbacks is one undo step,
|
||||
and undoing it, or the step that started the timer, stops the timer. The window only wakes
|
||||
when the next timer is due.
|
||||
|
||||
**Timers in a live session** run for one person: whoever's click (or typing, or menu choice)
|
||||
started the timer last. Starting a timer stamps the widget's layer with a mark only that
|
||||
person's app knows, and a timer fires only while its mark is still on the layer; when someone
|
||||
else starts the widget's timer, their mark replaces it and the earlier timer stops without
|
||||
calling anyone. So two people's timers never take turns rewriting a widget, everyone else sees
|
||||
the timer's updates arrive like any other edit, and when the person whose timer it is leaves, it
|
||||
stops until someone starts it again. A file you may only view runs no callbacks at all.
|
||||
|
||||
In a presented prototype (the presenter or the preview pane) clicks on a widget's clickable parts
|
||||
call its plugin back the same way, with the same limits, but on the prototype's own copy of the
|
||||
file: the widget changes in the prototype only, the file and its undo history stay as they are,
|
||||
and restarting the prototype starts the widget over. An interaction on the clicked layer wins over
|
||||
the widget.
|
||||
the widget. Hover styles and tooltips show there too; a click on an editable text opens a field
|
||||
over it (one line: Enter or a click elsewhere commits, Escape leaves the text as it was); and
|
||||
timers run on the player's clock, for the prototype's copy only. While a prototype is presented
|
||||
full screen, the file's own timers wait.
|
||||
|
||||
If a callback fails, it is rolled back and its message shown; a plugin that returned an error
|
||||
keeps its panel, one that crashed or ran out of fuel or memory loses it.
|
||||
|
|
@ -94,8 +138,18 @@ From scripts, the CLI and MCP the same commands apply, panels included:
|
|||
{"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.counter", "x": 0, "y": 0}}
|
||||
{"command": "widget.list", "params": {}}
|
||||
{"command": "widget.click", "params": {"id": 12, "target": "increment"}}
|
||||
{"command": "widget.menu", "params": {"id": 12}}
|
||||
{"command": "widget.menuAction", "params": {"id": 12, "item": "step", "value": "5"}}
|
||||
{"command": "plugin.import", "params": {"path": "target/plugins/dist/focus-list"}}
|
||||
{"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.focus-list", "x": 0, "y": 200}}
|
||||
{"command": "widget.setText", "params": {"input": "title", "text": "Release day"}}
|
||||
{"command": "widget.click", "params": {"target": "focus"}}
|
||||
{"command": "widget.fireTimer", "params": {}}
|
||||
```
|
||||
|
||||
A widget command without an `id` acts on the selected widget (`widget.insert` selects the one it
|
||||
makes).
|
||||
|
||||
`figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over
|
||||
MCP, `run_command` takes the same ids. A session started by the CLI or the MCP server starts with no
|
||||
plugins: import them in the script.
|
||||
|
|
@ -111,9 +165,13 @@ plugins: import them in the script.
|
|||
| `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. |
|
||||
| `plugins.runLast` | `{}` | Runs the last plugin command again. |
|
||||
| `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. |
|
||||
| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. |
|
||||
| `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. |
|
||||
| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. |
|
||||
| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, inputs, menu, timer, output, logs, calls, fuel}`: `targets` are the names of its clickable parts, `inputs` its editable texts (`[{name, text, maxLength, multiline}]`), `menu` its property menu (as `widget.menu` lists it), `timer` the milliseconds until its pending timer is due (null: none). |
|
||||
| `widget.click` | `{id?, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in; without `id`, the selected one), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. |
|
||||
| `widget.setText` | `{id?, input, text, fuel?}` | API 4. Commits `text` to the editable text named `input`, as typing it in place and pressing Enter does: cut to its maximum length, on one line unless it takes several, control characters dropped. The plugin is called back with it; one undo step; returns like `widget.insert`. |
|
||||
| `widget.menu` | `{id?}` | API 4. `{id, plugin, items: [{type, name, label, value, options, url}]}`: the widget's property menu (`type`: `action`, `toggle`, `dropdown`, `color`, `separator` or `link`). |
|
||||
| `widget.menuAction` | `{id?, item, value?, fuel?}` | API 4. Uses menu item `item`: an action takes no `value`; a toggle `true` or `false` (without one it flips); a dropdown or a colour one of its `options`. The plugin is called back; one undo step; returns like `widget.insert`. A link isn't opened and its plugin isn't called: `{id, item, url}`. |
|
||||
| `widget.fireTimer` | `{id?, fuel?}` | API 4. Fires the widget's pending timer now, for scripts and tests (the app fires it when it is due; a headless session has no clock for it): the plugin is called back. A run of timer callbacks is one undo step. Refused when no timer is pending. |
|
||||
| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, inputs, menu, timer}]}`: every widget in the file. |
|
||||
| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. |
|
||||
| `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. |
|
||||
| `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. |
|
||||
|
|
@ -203,12 +261,12 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p
|
|||
| `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. |
|
||||
| `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. |
|
||||
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
|
||||
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings) or `3` (widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. |
|
||||
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (text input, property menus, hover, timers and richer drawing in widgets; sliders, pictures and sections in panels). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. What a version added is read only from plugins that ask for it: an older plugin's descriptions are read as its version read them, so it behaves as it always did. |
|
||||
| `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). |
|
||||
| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. |
|
||||
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |
|
||||
| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`, unless it's a widget (a widget needs no command). |
|
||||
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. |
|
||||
| `params[]` | `{name, label?, type, default?, options?, min?, max?, step?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list), `color` (hex, `#RRGGBB` or `#RRGGBBAA`) or, with API 4, `slider` (a number from `min` to `max`, 0 to 100 when not given, in `step`s). A default that doesn't fit is dropped. |
|
||||
|
||||
## What a plugin can't do
|
||||
|
||||
|
|
@ -234,12 +292,19 @@ WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100
|
|||
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
|
||||
and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is
|
||||
at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and
|
||||
its images 1 MB each, 2 MB together and 4096 pixels on a side.
|
||||
its images 1 MB each, 2 MB together and 4096 pixels on a side. With API 4: a text or a typed text
|
||||
is at most 1,000 characters (`maxLength` lowers it), a vector's path data 8 KB with every
|
||||
coordinate within ±4,000 and 64 vectors in a widget, a gradient has 2–8 stops, a tooltip is at
|
||||
most 200 characters, a property menu has at most 16 items with 32 options each and links of at
|
||||
most 2,048 characters, a timer runs 100 ms to an hour with one pending per widget, and a panel is
|
||||
at most 1 MB with its pictures 512 KB together (a picture is shown up to 2,048 pixels on a side).
|
||||
Every one of those callbacks (typed text, a menu item, a timer) is metered and capped like a click.
|
||||
|
||||
## ABI (version 3)
|
||||
## ABI (version 4)
|
||||
|
||||
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2
|
||||
module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values
|
||||
Version 2 adds panels and settings to version 1, version 3 adds widgets, and version 4 adds one
|
||||
host function (`widget_timer`), more events for widgets and more in the panel spec and the widget
|
||||
description; a version 1, 2 or 3 module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values
|
||||
are `i32`.
|
||||
|
||||
The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts,
|
||||
|
|
@ -265,6 +330,16 @@ for a new widget) and what was clicked (`event` is null when it's inserted):
|
|||
{"api": 3, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"target": "increment"}}}
|
||||
```
|
||||
|
||||
With API 4, `event` is also one of these, for a text typed in place, an item of the property
|
||||
menu (`value`: a toggle's new state, a dropdown's or colour's choice, null for an action) and a
|
||||
timer that is due:
|
||||
|
||||
```json
|
||||
{"input": "title", "text": "Release day"}
|
||||
{"menu": "step", "value": "5"}
|
||||
{"timer": true}
|
||||
```
|
||||
|
||||
It returns `0` for success; anything else fails the run (and rolls it back), with the text passed
|
||||
to `set_output` as the error message.
|
||||
|
||||
|
|
@ -286,6 +361,7 @@ included, is refused when the plugin is imported):
|
|||
| `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. |
|
||||
| `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. |
|
||||
| `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). |
|
||||
| `widget_timer` | `(ms) -> status` | API 4. Asks for the widget being called back to be called again in `ms` milliseconds (100 to 3,600,000), replacing its pending timer; `0` stops it. `1`: refused (out of range, or the manifest's `api` is below 4), the reply says why. The timer starts when the callback succeeds. |
|
||||
|
||||
A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only
|
||||
be called while `figcraft_run` runs (not from a start function). Settings are forgotten when the
|
||||
|
|
@ -313,6 +389,37 @@ a note without resetting the other fields. Buttons next to each other share a ro
|
|||
is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values
|
||||
that don't fit their field.
|
||||
|
||||
API 4 adds these controls and fields:
|
||||
|
||||
```json
|
||||
{"title": "Dot grid", "controls": [
|
||||
{"type": "text", "name": "name", "label": "Name", "value": "", "error": "Give the grid a name"},
|
||||
{"type": "slider", "name": "columns", "label": "Dots per row", "min": 2, "max": 12, "step": 1, "value": 6},
|
||||
{"type": "section", "label": "Spacing", "value": false},
|
||||
{"type": "slider", "name": "gap", "label": "Gap", "min": 0, "max": 32, "step": 1, "value": 8},
|
||||
{"type": "divider"},
|
||||
{"type": "image", "label": "A preview of the dot grid", "data": "Qk02bAAA…", "height": 120, "fit": "fit"},
|
||||
{"type": "progress", "label": "36 of 144 dots", "value": 0.25},
|
||||
{"type": "button", "name": "generate", "label": "Generate", "disabled": true}
|
||||
]}
|
||||
```
|
||||
|
||||
- `slider`: a field holding a number from `min` to `max` (0 to 100 when not given) on a `step`
|
||||
counted from `min` (`number` takes `step` too).
|
||||
- `image`: a picture from its file's bytes as base64 in `data` (PNG, JPEG, BMP…, usually carried
|
||||
in the module), `height` high (16–400, 120 when not given), `fit` in the panel's width (`fit`
|
||||
shows all of it, `fill` stretches it, `crop` and `tile` cover the box); `label` names it for
|
||||
screen readers.
|
||||
- `progress`: a bar filled to `value` (0–1), with `label` above it.
|
||||
- `divider`: a line across the panel.
|
||||
- `section`: a heading (`label`) that folds the controls after it, up to the next section or
|
||||
divider; `value` says whether it starts open (true when not given). Folding is the user's and
|
||||
calls nobody.
|
||||
- `disabled: true` on any control greys it out: it can't be used (and `plugin.event` refuses it).
|
||||
- `error` on a field shows that message under it (200 characters at most).
|
||||
|
||||
None of these needs a `name` except `slider`. An API 4 panel may be 1 MB.
|
||||
|
||||
### Widget description
|
||||
|
||||
```json
|
||||
|
|
@ -339,11 +446,66 @@ as base64, usually carried in the module itself; without a size it takes the ima
|
|||
FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth,
|
||||
image sizes) and refuses it if anything is off.
|
||||
|
||||
API 4 adds:
|
||||
|
||||
```json
|
||||
{"state": {"title": "Launch checklist"},
|
||||
"menu": [
|
||||
{"type": "action", "name": "clear", "label": "Clear ticked"},
|
||||
{"type": "toggle", "name": "pinned", "label": "Pinned", "value": false},
|
||||
{"type": "dropdown", "name": "length", "label": "Countdown", "options": ["30 seconds", "1 minute"], "value": "30 seconds"},
|
||||
{"type": "color", "name": "paper", "label": "Paper", "options": ["#FFF3B0", "#D9F2E3"], "value": "#FFF3B0"},
|
||||
{"type": "separator"},
|
||||
{"type": "link", "name": "help", "label": "About FigCraft", "url": "https://getartcraft.com/apps/figcraft"}],
|
||||
"root": {"type": "frame", "layout": "vertical", "width": 272, "gap": 10, "padding": [16, 16, 20, 16], "radius": [2, 2, 14, 2],
|
||||
"gradient": {"type": "linear", "angle": 90, "stops": [{"at": 0, "color": "#FFF7C7"}, {"at": 1, "color": "#FFF3B0"}]},
|
||||
"shadow": {"x": 0, "y": 4, "blur": 12, "color": "#00000030"}, "children": [
|
||||
{"type": "text", "text": "Launch checklist", "input": "title", "maxLength": 60, "width": 240, "size": 16, "weight": 700,
|
||||
"textAlign": "left", "maxLines": 2, "tooltip": "Click to rename"},
|
||||
{"type": "line", "width": 240, "stroke": "#00000022", "fillWidth": true},
|
||||
{"type": "frame", "target": "toggle.0", "layout": "horizontal", "justify": "center", "width": 16, "height": 16, "radius": 4, "fill": "#2B2B2B",
|
||||
"hover": {"opacity": 0.8}, "children": [{"type": "vector", "path": "M2 6 L5 9 L10 3", "stroke": "#FFFFFF", "strokeWeight": 2}]},
|
||||
{"type": "image", "data": "iVBORw0KGgo…", "width": 24, "height": 24, "fit": "fit"}
|
||||
]}}
|
||||
```
|
||||
|
||||
- **Layers.** `line` is a horizontal line `width` long (black and 1 thick unless it has a
|
||||
`stroke`). `vector` is a shape from SVG path data in `path` (`M L H V C S Q T A Z`, absolute or
|
||||
relative); its box is the path's, and a `width` or `height` scales it.
|
||||
- **Auto layout.** `padding` is one number or four (`[top, right, bottom, left]`), `radius` one
|
||||
or four (`[top-left, top-right, bottom-right, bottom-left]`). `fillWidth` / `fillHeight` on a
|
||||
child take the room that's left on that axis of its auto layout frame. That frame needs a size
|
||||
on the axis (its own, or because it fills too): a frame can't hug what fills it, and the widget
|
||||
itself has nothing to fill.
|
||||
- **Looks.** `gradient` replaces `fill` on frames, rectangles, ellipses and vectors: `linear`
|
||||
along `angle` in degrees (0 runs left to right, 90 top to bottom, the default) or `radial` from
|
||||
the centre, with 2–8 `stops` (`at` 0–1, `color` hex). `shadow` is a drop shadow (`x`, `y`,
|
||||
`blur`, `spread`, `color`).
|
||||
- **Text.** `weight` (100–900; it overrides `bold`), `textAlign` (`left`, `center`, `right`),
|
||||
and with a `width`, `maxLines`: the text keeps a fixed box and what doesn't fit is cut with an
|
||||
ellipsis.
|
||||
- **Images.** `fit` is `fill` (the default), `fit`, `crop` or `tile`.
|
||||
- **Editable text.** `input` names a text the user can edit in place; the plugin gets
|
||||
`{"input": name, "text": …}` when it's committed. `maxLength` (1–1,000) caps it and `multiline`
|
||||
lets it take several lines. Each name is used once. The plugin decides what the widget shows
|
||||
afterwards: a callback that draws nothing leaves the text as it was.
|
||||
- **Hover and tooltip.** `hover` (`fill`, `stroke`, `opacity`) is what changes while the pointer
|
||||
is over the layer; `tooltip` (200 characters at most) shows when it rests there.
|
||||
- **The property menu.** `menu` beside `root` lists the items (16 at most): `action` (a button),
|
||||
`toggle` (`value`: true or false), `dropdown` (`value`: one of `options`), `color` (`value`:
|
||||
one of the hex colours in `options`), `separator` and `link` (`url`, http or https). Every item
|
||||
but a separator has a `name`, used once, and a `label`. Left out, the widget keeps the menu it
|
||||
has; an empty list removes it. FigCraft remembers a toggle's, dropdown's or colour's new value
|
||||
when it's chosen, so a callback needn't hand the menu back.
|
||||
|
||||
A plugin written for API 3 gets none of this: the fields are ignored as unknown fields always
|
||||
were, and the new kinds and the four-number forms are refused as they always were.
|
||||
|
||||
## Writing a plugin in Rust
|
||||
|
||||
`plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate
|
||||
`figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and
|
||||
`plugins/examples` has three plugins:
|
||||
`plugins/examples` has five plugins:
|
||||
|
||||
- **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left
|
||||
by duplicating) and optionally their letter case, inside the selection too. Two commands: one
|
||||
|
|
@ -354,13 +516,26 @@ image sizes) and refuses it if anything is off.
|
|||
button; the palette
|
||||
picked last is remembered as a setting for next time. **From hex colours…** takes a palette of
|
||||
hex colours in the parameters form.
|
||||
- **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the
|
||||
count kept in the widget's state. It only reads the document.
|
||||
- **Counter** (`counter`, API 4): a widget, a count between − and + buttons; each click changes the
|
||||
count kept in the widget's state. Its property menu resets the count and picks the step of a
|
||||
click and the count's colour, and its buttons have a hover style and a tooltip. It only reads
|
||||
the document.
|
||||
- **Focus list** (`focus-list`, API 4): a sticky checklist widget laid out with auto layout. Its
|
||||
title and items are edited in place, a last line adds an item, each item has a box to tick (a
|
||||
vector tick), and a button starts a focus countdown that a timer ticks down every second, with
|
||||
a progress line. The menu clears ticked items, sets the countdown's length and picks the note's
|
||||
colour.
|
||||
- **Dot grid** (`dot-grid`, API 4): a panel with sliders for the dots per row, their size and the
|
||||
gap between them (in a folding section), a colour, a preview picture that follows them (drawn
|
||||
pixel by pixel in the plugin: no image file) and a progress bar. Without a name the field says
|
||||
so and Generate is greyed out; Generate puts the grid in the middle of the view as one group.
|
||||
|
||||
`cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the
|
||||
`wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and
|
||||
builds them, and leaves each example's manifest and module side by side in
|
||||
`target/plugins/dist/<example>/`, ready to import.
|
||||
builds them, leaves each example's manifest and module side by side in
|
||||
`target/plugins/dist/<example>/`, ready to import (it prints each module's size: 75–92 KB), and
|
||||
then runs every built example in the engine as a script would, so a module that stops working
|
||||
fails the build.
|
||||
|
||||
A plugin of your own:
|
||||
|
||||
|
|
@ -412,8 +587,46 @@ panel calls back: `control`, `value` and every field's `values`); returning `Err
|
|||
rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the
|
||||
panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With
|
||||
`"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is
|
||||
inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. Outside
|
||||
WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`.
|
||||
inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With
|
||||
`"api": 4`: `input.widget.event` says what happened (`WidgetEvent::Insert`, `Click(target)`,
|
||||
`Text { input, text }`, `Menu { name, value }` or `Timer`), `set_timer(ms)` and `cancel_timer()`
|
||||
manage the widget's timer, and the `ui` module builds descriptions without spelling the JSON out:
|
||||
|
||||
```rust
|
||||
use figcraft_plugin_sdk::ui::{Node, menu_action, menu_toggle, render};
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, WidgetEvent, entry, json, set_timer};
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let w = input.widget.ok_or("Insert me from Plugins ▸ Widgets")?;
|
||||
let mut title = w.state["title"].as_str().unwrap_or("Untitled").to_string();
|
||||
match &w.event {
|
||||
WidgetEvent::Text { input, text } if input == "title" && !text.is_empty() => title = text.clone(),
|
||||
WidgetEvent::Click(target) if target == "remind" => set_timer(60_000)?,
|
||||
_ => {}
|
||||
}
|
||||
let root = Node::column()
|
||||
.width(240.0)
|
||||
.gap(8.0)
|
||||
.padding(12.0)
|
||||
.radius(12.0)
|
||||
.fill("#FFFFFF")
|
||||
.shadow(0.0, 2.0, 8.0, "#00000033")
|
||||
.child(Node::text(&title).input("title", 60).width(216.0).weight(600.0).tooltip("Click to rename"))
|
||||
.child(Node::line(216.0).stroke("#E6E6E6", 1.0).fill_width())
|
||||
.child(Node::row().target("remind").padding(8.0).radius(8.0).fill("#F0F0F0").hover(Some("#E0E0E0"), None, None)
|
||||
.child(Node::text("Remind me in a minute")));
|
||||
render(&json!({"title": title}), root, Some(vec![menu_action("clear", "Clear"), menu_toggle("pinned", "Pinned", false)]))?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
```
|
||||
|
||||
`Node::set(key, value)` reaches any property the builders don't name, and plain `json!` still
|
||||
works everywhere. For panels, `ui::slider`, `ui::image` (from a file's bytes; `ui::base64` encodes
|
||||
them), `ui::progress`, `ui::divider`, `ui::section`, `ui::disabled` and `ui::invalid` make the
|
||||
controls API 4 added. Outside WebAssembly every host call fails, so the plugin's own logic can be
|
||||
unit-tested with `cargo test`.
|
||||
|
||||
Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`,
|
||||
import what you need from `figcraft`.
|
||||
|
|
@ -425,9 +638,14 @@ import what you need from `figcraft`.
|
|||
install counts, and updates are found when you browse, not in the background. The browser build
|
||||
can't use a directory yet.
|
||||
- Plugins written for other apps (JavaScript plugins) don't run.
|
||||
- Widgets: clicks are their only events (no text input, hover, property menu or timers), they
|
||||
can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no
|
||||
vectors, components or variables).
|
||||
- Panels: no images, sliders or free layout inside a panel.
|
||||
- Widgets: they can't open a panel, their layers can't be components or use styles or variables,
|
||||
an image is bytes in the description (the module carries it; a widget can't name a file from
|
||||
the plugin's `assets/` folder), there are no pointer events beyond clicks and FigCraft's own
|
||||
hover styles (no drag, no callback on hover), and a timer doesn't run while its file is closed
|
||||
or behind another tab. In a presented prototype, typed text is one line and can't be emptied.
|
||||
- Panels: no free layout or tabs inside a panel (sections fold; controls stack), and a slider has
|
||||
no tick marks or labels of its own.
|
||||
- The property menu bar on the canvas is used with the pointer; from the keyboard its items are
|
||||
in the Design panel.
|
||||
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
|
||||
{pluginData: {key: value}}}`; there is no separate per-document store.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (plugin API 4: widget text input, property menus, hover styles, timers and richer drawing, panel sliders and pictures; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -58,7 +58,7 @@ blockers.
|
|||
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
|
||||
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 |
|
||||
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
|
||||
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 |
|
||||
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); plugin API 4 (text typed in place, property menus, hover styles, timers and richer drawing in widgets; sliders, pictures and sections in panels) | a public catalogue, publisher accounts and signatures, the directory in the browser, widgets that open panels or use components and variables; compatibility with plugins written for other apps is an owner decision | 5–20 |
|
||||
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
|
||||
|
||||
## Next, after the current focus
|
||||
|
|
@ -73,6 +73,7 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | M11: plugin API 4 (widgets: text typed in place, a property menu, hover styles, tooltips, timers, lines, vectors, gradients, shadows and text styles; panels: sliders, pictures, progress bars, dividers, sections, disabled controls and validation messages; `widget.setText` / `widget.menu` / `widget.menuAction` / `widget.fireTimer`; three examples); M11's tail 8–26 h → 5–20 h |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Focus item 4 adds the zh-Hans draft's review |
|
||||
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Focus item 4 no longer lists Latin American Spanish |
|
||||
| 2026-10-12 | minor | M5: prototyping tail: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, `proto.evaluate`; M5's tail 3–6 h → 2–5 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (plugin API 4: widget text input, property menus, hover styles, timers and richer drawing, panel sliders and pictures; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places.
|
|||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Full target (ready for real work) | ~53% | ~265–516 h | multiplayer (42–84), localization (29–51), plugins (5–20), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) |
|
||||
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 |
|
||||
| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 |
|
||||
| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 |
|
||||
| 18 | Plugins & widgets | 4 | 70% | 42% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version), then plugin API 4: widgets take text typed in place, a property menu, hover styles, tooltips and timers and draw lines, vectors, gradients, shadows and text styles; panels have sliders, pictures, progress bars, dividers, sections, disabled controls and validation messages; five examples. Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets can't open a panel, use components, styles or variables in their layers or take drags; plugins written for other apps are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 5–20 |
|
||||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
|
|
@ -131,7 +131,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) |
|
||||
| Localization | 3 | 39% | 29–51 | The layer, an English catalog and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) |
|
||||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| Ecosystem/plugins | 3 | 43% | 5–20 | Own WASM plugins with a Rust SDK and five examples (#101), plugin panels with sliders and pictures and settings (ABI v2, API 4), widgets that take clicks, typed text, a property menu, hover and timers (API 3, API 4), a self-hosted plugin directory on `figcraft-server`; no compatibility with plugins written for other apps, no public catalogue or community files; the CLI, control channel and MCP server are ahead for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
| **Ready for real work** | 100 | **~53%** | **~262–519** | | |
|
||||
|
||||
|
|
@ -509,8 +509,13 @@ Hours in this narrative are the builders' estimates; the tables above supersede
|
|||
several plugins side by side and the colour picker on plugin colour fields. Then a plugin
|
||||
directory on `figcraft-server`: bundles published with the server key and checked in full,
|
||||
browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads
|
||||
checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher
|
||||
accounts and signatures; richer widget events; the directory in the browser. 8–26 h.
|
||||
checked against their SHA-256, updates by version. Then plugin API 4: text typed in place in a
|
||||
widget, a property menu by the selected widget and in the Design panel, hover styles and
|
||||
tooltips shown by the app, timers (one per widget, for whoever started it last in a live
|
||||
session), lines, vectors, gradients, shadows and text styles in widgets, sliders, pictures and
|
||||
sections in panels, `widget.setText` / `widget.menu` / `widget.menuAction` / `widget.fireTimer`.
|
||||
*Left:* a public catalogue, publisher accounts and signatures; widgets that open panels or use
|
||||
components and variables; the directory in the browser. 5–20 h.
|
||||
9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer
|
||||
pages: engine, render and headless UI-frame timings). The canvas keeps its last image and
|
||||
re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders
|
||||
|
|
@ -625,6 +630,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Plugin API 4 (gap 6; plugins written for API 1–3 run unchanged, proved by their tests and by running modules built before it): a widget's texts can be edited in place with the text editor (caret, selection, input method; a maximum length and one line unless the plugin allows more) and are committed to the plugin as one undo step; a selected widget shows the property menu its plugin declares, as a bar by the widget on the canvas and as the same items in the Design panel (buttons, toggles, dropdowns, a row of colours, separators, links that open only on a click and only to http or https addresses); parts have hover styles (fill, stroke, opacity) and tooltips the app shows itself, never calling the plugin, never part of the document; a widget can ask to be called back after 100 ms to an hour (`widget_timer`, the one new host function): one timer per widget, kept with the open file, waking the window only when the next is due, a run of callbacks one undo step, and in a live session running for whoever started it last (a stamp on the layer decides, so two people's timers never take turns), never in a file you may only view; widgets draw lines, vectors from SVG path data, gradients, shadows, per-corner radii, per-side padding, children that fill their frame, text weights, alignment and truncation, and image fit modes, all validated and capped; panels have sliders (throttled while dragged), pictures, progress bars, dividers, folding sections, disabled controls and validation messages; `widget.setText`, `widget.menu`, `widget.menuAction` and `widget.fireTimer` drive all of it headless, and a presented prototype takes typing, hover and timers on its own copy; the SDK has builders (`ui::Node`, menu items, panel controls), the Counter example gained a menu, and Focus list (typed text, a timer, auto layout) and Dot grid (sliders and a generated preview picture) are new; `cargo xtask plugins` now also runs every built example in the engine. Left for widgets: opening a panel, components, styles and variables in their layers, drags, image files from the plugin's folder, timers while the file is closed. Plugins & widgets 60%/35% → 70%/42%, 8–26 h → 5–20 h; Ecosystem dimension 38% → 43%; weighted totals unchanged at this rounding; full parity ~268–522 h → ~265–516 h |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (session restore: windows and tabs come back as they were left; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
|
|
@ -24,7 +24,8 @@ interaction feel not yet compared.
|
|||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open and recent files, a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content |
|
||||
| Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; each window comes back at the next start where it was, with its size, state, panels and tabs (session restore, next row). A screen-reader tree in windows other than the main one is blocked upstream: eframe 0.36 gives only the main window an accessibility adapter, so while a screen reader runs files stay tabs of the main window (restored ones too) |
|
||||
| Session restore | done (ours) | The app comes back the way it was left. Kept with the preferences, written whenever it changes (at once, then at most once a second, never while nothing changes; a crash or a forced quit has it too): each window's place, size, maximized or full-screen state and screen, its side panels' tabs and widths, its tabs in order with the one in front, and per tab the saved file's path or, for one of your online files, its server and id (never a token), the page shown and the view (one view per file, not per page). At the next start the windows and tabs open again; a window whose screen is gone or that no screen shows enough of to grab moves onto a screen (its own if it is still there, else the nearest) and is never larger than it; files that are gone are skipped with one message ("2 files couldn't be reopened") and stay in Open recent; an online file is joined again as its owner and takes its place among the tabs when it arrives (one whose server can't be reached is skipped with a message saying so, and stays under Home ▸ Online); a file or invite link the app was started with opens in addition, in front. Documents never saved aren't part of it: unsaved work comes back from the recovery copies after a crash, and a normal quit keeps none. Preferences ▸ Reopen windows and tabs from last time (`pref.restoreSession`, on by default) and the `--fresh` flag start without it. If the last run never got through restoring (a mark written before and cleared once the windows have settled), this run starts on the home screen without restoring, and the home screen offers Restore last session (`session.restore`) instead. A first run is still the home screen. Holding Shift at start isn't read (the flag is the way); the browser keeps no preferences, so nothing is restored there |
|
||||
| Multiplayer avatars, Share button | missing | waits for multiplayer |
|
||||
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
|
||||
|
||||
|
|
@ -164,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Session restore: the windows and tabs open when the app was left open again at the next start: each window's place, size, maximized or full-screen state, screen, panels and tabs in order with the one in front; per tab the file (or your online file, joined again as its owner), its page and view. Written with the preferences as it changes; windows off every screen move onto one; files that are gone get one message; a crash during restoring starts the next run without it and offers it on the home screen; Preferences ▸ Reopen windows and tabs from last time, `--fresh`, `session.restore`, `pref.restoreSession`; `window.list` reports each window's rectangle and state. Several windows: positions are restored; the screen-reader tree in other windows stays blocked upstream |
|
||||
| 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |
|
||||
| 2026-10-12 | minor | Everyday editing partials: number fields (scrub a step a point, ×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it; arrow keys by the nudge preferences, opacity and rotation by 1 and 10; brackets and units in arithmetic; `50%` relative outside percentage fields; Tab commits and moves on; an untouched field commits nothing; relative edits and arrow keys on Mixed go to each layer in one undo step) done; outline mode's Include hidden layers and Include object bounds, drawn and clickable, done; Hide when publishing from the Assets panel done (the name's leading `.`); cursors per tool and modifier done (a table in the engine, drawn rotate, pen, eyedropper and comment cursors, not-allowed on refused edits; guides and handles keep their cursors); resizable panels done (limits, double-click reset, kept per window and in the preferences, the canvas keeps its room in a small window); a file or page made in the dark theme has a #1E1E1E page (the light theme and sessions without an interface: #F5F5F5) and text drawn on a dark page is white; Enter on a text selects all of it (test added). Menu items: View ▸ Outlines ▸ Include hidden layers / Include object bounds, View ▸ Panels ▸ Reset panel widths, Libraries ▸ Hide when publishing |
|
||||
| 2026-10-12 | minor | Drag threshold and double-clicks: the second press of a double-click began a move or resize that was never finished, so double-clicking text inside a group often didn't start editing (from the middle of the text) or dragged and resized it when the hand moved a pixel or two; a move or resize now starts only after 3 px of travel on screen and a double-click drops the press's gesture first |
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ dependencies = [
|
|||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dot-grid"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
|
|
@ -16,6 +23,13 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "focus-list"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"id": "org.figcraft.examples.counter",
|
||||
"name": "Counter",
|
||||
"version": "1.0.0",
|
||||
"description": "A widget that counts on the canvas: click its + and − buttons. The count is kept in the document.",
|
||||
"version": "1.1.0",
|
||||
"description": "A widget that counts on the canvas: click its + and − buttons. Its property menu resets it and picks the step and the colour. The count is kept in the document.",
|
||||
"main": "counter.wasm",
|
||||
"api": 3,
|
||||
"api": 4,
|
||||
"widget": true,
|
||||
"permissions": { "access": "read" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,94 @@
|
|||
//! Counter: a widget on the canvas with − and + buttons around a count. Each click calls the
|
||||
//! plugin back with the widget's state; it hands back the new count and the redrawn widget.
|
||||
//! plugin back with the widget's state; it hands back the new count and the redrawn widget. Its
|
||||
//! property menu (shown by the selected widget and in the Design panel) resets the count, picks
|
||||
//! the step of a click and the count's colour; the buttons have a hover style and a tooltip.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, entry, json, render_widget};
|
||||
use figcraft_plugin_sdk::ui::{Node, menu_action, menu_color, menu_dropdown, menu_link, menu_separator, render};
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, WidgetEvent, entry, json};
|
||||
|
||||
/// The count stays within ± this.
|
||||
const LIMIT: i64 = 999_999;
|
||||
/// The steps the menu offers, and the colours for the count.
|
||||
const STEPS: [&str; 3] = ["1", "5", "10"];
|
||||
const TINTS: [&str; 4] = ["#1E1E1E", "#0D99FF", "#14AE5C", "#F24822"];
|
||||
|
||||
/// The count after clicking `target` (none: the widget was just inserted).
|
||||
fn next(count: i64, target: Option<&str>) -> i64 {
|
||||
let n = match target {
|
||||
Some("increment") => count.saturating_add(1),
|
||||
Some("decrement") => count.saturating_sub(1),
|
||||
_ => count,
|
||||
};
|
||||
n.clamp(-LIMIT, LIMIT)
|
||||
/// What the widget remembers.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Counter {
|
||||
count: i64,
|
||||
step: i64,
|
||||
tint: String,
|
||||
}
|
||||
|
||||
/// A square button that calls back with `target`.
|
||||
fn button(target: &str, name: &str, label: &str) -> Value {
|
||||
json!({"type": "frame", "name": name, "target": target, "layout": "horizontal", "justify": "center", "width": 32, "height": 32, "radius": 8, "fill": "#F0F0F0",
|
||||
"children": [{"type": "text", "text": label, "size": 16, "bold": true, "color": "#1E1E1E"}]})
|
||||
}
|
||||
impl Counter {
|
||||
fn from(state: &Value) -> Counter {
|
||||
let tint = state.get("tint").and_then(Value::as_str).filter(|t| TINTS.contains(t)).unwrap_or(TINTS[0]);
|
||||
let step = state.get("step").and_then(Value::as_i64).filter(|s| STEPS.contains(&s.to_string().as_str())).unwrap_or(1);
|
||||
Counter { count: state.get("count").and_then(Value::as_i64).unwrap_or(0).clamp(-LIMIT, LIMIT), step, tint: tint.into() }
|
||||
}
|
||||
|
||||
/// The widget showing `count`.
|
||||
fn view(count: i64) -> Value {
|
||||
json!({"type": "frame", "name": "Counter", "layout": "horizontal", "gap": 12, "padding": 12, "radius": 12,
|
||||
"fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [
|
||||
button("decrement", "Decrease", "−"),
|
||||
{"type": "text", "name": "Count", "text": count.to_string(), "size": 24, "bold": true, "color": "#1E1E1E"},
|
||||
button("increment", "Increase", "+"),
|
||||
]})
|
||||
/// The counter after `event`.
|
||||
fn after(mut self, event: &WidgetEvent) -> Counter {
|
||||
match event {
|
||||
WidgetEvent::Click(target) if target == "increment" => self.count = self.count.saturating_add(self.step),
|
||||
WidgetEvent::Click(target) if target == "decrement" => self.count = self.count.saturating_sub(self.step),
|
||||
WidgetEvent::Menu { name, .. } if name == "reset" => self.count = 0,
|
||||
WidgetEvent::Menu { name, value } if name == "step" => {
|
||||
let offered = value.as_str().filter(|s| STEPS.contains(s));
|
||||
self.step = offered.and_then(|s| s.parse().ok()).unwrap_or(self.step);
|
||||
}
|
||||
WidgetEvent::Menu { name, value } if name == "tint" => {
|
||||
self.tint = value.as_str().filter(|t| TINTS.contains(t)).unwrap_or(&self.tint).into();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.count = self.count.clamp(-LIMIT, LIMIT);
|
||||
self
|
||||
}
|
||||
|
||||
fn state(&self) -> Value {
|
||||
json!({"count": self.count, "step": self.step, "tint": self.tint})
|
||||
}
|
||||
|
||||
/// A square button that calls back with `target`.
|
||||
fn button(target: &str, name: &str, label: &str, tip: &str) -> Node {
|
||||
Node::row()
|
||||
.name(name)
|
||||
.target(target)
|
||||
.justify("center")
|
||||
.size(32.0, 32.0)
|
||||
.radius(8.0)
|
||||
.fill("#F0F0F0")
|
||||
.hover(Some("#DCDCDC"), None, None)
|
||||
.tooltip(tip)
|
||||
.child(Node::text(label).font_size(16.0).bold().color("#1E1E1E"))
|
||||
}
|
||||
|
||||
fn view(&self) -> Node {
|
||||
let by = |verb: &str| if self.step == 1 { verb.to_string() } else { format!("{verb} by {}", self.step) };
|
||||
Node::row().name("Counter").gap(12.0).padding(12.0).radius(12.0).fill("#FFFFFF").stroke("#D9D9D9", 1.0).children([
|
||||
Counter::button("decrement", "Decrease", "−", &by("Decrease")),
|
||||
Node::text(&self.count.to_string()).name("Count").font_size(24.0).bold().color(&self.tint),
|
||||
Counter::button("increment", "Increase", "+", &by("Increase")),
|
||||
])
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<Value> {
|
||||
vec![
|
||||
menu_action("reset", "Reset"),
|
||||
menu_dropdown("step", "Step", &STEPS, &self.step.to_string()),
|
||||
menu_color("tint", "Colour", &TINTS, &self.tint),
|
||||
menu_separator(),
|
||||
menu_link("help", "About FigCraft", "https://getartcraft.com/apps/figcraft"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?;
|
||||
let count = next(w.state.get("count").and_then(Value::as_i64).unwrap_or(0), w.target.as_deref());
|
||||
render_widget(&json!({"state": {"count": count}, "root": view(count)}))?;
|
||||
Ok(json!({"count": count}))
|
||||
let counter = Counter::from(&w.state).after(&w.event);
|
||||
render(&counter.state(), counter.view(), Some(counter.menu()))?;
|
||||
Ok(json!({"count": counter.count}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
|
@ -45,15 +97,42 @@ entry!(run);
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn click(target: &str) -> WidgetEvent {
|
||||
WidgetEvent::Click(target.into())
|
||||
}
|
||||
|
||||
fn menu(name: &str, value: Value) -> WidgetEvent {
|
||||
WidgetEvent::Menu { name: name.into(), value }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_within_its_limit() {
|
||||
assert_eq!(next(0, None), 0);
|
||||
assert_eq!(next(4, Some("increment")), 5);
|
||||
assert_eq!(next(4, Some("decrement")), 3);
|
||||
assert_eq!(next(LIMIT, Some("increment")), LIMIT);
|
||||
assert_eq!(next(i64::MIN, Some("decrement")), -LIMIT);
|
||||
assert_eq!(next(2, Some("other")), 2);
|
||||
assert_eq!(view(3)["children"][1]["text"], "3");
|
||||
let at = |count: i64| Counter::from(&json!({"count": count}));
|
||||
assert_eq!(at(0).after(&WidgetEvent::Insert).count, 0);
|
||||
assert_eq!(at(4).after(&click("increment")).count, 5);
|
||||
assert_eq!(at(4).after(&click("decrement")).count, 3);
|
||||
assert_eq!(at(LIMIT).after(&click("increment")).count, LIMIT);
|
||||
assert_eq!(at(i64::MIN).after(&click("decrement")).count, -LIMIT);
|
||||
assert_eq!(at(2).after(&click("other")).count, 2);
|
||||
assert_eq!(Value::from(at(3).view())["children"][1]["text"], "3");
|
||||
assert!(run(Input::default()).is_err(), "it only runs as a widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_resets_steps_and_tints() {
|
||||
let c = Counter::from(&json!({"count": 7})).after(&menu("step", json!("5")));
|
||||
assert_eq!((c.step, c.clone().after(&click("increment")).count), (5, 12));
|
||||
assert_eq!(c.clone().after(&menu("step", json!("7"))).step, 5, "only the offered steps");
|
||||
assert_eq!(c.clone().after(&menu("reset", Value::Null)).count, 0);
|
||||
let tinted = c.after(&menu("tint", json!("#0D99FF")));
|
||||
assert_eq!(tinted.tint, "#0D99FF");
|
||||
assert_eq!(tinted.clone().after(&menu("tint", json!("red"))).tint, "#0D99FF");
|
||||
assert_eq!(Counter::from(&tinted.state()), tinted, "the state round-trips");
|
||||
assert_eq!(tinted.menu()[1]["value"], "5");
|
||||
assert_eq!(
|
||||
Counter::from(&json!({"step": 3, "tint": "#123456"})),
|
||||
Counter { count: 0, step: 1, tint: TINTS[0].into() },
|
||||
"junk state falls back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "dot-grid"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-plugin-sdk = { workspace = true }
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"id": "org.figcraft.examples.dot-grid",
|
||||
"name": "Dot grid",
|
||||
"version": "1.0.0",
|
||||
"description": "Makes a grid of dots. Its panel has sliders for the count, size and gap, and a preview picture that follows them.",
|
||||
"main": "dot_grid.wasm",
|
||||
"api": 4,
|
||||
"permissions": {
|
||||
"access": "write",
|
||||
"commands": ["view.get", "shape.ellipse", "object.group", "select.set"]
|
||||
},
|
||||
"commands": [{ "id": "panel", "label": "Dot grid…" }]
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
//! Dot grid: a panel with sliders (dots per row, their size, the gap between them), a colour and
|
||||
//! a preview picture that follows them, drawn here pixel by pixel. Generate puts the grid in the
|
||||
//! middle of the view as one group. The grid needs a name: without one the field says so and
|
||||
//! Generate is greyed out.
|
||||
|
||||
use figcraft_plugin_sdk::ui::{disabled, divider, image, invalid, progress, section, slider};
|
||||
use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, notify, show_ui};
|
||||
|
||||
/// The preview's side in pixels, and the most dots on a side.
|
||||
const PREVIEW: u32 = 96;
|
||||
const MAX_COLUMNS: f64 = 12.0;
|
||||
|
||||
/// What the panel's fields say.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Grid {
|
||||
name: String,
|
||||
columns: f64,
|
||||
size: f64,
|
||||
gap: f64,
|
||||
color: String,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// From the panel's values (null: the defaults).
|
||||
fn from(values: &Value) -> Grid {
|
||||
let num = |key: &str, default: f64, max: f64| values[key].as_f64().filter(|v| v.is_finite()).unwrap_or(default).clamp(0.0, max);
|
||||
Grid {
|
||||
name: values["name"].as_str().unwrap_or("Dot grid").trim().into(),
|
||||
columns: num("columns", 6.0, MAX_COLUMNS).max(2.0).round(),
|
||||
size: num("size", 16.0, 48.0).max(4.0),
|
||||
gap: num("gap", 8.0, 32.0),
|
||||
color: values["color"].as_str().unwrap_or("#0D99FF").into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dots(&self) -> usize {
|
||||
(self.columns * self.columns) as usize
|
||||
}
|
||||
|
||||
/// The colour as red, green and blue (black when it isn't `#RRGGBB…`).
|
||||
fn rgb(&self) -> [u8; 3] {
|
||||
let hex = self.color.trim_start_matches('#');
|
||||
[0, 2, 4].map(|i| hex.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Whether preview pixel (`x`, `y`) is on a dot: the grid fills the picture, the dots keep
|
||||
/// their share of each cell.
|
||||
fn on_dot(&self, x: u32, y: u32) -> bool {
|
||||
let cell = f64::from(PREVIEW) / self.columns;
|
||||
let radius = cell * self.size / (self.size + self.gap) / 2.0;
|
||||
let off = |v: u32| (f64::from(v) + 0.5) % cell - cell / 2.0;
|
||||
off(x).hypot(off(y)) <= radius
|
||||
}
|
||||
|
||||
fn preview(&self) -> Vec<u8> {
|
||||
let ink = self.rgb();
|
||||
bmp(PREVIEW, |x, y| if self.on_dot(x, y) { ink } else { [245, 245, 245] })
|
||||
}
|
||||
|
||||
/// Show the panel with these values.
|
||||
fn show(&self) -> Result<()> {
|
||||
let unnamed = self.name.is_empty();
|
||||
let name = json!({"type": "text", "name": "name", "label": "Name", "value": self.name});
|
||||
let generate = json!({"type": "button", "name": "generate", "label": "Generate"});
|
||||
show_ui(&json!({"title": "Dot grid", "controls": [
|
||||
if unnamed { invalid(name, "Give the grid a name") } else { name },
|
||||
slider("columns", "Dots per row", 2.0, MAX_COLUMNS, 1.0, self.columns),
|
||||
slider("size", "Dot size", 4.0, 48.0, 2.0, self.size),
|
||||
{"type": "color", "name": "color", "label": "Colour", "value": self.color},
|
||||
section("Spacing", false),
|
||||
slider("gap", "Gap", 0.0, 32.0, 1.0, self.gap),
|
||||
divider(),
|
||||
image("A preview of the dot grid", &self.preview(), 120.0),
|
||||
progress(&format!("{} of {} dots", self.dots(), (MAX_COLUMNS * MAX_COLUMNS) as usize), self.dots() as f64 / (MAX_COLUMNS * MAX_COLUMNS)),
|
||||
if unnamed { disabled(generate) } else { generate },
|
||||
{"type": "button", "name": "close", "label": "Close"}
|
||||
]}))
|
||||
}
|
||||
|
||||
/// Put the dots in the middle of the view, grouped and selected.
|
||||
fn generate(&self) -> Result<Value> {
|
||||
if self.name.is_empty() {
|
||||
return Err("Give the grid a name".into());
|
||||
}
|
||||
let step = self.size + self.gap;
|
||||
let side = self.columns * step - self.gap;
|
||||
let view = call("view.get", &json!({}))?;
|
||||
let corner = |c: &str| (view[c].as_f64().unwrap_or(0.0) - side / 2.0).round();
|
||||
let (x0, y0) = (corner("cx"), corner("cy"));
|
||||
let mut made = Vec::new();
|
||||
for i in 0..self.dots() {
|
||||
let (col, row) = ((i % self.columns as usize) as f64, (i / self.columns as usize) as f64);
|
||||
let dot = json!({"x": x0 + col * step, "y": y0 + row * step, "width": self.size, "height": self.size, "props": {"name": "Dot", "fill": self.color}});
|
||||
made.extend(call("shape.ellipse", &dot)?["id"].as_u64());
|
||||
}
|
||||
let group = call("object.group", &json!({"ids": made, "name": self.name}))?;
|
||||
call("select.set", &json!({"ids": [group["id"]]}))?;
|
||||
notify(&format!("Made {} dots", made.len()));
|
||||
Ok(json!({"dots": made.len(), "group": group["id"]}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A `size`×`size` picture as an uncompressed 24-bit BMP file, `pixel` giving red, green and blue
|
||||
/// for each (x, y) from the top-left.
|
||||
fn bmp(size: u32, pixel: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
|
||||
let row = (size * 3).div_ceil(4) * 4;
|
||||
let mut out = Vec::with_capacity((54 + row * size) as usize);
|
||||
// The file header (the pixels start at byte 54), then the 40-byte picture header.
|
||||
out.extend(b"BM");
|
||||
for v in [54 + row * size, 0, 54, 40, size, size] {
|
||||
out.extend(v.to_le_bytes());
|
||||
}
|
||||
out.extend([1, 0, 24, 0]);
|
||||
out.extend([0u8; 24]);
|
||||
// Rows run bottom to top, each padded to four bytes, blue first.
|
||||
for y in (0..size).rev() {
|
||||
for x in 0..size {
|
||||
let [r, g, b] = pixel(x, y);
|
||||
out.extend([b, g, r]);
|
||||
}
|
||||
out.extend(core::iter::repeat_n(0u8, (row - size * 3) as usize));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn on_event(e: &Event) -> Result<Value> {
|
||||
let grid = Grid::from(&e.values);
|
||||
match e.control.as_str() {
|
||||
"generate" => grid.generate(),
|
||||
"close" => {
|
||||
close_ui();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// A field changed: the preview, the count and the name's message follow.
|
||||
_ => grid.show().map(|()| Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
match &input.event {
|
||||
Some(e) => on_event(e),
|
||||
None => Grid::from(&Value::Null).show().map(|()| Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn values_are_read_and_clamped() {
|
||||
let g = Grid::from(&Value::Null);
|
||||
assert_eq!((g.name.as_str(), g.columns, g.dots()), ("Dot grid", 6.0, 36));
|
||||
let g = Grid::from(&json!({"name": " ", "columns": 99, "size": 1, "gap": -4, "color": "#FF8000"}));
|
||||
assert_eq!((g.name.as_str(), g.columns, g.size, g.gap, g.rgb()), ("", MAX_COLUMNS, 4.0, 0.0, [255, 128, 0]));
|
||||
assert!(g.generate().is_err(), "a grid needs a name");
|
||||
assert_eq!(Grid { color: "nope".into(), ..g }.rgb(), [0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_is_a_bmp_of_dots() {
|
||||
let g = Grid::from(&json!({"columns": 2, "size": 10, "gap": 10, "color": "#FF0000"}));
|
||||
// Two dots a side: a dot's middle is on, a cell's corner is off.
|
||||
assert!(g.on_dot(24, 24) && g.on_dot(72, 72) && !g.on_dot(0, 0) && !g.on_dot(48, 48));
|
||||
let file = g.preview();
|
||||
assert_eq!(&file[..2], b"BM");
|
||||
assert_eq!(file.len(), 54 + (PREVIEW * PREVIEW * 3) as usize);
|
||||
assert_eq!(u32::from_le_bytes([file[2], file[3], file[4], file[5]]) as usize, file.len());
|
||||
assert_eq!((u32::from_le_bytes([file[18], file[19], file[20], file[21]]), file[28]), (PREVIEW, 24));
|
||||
// The last row stored is the top one: its first pixel is background, blue first.
|
||||
let top_left = file.len() - (PREVIEW * 3) as usize;
|
||||
assert_eq!(&file[top_left..top_left + 3], &[245, 245, 245]);
|
||||
assert_eq!(bmp(1, |_, _| [1, 2, 3])[54..], [3, 2, 1, 0], "rows are padded to four bytes");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "focus-list"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-plugin-sdk = { workspace = true }
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "org.figcraft.examples.focus-list",
|
||||
"name": "Focus list",
|
||||
"version": "1.0.0",
|
||||
"description": "A sticky checklist widget: edit its title and items in place, tick them off, and start a focus countdown that ticks every second.",
|
||||
"main": "focus_list.wasm",
|
||||
"api": 4,
|
||||
"widget": true,
|
||||
"permissions": { "access": "read" }
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
//! Focus list: a sticky checklist widget. Its title and items are texts edited in place (a click,
|
||||
//! type, Enter), each item has a box to tick, a last line adds an item, and a button starts a
|
||||
//! focus countdown that a timer ticks down every second. The property menu clears the ticked
|
||||
//! items, sets the countdown's length and picks the note's colour. Everything it knows is its state.
|
||||
|
||||
use figcraft_plugin_sdk::ui::{Node, menu_action, menu_color, menu_dropdown, render};
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, WidgetEvent, cancel_timer, entry, json, notify, set_timer};
|
||||
|
||||
/// Most items on one list, and the longest title or item.
|
||||
const MAX_ITEMS: usize = 8;
|
||||
const MAX_CHARS: u32 = 60;
|
||||
/// The countdown lengths the menu offers (label, seconds), and the note colours.
|
||||
const LENGTHS: [(&str, i64); 3] = [("30 seconds", 30), ("1 minute", 60), ("5 minutes", 300)];
|
||||
const PAPERS: [&str; 4] = ["#FFF3B0", "#D9F2E3", "#DDE9FF", "#FFE0E0"];
|
||||
const INK: &str = "#2B2B2B";
|
||||
const WIDTH: f64 = 240.0;
|
||||
|
||||
/// What the widget remembers.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct List {
|
||||
title: String,
|
||||
/// (text, ticked)
|
||||
items: Vec<(String, bool)>,
|
||||
/// Seconds a countdown runs, and what's left of the one running (0: none is).
|
||||
length: i64,
|
||||
left: i64,
|
||||
paper: String,
|
||||
}
|
||||
|
||||
impl List {
|
||||
/// The list a new widget starts with.
|
||||
fn new() -> List {
|
||||
let items = ["Write the release notes", "Tag the build", "Tell the team"].map(|t| (t.to_string(), false)).to_vec();
|
||||
List { title: "Launch checklist".into(), items, length: 30, left: 0, paper: PAPERS[0].into() }
|
||||
}
|
||||
|
||||
fn from(state: &Value) -> List {
|
||||
let new = List::new();
|
||||
if state.as_object().is_none_or(|o| o.is_empty()) {
|
||||
return new;
|
||||
}
|
||||
let item = |i: &Value| Some((i.get("text")?.as_str()?.to_string(), i.get("done").and_then(Value::as_bool).unwrap_or(false)));
|
||||
let length = state["length"].as_i64().filter(|l| LENGTHS.iter().any(|(_, s)| s == l)).unwrap_or(new.length);
|
||||
List {
|
||||
title: state["title"].as_str().map_or(new.title, str::to_string),
|
||||
items: state["items"].as_array().map(|a| a.iter().filter_map(item).take(MAX_ITEMS).collect()).unwrap_or_default(),
|
||||
length,
|
||||
left: state["left"].as_i64().unwrap_or(0).clamp(0, length),
|
||||
paper: state["paper"].as_str().filter(|p| PAPERS.contains(p)).unwrap_or(PAPERS[0]).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn state(&self) -> Value {
|
||||
let items: Vec<Value> = self.items.iter().map(|(text, done)| json!({"text": text, "done": done})).collect();
|
||||
json!({"title": self.title, "items": items, "length": self.length, "left": self.left, "paper": self.paper})
|
||||
}
|
||||
|
||||
/// The list after `event`, and what to do about its timer.
|
||||
fn after(mut self, event: &WidgetEvent) -> (List, Clock) {
|
||||
let mut clock = Clock::Leave;
|
||||
let nth = |name: &str, prefix: &str| name.strip_prefix(prefix).and_then(|n| n.parse::<usize>().ok());
|
||||
match event {
|
||||
WidgetEvent::Click(target) if target == "focus" => {
|
||||
// The button starts a countdown, or stops the one running.
|
||||
(self.left, clock) = if self.left > 0 { (0, Clock::Stop) } else { (self.length, Clock::Tick) };
|
||||
}
|
||||
WidgetEvent::Click(target) => {
|
||||
if let Some((_, done)) = nth(target, "toggle.").and_then(|i| self.items.get_mut(i)) {
|
||||
*done = !*done;
|
||||
}
|
||||
}
|
||||
WidgetEvent::Text { input, text } => {
|
||||
let text = text.trim();
|
||||
match (input.as_str(), nth(input, "item.")) {
|
||||
("title", _) if !text.is_empty() => self.title = text.into(),
|
||||
("new", _) if !text.is_empty() && self.items.len() < MAX_ITEMS => self.items.push((text.into(), false)),
|
||||
// An item left empty goes.
|
||||
(_, Some(i)) if text.is_empty() && i < self.items.len() => drop(self.items.remove(i)),
|
||||
(_, Some(i)) => {
|
||||
if let Some(item) = self.items.get_mut(i) {
|
||||
item.0 = text.into();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WidgetEvent::Menu { name, value } => match (name.as_str(), value.as_str()) {
|
||||
("clear", _) => self.items.retain(|(_, done)| !done),
|
||||
("length", Some(label)) => self.length = LENGTHS.iter().find(|(l, _)| *l == label).map_or(self.length, |(_, s)| *s),
|
||||
("paper", Some(p)) if PAPERS.contains(&p) => self.paper = p.into(),
|
||||
_ => {}
|
||||
},
|
||||
WidgetEvent::Timer if self.left > 0 => {
|
||||
self.left -= 1;
|
||||
clock = if self.left > 0 { Clock::Tick } else { Clock::Done };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
(self, clock)
|
||||
}
|
||||
|
||||
/// "0:42"
|
||||
fn clock_text(&self) -> String {
|
||||
format!("{}:{:02}", self.left / 60, self.left % 60)
|
||||
}
|
||||
|
||||
fn view(&self) -> Node {
|
||||
let rule = || Node::line(WIDTH).stroke("#00000022", 1.0).fill_width();
|
||||
let items = self.items.iter().enumerate().map(|(i, (text, done))| {
|
||||
let mut tick =
|
||||
Node::row().name("Tick").target(&format!("toggle.{i}")).size(16.0, 16.0).radius(4.0).justify("center").tooltip("Tick it off");
|
||||
tick = if *done {
|
||||
tick.fill(INK).child(Node::vector("M2 6 L5 9 L10 3").stroke("#FFFFFF", 2.0))
|
||||
} else {
|
||||
tick.fill("#FFFFFF").stroke(INK, 1.5).hover(Some("#00000018"), None, None)
|
||||
};
|
||||
let label = Node::text(text).name("Item").input(&format!("item.{i}"), MAX_CHARS).width(WIDTH - 24.0).font_size(13.0).color(INK);
|
||||
Node::row().name("Row").gap(8.0).align("min").child(tick).child(if *done { label.opacity(0.45) } else { label })
|
||||
});
|
||||
let running = self.left > 0;
|
||||
let button = Node::row()
|
||||
.name("Focus")
|
||||
.target("focus")
|
||||
.paddings([6.0, 12.0, 6.0, 12.0])
|
||||
.radius(14.0)
|
||||
.fill(INK)
|
||||
.hover(None, None, Some(0.8))
|
||||
.tooltip(if running { "Stop the countdown" } else { "Start a focus countdown" })
|
||||
.child(
|
||||
Node::text(&if running { format!("Stop · {}", self.clock_text()) } else { "Start focus".into() })
|
||||
.font_size(12.0)
|
||||
.weight(600.0)
|
||||
.color("#FFFFFF"),
|
||||
);
|
||||
// How much of the countdown is gone.
|
||||
let gone = if running { (self.length - self.left) as f64 / self.length.max(1) as f64 } else { 0.0 };
|
||||
let track = Node::frame()
|
||||
.name("Progress")
|
||||
.height(4.0)
|
||||
.radius(2.0)
|
||||
.fill("#00000018")
|
||||
.fill_width()
|
||||
.child(Node::rectangle((WIDTH - 110.0) * gone, 4.0).radius(2.0).fill(INK));
|
||||
let mut note = Node::column()
|
||||
.name("Focus list")
|
||||
.width(WIDTH + 32.0)
|
||||
.gap(10.0)
|
||||
.padding(16.0)
|
||||
.radii([2.0, 2.0, 14.0, 2.0])
|
||||
.fill(&self.paper)
|
||||
.shadow(0.0, 4.0, 12.0, "#00000030")
|
||||
.align("min")
|
||||
.child(
|
||||
Node::text(&self.title)
|
||||
.name("Title")
|
||||
.input("title", MAX_CHARS)
|
||||
.width(WIDTH)
|
||||
.font_size(16.0)
|
||||
.weight(700.0)
|
||||
.color(INK)
|
||||
.tooltip("Click to rename"),
|
||||
)
|
||||
.child(rule())
|
||||
.children(items);
|
||||
if self.items.len() < MAX_ITEMS {
|
||||
note = note.child(Node::text("Add an item…").name("New item").input("new", MAX_CHARS).width(WIDTH).font_size(13.0).color("#00000066"));
|
||||
}
|
||||
note.child(rule()).child(Node::row().name("Footer").gap(10.0).width(WIDTH).child(button).child(track))
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<Value> {
|
||||
let labels = LENGTHS.map(|(label, _)| label);
|
||||
let length = LENGTHS.iter().find(|(_, s)| *s == self.length).map_or(labels[0], |(l, _)| l);
|
||||
vec![
|
||||
menu_action("clear", "Clear ticked"),
|
||||
menu_dropdown("length", "Countdown", &labels, length),
|
||||
menu_color("paper", "Paper", &PAPERS, &self.paper),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// What an event means for the widget's timer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Clock {
|
||||
/// Keep the timer as it is.
|
||||
Leave,
|
||||
/// Call back in a second.
|
||||
Tick,
|
||||
Stop,
|
||||
/// The countdown just ran out.
|
||||
Done,
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let w = input.widget.ok_or("Insert the focus list from Plugins ▸ Widgets")?;
|
||||
let (list, clock) = List::from(&w.state).after(&w.event);
|
||||
match clock {
|
||||
Clock::Tick => set_timer(1000)?,
|
||||
Clock::Stop => cancel_timer(),
|
||||
Clock::Done => notify(&format!("{}: time's up", list.title)),
|
||||
Clock::Leave => {}
|
||||
}
|
||||
render(&list.state(), list.view(), Some(list.menu()))?;
|
||||
Ok(json!({"left": list.left, "items": list.items.len()}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(input: &str, text: &str) -> WidgetEvent {
|
||||
WidgetEvent::Text { input: input.into(), text: text.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_are_edited_ticked_added_and_removed() {
|
||||
let (l, _) = List::from(&json!({})).after(&WidgetEvent::Insert);
|
||||
assert_eq!((l.title.as_str(), l.items.len()), ("Launch checklist", 3));
|
||||
let (l, _) = l.after(&text("title", " Ship it "));
|
||||
let (l, _) = l.after(&text("item.1", "Tag v2"));
|
||||
let (l, _) = l.after(&text("new", "Celebrate"));
|
||||
let (l, _) = l.after(&WidgetEvent::Click("toggle.0".into()));
|
||||
assert_eq!(l.title, "Ship it");
|
||||
assert_eq!(l.items[1], ("Tag v2".to_string(), false));
|
||||
assert_eq!((l.items.len(), l.items[3].0.as_str(), l.items[0].1), (4, "Celebrate", true));
|
||||
assert_eq!(List::from(&l.state()), l, "the state round-trips");
|
||||
// An emptied item goes; an empty title or new item changes nothing; junk is ignored.
|
||||
let (l, _) = l.after(&text("item.2", " "));
|
||||
let (l, _) = l.after(&text("title", ""));
|
||||
let (l, _) = l.after(&text("new", ""));
|
||||
let (l, _) = l.after(&text("item.99", "x"));
|
||||
let (l, _) = l.after(&WidgetEvent::Click("toggle.99".into()));
|
||||
assert_eq!((l.items.len(), l.title.as_str()), (3, "Ship it"));
|
||||
let (l, _) = l.after(&WidgetEvent::Menu { name: "clear".into(), value: Value::Null });
|
||||
assert_eq!(l.items.len(), 2);
|
||||
let full = (0..20).fold(l, |l, i| l.after(&text("new", &format!("Item {i}"))).0);
|
||||
assert_eq!(full.items.len(), MAX_ITEMS);
|
||||
assert!(!Value::from(full.view()).to_string().contains("\"new\""), "a full list has no line to add one");
|
||||
assert!(run(Input::default()).is_err(), "it only runs as a widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_countdown_ticks_down_and_stops() {
|
||||
let l = List::from(&json!({"title": "T", "items": [], "length": 30}));
|
||||
let (l, clock) = l.after(&WidgetEvent::Click("focus".into()));
|
||||
assert_eq!((l.left, clock, l.clock_text().as_str()), (30, Clock::Tick, "0:30"));
|
||||
let (l, clock) = l.after(&WidgetEvent::Timer);
|
||||
assert_eq!((l.left, clock), (29, Clock::Tick));
|
||||
let (stopped, clock) = l.clone().after(&WidgetEvent::Click("focus".into()));
|
||||
assert_eq!((stopped.left, clock), (0, Clock::Stop));
|
||||
let (l, clock) = List { left: 1, ..l }.after(&WidgetEvent::Timer);
|
||||
assert_eq!((l.left, clock), (0, Clock::Done));
|
||||
// A timer that fires with nothing running does nothing.
|
||||
assert_eq!(l.clone().after(&WidgetEvent::Timer).1, Clock::Leave);
|
||||
let (l, _) = l.after(&WidgetEvent::Menu { name: "length".into(), value: json!("5 minutes") });
|
||||
assert_eq!((l.length, l.menu()[1]["value"].as_str()), (300, Some("5 minutes")));
|
||||
assert_eq!(List::from(&json!({"title": "T", "length": 7, "left": 99, "paper": "blue"})).length, 30, "junk state falls back");
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,11 @@
|
|||
//! [`Input::widget`] set (its state and what was clicked) and hands back the new state and the
|
||||
//! layers with [`render_widget`]. Each call starts afresh: the state is all a widget remembers.
|
||||
//!
|
||||
//! With API 4 a widget hears of more than clicks ([`Widget::event`]): a text of it the user edited
|
||||
//! in place, an item of its property menu, a timer it asked for ([`set_timer`]). It can also draw
|
||||
//! more (lines, vectors, gradients, shadows, text styles, hover styles, tooltips); [`ui`] has
|
||||
//! builders for descriptions and panels, so a plugin needn't spell the JSON out.
|
||||
//!
|
||||
//! The SDK is `no_std` (it needs `alloc`); outside WebAssembly every host call fails, so a plugin's
|
||||
//! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath.
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
|
|
@ -40,6 +45,8 @@ use alloc::vec::Vec;
|
|||
|
||||
pub use serde_json::{self, Value, json};
|
||||
|
||||
pub mod ui;
|
||||
|
||||
/// What a plugin function returns: its output (shown or returned to the caller), or an error that
|
||||
/// fails the run and rolls the document back.
|
||||
pub type Result<T> = core::result::Result<T, String>;
|
||||
|
|
@ -68,6 +75,43 @@ pub struct Widget {
|
|||
pub state: Value,
|
||||
/// The target clicked.
|
||||
pub target: Option<String>,
|
||||
/// What happened to the widget.
|
||||
pub event: WidgetEvent,
|
||||
}
|
||||
|
||||
/// What a widget is called for.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub enum WidgetEvent {
|
||||
/// It was just inserted: draw it for the first time.
|
||||
#[default]
|
||||
Insert,
|
||||
/// The part with this `target` was clicked.
|
||||
Click(String),
|
||||
/// API 4. The user edited the text named `input` in place; `text` is what they left there
|
||||
/// (already cut to its maximum length, and one line unless it's multiline).
|
||||
Text { input: String, text: String },
|
||||
/// API 4. The user chose item `name` of the property menu; `value` is a toggle's new state
|
||||
/// (a boolean), a dropdown's or colour's choice (a string), or null for an action.
|
||||
Menu { name: String, value: Value },
|
||||
/// API 4. The timer asked for with [`set_timer`] is due.
|
||||
Timer,
|
||||
}
|
||||
|
||||
impl WidgetEvent {
|
||||
fn parse(e: Option<&Value>) -> WidgetEvent {
|
||||
let text = |key: &str| e.and_then(|e| e.get(key)).and_then(Value::as_str).map(ToString::to_string);
|
||||
if let Some(target) = text("target") {
|
||||
WidgetEvent::Click(target)
|
||||
} else if let Some(input) = text("input") {
|
||||
WidgetEvent::Text { input, text: text("text").unwrap_or_default() }
|
||||
} else if let Some(name) = text("menu") {
|
||||
WidgetEvent::Menu { name, value: e.and_then(|e| e.get("value")).cloned().unwrap_or(Value::Null) }
|
||||
} else if e.and_then(|e| e.get("timer")).is_some() {
|
||||
WidgetEvent::Timer
|
||||
} else {
|
||||
WidgetEvent::Insert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A button pressed or a value changed in the plugin's panel.
|
||||
|
|
@ -98,6 +142,7 @@ impl Input {
|
|||
id: w.get("id").and_then(Value::as_u64).unwrap_or_default(),
|
||||
state: w.get("state").cloned().unwrap_or_else(|| json!({})),
|
||||
target: w.get("event").and_then(|e| e.get("target")).and_then(Value::as_str).map(ToString::to_string),
|
||||
event: WidgetEvent::parse(w.get("event")),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
@ -168,6 +213,19 @@ pub fn render_widget(widget: &Value) -> Result<()> {
|
|||
if host::widget_render(spec.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
|
||||
}
|
||||
|
||||
/// API 4. Ask for the widget being called to be called again (with [`WidgetEvent::Timer`]) in
|
||||
/// `ms` milliseconds: 100 to 3,600,000 (an hour). A widget has one timer at most (asking again
|
||||
/// replaces it), and it runs only while its file is open and in front, for whoever started it
|
||||
/// last. Calling back for a timer is like any other callback: draw the widget again, or don't.
|
||||
pub fn set_timer(ms: u32) -> Result<()> {
|
||||
if host::widget_timer(i32::try_from(ms).unwrap_or(i32::MAX)) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
|
||||
}
|
||||
|
||||
/// API 4. Stop the widget's pending timer.
|
||||
pub fn cancel_timer() {
|
||||
host::widget_timer(0);
|
||||
}
|
||||
|
||||
/// The plugin's setting `key`, as stored by [`set_setting`].
|
||||
pub fn setting(key: &str) -> Option<Value> {
|
||||
if host::setting_get(key.as_bytes()) != 0 {
|
||||
|
|
@ -238,6 +296,7 @@ mod host {
|
|||
pub fn setting_set(key: *const u8, key_len: usize, value: *const u8, value_len: usize) -> i32;
|
||||
pub fn setting_delete(key: *const u8, key_len: usize);
|
||||
pub fn widget_render(ptr: *const u8, len: usize) -> i32;
|
||||
pub fn widget_timer(ms: i32) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,6 +342,9 @@ mod host {
|
|||
pub fn widget_render(s: &[u8]) -> i32 {
|
||||
unsafe { sys::widget_render(s.as_ptr(), s.len()) }
|
||||
}
|
||||
pub fn widget_timer(ms: i32) -> i32 {
|
||||
unsafe { sys::widget_timer(ms) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
|
||||
|
|
@ -316,6 +378,9 @@ mod host {
|
|||
pub fn widget_render(_s: &[u8]) -> i32 {
|
||||
1
|
||||
}
|
||||
pub fn widget_timer(_ms: i32) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -344,5 +409,13 @@ mod tests {
|
|||
.widget
|
||||
.unwrap_or_default();
|
||||
assert_eq!((w.id, &w.state, w.target.as_deref()), (9, &json!({"n": 1}), Some("inc")));
|
||||
assert_eq!(w.event, WidgetEvent::Click("inc".into()));
|
||||
let event =
|
||||
|e: Value| Input::parse(&json!({"widget": {"id": 1, "event": e}}).to_string()).unwrap_or_default().widget.unwrap_or_default().event;
|
||||
assert_eq!(event(Value::Null), WidgetEvent::Insert);
|
||||
assert_eq!(event(json!({"input": "title", "text": "Hi"})), WidgetEvent::Text { input: "title".into(), text: "Hi".into() });
|
||||
assert_eq!(event(json!({"menu": "tint", "value": "#FF0000"})), WidgetEvent::Menu { name: "tint".into(), value: json!("#FF0000") });
|
||||
assert_eq!(event(json!({"timer": true})), WidgetEvent::Timer);
|
||||
assert!(set_timer(1000).is_err(), "no host off WebAssembly");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
//! Builders for what a plugin describes to FigCraft: a widget's layers ([`Node`]) and property
|
||||
//! menu ([`menu_action`] and its siblings), and the controls of a panel that plugin API 4 added
|
||||
//! ([`slider`], [`image`], [`progress`], [`divider`], [`section`]). They only assemble the JSON
|
||||
//! that `docs/plugins.md` documents; [`Node::set`] reaches anything they don't name, and plain
|
||||
//! `json!` works everywhere too.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use figcraft_plugin_sdk::ui::{Node, menu_toggle, render};
|
||||
//!
|
||||
//! let root = Node::column()
|
||||
//! .padding(12.0)
|
||||
//! .gap(8.0)
|
||||
//! .radius(12.0)
|
||||
//! .fill("#FFFFFF")
|
||||
//! .shadow(0.0, 2.0, 8.0, "#00000033")
|
||||
//! .child(Node::text("Title").input("title", 60).weight(600.0).tooltip("Click to rename"))
|
||||
//! .child(Node::line(160.0).stroke("#E6E6E6", 1.0).fill_width());
|
||||
//! render(&json!({"title": "Title"}), root, Some(vec![menu_toggle("done", "Done", false)]))?;
|
||||
//! ```
|
||||
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// One layer of a widget's description.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Node(Map<String, Value>);
|
||||
|
||||
impl From<Node> for Value {
|
||||
fn from(n: Node) -> Value {
|
||||
Value::Object(n.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Setters that put one value under one key.
|
||||
macro_rules! setters {
|
||||
($($(#[$doc:meta])* $name:ident($ty:ty) => $key:literal;)*) => {
|
||||
$($(#[$doc])* pub fn $name(self, v: $ty) -> Node { self.set($key, v) })*
|
||||
};
|
||||
}
|
||||
|
||||
impl Node {
|
||||
fn of(kind: &str) -> Node {
|
||||
Node::default().set("type", kind)
|
||||
}
|
||||
|
||||
/// A frame without auto layout: its children sit at their `x` and `y`.
|
||||
pub fn frame() -> Node {
|
||||
Node::of("frame")
|
||||
}
|
||||
|
||||
/// An auto layout frame with its children side by side; it hugs them on an axis without a size.
|
||||
pub fn row() -> Node {
|
||||
Node::of("frame").set("layout", "horizontal")
|
||||
}
|
||||
|
||||
/// An auto layout frame with its children one under the other.
|
||||
pub fn column() -> Node {
|
||||
Node::of("frame").set("layout", "vertical")
|
||||
}
|
||||
|
||||
/// A text. With a `width` it wraps; without one it grows with its text.
|
||||
pub fn text(text: &str) -> Node {
|
||||
Node::of("text").set("text", text)
|
||||
}
|
||||
|
||||
pub fn rectangle(width: f64, height: f64) -> Node {
|
||||
Node::of("rectangle").width(width).height(height)
|
||||
}
|
||||
|
||||
pub fn ellipse(width: f64, height: f64) -> Node {
|
||||
Node::of("ellipse").width(width).height(height)
|
||||
}
|
||||
|
||||
/// API 4. A horizontal line (black and 1 thick unless it gets a [`stroke`](Node::stroke)).
|
||||
pub fn line(length: f64) -> Node {
|
||||
Node::of("line").width(length)
|
||||
}
|
||||
|
||||
/// API 4. A shape from SVG path data (`M0 0 L10 0 L5 8 Z`); with a size it's scaled to it.
|
||||
pub fn vector(path: &str) -> Node {
|
||||
Node::of("vector").set("path", path)
|
||||
}
|
||||
|
||||
/// An image from its file's bytes (PNG, JPEG, BMP…), usually `include_bytes!` in the plugin.
|
||||
pub fn image(bytes: &[u8]) -> Node {
|
||||
Node::of("image").set("data", base64(bytes))
|
||||
}
|
||||
|
||||
/// Any property by its name in the widget description.
|
||||
pub fn set(mut self, key: &str, value: impl Into<Value>) -> Node {
|
||||
self.0.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
setters! {
|
||||
/// The layer's name in the layers panel.
|
||||
name(&str) => "name";
|
||||
/// Clicking the layer calls the plugin back with this name.
|
||||
target(&str) => "target";
|
||||
x(f64) => "x";
|
||||
y(f64) => "y";
|
||||
width(f64) => "width";
|
||||
height(f64) => "height";
|
||||
/// A hex colour (`#RRGGBB` or `#RRGGBBAA`).
|
||||
fill(&str) => "fill";
|
||||
opacity(f64) => "opacity";
|
||||
/// The same radius on every corner.
|
||||
radius(f64) => "radius";
|
||||
/// Auto layout: the space between children.
|
||||
gap(f64) => "gap";
|
||||
/// Auto layout: the same padding on every side.
|
||||
padding(f64) => "padding";
|
||||
/// Auto layout: where the children sit across it: `min`, `center` or `max`.
|
||||
align(&str) => "align";
|
||||
/// Auto layout: where the children sit along it: `min`, `center` or `max`.
|
||||
justify(&str) => "justify";
|
||||
/// A text's font size.
|
||||
font_size(f64) => "size";
|
||||
/// A text's colour (hex).
|
||||
color(&str) => "color";
|
||||
/// API 4. A text's font weight, 100–900.
|
||||
weight(f64) => "weight";
|
||||
/// API 4. A text's alignment in its width: `left`, `center` or `right`.
|
||||
text_align(&str) => "textAlign";
|
||||
/// API 4. A text with a width shows this many lines at most; the rest is cut with an ellipsis.
|
||||
max_lines(f64) => "maxLines";
|
||||
/// API 4. An image's fit in its box: `fill`, `fit`, `crop` or `tile`.
|
||||
fit(&str) => "fit";
|
||||
/// API 4. Shown while the pointer rests on the layer.
|
||||
tooltip(&str) => "tooltip";
|
||||
}
|
||||
|
||||
pub fn size(self, width: f64, height: f64) -> Node {
|
||||
self.width(width).height(height)
|
||||
}
|
||||
|
||||
pub fn stroke(self, color: &str, weight: f64) -> Node {
|
||||
self.set("stroke", color).set("strokeWeight", weight)
|
||||
}
|
||||
|
||||
pub fn bold(self) -> Node {
|
||||
self.set("bold", true)
|
||||
}
|
||||
|
||||
/// API 4. Radii for the top-left, top-right, bottom-right and bottom-left corners.
|
||||
pub fn radii(self, corners: [f64; 4]) -> Node {
|
||||
self.set("radius", json!(corners))
|
||||
}
|
||||
|
||||
/// API 4. Paddings for the top, right, bottom and left sides.
|
||||
pub fn paddings(self, sides: [f64; 4]) -> Node {
|
||||
self.set("padding", json!(sides))
|
||||
}
|
||||
|
||||
/// API 4. In an auto layout frame: take the width that's left.
|
||||
pub fn fill_width(self) -> Node {
|
||||
self.set("fillWidth", true)
|
||||
}
|
||||
|
||||
/// API 4. In an auto layout frame: take the height that's left.
|
||||
pub fn fill_height(self) -> Node {
|
||||
self.set("fillHeight", true)
|
||||
}
|
||||
|
||||
fn gradient(self, kind: &str, angle: Option<f64>, stops: &[(f64, &str)]) -> Node {
|
||||
let stops: Vec<Value> = stops.iter().map(|(at, color)| json!({"at": at, "color": color})).collect();
|
||||
self.set("gradient", json!({"type": kind, "angle": angle, "stops": stops}))
|
||||
}
|
||||
|
||||
/// API 4. A linear gradient instead of a fill: `angle` in degrees (0 runs left to right, 90
|
||||
/// top to bottom) and two to eight `(position 0–1, hex colour)` stops.
|
||||
pub fn linear_gradient(self, angle: f64, stops: &[(f64, &str)]) -> Node {
|
||||
self.gradient("linear", Some(angle), stops)
|
||||
}
|
||||
|
||||
/// API 4. A radial gradient from the centre instead of a fill.
|
||||
pub fn radial_gradient(self, stops: &[(f64, &str)]) -> Node {
|
||||
self.gradient("radial", None, stops)
|
||||
}
|
||||
|
||||
/// API 4. A drop shadow.
|
||||
pub fn shadow(self, x: f64, y: f64, blur: f64, color: &str) -> Node {
|
||||
self.set("shadow", json!({"x": x, "y": y, "blur": blur, "color": color}))
|
||||
}
|
||||
|
||||
/// API 4. A text the user can edit in place (a click starts it; Enter or clicking elsewhere
|
||||
/// commits): the plugin is then called with [`WidgetEvent::Text`](crate::WidgetEvent::Text)
|
||||
/// carrying `name` and the text, at most `max_length` characters, on one line.
|
||||
pub fn input(self, name: &str, max_length: u32) -> Node {
|
||||
self.set("input", name).set("maxLength", max_length)
|
||||
}
|
||||
|
||||
/// API 4. An [`input`](Node::input) that takes several lines (Enter starts a new one;
|
||||
/// clicking elsewhere or Escape commits).
|
||||
pub fn multiline(self) -> Node {
|
||||
self.set("multiline", true)
|
||||
}
|
||||
|
||||
/// API 4. What changes while the pointer is over the layer: its fill, its stroke and its
|
||||
/// opacity (`None`: as it is). FigCraft shows it; the plugin isn't called.
|
||||
pub fn hover(self, fill: Option<&str>, stroke: Option<&str>, opacity: Option<f64>) -> Node {
|
||||
self.set("hover", json!({"fill": fill, "stroke": stroke, "opacity": opacity}))
|
||||
}
|
||||
|
||||
/// Add a layer inside this frame.
|
||||
pub fn child(mut self, child: Node) -> Node {
|
||||
match self.0.entry("children").or_insert_with(|| Value::Array(Vec::new())) {
|
||||
Value::Array(a) => a.push(child.into()),
|
||||
other => *other = json!([Value::from(child)]),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Add layers inside this frame.
|
||||
pub fn children(self, children: impl IntoIterator<Item = Node>) -> Node {
|
||||
children.into_iter().fold(self, Node::child)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the widget being called: its new `state` (a JSON object), its layers and, with `Some`,
|
||||
/// its property menu (`None` keeps the menu it has; an empty one removes it).
|
||||
pub fn render(state: &Value, root: Node, menu: Option<Vec<Value>>) -> crate::Result<()> {
|
||||
let mut w = json!({"state": state, "root": Value::from(root)});
|
||||
if let Some(menu) = menu {
|
||||
w["menu"] = Value::Array(menu);
|
||||
}
|
||||
crate::render_widget(&w)
|
||||
}
|
||||
|
||||
/// API 4. A button in the widget's property menu: choosing it calls the plugin with
|
||||
/// [`WidgetEvent::Menu`](crate::WidgetEvent::Menu) carrying `name`.
|
||||
pub fn menu_action(name: &str, label: &str) -> Value {
|
||||
json!({"type": "action", "name": name, "label": label})
|
||||
}
|
||||
|
||||
/// API 4. An on/off item; the plugin gets the new state as the value.
|
||||
pub fn menu_toggle(name: &str, label: &str, on: bool) -> Value {
|
||||
json!({"type": "toggle", "name": name, "label": label, "value": on})
|
||||
}
|
||||
|
||||
/// API 4. A menu of `options` showing `value`; the plugin gets the option picked.
|
||||
pub fn menu_dropdown(name: &str, label: &str, options: &[&str], value: &str) -> Value {
|
||||
json!({"type": "dropdown", "name": name, "label": label, "options": options, "value": value})
|
||||
}
|
||||
|
||||
/// API 4. A row of hex colours to pick one from, `value` marked; the plugin gets the one picked.
|
||||
pub fn menu_color(name: &str, label: &str, palette: &[&str], value: &str) -> Value {
|
||||
json!({"type": "color", "name": name, "label": label, "options": palette, "value": value})
|
||||
}
|
||||
|
||||
/// API 4. A line between menu items.
|
||||
pub fn menu_separator() -> Value {
|
||||
json!({"type": "separator"})
|
||||
}
|
||||
|
||||
/// API 4. A link (http or https) FigCraft opens when it's clicked; the plugin isn't called.
|
||||
pub fn menu_link(name: &str, label: &str, url: &str) -> Value {
|
||||
json!({"type": "link", "name": name, "label": label, "url": url})
|
||||
}
|
||||
|
||||
/// API 4, panels. A slider from `min` to `max` in `step`s. While it's dragged the plugin is
|
||||
/// called back a few times a second, and once more when it's let go.
|
||||
pub fn slider(name: &str, label: &str, min: f64, max: f64, step: f64, value: f64) -> Value {
|
||||
json!({"type": "slider", "name": name, "label": label, "min": min, "max": max, "step": step, "value": value})
|
||||
}
|
||||
|
||||
/// API 4, panels. A picture from its file's bytes, `height` high (16–400), fitted in the panel's
|
||||
/// width; `label` names it for screen readers.
|
||||
pub fn image(label: &str, bytes: &[u8], height: f64) -> Value {
|
||||
json!({"type": "image", "label": label, "data": base64(bytes), "height": height, "fit": "fit"})
|
||||
}
|
||||
|
||||
/// API 4, panels. A progress bar filled to `fraction` (0–1).
|
||||
pub fn progress(label: &str, fraction: f64) -> Value {
|
||||
json!({"type": "progress", "label": label, "value": fraction})
|
||||
}
|
||||
|
||||
/// API 4, panels. A line between controls.
|
||||
pub fn divider() -> Value {
|
||||
json!({"type": "divider"})
|
||||
}
|
||||
|
||||
/// API 4, panels. A heading that folds the controls after it, up to the next section or divider.
|
||||
pub fn section(label: &str, open: bool) -> Value {
|
||||
json!({"type": "section", "label": label, "value": open})
|
||||
}
|
||||
|
||||
/// API 4, panels. `control` greyed out: it can't be used.
|
||||
pub fn disabled(mut control: Value) -> Value {
|
||||
control["disabled"] = Value::Bool(true);
|
||||
control
|
||||
}
|
||||
|
||||
/// API 4, panels. `control` with a validation message under it.
|
||||
pub fn invalid(mut control: Value, message: &str) -> Value {
|
||||
control["error"] = Value::String(message.into());
|
||||
control
|
||||
}
|
||||
|
||||
/// `bytes` as base64, the way image data travels in descriptions.
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i));
|
||||
for i in 0..4 {
|
||||
let sextet = ALPHABET.get((n >> (18 - 6 * i)) as usize & 63).copied().unwrap_or(b'A');
|
||||
out.push(if i <= chunk.len() { char::from(sextet) } else { '=' });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builders_make_the_documented_json() {
|
||||
let n = Node::row().gap(4.0).paddings([1.0, 2.0, 3.0, 4.0]).child(Node::text("Hi").input("title", 20).multiline()).child(Node::line(10.0));
|
||||
let v = Value::from(n);
|
||||
assert_eq!(
|
||||
(v["type"].as_str(), v["layout"].as_str(), v["padding"].clone()),
|
||||
(Some("frame"), Some("horizontal"), json!([1.0, 2.0, 3.0, 4.0]))
|
||||
);
|
||||
assert_eq!(v["children"][0], json!({"type": "text", "text": "Hi", "input": "title", "maxLength": 20, "multiline": true}));
|
||||
assert_eq!(v["children"][1], json!({"type": "line", "width": 10.0}));
|
||||
let g = Value::from(Node::rectangle(8.0, 8.0).linear_gradient(90.0, &[(0.0, "#000000"), (1.0, "#FFFFFF")]).hover(
|
||||
Some("#FF0000"),
|
||||
None,
|
||||
Some(0.5),
|
||||
));
|
||||
assert_eq!(g["gradient"]["stops"][1], json!({"at": 1.0, "color": "#FFFFFF"}));
|
||||
assert_eq!(g["hover"], json!({"fill": "#FF0000", "stroke": null, "opacity": 0.5}));
|
||||
assert_eq!(menu_color("tint", "Tint", &["#FF0000"], "#FF0000")["options"], json!(["#FF0000"]));
|
||||
assert_eq!(disabled(invalid(slider("n", "N", 0.0, 10.0, 1.0, 3.0), "Too low"))["error"], "Too low");
|
||||
assert!(render(&json!({}), Node::frame(), Some(Vec::new())).is_err(), "no host off WebAssembly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_pads() {
|
||||
for (bytes, text) in
|
||||
[("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
|
||||
{
|
||||
assert_eq!(base64(bytes.as_bytes()), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@
|
|||
//! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples
|
||||
//! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a
|
||||
//! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and
|
||||
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…
|
||||
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…; then runs every
|
||||
//! built example in the engine (`figcraft-cli run`), so a module that no longer works fails here
|
||||
//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and
|
||||
//! with a synthetic design system used by 2,000 instances
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ fn wasm() -> bool {
|
|||
|
||||
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
|
||||
/// `<name with _>.wasm`).
|
||||
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"];
|
||||
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter", "focus-list", "dot-grid"];
|
||||
|
||||
/// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest
|
||||
/// and module side by side in `<target>/plugins/dist/<example>/`.
|
||||
|
|
@ -116,7 +117,48 @@ fn plugins() -> bool {
|
|||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
smoke(&target)
|
||||
}
|
||||
|
||||
/// What the built examples are asked to do, each in the engine as a script would: the commands of
|
||||
/// every plugin API (a run, a panel, a widget with its clicks, typed text, menu and timer). A
|
||||
/// widget command without an `id` acts on the selected widget (the one just inserted).
|
||||
const SMOKE: &[(&str, &str)] = &[
|
||||
("shape.rectangle", r#"{"x": 0, "y": 0, "width": 40, "height": 40, "props": {"name": " a layer copy "}}"#),
|
||||
("select.all", "{}"),
|
||||
("plugin.run", r#"{"id": "org.figcraft.examples.tidy-names", "command": "tidy", "params": {"case": "Title Case"}}"#),
|
||||
("plugin.run", r#"{"id": "org.figcraft.examples.swatch-grid", "command": "panel"}"#),
|
||||
("plugin.event", r#"{"id": "org.figcraft.examples.swatch-grid", "control": "palette", "value": "Ocean"}"#),
|
||||
("plugin.event", r#"{"id": "org.figcraft.examples.swatch-grid", "control": "generate"}"#),
|
||||
("plugin.close", r#"{"id": "org.figcraft.examples.swatch-grid"}"#),
|
||||
("widget.insert", r#"{"plugin": "org.figcraft.examples.counter", "x": 0, "y": 200}"#),
|
||||
("widget.click", r#"{"target": "increment"}"#),
|
||||
("widget.menuAction", r#"{"item": "step", "value": "5"}"#),
|
||||
("widget.menuAction", r#"{"item": "reset"}"#),
|
||||
("widget.insert", r#"{"plugin": "org.figcraft.examples.focus-list", "x": 0, "y": 400}"#),
|
||||
("widget.setText", r#"{"input": "title", "text": "Smoke test"}"#),
|
||||
("widget.setText", r#"{"input": "new", "text": "One more"}"#),
|
||||
("widget.click", r#"{"target": "toggle.0"}"#),
|
||||
("widget.click", r#"{"target": "focus"}"#),
|
||||
("widget.fireTimer", "{}"),
|
||||
("widget.list", "{}"),
|
||||
("plugin.run", r#"{"id": "org.figcraft.examples.dot-grid"}"#),
|
||||
("plugin.event", r#"{"id": "org.figcraft.examples.dot-grid", "control": "columns", "value": 4}"#),
|
||||
("plugin.event", r#"{"id": "org.figcraft.examples.dot-grid", "control": "generate"}"#),
|
||||
("plugin.close", r#"{"id": "org.figcraft.examples.dot-grid"}"#),
|
||||
];
|
||||
|
||||
/// Import the examples built into `<target>/dist` and run [`SMOKE`] with them.
|
||||
fn smoke(target: &std::path::Path) -> bool {
|
||||
let dist = target.join("dist").to_string_lossy().replace('\\', "/");
|
||||
let imports = EXAMPLE_PLUGINS.iter().map(|name| format!(r#"{{"command": "plugin.import", "params": {{"path": "{dist}/{name}"}}}}"#));
|
||||
let steps = SMOKE.iter().map(|(command, params)| format!(r#"{{"command": "{command}", "params": {params}}}"#));
|
||||
let script = target.join("smoke.jsonl");
|
||||
if let Err(e) = std::fs::write(&script, imports.chain(steps).collect::<Vec<_>>().join("\n")) {
|
||||
eprintln!("plugins: {}: {e}", script.display());
|
||||
return false;
|
||||
}
|
||||
run(cargo().args(["run", "-q", "-p", "figcraft-cli", "--", "run"]).arg(&script).stdout(std::process::Stdio::null()))
|
||||
}
|
||||
|
||||
/// Engine/render timings (`figcraft-cli perf`) and UI frame timings (an ignored test driving the
|
||||
|
|
|
|||
Loading…
Reference in New Issue