3 Commits

Author SHA1 Message Date
Marcos Paulo 92c63b0a46 chore: release 0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:47:43 -03:00
Marcos Paulo dcbc213d1f docs: README, design-system and agent docs, specs, skills for 0.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:47:24 -03:00
Marcos Paulo 30dba88e1c feat: variable collections, shared libraries, assets and offline CLI
Variables move to a project-level design.json: collections with their
own modes, slash groups, color/number/string/boolean types and aliases.
Pages keep a derived cache, every writer goes through one path, and a
change reaches every page in one undo step per page. Strokes, text
color, size, gap, padding, radius, font size, line height, opacity and
visibility can bind to variables. Projects migrate to version 2.

Components work across pages, and any file can publish itself as a
library that other files enable, update after review, or disable.

New Variables and Libraries modals, an Assets view grouped by name
prefix, variable pickers in the inspector, and a slimmer Design tab.

papercraft-cli runs tools in-process when the app is closed, with a
`shell` command for interactive sessions and scripts.

Also centres the sidebar footer and title bar text on their icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:47:23 -03:00
77 changed files with 10635 additions and 1407 deletions
Generated
+1 -1
View File
@@ -2709,7 +2709,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"arboard",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.4.0"
version = "0.5.0"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
+10 -1
View File
@@ -34,7 +34,7 @@ are credited in `THIRD_PARTY.md`.
to that page's own document and history (`Editor::with_page`) without switching the view.
* **Bridge** (`paper-rs-mcp`): rmcp stdio server that lists tools offline and relays calls to
the app over HTTP, reconnecting after app restarts.
* **Tools** (`src/mcp/tools.rs`): 53 tools, using Paper's names and argument shapes where there is an
* **Tools** (`src/mcp/tools.rs`): 53 tools at the time (61 now), using Paper's names and argument shapes where there is an
equivalent (see README), plus `create_shape`, `select_nodes`, `undo`, `redo`.
`write_html` (`src/mcp/{html,css}.rs`, `exec.rs` `Builder`) maps flex/block layout onto auto
layout with CSS stretch semantics, text styles and rich-text runs, backgrounds, gradients,
@@ -82,6 +82,15 @@ are credited in `THIRD_PARTY.md`.
* Agent view lists runs and supports review, whole-run revert, per-change undo and accept all. Review adds canvas outlines and a finish toast; palette commands review or revert the last run.
* Id-paired page diffs power review; History version compare UI remains todo. Completed with full restore (including variables, modes and tokens), tool edit classification, and wasm support.
### 2026-10-21: Design libraries and offline CLI
* Variables are now collections with modes, slash groups, four types and aliases, stored in `design.json` (project version 2; 0.4.0 refuses migrated projects). Each page keeps a derived cache; every edit is one undo step per page, and the page-only removals are gone. Per-page active mode per collection.
* Fields bind to variables (stroke, text color, size, gap, padding, radius, font size, line height, opacity, visible) in the inspector and with `bind_variable`; gradients do not bind yet.
* Instances work across pages (`component_key`, source page or library); deleting a main flags its instances.
* Libraries: publish a snapshot (`library.json`), enable, update with review (overrides kept), disable, and keep working when the source is missing. Variables modal, Libraries modal and Assets view in the UI; `create_collection`, `delete_variables`, `bind_variable` and five `*_library` tools in MCP (61 tools).
* `papercraft-cli call --offline` and `papercraft-cli shell` run the tools in-process on the project store, refuse a project the app has open, and fall back to the software adapter for screenshots. See `docs/agents.md`.
* Evidence: `src/variables.rs`, `src/editor/design.rs`, `src/libraries.rs`, `src/cli.rs`, `tests/{libraries,cross_page_components,variables_ui,assets_panel,cli_offline}.rs`, specs in `specs/design-libraries/` and `specs/cli-offline/`.
### P6 notes
* **Export** (`io/export.rs`): scope = selection, each frame, page or all pages; format =
+153 -243
View File
@@ -1,365 +1,275 @@
<h1 align="center">PaperCraft</h1>
<p align="center">
<b>Design and whiteboarding on an infinite canvas; a native, local-first, agent-driven design tool, built in pure Rust.</b>
<b>A native, local-first design and whiteboard canvas that AI agents can drive.</b>
</p>
<p align="center">
Frames, auto layout, components, variables and icons, plus a hand-drawn whiteboard mode,<br>
in a native app written entirely in Rust. Open source, offline, and drivable by AI agents.
</p>
<p align="center">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
<img alt="Native on egui and wgpu" src="https://img.shields.io/badge/egui%20%C2%B7%20wgpu-native-2f7bf5?style=flat-square">
<img alt="MCP server and CLI for agents" src="https://img.shields.io/badge/agents-MCP%20%2B%20CLI-555555?style=flat-square">
<img alt="Status: alpha" src="https://img.shields.io/badge/status-alpha-d69e2e?style=flat-square">
<img alt="Rust" src="https://img.shields.io/badge/Rust-egui%20%C2%B7%20wgpu-b7410e?style=flat-square&logo=rust">
<img alt="Agents: MCP and CLI" src="https://img.shields.io/badge/agents-MCP%20%2B%20CLI-555555?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
</p>
<br>
<p align="center">
<img src="docs/screenshots/app_demo.png" alt="PaperCraft with a launch plan: a Week plan frame with sticky notes and a progress bar, a Sketches frame with hand-drawn shapes, the Layers panel and the Design inspector" width="100%">
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Redesign page of its own design file open: layers on the left, the canvas in the middle, the inspector on the right" width="100%">
<br>
<sub>A planning board: frames, sticky notes, a progress bar, hand-drawn sketches, the Layers panel and the Design inspector.</sub>
<sub>PaperCraft editing its own design file (the Redesign page).</sub>
</p>
<p align="center">
<a href="#what-it-is">What it is</a> ·
<a href="#features">Features</a> ·
<a href="#drive-it-with-agents">Agents</a> ·
<a href="#design-systems-variables-components-icons">Design systems</a> ·
<a href="#agents">Agents</a> ·
<a href="#get-started">Get started</a> ·
<a href="#keyboard-shortcuts">Shortcuts</a> ·
<a href="#status">Status</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#status-and-known-limits">Status</a> ·
<a href="#project-layout">Layout</a> ·
<a href="#license-and-credits">License</a>
</p>
<br>
## What it is
<table>
<tr>
<td width="25%" valign="top">
<h3>Local-first</h3>
Projects are plain folders of JSON in <code>~/Documents/papercraft</code>. Autosave with atomic writes, no account, no cloud.
</td>
<td width="25%" valign="top">
<h3>Native and fast</h3>
egui on wgpu (Metal, Vulkan, DX12). No Electron, no web view. Cached tessellation: a frame with 10k visible nodes draws in about 3 ms.
</td>
<td width="25%" valign="top">
<h3>Design and whiteboard</h3>
Auto layout, components and variables next to hand-drawn shapes, bound arrows, sticky notes and a laser pointer, on the same canvas.
</td>
<td width="25%" valign="top">
<h3>Agent-ready</h3>
40 MCP tools and a shell CLI. Agents write HTML into layers, screenshot the result and lint it, one undo step per call.
</td>
</tr>
</table>
PaperCraft is an infinite-canvas design tool and whiteboard written in Rust (egui on wgpu). It runs as a desktop app with no account and no cloud: a project is a folder of JSON files on your disk. It also serves the Model Context Protocol and ships a CLI, so an agent such as Claude Code or Codex can read and edit the open canvas, screenshot the result and lint it, one undo step per tool call.
<br>
It is an alpha. It is good for building screens, design systems and sketches, and it is not a replacement for a mature design tool. See [Status and known limits](#status-and-known-limits).
## Features
**Canvas and layout**
* Frames with clipping, groups, vector shapes with the Pen and Bézier editing, boolean operations, gradients, image fills, strokes, per-corner radii, opacity and blend modes, drop shadows, rich text, smart guides, measurements, align and distribute.
* Auto layout (direction, gap, padding, hug, fill, wrap, space-between) and constraints.
* A layers tree you can drag to reorder or re-parent, an inspector with Design and Prototype tabs, and a command palette over every action, page and layer (⌘K).
* Whiteboard mode: hand-drawn (rough.js-style) shapes, arrows bound to shapes, elbow connectors, sticky notes, freehand ink and a laser pointer on the same canvas.
* Pages: each page is its own canvas with its own undo history and view.
**Design systems**
* **Components** work across pages and across projects. Edit a main component and its instances follow, except the properties an instance overrode.
* **Shared libraries:** publish a project as a library, enable it in other projects, and take updates when it changes.
* **Variables:** collections with modes, slash groups (`color/brand/primary`), types (color, number, string, boolean) and aliases. Fills, strokes, text color, size, gap, padding, radius, font size, line height, opacity and visibility bind to variables.
* **Variables modal** (⌘⌥⇧V, or "Variables…" in the palette) to edit all of it; **Assets view** (side tab) with components grouped by name; **Libraries modal** to publish, enable and update.
* Lint (`lint_design`) for contrast, the 4px grid, type scale, touch targets, default names and hard-coded colors, with auto-fix.
* Design tokens out as CSS, JSON (W3C DTCG) or Tailwind `@theme`; code out as JSX with inline styles, Tailwind or HTML.
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone3_figma_core.png" alt="A design with frames, auto layout, a component and its instances, and vector shapes" width="100%">
<br>
<h3>Design tools</h3>
Frames with clipping, groups, components with instances and per-property overrides, auto layout (direction, gap, padding, hug, fill, wrap, space-between), constraints, the Pen with Bézier editing, boolean operations, gradients, image fills, strokes, per-corner radii, opacity and blend modes, rich text, smart guides and measurements, align and distribute.
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with four Catppuccin modes" width="100%">
<br><sub>Variables modal: collections, modes, groups and types.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone4_whiteboard.png" alt="A whiteboard with hand-drawn shapes, bound arrows, an elbow connector and sticky notes" width="100%">
<br>
<h3>A whiteboard that sketches</h3>
Hand-drawn (rough.js-style) rectangles, ellipses, diamonds and lines, arrows that bind to shapes and re-route when they move, elbow connectors, sticky notes, freehand ink with pressure, a laser pointer and a shape library.
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with components grouped into Banner, Button, Checkbox and Chip" width="100%">
<br><sub>Assets view: components grouped by name, from this file and enabled libraries.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone5_inspector.png" alt="The inspector with position, size, constraints, auto layout, fills, strokes and export sections" width="100%">
<br>
<h3>Inspector, layers and a palette</h3>
A sectioned inspector, a layers tree you can drag to reorder or re-parent, 100+ keyboard shortcuts, and a command palette over every action, page and layer (⌘K).
<img src="docs/screenshots/readme/libraries.png" alt="The Libraries modal with one enabled library" width="100%">
<br><sub>Libraries modal: publish this file, enable libraries from the store.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone6_svg_import.png" alt="An imported SVG on the canvas" width="100%">
<br>
<h3>Import and export</h3>
Export a selection, each frame, a page or the whole project to PNG (1 to 4x), SVG or vector PDF. Import <code>.excalidraw</code> scenes and libraries, SVG and images; paste between pages, projects and apps.
<img src="docs/screenshots/readme/agent-review.png" alt="Agent run review: a list of changes on the left and green, yellow and red outlines on the canvas" width="100%">
<br><sub>Review of an agent run, with the changes outlined on the canvas.</sub>
</td>
</tr>
</table>
### Themes
**Prototyping and review**
* Prototype links between frames and a **Present** mode that follows them with a camera glide.
* **Version history:** named and automatic saved versions per page; rename, restore, delete.
* **Agent runs:** editing tool calls group into a run. PaperCraft saves an auto-version before the first edit, lists the run, and lets you review it (added, changed and removed layers, outlined on the canvas), revert one change, revert the whole run or accept it.
* **Comments** on the canvas.
System, Light, Dark and the four Catppuccin flavours: Latte, Frappé, Macchiato and Mocha. Pick one in **View › Theme** or search "theme" in the ⌘K palette.
**Sharing and platforms**
* **Shared projects** through an optional relay (`papercraft-relay`); concurrent edits to a page are merged. Without a relay nothing leaves your machine.
* **Themes:** System, Light, Dark and the four Catppuccin flavours (Latte, Frappé, Macchiato, Mocha).
* **Web build** that runs the editor in the browser (`sh web/build.sh`, served by `papercraft-relay --static web`).
* **Import and export:** Figma `.fig` files, `.excalidraw` scenes and libraries, SVG and images in; PNG (1 to 4x), SVG and vector PDF out, for a selection, each frame, a page or the whole project.
### Projects and pages
Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file per page in `pages/`, plus `versions/`, `runs/` and library files). Work autosaves with atomic writes and files are versioned and migrated on load. `PAPERCRAFT_HOME` points to another folder.
PaperCraft opens on a **home screen** of your projects (recents grid with thumbnails; New, Import, and Rename, Duplicate or Move to Trash from each card's ⋯ menu or right-click; restore from Trash). A project is a folder in `~/Documents/papercraft/<name>/` with `project.json` and one JSON file per page in `pages/`. Work autosaves about a second after each change with crash-safe atomic writes; files are versioned and older versions migrate on load. Set `PAPERCRAFT_HOME` to use another folder. If you used the app under its earlier name, `~/Documents/paper-rs` is still read when it is the only one that exists.
## Agents
Each project has any number of **pages** (the Pages list at the top of the left sidebar: + to add, double-click to rename, right-click to duplicate or delete, drag to reorder). Each page is its own infinite canvas with its own undo history and view. Copy and paste works between pages and projects.
PaperCraft exposes **61 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
### File formats
| Format | Use |
| Way | Notes |
|---|---|
| Project folder (`project.json` + `pages/*.json`) | The native format. Plain, versioned JSON. |
| `.papercraft` | A single-file document (`papercraft FILE.papercraft`, not autosaved). `--import` turns it into a project. |
| `.paper-rs` | The earlier single-file extension. Old files still open. |
| PNG, SVG, PDF | Export: selection, frames, page or all pages. PDF is vector, with gradients, images and blend modes. |
| `.excalidraw`, `.excalidrawlib`, SVG, images | Import (drag and drop, ⌘O, or `--import`). |
## Drive it with agents
![A sign-in screen written by an agent through write_html](docs/screenshots/milestone7_mcp.png)
PaperCraft serves the [Model Context Protocol](https://modelcontextprotocol.io) while it runs, so agents such as Claude Code and Codex can read and design on the open canvas. Every tool call runs through the editor like a user edit: one undo step per call, and the window repaints immediately. Tool names and arguments follow other design-tool MCP servers where there is an equivalent, so prompts written for them carry over.
<table>
<tr>
<td width="33%" valign="top">
<img src="docs/screenshots/agent_signin.png" alt="A dark sign-in screen built from a component, an icon and variables" width="100%">
<br><sub>A sign-in screen: variables, an icon and a component instance.</sub>
</td>
<td width="67%" valign="top">
<img src="docs/screenshots/agent_dashboard.png" alt="A dashboard with a sidebar, three stat cards and a bar chart" width="100%">
<br><sub>A dashboard written in one <code>write_html</code> call, then checked with <code>lint_design</code>.</sub>
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/screenshots/agent_landing.png" alt="A landing page with a hero and three feature cards" width="100%">
<br><sub>A landing page: hero, call to action and feature cards, all colors from variables.</sub>
</td>
</tr>
</table>
* **Streamable HTTP** (built into the app): `http://127.0.0.1:29980/mcp`. Loopback only; requests from browsers (with an `Origin` header) and foreign `Host` headers are rejected. `PAPERCRAFT_MCP_PORT=NNNN` changes the port; `PAPERCRAFT_MCP=0` turns the server off.
* **stdio**: `papercraft-mcp` (built next to `papercraft`) relays stdio to the running app. It lists the tools even when the app is closed, and tells the agent to start PaperCraft if a call can't reach it.
* **Shell**: `papercraft-cli` calls the same tools, so an agent with a terminal needs no schemas in its context.
### Claude Code
| MCP over HTTP | Built into the app: `http://127.0.0.1:29980/mcp`. Loopback only; browser requests (with an `Origin` header) and foreign `Host` headers are rejected. `PAPERCRAFT_MCP_PORT` changes the port, `PAPERCRAFT_MCP=0` turns it off. |
| MCP over stdio | `papercraft-mcp` relays stdio to the running app. It lists tools even when the app is closed. |
| Shell | `papercraft-cli` calls the same tools, so an agent with a terminal needs no schemas in its context. |
```sh
# HTTP (the app must be running)
# Claude Code
claude mcp add --transport http --scope user papercraft http://127.0.0.1:29980/mcp
# or stdio through the bridge
claude mcp add --scope user papercraft -- /path/to/papercraft-mcp
```
### Design tab
The sidebar's **Design** tab (next to Layers and Library) lists the project's color variables as
editable swatches, switches variable modes (for example the four Catppuccin flavours), and lists the
page's components with Go to and Insert buttons. Editing a variable recolours every fill that follows it.
### Codex
```sh
# Codex
codex mcp add papercraft --url http://127.0.0.1:29980/mcp
# or stdio
codex mcp add papercraft -- /path/to/papercraft-mcp
```
which writes this to `~/.codex/config.toml` (either form works):
The main write tool is `write_html`: HTML with inline styles becomes layers (flexbox maps onto auto layout), and `update_styles` takes the same CSS as JSON. Other groups cover reading and screenshots, variables and bindings, components, libraries, icons, lint, diffs, export and agent-run bookkeeping. The full list is `papercraft-cli list`; each schema is `papercraft-cli schema <tool>`; the reference is [skills/papercraft/references/tools.md](skills/papercraft/references/tools.md).
```toml
[mcp_servers.papercraft]
url = "http://127.0.0.1:29980/mcp"
# command = "/path/to/papercraft-mcp"
```
### Without the app
Then ask the agent something like *"In PaperCraft, create a 390×844 mobile artboard and design a sign-in screen"*, or *"draw a flowchart of our deploy process on a new page, hand-drawn style"*. The [`papercraft` and `papercraft-design` agent skills](skills) teach an agent the workflow (build, screenshot, lint, fix) and design rules; [docs/agents.md](docs/agents.md) has the full guide.
### Shell (no MCP client needed)
The app must be running with a project open.
`papercraft-cli` works with PaperCraft closed. When nothing answers on the port, or with `--offline`, tools run inside the CLI on the project store and every editing call is saved; the project opens normally in the app later. With the app open on a project, `--offline` on that same project is refused, so two writers never share a folder. Screenshots use the GPU, or the software adapter when there is none.
```sh
papercraft-cli list # tool names, one line each (works offline)
papercraft-cli schema write_html # one tool's full schema
papercraft-cli call get_basic_info
papercraft-cli call create_artboard '{"name":"Hero","styles":{"width":"800px","height":"400px"}}'
echo '{"nodeId":"12"}' | papercraft-cli call get_screenshot - --out hero.png
papercraft-cli call create_file '{"name":"Demo"}' --offline
papercraft-cli call get_screenshot '{"nodeId":"abc123:1"}' --project Demo -o card.png
```
Text goes to stdout, images are saved to `--out` (default `<tool>.png`) and the path is printed, and the exit status is 1 on a tool error or when the app isn't running.
### Tools
| Group | Tools |
|---|---|
| Projects & pages | `list_resources`, `open_file`, `create_file`, `create_page`, `rename_pages`, `delete_pages` |
| Read | `get_basic_info`, `get_selection`, `get_node_info`, `get_children`, `get_tree_summary`, `get_computed_styles`, `get_jsx`, `find_nodes`, `get_fill_image`, `get_font_family_info`, `get_guide` |
| See | `get_screenshot` (PNG image content of a node or the page), `export` (PNG/SVG/PDF files), `export_combined_pdf` |
| Write | `create_artboard`, `write_html`, `update_styles`, `set_text_content`, `rename_nodes`, `duplicate_nodes`, `move_nodes`, `delete_nodes`, `finish_working_on_nodes` (saves) |
| Whiteboard and session | `create_shape` (rectangles, ellipses, diamonds, stars, stickies, text, lines, arrows bound to shapes), `select_nodes`, `undo`, `redo` |
| Design system | `get_variables`, `set_variables`, `set_variable_mode`, `get_components`, `create_component`, `create_instance`, `search_icons`, `lint_design` |
`write_html` takes HTML with inline styles: flexbox (`display:flex`, direction, gap, padding, alignment, `flex-grow`, wrap) and block stacking map onto auto layout with CSS's stretch rules; text gets size, weight, italic, color, line height, letter spacing, alignment and rich-text runs (`<b>`, `<i>`, `<span style>`); boxes get backgrounds (colors, gradients, images), borders, radii, opacity and blend modes; `<img>` takes local paths or `data:` URIs and `<svg>` is imported as vectors. `update_styles` takes the same CSS as JSON, plus the hand-drawn keys `sketch`, `roughness`, `fillStyle`, `strokeStyle`. Node ids look like `3fa9c1:14` (page and node); `pageId` lets an agent work on another page without switching the user's view. The full reference is in [skills/papercraft/references/tools.md](skills/papercraft/references/tools.md).
## Design systems: variables, components, icons
Agents (and `papercraft-cli`) get the pieces of a design system, so files aren't a pile of hex codes and copies:
`papercraft-cli shell` keeps one session open and reads a script from stdin. `:watch on` re-renders the artboard last edited to `preview.png` after every editing call (and shows it inline on iTerm2, WezTerm and kitty).
```sh
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7"}}'
papercraft-cli call write_html '{"targetNodeId":"root","mode":"insert-children","html":"<div style=\"background:var(--surface)\">…</div>"}'
papercraft-cli call create_component '{"nodeId":"<frame>","name":"Button / Primary"}'
papercraft-cli call create_instance '{"componentId":"<id>","parentId":"<frame>"}' # then set_text_content on its layers
papercraft-cli call lint_design '{"nodeId":"<frame>"}' # contrast, grid, type scale, names
papercraft-cli icons add phosphor lucide # open icon sets from npm, with their licences
papercraft-cli icons search arrow left
# in write_html: <x-paper-icon name="phosphor:gear" size="20" color="var(--accent)"/>
papercraft-cli shell --offline --project Demo <<'EOF'
set_variables {"variables":{"bg":"#1e1e2e","surface":"#313244","text":"#cdd6f4","accent":"#cba6f7"}}
:watch on
write_html {
"targetNodeId": "abc123:1",
"mode": "insert-children",
"html": "<div style='font-size:28px;color:var(--text)'>Welcome back</div>"
}
EOF
```
* **Variables** are named colors per project (`get_variables`, `set_variables`). A fill or text color written as `var(--name)` stays linked; `set_variables` moves every linked fill on every page in one undoable step, and the link is saved in the file. `set_variables` with a `mode` stores one set of values per mode (Light, Dark) and `set_variable_mode` switches the whole project. Strokes and gradients keep fixed colors; fill-based icon sets (Phosphor, Material) follow variables, stroke sets (Lucide) don't.
* **Components** (`create_component`, `create_instance`, `get_components`, `<x-paper-instance>`): edit the main component and every instance follows, except what an instance overrode (text, fills, size, ... are marked automatically when set through `set_text_content` / `update_styles`). Components and their instances live on one page.
* **Icons** live in `<data dir>/papercraft/icons/<set>/` (override with `PAPERCRAFT_ICONS`). Sets: Lucide (ISC), Tabler (MIT), Phosphor (MIT), Heroicons (MIT), Material Symbols (Apache-2.0); each keeps its licence file. `examples/taskflow.py` builds an 8-screen app with all three.
* **Lint** (`lint_design`) checks WCAG contrast, spacing on a 4px grid, the type scale, text under 12px, touch targets under 44px, default layer names and hard-coded colors that should be variables, and says how to fix each.
<p align="center">
<img src="docs/screenshots/readme/offline-shell.png" alt="preview.png written by the offline shell: a sign-in card with a heading, two fields and a button" width="240">
<br>
<sub><code>preview.png</code> from an offline shell script, with no app running.</sub>
</p>
More in [docs/design-system.md](docs/design-system.md).
### Skills
[`skills/`](skills) holds agent skills: `papercraft` (the build, screenshot, lint, fix loop and tool cheat sheet), `papercraft-design` (design-quality rules) and `papercraft-critique`. [docs/agents.md](docs/agents.md) is the full guide: install, registering with agents, scripts, runs and review, safety. [docs/design-system.md](docs/design-system.md) covers variables, components and libraries.
## Get started
### Download
The macOS app (`papercraft-<version>-macos-<arch>.dmg`, containing PaperCraft.app) and the command-line tools (`papercraft-cli-<version>-macos-<arch>.zip`, with `papercraft-cli` and `papercraft-mcp`) are attached to each release of [masi/papercraft](https://git.marcospaulo.dev.br/masi/papercraft/releases). Builds are ad-hoc signed and not notarized, so on another Mac open the app with right-click › Open the first time.
Releases are at [git.marcospaulo.dev.br/masi/papercraft/releases](https://git.marcospaulo.dev.br/masi/papercraft/releases).
| Build | File |
|---|---|
| macOS app (Apple silicon) | `papercraft-<version>-macos-aarch64.dmg`, containing PaperCraft.app |
| Linux (x86_64) | `papercraft-<version>-linux-x86_64.tar.gz` |
| CLI tools, macOS | `papercraft-cli-<version>-macos-aarch64.zip` (`papercraft-cli` and `papercraft-mcp`) |
| CLI tools, Linux | `papercraft-cli-<version>-linux-x86_64.tar.gz` |
macOS builds are ad-hoc signed and not notarized: on another Mac, open the app with right-click, Open the first time. Other platforms: build from source.
### Build from source
You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`). Install it with [rustup](https://rustup.rs).
You need Rust 1.95 or newer (see `rust-version` in `Cargo.toml`); install it with [rustup](https://rustup.rs).
```sh
cargo build --release
./target/release/papercraft # projects home screen
./target/release/papercraft --demo # sample planning board (not saved)
./target/release/papercraft ~/Documents/papercraft/Plan # open a project folder
./target/release/papercraft board.papercraft # open a document
packaging/macos/package.sh # PaperCraft.app on a DMG, plus the CLI zip (macOS)
./target/release/papercraft # projects home screen
./target/release/papercraft ~/Documents/papercraft/Plan # open a project folder
./target/release/papercraft --demo # sample board, not saved
packaging/macos/package.sh # PaperCraft.app on a DMG, plus the CLI zip
```
**macOS:** `xcode-select --install` for the linker, then the commands above. It uses Metal through wgpu. Shortcuts use ⌘, and trackpad pinch zooms.
**macOS:** `xcode-select --install` for the linker. It renders with Metal through wgpu. **Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian or Ubuntu); it runs on X11 and Wayland through Vulkan, with GL as a fallback, and file dialogs use the XDG desktop portal.
**Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian/Ubuntu), then build. It runs on X11 and Wayland through Vulkan, or GL as a fallback. Shortcuts use Ctrl. File dialogs use the XDG desktop portal.
To run a test copy next to your own PaperCraft, give it its own folder and port: `PAPERCRAFT_HOME=/tmp/pc PAPERCRAFT_MCP_PORT=29998 ./target/release/papercraft`.
### Headless (servers, CI)
No display is needed. wgpu renders offscreen, through Mesa lavapipe when there is no GPU:
Headless rendering needs no display (wgpu renders offscreen, through Mesa lavapipe without a GPU):
```sh
papercraft --screenshot ui.png --demo --size 1440x900 --scale 2 # full UI
papercraft --screenshot home.png --home # projects home screen
papercraft --export-png out.png PROJECT_DIR --page 0 --scale 2 [--transparent]
papercraft --export-svg out.svg PROJECT_DIR
papercraft --export-pdf out.pdf PROJECT_DIR --frames # one PDF page per frame
papercraft --export out_dir PROJECT_DIR --format png --scope frames # a folder of PNGs
papercraft --import board.excalidraw # new project, prints its folder
papercraft --screenshot ui.png --demo --size 1440x900 --scale 2
papercraft --export-png out.png PROJECT_DIR --page 0 --scale 2
papercraft --export-pdf out.pdf PROJECT_DIR --frames
papercraft --import board.excalidraw # new project, prints its folder
```
### Tests
```sh
cargo test # unit, scenario, headless GPU and CLI tests
PAPERCRAFT_REQUIRE_GPU=1 cargo test # fail (instead of skip) when no GPU adapter exists
cargo test --no-fail-fast
PAPERCRAFT_REQUIRE_GPU=1 cargo test # fail instead of skip when there is no GPU adapter
cargo test --release --test perf -- --nocapture # 10k-element frame timings
```
The scenario tests drive the editor with synthetic pointer and keyboard events; `tests/mcp.rs` exercises every MCP tool on a headless session, then through an rmcp client over HTTP and through the `papercraft-mcp` stdio bridge. The SVG export is rasterized with resvg and compared with the GPU render.
The scenario tests drive the editor with synthetic pointer and keyboard events; `tests/mcp.rs` exercises every MCP tool on a headless session, then over HTTP and through the stdio bridge. `cargo check --lib --target wasm32-unknown-unknown` covers the web build.
## Keyboard shortcuts
⌘ on macOS, Ctrl on Linux and Windows. Press `?` or ⌘/ in the app for the full grouped list (100+ bindings), or ⌘K to search every command.
⌘ on macOS, Ctrl on Linux. Press `?` or ⌘/ in the app for the full grouped list, or ⌘K to search every command.
| Keys | Action | Keys | Action |
|---|---|---|---|
| V / 1 | Select | ⌘K | Command palette |
| H, or hold Space | Hand (pan) | ⌘Z / ⌘⇧Z | Undo / redo |
| F or A | Frame | ⌘G / ⌘⇧G | Group / ungroup |
| R / 2, O / 4, D / 3 | Rectangle, ellipse, diamond | ⌘⌥G | Frame selection |
| ⇧Y, ⇧S | Polygon, star | ⌘⌥K / ⌘⌥B | Create component / detach |
| R / 2, O / 4, D / 3 | Rectangle, ellipse, diamond | ⌘⌥G | Wrap selection in frame |
| ⇧Y, ⇧S | Polygon, star | ⌘⌥K / ⌘⌥B | Create component / detach instance |
| L / 6, ⇧L / 5 | Line, arrow | ⇧A / ⌥⇧A | Add / remove auto layout |
| P, ⇧P / 7 | Pen, pencil | ⌥A ⌥H ⌥D / ⌥W ⌥V ⌥S | Align left/center/right, top/middle/bottom |
| P, ⇧P / 7 | Pen, pencil | ⌥A ⌥H ⌥D / ⌥W ⌥V ⌥S | Align left, center, right / top, middle, bottom |
| T / 8, S | Text, sticky note | ⌥⇧H / ⌥⇧V | Distribute spacing |
| K | Laser pointer | ⌥⇧U S I E | Union, subtract, intersect, exclude |
| E / 0 | Eraser | ⌘E | Flatten |
| 9, ⌘⇧K | Place image | ⌘B / ⌘I / ⌘U / ⌘⇧X | Bold, italic, underline, strikethrough |
| Enter / ⇧Enter | Edit (text, path) / select parent | ⌘⌥H | Hand-drawn style |
| Enter / ⇧Enter | Edit / select parent | ⌘⌥H | Hand-drawn style |
| Tab / ⇧Tab | Next / previous sibling | ⇧H / ⇧V | Flip |
| ⌥-drag | Duplicate while dragging | ⌘⌥C / ⌘⌥V | Copy / paste properties |
| ⌥ hover | Measure distances | ⌘R | Rename |
| ⌘⌥⇧V | Variables | ⌘⌥C / ⌘⌥V | Copy / paste properties |
| ⌘= / ⌘- / ⇧0 | Zoom in / out / 100% | ⇧1 / ⇧2 | Zoom to fit / selection |
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
Draw a frame around existing shapes and it adopts them; shapes drawn inside a frame become its children, and dragging a layer into or out of a frame re-parents it. Hold ⌘/Ctrl while dragging to suspend snapping.
Draw a frame around existing shapes and it adopts them; dragging a layer into or out of a frame re-parents it. Hold ⌘ while dragging to suspend snapping.
## Status
## Status and known limits
> [!IMPORTANT]
> **PaperCraft is in early alpha.** It is usable for planning, sketching and building screens, but it is not a replacement for a mature design tool. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
Known limits:
> PaperCraft is an alpha. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
* **No rotation.** Nodes are axis-aligned boxes; rotated imports are baked into paths.
* **Text** is Inter only (regular, bold, italic, bold italic) with no shaping: no ligatures, complex scripts or RTL, and no font picker. IME preedit is not displayed (committed text works).
* **Rendering approximations.** Frame clipping is a rectangle, so rounded frames don't clip their corners. Group opacity multiplies into children. Overlay, dodge, burn, light, difference, exclusion and the HSL blend modes draw as Normal on the canvas (they are exact in SVG export).
* **Booleans are destructive**: the result is a new vector path with flattened curves.
* **Variables are colors only** and apply to fills and text color; strokes and gradients keep fixed colors.
* **`write_html`** ignores margin, grid, tables, shadows and transforms (it reports them as `ignoredStyles`). Remote `http(s)` images aren't fetched; pass a local path or a `data:` URI.
* **MCP**: one project is open at a time, so `fileId` must name the open project. Moving or duplicating layers between pages isn't supported.
* **Pen pressure.** winit 0.30 does not expose tablet pressure for pen events that arrive as mouse input, which is common with macOS and Wacom tablets. Otherwise it is simulated from stroke velocity.
* **Linux file dialogs** need an XDG desktop portal. The headless CLI is unaffected.
* **PDF** text is outlined (not selectable).
* **Text** is Inter only, with no shaping (no ligatures, complex scripts or RTL) and no font picker. IME preedit is not displayed.
* **Rendering approximations.** Frame clipping is a rectangle, so rounded frames do not clip their corners. Overlay, dodge, burn, light, difference, exclusion and the HSL blend modes draw as Normal on the canvas (they are exact in SVG and PDF export). There is no layer or background blur yet.
* **Variables:** gradients keep fixed colors; there are no text styles and no component variants or properties.
* **Booleans are destructive:** the result is a new vector path with flattened curves.
* **`write_html`** ignores margin, tables and transforms (it reports them as `ignoredStyles`) and does not fetch remote images; pass a local path or a `data:` URI.
* **Agents:** one project is open at a time in the app, and `move_nodes` stays on one page. There is no in-app prompt box; agents run outside and connect over MCP or the CLI.
* **Sharing** has no live presence (cursors, selections) yet.
* **Pen pressure** is simulated from stroke velocity on macOS tablets, because winit 0.30 does not expose it for mouse-like pen events.
* **Releases** cover macOS (Apple silicon) and Linux x86_64. Windows packaging scripts exist but are not part of releases.
* **PDF** text is outlined, not selectable.
## Project layout
```
src/geom.rs vectors, rects, camera
src/vector.rs bezier paths: flatten, hit test, SVG data, shapes, dashes
src/model/ Node / NodeKind / paints / Document arena
src/history.rs transactions, undo/redo
src/layout.rs auto layout, constraints, group bounds
src/ink.rs stroke smoothing, pressure, outline
src/text.rs rich text layout and glyph outlines
src/project/ project store, manifest/page formats, migrations
src/session.rs open project, autosave, thumbnails
src/editor/ tools, structure (groups/components), vector ops, pages, text editing
src/render/ draw ops → cached meshes → wgpu; headless capture
src/io/ PNG, SVG, PDF, export scopes, Excalidraw + SVG import, clipboard
src/mcp/ MCP server (rmcp): tools, HTML/CSS mapping, lint, HTTP service
src/ui/ home screen, pages, layers, inspector, palette
src/bin/ papercraft-cli (shell access) and papercraft-mcp (stdio bridge)
skills/ agent skills: papercraft, papercraft-design
analysis/ behaviour studies and design notes
docs/ guides and headless screenshots produced by `cargo test`
src/geom.rs vectors, rects, camera
src/vector.rs bezier paths: flatten, hit test, SVG data, shapes, dashes
src/model/ Node, paints, Document arena
src/history.rs transactions, undo and redo
src/layout.rs auto layout, constraints
src/text.rs, ink.rs rich text layout, stroke smoothing
src/project/ project store, manifest and page formats, migrations
src/session.rs open project, autosave, thumbnails
src/variables.rs collections, modes, groups, types, aliases, bindings
src/libraries.rs publish, enable and update shared libraries
src/design_system.rs design-system import and export
src/versions.rs saved versions
src/runs.rs agent runs, auto-versions, revert
src/sync.rs, merge.rs relay file sync and page merge
src/editor/ tools, structure, vector ops, pages, text editing, design bindings
src/render/ draw ops, cached meshes, wgpu, headless capture
src/io/ PNG, SVG, PDF, export, Figma, Excalidraw and SVG import
src/mcp/ tools, HTML and CSS mapping, lint, diff, codegen, HTTP server
src/cli.rs offline calls and the shell behind papercraft-cli
src/ui/ home, panels, inspector, palette, Variables, Assets, Libraries, agent view
src/lottie/ icon motion
src/web/ browser build
src/bin/ papercraft-cli, papercraft-mcp, papercraft-relay
skills/ agent skills
specs/ feature specs (agent runs, design libraries, offline CLI, ...)
analysis/ behaviour studies and design notes
docs/ guides and screenshots
```
## Downloads
| Build | File | Notes |
|---|---|---|
| Windows installer | `papercraft-<ver>-windows-<arch>.msi` | Per-machine installer (WiX) |
| Windows portable | `papercraft-<ver>-windows-<arch>-portable.zip` | `papercraft.exe` + CLI and MCP bridge |
| macOS app | `papercraft-<ver>-macos-<arch>.dmg` | PaperCraft.app, drag to Applications. Ad-hoc signed, not notarized |
| Command-line tools | `papercraft-cli-<ver>-<platform>-<arch>.zip` | `papercraft-cli` and `papercraft-mcp` |
Releases: https://git.marcospaulo.dev.br/masi/papercraft/releases. Other platforms: build from source.
## License and credits
PaperCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Bundled fonts, icons and other assets keep their own open licenses; each one is listed with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Inter (Regular, Medium, SemiBold, Bold and italics) is © The Inter Project Authors under the SIL Open Font License 1.1 (`assets/fonts/OFL.txt`). A few algorithms are ported from MIT-licensed projects (rough.js, hachure-fill, Excalidraw); they are listed with their notices in [THIRD_PARTY.md](THIRD_PARTY.md).
Bundled fonts, icons and other assets keep their own licenses, listed in [ATTRIBUTION.md](ATTRIBUTION.md); Inter is © The Inter Project Authors under the SIL Open Font License 1.1. Some algorithms are ported from MIT-licensed projects (rough.js, hachure-fill, Excalidraw), with their notices in [THIRD_PARTY.md](THIRD_PARTY.md).
The app icon is original artwork ([`assets/app-icon/`](assets/app-icon/README.md)).
PaperCraft started as a fork of [storytold/paper-rs](https://git.marcospaulo.dev.br/storytold/paper-rs); thanks to its contributors.
### Reverse-engineered origin
PaperCraft is an independent, clean-room-style reimplementation. It was reverse engineered from [Paper](https://paper.design) by studying the behavior of the Paper desktop app (`analysis/PAPER_ARCHITECTURE.md`) with [REA](https://github.com/morluto/rea) and Claude Code; Figma and Excalidraw were studied from their public docs and, for Excalidraw, its MIT source (`analysis/`). Only *behavior* was studied: no Paper or Figma code, shaders, icons, fonts or assets are used or committed. PaperCraft is not affiliated with, sponsored by or endorsed by Paper or Figma; those names are used only to describe the workflows it is compatible with.
<sub>Photoshop, Illustrator and Adobe are trademarks of Adobe Inc.; Figma is a trademark of Figma, Inc.; Paper is a product of its respective owners. PaperCraft is an independent, open-source project.</sub>
PaperCraft is not affiliated with Paper or Figma. Only the behavior of other tools was studied (see `analysis/`); no code, shaders, icons, fonts or assets from them are used.
+6 -5
View File
@@ -2,7 +2,7 @@
PaperCraft is an early alpha: a local-first infinite-canvas design app that agents can drive. This
file lists what is still missing, in priority order, with an honest status. Done items are checked
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 15, 23, 25, 32, 34, 39, 40, 45 and section 6 were updated 2026-10-21. Milestone history is in
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 7, 8, 12, 15, 23, 25, 32, 34, 39, 40, 45, 46 and section 6 were updated 2026-10-21. Milestone history is in
[PROGRESS.md](PROGRESS.md); known rendering and import limits are in the README.
Status: **done** (works today), **partial** (some of it works), **todo**.
@@ -19,12 +19,12 @@ Agents can already build a design system through the tools; people cannot yet ma
| 4 | Lint auto-fix | **done** | `lint_design {fix: true}` and UI `Fix` / `Fix all`: snaps spacing to 4px grid, snaps type scale, enforces 44px targets, links matching colors to variables. |
| 5 | Lint in the UI | **done** | Live Lint section in Design tab; click issue to select & zoom, or click `Fix` / `Fix all`. |
| 6 | Variable modes | **done** | Mode selector + create/delete modes in Design tab and via `set_variables {mode}` / `set_variable_mode`. |
| 7 | Number variables (spacing, radius, font size) | partial | Non-color tokens (`--spacing-md`) editable in Design tab and substituted in CSS; typed per-field bindings todo. |
| 8 | Stroke and gradient variables | todo | Strokes and gradients keep fixed colors. |
| 7 | Number variables (spacing, radius, font size) | **done** | Typed number, string and boolean variables in collections with groups and aliases; fields bind (size, gap, padding, radius, font size, line height, opacity, visible) in the inspector and `bind_variable`. |
| 8 | Stroke and gradient variables | partial | Strokes and text color bind to variables. Gradients keep fixed colors. |
| 9 | Text styles | todo | Named type styles (Heading / L, Body / M) that update every text layer using them. |
| 10 | Component variants and properties | todo | Variants are separate components named `Button / Primary`. No variant sets, boolean or text properties. |
| 11 | Swap instance, detach by tool | **done** | `swap_instance` and `detach_instance` MCP tools + UI controls in Design tab and Inspector. |
| 12 | Cross-page and shared libraries | todo | Components and instances must live on one page. |
| 12 | Cross-page and shared libraries | **done** | Instances of a main on any page; publish a project as a library, enable, update and disable it in other projects (Libraries modal, `*_library` tools). |
| 13 | Export design tokens | **done** | `get_variables` with `format: "css"`, `"json"` (W3C DTCG + modes), or `"tailwind"` (`@theme`), plus Copy CSS / Copy JSON in Design tab. |
| 14 | Apply a design system to existing work | partial | `lint_design {fix: true}` links matching literal colors to variables and snaps spacing/type scale. |
@@ -32,7 +32,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| # | Item | Status | Notes |
|---|---|---|---|
| 15 | MCP and CLI tool surface | **done** | 53 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 15 | MCP and CLI tool surface | **done** | 61 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
@@ -40,6 +40,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| 20 | Screenshot diffing | **done** | `diff_frames` tool (`src/mcp/diff.rs`) for structural/property frame diffing + `diff_rgba` pixel diffing with highlight PNG. |
| 21 | Moving nodes between pages | todo | `move_nodes` stays on one page. |
| 22 | Remote images in `write_html` | todo | Only local paths and `data:` URIs. |
| 46 | Design from the CLI without the app | **done** | `papercraft-cli call --offline` and `shell` run the tools in-process on the project store; screenshots use a GPU or the software adapter. |
## 3. Design features
+37 -2
View File
@@ -1,6 +1,6 @@
# Driving PaperCraft with agents
PaperCraft exposes one set of 53 tools three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
PaperCraft exposes one set of 61 tools three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
| Way | Use it when | Needs the app running |
|---|---|---|
@@ -75,6 +75,41 @@ papercraft-cli call finish_working_on_nodes '{"nodeIds":["abc123:1"]}'
Pass JSON inline, or `-` to read it from stdin (best for HTML, which is full of quotes; `jq -Rs` can wrap a file into the right JSON). Text results go to stdout. Images are saved to `--out` (default `<tool>.png`) and the path is printed.
## Without the app
`papercraft-cli` also works with PaperCraft closed (a server, CI, a headless box). When nothing answers on the port, or with `--offline`, tools run inside the CLI on the project store (`$PAPERCRAFT_HOME`, else the default folder) and every editing call is saved. Projects made this way open normally in the app.
```sh
papercraft-cli call create_file '{"name":"Demo"}' # -> fileId
papercraft-cli call create_artboard '{"name":"Card"}' --project Demo
papercraft-cli call get_screenshot '{"nodeId":"abc123:1"}' --project Demo -o card.png
```
`--project <name|id|path>` opens that project first; without it the most recently modified one is used. With the app open on a project, plain `call` goes to the app, and `--offline` on that same project is refused ("PaperCraft has this project open") so two writers never share a folder. Screenshots need a GPU adapter; with none, the software adapter is tried, and if that fails the image tools return an error while all other tools work.
### Shell and scripts
`papercraft-cli shell [--project X] [--offline]` keeps one session open (offline, one in-process project; otherwise the app). A line is `<tool> <json>` (JSON may span lines until its braces close) or a command: `:help :tools :schema <tool> :open <project> :pages :page <name|id> :shot [nodeId] [file] :undo :redo :save :watch on|off :quit`. `:watch on` renders the artboard last edited to `preview.png` after each editing call, shown inline on iTerm2, WezTerm and kitty. Input is plain stdin (no line editing or history; use `rlwrap` for that). Piped input prints no prompt and exits 1 if any line failed, listing the line numbers on stderr.
```sh
papercraft-cli shell --offline --project Demo <<'EOF'
# build a card
create_artboard {"name":"Card","styles":{"width":"320px","height":"180px","backgroundColor":"#1e1e2e"}}
write_html {
"targetNodeId": "abc123:1",
"mode": "insert-children",
"html": "<div style='font-size:28px;color:#cdd6f4'>Hello</div>"
}
:shot abc123:1 card.png
EOF
```
## Design-system tools
* Variables: `get_variables` and `set_variables` take collections, groups (`color/brand/primary`), types (`color`, `number`, `string`, `boolean`), `mode`, `alias`, `description` and `hidden`; the old `{"name":"#hex"}` call still works and lands in the first collection. `create_collection` makes a collection with modes, `delete_variables` removes by name (refuses aliased ones unless `force`), `bind_variable` binds or detaches a field of layers (`stroke`, `text-color`, `width`, `height`, `gap`, `padding`, `radius`, `font-size`, `line-height`, `opacity`, `visible`).
* Components: `get_components` lists mains of every page and enabled library with a `componentKey`; `create_instance` and `swap_instance` accept a main from any page or library (`componentId` or `componentKey`).
* Libraries: `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library`. They work offline too, on the project store.
## Safety
The server listens on loopback only, rejects requests that carry an `Origin` header (browsers) and foreign `Host` headers. Anything on your machine that can reach `127.0.0.1:29980` can edit the open project, so do not forward the port. Undo (⌘Z, or the `undo` tool) reverts any agent edit.
@@ -88,6 +123,6 @@ The Agent view lists runs and lets you review changes, revert a whole run, undo
## Reference
* [Tool reference](../skills/papercraft/references/tools.md), generated from `papercraft-cli schema`.
* [Design systems](design-system.md): variables, components, icons, lint.
* [Design systems](design-system.md): collections, variables and bindings, components, libraries, icons, lint.
* [Design-system packages](design-system-packages.md): importing and exporting an OpenDesign package.
* `papercraft-cli --help`-style usage: run `papercraft-cli` with no arguments.
+58 -12
View File
@@ -1,23 +1,53 @@
# Design systems in PaperCraft
A PaperCraft project can hold a small design system: color variables, components with instances, and icon sets. Today these are built and managed through the tools (MCP or `papercraft-cli`); the editor shows the results but has no panel for them yet (see [ROADMAP.md](../ROADMAP.md)).
A PaperCraft project can hold a design system: collections of typed variables with modes, components usable from any page, icon sets, and libraries that other projects can enable. The editor has a Variables modal, a Libraries modal and an Assets view for them (see [ROADMAP.md](../ROADMAP.md)); the same things are available through the tools (MCP or `papercraft-cli`).
## Variables
Named colors per project. Define them once, use them as `var(--name)`:
Variables live in **collections**. A collection has one or more **modes** (`Light`, `Dark`) and each variable has one value per mode. The project stores them in `design.json` next to `project.json`; every page keeps a derived copy that the renderer, lint and exports read, rebuilt on every change. A change from the UI or a tool updates every page in one undo step per page.
```sh
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7","on-accent":"#11111b"}}'
papercraft-cli call set_variables '{"collection":"Spacing","type":"number","variables":{"space/md":16,"space/lg":{"value":24}}}'
papercraft-cli call set_variables '{"variables":{"accent-hover":{"alias":"accent"}}}'
```
* Use `var(--name)` in `write_html`, `update_styles` and `create_artboard` for `background`, `backgroundColor` and `color`. The fill stays linked and the link is saved in the file.
* Calling `set_variables` again with a new value moves every linked fill, on every page, in one undo step.
* **Modes.** `set_variables {"mode":"Dark", ...}` stores one set of values for a mode; define another mode the same way. The first mode you define is active. `set_variable_mode {"mode":"Light"}` switches the whole project.
* `get_variables` lists the variables and modes. With `"format":"css"` it returns a `:root { --name: #hex }` block, with `"json"` a design-tokens object (`{"accent":{"$type":"color","$value":"#cba6f7"}}`).
* **Limits.** Colors only. Fills and text color follow variables; strokes and gradients get the resolved color at write time and do not follow later changes. No spacing, radius or font-size variables yet.
* **Types.** `color`, `number`, `string`, `boolean`. Numbers have no unit and bind as px. Strings are substituted as text (a font family, a shadow). Booleans bind to layer visibility. An alias points at a variable of the same type, in any collection or enabled library, and resolves per mode. A cycle is refused.
* **Groups.** A name is split on `/`: `color/brand/primary` sits in the group `color/brand`. Renaming a group in the Variables modal renames every variable under it and the bindings follow.
* **CSS names.** The name's segments joined with `-`, sanitized: `--color-brand-primary`. Use `var(--color-brand-primary)` in `write_html`, `update_styles` and `create_artboard` for `background`, `backgroundColor` and `color`. Two variables with the same CSS name across the collections of a project (library ones included) are refused when written.
* **Modes.** The active mode is chosen per page and per collection (Design tab), not per project; a new page inherits the active page's choice. `set_variables {"mode":"Dark", ...}` stores values for a mode, and the first mode you define is active. `set_variable_mode {"mode":"Light"}` switches the whole project. `create_collection {"name":"Brand","modes":["Light","Dark"]}` makes a collection with its modes up front.
* **Reading and exporting.** `get_variables` returns the collections (id, groups, type, value per mode, aliases, description, hidden), `pageModes`, and the older `variables` and `tokens` maps. With `"format":"css"` it returns a `:root { --name: #hex }` block plus `[data-theme]` rules for modes, with `"json"` a design-tokens object where groups nest on `/` and aliases are `{group.name}`, with `"tailwind"` an `@theme` block. Copy CSS, JSON and Tailwind are in the Variables modal too.
* **Deleting.** `delete_variables {"names":[...]}` refuses a variable that another one aliases and names the dependents; `"force":true` detaches them to the resolved value first. In the UI the same asks for confirmation. A removal affects every page.
* **Hidden.** A variable marked `hidden` stays in the file and is left out of published libraries.
Naming that works: roles, not hues (`surface`, `text-muted`, `accent`, `on-accent`, `danger`), so a mode can change the value without changing the meaning.
### Bindings
A fill that was written with `var(--name)` stays linked. Other fields bind with `bind_variable {"nodeIds":[...],"field":"gap","variable":"space/md"}` or with the variable button next to the field in the inspector. A bound field shows the variable as a chip with a detach button; detaching keeps the current value. `variable` may be the name or the CSS name; `null` detaches.
| Field | Type | Applies to |
|---|---|---|
| `stroke`, `text-color` | color | stroke color, text color |
| `width`, `height` | number | size in px |
| `gap` | number | auto-layout gap |
| `padding`, `padding-top`, `padding-right`, `padding-bottom`, `padding-left` | number | auto-layout padding (`padding` sets all four) |
| `radius` | number | corner radius |
| `font-size` | number | text size |
| `line-height` | number | below 4 it is a multiplier (`1.5`), from 4 up it is px |
| `opacity` | number | 0 to 1, values outside are clamped |
| `visible` | boolean | layer visibility |
A bound field is applied again when the variable, an alias target or the page's mode changes. Instances take bindings from their main; changing a bound field on an instance is an override for that instance only. Gradients cannot be bound yet.
### Migration
Projects made before collections open as project version 2: the first page's colors and modes become the collection `Colors`, its tokens become `Tokens` (a `16px` value becomes a number, anything else, including a unitless `1.5`, stays a string). CSS names do not change, so existing fill bindings keep working. PaperCraft 0.4.0 does not know `design.json`, so it refuses a migrated project with "made by a newer PaperCraft" instead of writing pages that would drift from it. Update before opening such a project on another machine.
### Variables modal
Open it from the Design tab ("Open variables"), the command palette (`Variables…`) or ⌘⌥⇧V. Collections of this file are on the left, then each enabled library (read-only), each with its group tree. The table has one row per variable in the selected group and one column per mode; cells edit in place (swatch and hex, number, text, toggle) and an alias picker links to another variable. Add a variable (choose its type), a mode or a collection from the header; rename, delete, search and a details pane (description, hidden, CSS name, how many layers use it) are there too.
## Components and instances
```sh
@@ -29,8 +59,9 @@ papercraft-cli call set_text_content '{"updates":[{"nodeId":"<label layer id fro
* Build the main component as a frame (usually its own artboard), then convert it. Name it `Component / Variant`.
* `create_instance` returns the instance id and its layers, so you can set text and styles on them. Changes made through `set_text_content` or `update_styles` on an instance are recorded as overrides; everything else follows the main component.
* In `write_html`, `<x-paper-instance component-id="<id>" layer-name="Button / Save"/>` places an instance inline.
* `get_components` lists main components and how many instances each has.
* **Limits.** Components and their instances must be on the same page. No variants or properties: model variants as separate components (`Button / Primary`, `Button / Secondary`). In the editor, detach with ⌘⌥B; there is no detach or swap tool.
* `get_components` lists the main components of every page (and library components) with `componentKey`, page and how many instances each has.
* **Across pages.** Instances can be placed from a main on any page: `create_instance` and `swap_instance` take a `componentId` (`pageId:num`) or a `componentKey` (stable, from `get_components`). Editing a main on page A updates its instances on every other page, one undo step per page. If the main is deleted its instances stay as they are, marked "Main missing" in Assets, where you can detach them. `import_components` still copies a main onto a page, but reuse no longer needs it.
* **Limits.** No variants or properties: model variants as separate components (`Button / Primary`, `Button / Secondary`). `detach_instance` and `swap_instance` exist as tools; in the editor detach with ⌘⌥B.
## Icons
@@ -44,9 +75,8 @@ Fill-based sets (Phosphor, Material) follow variables; Lucide is stroke-based an
> **Known limitation.** An icon's *stroke* color is frozen at import time: the value of the
> variable is baked into the stroke, so a stroke-based icon (Lucide, Tabler, Heroicons) does
> not follow the variable when it changes later — only fills keep the link. Letting strokes
> follow a variable needs a `stroke_var` field on the style, which changes the saved page
> format, so it is on the backlog. A `var(--x)` that does not exist is reported as a warning
> not follow the variable when it changes later. A layer's `stroke` can follow a variable with
> `bind_variable`; the icon's own stroke does not. A `var(--x)` that does not exist is reported as a warning
> and the icon keeps the color it inherits.
## Lint
@@ -65,6 +95,22 @@ Fill-based sets (Phosphor, Material) follow variables; Lucide is stroke-based an
Instances are skipped (their main component is linted). The rules are conventions, not law: pass `scale` to match your own type scale and ignore `info` items you disagree with. Lint does not judge taste; always look at `get_screenshot` too.
## Libraries
Any project can publish itself as a library, and other projects can enable it. This is a published snapshot, as in Figma, not a live link.
* **Publish.** Libraries modal, tab "This file", or `publish_library`. It writes `library.json` in the project folder with a version counter, the non-hidden variables, and every main component whose name does not start with `_` or `.`, each with its layers and a 256px preview. Before publishing the modal shows what changed since the last publish ("3 variables changed, 1 component added"); the tool returns the same list.
* **Enable.** Every project in the store with a `library.json` is available (projects synced from other devices through the relay show up too). `enable_library {"libraryId":...}` or the toggle in the Libraries modal copies the snapshot into the project (`libraries/<id>.json`). Its variables show read-only in the Variables modal and can be bound and aliased; its components show in Assets and are placed with `create_instance {"componentKey":...}`. Enabling is refused if a CSS name already exists in the project.
* **Updates.** When the published version is newer than the copy, the Libraries modal tab "Updates" and a dot on the Assets tab say so, and `list_libraries` reports `updateAvailable`. The review lists changed variables and components. `update_library` (or Update in the modal) replaces the snapshot, rebuilds every page and re-syncs the library's instances; instance overrides are kept. Updates are never applied by themselves.
* **Missing source.** If the library's project is deleted or not in the store, the copy keeps working. The modal shows "Source not found" and `list_libraries` shows `sourceFound: false`.
* **Disable.** `disable_library` or the toggle (after a confirm when it is in use) stops using a library: bindings to its variables are detached and keep the resolved value, local variables that aliased it keep the resolved value, its instances become plain frames, and its variables and components leave the project. One undo step per page.
Open the Libraries modal from the Share menu (`Libraries…`), the command palette, or the Assets header.
## Assets
The Assets view (side panel tab) replaces the old Library tab. It has a search box and sections for this file (by page), each enabled library, and My shapes (the personal shape library). Components are grouped by the part of the name before the first ` / ` (`Button / Primary` is in group `Button`) and shown as previews. Click one to place it at the viewport center, or drag it onto the canvas. Instances whose main was deleted are listed as "Main missing" with a detach action.
## Packages
A design system can travel as an **OpenDesign design-system package**: a folder with
+3 -1
View File
@@ -11,6 +11,8 @@ Ranked by value for the effort. Done items say where.
* Merge of concurrent edits to one shared page (`src/merge.rs`, `src/sync.rs`).
* Interface scale in Settings and the command palette (native).
* Export presets (inspector Export "+").
* Shared libraries: publish a project as a library, enable and update it in others (`src/libraries.rs`, Libraries modal); cross-page components; number variables.
* Design from the CLI with the app closed (`papercraft-cli --offline`, `shell`).
* Auto versions before agent edits: each agent run snapshots edited pages and can be reviewed or reverted (`src/runs.rs`, Agent view).
## Next
@@ -22,4 +24,4 @@ Ranked by value for the effort. Done items say where.
7. **Comment threads with replies from people, mentions, and a canvas pin that opens the thread**.
8. **Flatten menus into icon toolbars**: the Share menu and the folded Menu are flat lists; next is an icon rail for the most used actions with labels on hover.
9. **Window**: Windows asks the system for rounded corners and keeps the shadow. On Linux the window manager (or the Wayland client-side frame) draws the frame, and it is round on GNOME and KDE. A frame drawn by the app needs three things that cannot be checked without a Linux desktop: a transparent surface, a paint callback that clears the four corners with replace blending (egui cannot erase pixels it has already drawn), and its own edge resizing through `drag_resize_window`. Not built for that reason.
10. **One design-system package per brand** imported by every app's design file, plus a token export that generates the theme code (see `docs/shared-libs.md`).
10. **One design-system package per brand** imported by every app's design file, plus a token export that generates the theme code (see `docs/shared-libs.md`). Publishing the brand project as a library covers the sharing; the generated theme code is still todo.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 KiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+1 -1
View File
@@ -36,7 +36,7 @@ Allowed spacing values: **4, 8, 12, 16, 24, 32, 48, 64** (and 96 for page sectio
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`.
* Semantic colors carry meaning only: red means error or destructive, green means success. Do not use them decoratively.
* If a color you need is not a variable, add the variable, then use it. `lint_design` reports hard-coded colors that equal a variable.
* Radii, spacing and type sizes cannot be variables yet, so keep them on the lists in this skill and define them once on the design-system page.
* Spacing, radius and type sizes are number variables (`space/md`, `radius/sm`, `font/body`): define them once, in a collection of their own, and bind fields with `bind_variable`. Keep the values on the lists in this skill. Group names with `/` and alias semantic names to base ones (`color/button/bg` to `color/brand/primary`).
## 3. Type scale
+7 -5
View File
@@ -19,6 +19,7 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
* Not running: ask the user to start PaperCraft and open a project (or `create_file` then `open_file`). `PAPERCRAFT_MCP_PORT` must match if the port was changed. Do not try to launch the GUI yourself unless asked.
* Read `get_basic_info` (pages, artboards, sizes, fonts), then `get_selection` (what the user is looking at) and `get_variables` and `get_components`. Reuse what exists before creating anything.
* `pageId` defaults to the page the user is viewing, which can change between calls: pass it explicitly. Node ids look like `3fa9c1:14`; never show them to the user.
* App closed (server, CI)? `papercraft-cli call --offline ... --project <name>` runs the same tools on the project store and saves after every editing call; `papercraft-cli shell` keeps one session for many calls and reads scripts from stdin. It is refused while the app has that project open. See `docs/agents.md`.
* With MCP tools you do not need the CLI; with a shell, `papercraft-cli list` and `papercraft-cli schema <tool>` give the schemas on demand. Pass big JSON on stdin: `... | papercraft-cli call write_html -`.
## 2. The loop: build, look, lint, fix
@@ -85,8 +86,9 @@ Full flows: [recipes.md](references/recipes.md).
| Change styles or text | `update_styles`, `set_text_content`, `rename_nodes` |
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
| Tokens | `get_variables` (`format` css or json), `set_variables` (`mode`), `set_variable_mode` |
| Reuse | `create_component`, `create_instance`, `get_components`, `<x-paper-instance>` |
| Tokens | `get_variables` (`format` css, json or tailwind), `set_variables` (`collection`, `type`, `mode`, `alias`), `create_collection`, `delete_variables`, `set_variable_mode`, `bind_variable` |
| Reuse | `create_component`, `create_instance`, `swap_instance`, `get_components`, `<x-paper-instance>` |
| Shared libraries | `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library` |
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
| Check quality | `lint_design` |
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
@@ -97,11 +99,11 @@ Full flows: [recipes.md](references/recipes.md).
## 6. Known limits (design around them)
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
* **Variables are colors only**, for fills and text color. Strokes and gradients keep fixed colors. Spacing, radius and type sizes are plain numbers: keep them on the scale by discipline and `lint_design`.
* **Variables** are typed (`color`, `number`, `string`, `boolean`), live in collections with modes, group with `/` (`space/md`, `color/brand/primary`) and can alias each other. Colors work as `var(--name)` on fills and text; every other field binds with `bind_variable` (stroke, text-color, width, height, gap, padding, radius, font-size, line-height, opacity, visible). Numbers bind as px; line-height below 4 is a multiplier. Gradients keep fixed colors.
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
* **No rotation.** Nodes are axis-aligned.
* **Components and their instances live on one page.** Put shared components on the page where they are used, or duplicate the component per page.
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). No swap or detach tool.
* **Components work across pages**: `create_instance` takes a main from any page or enabled library (`componentId` or `componentKey` from `get_components`) and follows later edits. To share with other projects, `publish_library` there and `enable_library` here; `update_library` pulls a newer publish.
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). `swap_instance` and `detach_instance` exist.
* **Remote images are not fetched.** Use a local path or `data:` URI.
* **One project open at a time**; moving layers between pages is not supported.
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
+125 -93
View File
@@ -5,12 +5,10 @@ Generated from `papercraft-cli schema <tool>`; run that command for the authorit
## Projects and pages
### `list_resources` (read-only)
Lists the user's papercraft projects (local folders), most recently modified first. Each item has id, name, path, page count, updatedAt, and `open` for the project open in the app.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `limit` | integer | | Maximum projects to return. Defaults to 50. |
@@ -25,7 +23,6 @@ Opens a project in the app (saving the current one) and returns a get_basic_info
Creates a new empty project and returns its id. Call open_file with the id to work in it.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | | Optional project name. |
@@ -35,7 +32,6 @@ Creates a new empty project and returns its id. Call open_file with the id to wo
Creates a new page at the end of the page list and returns its id, without switching the user's page. Use the returned pageId in later calls.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | | Optional page name. Defaults to "Page N". |
@@ -45,7 +41,6 @@ Creates a new page at the end of the page list and returns its id, without switc
Rename one or more pages. Does not switch the user's page.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | Page renames. |
@@ -55,16 +50,14 @@ Rename one or more pages. Does not switch the user's page.
Delete one or more pages. The last page of a project cannot be deleted. A page that still has layers is only deleted with `confirm: true`; check what is on it first (get_basic_info lists node counts).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `pageIds` | array of string | yes | Pages to delete. |
| `confirm` | boolean | | Required to delete a page that has layers. |
| `pageIds` | array of string | yes | Pages to delete. |
## Read
### `get_basic_info` (read-only)
Essential context about the open project: project name, the page, node count, artboards (top-level frames) with ids, sizes and world positions, other top-level layers, fonts, and `pages` with the active one marked isActive.
@@ -81,7 +74,6 @@ The selected layers on the page the user is viewing: ids, names, types, sizes, p
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | The node to inspect. |
@@ -91,7 +83,6 @@ Details of one node: type, name, size, visibility, lock state, parent, children
Direct children of a node (or of the page root when nodeId is "root"), with ids, names, types, child counts and positions.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | The parent node, or "root". |
@@ -101,7 +92,6 @@ Direct children of a node (or of the page root when nodeId is "root"), with ids,
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `depth` | number | | Maximum depth (default 3, max 10). |
@@ -110,54 +100,50 @@ Compact indented text tree of a subtree: type, name, id and size per node. Much
### `get_jsx` (read-only)
Export a node and its descendants to code: `"inline"` (default, React JSX with inline styles), `"tailwind"` (React JSX with Tailwind CSS utility classes), `"react"` (typed React TSX component with Tailwind classes), `"angular"` (Angular standalone `@Component` with template and CSS), `"html"` (self-contained `<style>` + HTML markup), or `"egui"` (Rust `egui` layout code with a `tokens` module).
Export a node and its descendants to code: "inline" (default, React JSX with inline styles), "tailwind" (React JSX with Tailwind CSS utility classes), "react" (React TSX component), "angular" (Angular v17+ standalone component), "html" (self-contained <style> + HTML markup), or "egui" (Rust egui 0.36 code with tokens).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: `"inline"` (default), `"tailwind"`, `"react"`, `"angular"`, `"html"`, or `"egui"`. |
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: "inline" (default), "tailwind", "react", "angular", "html", or "egui". |
| `nodeId` | string | yes | The node to convert. |
### `get_design_spec` (read-only)
Extract a comprehensive AI design-to-code specification (`ai_spec_markdown` and structured JSON `spec`) for a node and its descendants. Includes the hierarchy, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), bound design tokens (`--var`), component instances (`instanceOf` on each node plus `componentDefinitions`), icons (`kind:"icon"` with set, name, size and tint), vector SVG (`svg`, `svgBounds`), text metrics (weight, line height, letter spacing), min and max sizes, fills and gradients, token matches for spacing, radius and type, implementation rules, and reference code for the chosen `target`. Always call this before implementing a design in code.
Export a deterministic AI Design-to-Code specification for a frame or subtree. Includes all design tokens/variables, component instances, exact auto-layout tree (sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory enforcement rules, and reference target code. Always call this before implementing a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing from a screenshot.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | The root frame or node to extract the spec for. |
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target framework/format (defaults to `"react"`). |
| `format` | `markdown` \| `json` | | Output format: "markdown" (default) or "json". |
| `nodeId` | string | yes | Root frame or node id to export. |
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target UI framework (default "react"). |
### `verify_code` (read-only)
Enforce design-to-code fidelity by checking written `code` against the source PaperCraft node (`nodeId`) for the given `target`. Reports a `score` (0–100), `passed` boolean, and structured violations (`missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, `spacing-drift`) with actionable fixes.
Verify that AI-generated UI code (`react`, `tailwind`, `angular`, `html`, or `egui`) accurately implements a design subtree. Checks for missing text strings, hardcoded hex colors where `--var` or `tokens::*` must be used, forbidden `position: absolute` on auto-layout flow children, missing flex/grid/egui layout containers, unreferenced component instances, and spacing drift. Returns `{passed, score, errors, warnings, violations}`.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `code` | string | yes | The generated or hand-written implementation code to verify. |
| `nodeId` | string | yes | The PaperCraft node id the code implements. |
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | yes | Target language/framework of `code`. |
| `code` | string | yes | The generated UI source code to verify against the design. |
| `nodeId` | string | yes | Root frame or node id that the code implements. |
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | yes | Target UI framework. |
### `diff_frames` (read-only)
Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level differences (size, position, fills, stroke, corner radius, opacity, text, layout, added/removed children).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `beforeId` | string | yes | First frame or node id (baseline). |
| `afterId` | string | yes | Second frame or node id (comparison). |
| `beforeId` | string | yes | First frame or node id (baseline). |
### `get_computed_styles` (read-only)
CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to read. |
@@ -167,7 +153,6 @@ CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
Find nodes by text content and/or computed style. Searches the whole project by default; pass pageId for one page or nodeId for a subtree. `textValue` matches text case-insensitively with * wildcards anchored to the whole value. `filters` are {styleName, styleValue} pairs (AND), values may use * wildcards; colors match by value.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `filters` | array of object | | |
@@ -179,7 +164,6 @@ Find nodes by text content and/or computed style. Searches the whole project by
The image used by a node's image fill, as image content.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | A node with an image fill. |
@@ -189,7 +173,6 @@ The image used by a node's image fill, as image content.
Whether font families are available and their weights/styles. papercraft renders text with Inter (400, 700, normal and italic); other families fall back to Inter.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `familyNames` | array of string | yes | |
@@ -199,7 +182,6 @@ Whether font families are available and their weights/styles. papercraft renders
Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what write_html supports), "whiteboard" (shapes, arrows, stickies, hand-drawn style).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `topic` | string | yes | |
@@ -207,16 +189,14 @@ Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what w
## See and export
### `get_screenshot` (read-only)
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits. In `papercraft-cli`, pass `-o`/`--out FILE` or `{"out": "FILE"}` to choose the output file path.
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | | The node to capture; omit for the whole page. |
| `out` | string | | Optional file path to save the PNG to (also used by `papercraft-cli`). |
| `out` | string | | Optional file path to save the PNG to (also used by papercraft-cli). |
| `scale` | number | | Render scale: 1 (default) or 2. |
@@ -224,7 +204,6 @@ Render a node (or the whole page when nodeId is omitted or "root") to a PNG and
Export nodes to files. `nodes` maps node ids to export settings ([{format: png|svg|pdf, scale: "2x" | "512w" | "512h" | "720p"}]; [] = 2x PNG). Files go to `directory` (default: the Downloads folder). Returns the paths written.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `directory` | string | | Output folder (absolute path). |
@@ -235,7 +214,6 @@ Export nodes to files. `nodes` maps node ids to export settings ([{format: png|s
Export several nodes into one PDF, one page per node, ordered top-to-bottom then left-to-right. Returns the file path.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to put on the pages. |
@@ -244,12 +222,10 @@ Export several nodes into one PDF, one page per node, ordered top-to-bottom then
## Write
### `create_artboard`
Creates an artboard (top-level frame) and returns its id; then add content with write_html({mode: "insert-children"}). `styles` are camelCase CSS; width and height are required (e.g. "1440px"). Artboards default to display:flex, flexDirection:column and a white background, and are placed in an empty spot unless left/top are given. Default sizes: desktop 1440×900, tablet 768×1024, mobile 390×844.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Layer name. |
@@ -261,18 +237,17 @@ Creates an artboard (top-level frame) and returns its id; then add content with
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including `wavy` and `text-decoration-color`); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component="Name" component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node (supports cross-page `<page>:<id>`).
Returns the ids of the created top-level layers and the styles that were ignored.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `html` | string | yes | HTML to parse into layers. |
| `mode` | insert-children/replace | yes | |
| `mode` | `insert-children` \| `replace` | yes | |
| `targetNodeId` | string | yes | Target node id, or "root" for the page. |
@@ -280,7 +255,6 @@ Returns the ids of the created top-level layers and the styles that were ignored
Update styles on one or more nodes (camelCase CSS, like React.CSSProperties). Setting left/top on an artboard moves it. Hand-drawn keys: sketch, roughness, fillStyle, strokeStyle, seed. Styles with no effect are returned under ignoredStyles.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | Each item applies styles to nodeIds. |
@@ -290,7 +264,6 @@ Update styles on one or more nodes (camelCase CSS, like React.CSSProperties). Se
Set the text of one or more Text or Sticky nodes (batch).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | |
@@ -300,7 +273,6 @@ Set the text of one or more Text or Sticky nodes (batch).
Rename layers (batch). Names are truncated to 50 characters.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | |
@@ -310,7 +282,6 @@ Rename layers (batch). Names are truncated to 50 characters.
Delete nodes and their descendants. Verify parents with get_node_info first when unsure.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to delete. |
@@ -320,27 +291,24 @@ Delete nodes and their descendants. Verify parents with get_node_info first when
Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; `<x-paper-clone node-id="<page>:<id>"/>` also works across pages). Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodes` | array of object | yes | Items accept `{id, parentId?, toPageId?}`. |
| `nodes` | array of object | yes | |
### `move_nodes`
Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page `parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `moves` | array of object | yes | Items accept `{nodeId, before?, after?, parentId?, toPageId?, index?}`. |
| `moves` | array of object | yes | |
### `create_shape`
Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, star, line, arrow, sticky (sticky note with text) or text. Coordinates are world units, or relative to parentId when given. Lines and arrows take `points` ([[x,y],...], at least two) and arrows may bind to layers with startNodeId/endNodeId (the arrow then follows them). `styles` takes the same CSS as update_styles plus the hand-drawn keys sketch, roughness (0 architect, 1 artist, 2 cartoonist), fillStyle (hachure, cross-hatch, solid, zigzag, dots), strokeStyle (solid, dashed, dotted). Arrows also take route (straight, curved, elbow) and startHead/endHead (none, arrow, triangle, dot, bar, diamond).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `endHead` | string | | |
@@ -349,13 +317,13 @@ Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, sta
| `name` | string | | |
| `parentId` | string | | Frame to place the shape in. |
| `points` | array of array | | |
| `route` | straight/curved/elbow | | |
| `route` | `straight` \| `curved` \| `elbow` | | |
| `sides` | integer | | Polygon sides / star points. |
| `startHead` | string | | |
| `startNodeId` | string | | |
| `styles` | object | | CSS and hand-drawn styles. |
| `text` | string | | Text for sticky and text. |
| `type` | rectangle/ellipse/diamond/triangle/polygon/star/line/arrow/sticky/text | yes | |
| `type` | `rectangle` \| `ellipse` \| `diamond` \| `triangle` \| `polygon` \| `star` \| `line` \| `arrow` \| `sticky` \| `text` | yes | |
| `width` | number | | |
| `x` | number | | |
| `y` | number | | |
@@ -363,33 +331,71 @@ Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, sta
## Design system
### `get_variables` (read-only)
Color variables and design tokens of the page's project (name -> hex/value). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` `css` returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), `json` returns a W3C DTCG design-tokens object, and `tailwind` returns a Tailwind v4 `@theme` block.
Variables of the page's project. Default JSON: `variables` (name -> hex), `tokens`, and `collections` (id, name, modes, defaultMode, variables with id, name, cssName, group, type color|number|string|boolean, values per mode name, aliases as {"alias": name}, description, hidden) plus `pageModes` (active mode per collection on this page). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` css returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), json a W3C DTCG object (groups nest on `/`, aliases are {group.name}), and tailwind a Tailwind v4 `@theme` block.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `format` | `css` \| `json` \| `tailwind` | | Output format: `css`, `json` (W3C DTCG), or `tailwind` (`@theme`). |
| `format` | `css` \| `json` \| `tailwind` | | |
### `set_variables`
Define or change color variables (names like `surface`, `accent-text`), on every page. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
Define or change variables (names like `surface`, `accent-text`, or grouped `color/brand/primary`), on every page. Plain {"name": "#hex"} defines a color. An entry may be an object {value, type, collection, mode, alias, description, hidden}: `type` color|number|string|boolean (numbers bind as px), `collection` a name or id (created if missing), `alias` the name of another variable of the same type. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `collection` | string | | Default collection (name or id) for entries that name none. |
| `mode` | string | | Store the values for this mode (e.g. "Light", "Dark"); they apply when it is the active mode. The first mode you define becomes active. |
| `variables` | object | yes | {"accent": "#cba6f7", "surface": "#313244"} |
| `type` | `color` \| `number` \| `string` \| `boolean` | | Default type for entries that name none. |
| `variables` | object | yes | {"accent": "#cba6f7"} or {"space/md": {"value": 16, "type": "number"}, "accent-hover": {"alias": "accent"}} |
### `create_collection`
Create a variable collection (a set of variables with modes), e.g. `Brand` with modes ["Light", "Dark"]. Without `modes` it has one mode, `Default`. Then fill it with set_variables {collection}.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `modes` | array of string | | |
| `name` | string | yes | |
### `delete_variables`
Delete variables by name. A variable another one aliases is refused (the error names the dependents) unless `force` is true, which gives the dependents its resolved value.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `force` | boolean | | |
| `names` | array of string | yes | |
### `set_tokens`
Define non-color design tokens (spacing, radius, font size, shadows, widths), on every page: {"spacing-md": "16px", "radius-md": "8px", "shadow-md": "0 4px 12px rgba(0,0,0,0.08)"}. Use `var(--spacing-md)` in any style value (padding: var(--spacing-sm) var(--spacing-md), boxShadow: var(--shadow-md)); it is replaced by the value when written, so tokens are a shared vocabulary, not live links. Listed by get_variables.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `tokens` | object | yes | |
### `bind_variable`
Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, width, height, gap, padding, padding-top/right/bottom/left, radius, font-size, line-height, opacity, visible. `variable` is a name like color/brand/primary or its css name; null or omitted detaches (the current value stays). The value applies at once; on an instance the field becomes an override.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `field` | string | yes | |
| `nodeIds` | array of string | yes | Layers to bind. |
| `variable` | string \| null | | |
### `set_variable_mode`
Switch the active variable mode (defined with set_variables {mode}): every variable takes that mode's value and all fills following them change, on every page. One call turns a whole design from light to dark.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `mode` | string | yes | |
@@ -399,44 +405,38 @@ Switch the active variable mode (defined with set_variables {mode}): every varia
Import an OpenDesign design-system package: a folder with manifest.json, tokens.css and (optional) DESIGN.md. The colors of the `:root` block become variables, the colors of the `[data-theme="name"]` blocks become modes and every other value becomes a token. Replaces the variables, modes and tokens of every page in one undo step. Only hex colors become variables; oklch(), rgb() and sizes stay tokens.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `path` | string | yes | Folder of the package to import. |
| `pageId` | string | | The design system is project-wide, so it lands on every page. |
### `export_design_system`
Write the page's variables, modes and tokens as an OpenDesign design-system package (manifest.json, tokens.css and DESIGN.md) in `path`, so it can be read back with import_design_system or by other tools. Returns the files written.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `path` | string | yes | Folder to write the package into. |
| `pageId` | string | | The page whose design system is written. |
### `get_components` (read-only)
Main components on a page (id, name, size) with how many instances each has.
Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), `pageId`, `pageName`, size, and `uses` (instances across all pages).
### `import_components`
Copy the main components anywhere on `fromPageId` (including inside artboards, typically on a `Design System` page) onto this one, so you can place instances here with `create_instance` or `<x-paper-instance component="Name" component-id="ID"/>`. Components with the same name already here are skipped. Copies are placed in rows (wrapping at 3200 px) to the right of the page content; shelf-pack them yourself using their real sizes if you want a tidy parking area. Variables and tokens are already shared by every page; components are copies, not live links.
Copy the main components anywhere on `fromPageId` (including inside artboards, typically on a `Design System` page) onto this one, so you can place instances here with create_instance or <x-paper-instance component="Name"/> / component-id="ID". Components with the same name already here are skipped. Variables and tokens are already shared by every page; components are copies, not live links.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `fromPageId` | string | yes | Source page id. |
| `fromPageId` | string | yes | |
| `names` | array of string | | Only these components (default all). |
### `create_component`
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or `<x-paper-instance component="Name" component-id="ID"/>`. Components and instances live on one page.
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or <x-paper-instance>. Instances may live on any page and follow the main.
| Argument | Type | Required | Notes |
|---|---|---|---|
@@ -448,10 +448,10 @@ Turn a frame or artboard into a main component. Build reusable parts (button, ro
Place an instance of a component. Edit text and styles on the instance's descendants with set_text_content / update_styles (they become overrides); edits to the main component flow to every instance. Returns the instance id and its text layers.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `componentId` | string | yes | |
| `componentId` | string | | A main on any page ("pageId:num"). |
| `componentKey` | string | | Instead of componentId: the key from get_components. |
| `index` | number | | Position among the parent's children. |
| `name` | string | | |
| `parentId` | string | | Parent frame; omit or "root" for the page. |
@@ -461,12 +461,12 @@ Place an instance of a component. Edit text and styles on the instance's descend
### `swap_instance`
Swap an instance root to point at a different main component on the same page, preserving overrides on descendants matched by name and layer type.
Swap an instance root to point at a different main component (any page), preserving overrides on descendants matched by name and layer type.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `componentId` | string | yes | The new main component id. |
| `componentId` | string | | The new main component id. |
| `componentKey` | string | | Instead of componentId: a key from get_components, including a library component. |
| `nodeId` | string | yes | The instance root node to swap. |
@@ -474,7 +474,6 @@ Swap an instance root to point at a different main component on the same page, p
Detach one or more component instances into plain frames, breaking their link to the main component while keeping their current appearance and children.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Instance root nodes to detach. |
@@ -484,7 +483,6 @@ Detach one or more component instances into plain frames, breaking their link to
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `limit` | number | | Default 20. |
@@ -492,11 +490,10 @@ Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material).
| `set` | string | | |
### `lint_design` (read-only)
### `lint_design`
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color variables, snap text sizes, and enlarge small touch targets.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `fix` | boolean | | Apply safe automatic fixes before reporting remaining issues. |
@@ -504,37 +501,75 @@ Check a page or subtree for common design mistakes and get a fix for each: text
| `scale` | array of number | | Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64. |
## Session
## Libraries
### `publish_library`
### `get_feedback` (read-only)
Notes the user left on layers (the Feedback box in the inspector, shown as amber dots on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries all pages across the project; `openCount` and `resolvedCount` are returned alongside per-page counts in `get_basic_info`. Call it at the start of a session and after each round of edits; fix every open note, then `resolve_feedback`. `get_selection` also includes the selected layers' notes.
Publish this project's variables (not hidden) and components (not named with a leading `_` or `.`) as a library other projects can enable. Writes library.json with the next version and returns what changed since the last publish.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `includeResolved` | boolean | | Include resolved notes. |
| `description` | string | | What the library is for (kept from the last publish when omitted). |
### `list_libraries` (read-only)
Libraries other projects published (`available`, with `enabled`) and the ones this project uses (`enabled`: version, sourceVersion, sourceFound, updateAvailable).
### `enable_library`
Use a published library: its variables become read-only, bindable and aliasable, and its components can be placed with create_instance componentKey. Refused when a CSS name is already defined here.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `libraryId` | string | yes | From list_libraries. |
### `update_library`
Apply the library's newest publish to this project: variables and components are replaced and every instance of it re-synced, overrides kept. Returns the changes.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `libraryId` | string | yes | |
### `disable_library`
Stop using a library: bindings to its variables are detached (values kept), its instances become plain frames, its variables and components leave this project.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `libraryId` | string | yes | |
## Session
### `get_feedback` (read-only)
Notes the user left on layers (the Feedback box in the inspector, shown as amber dots on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries all pages across the project; `openCount` and `resolvedCount` are returned alongside per-page counts in `get_basic_info`. Call it at the start of a session and after each round of edits; fix every open note, then resolve_feedback. `get_selection` also includes the selected layers' notes.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `includeResolved` | boolean | | |
| `nodeId` | string | | Only this layer. |
| `pageId` | string | | Omit to query all pages across the project. |
### `add_feedback`
Leave a note on a layer (author `agent` by default), e.g. a question or a problem you could not fix. The user sees it in the inspector and as a dot on the canvas.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `author` | string | | Defaults to `agent`. |
| `author` | string | | |
| `nodeId` | string | yes | |
| `text` | string | yes | |
### `resolve_feedback`
Mark notes on a layer resolved, with a short reply saying what you changed. Without `commentId` every open note on the layer is resolved. `delete` removes instead.
Mark notes on a layer resolved, with a short reply saying what you changed. Without commentId every open note on the layer is resolved. `delete` removes instead.
| Argument | Type | Required | Notes |
|---|---|---|---|
@@ -548,7 +583,6 @@ Mark notes on a layer resolved, with a short reply saying what you changed. With
Select nodes on the page the user is viewing so they can see what you mean; zoom: true also frames them in the viewport. An empty list clears the selection.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to select. |
@@ -569,8 +603,6 @@ Redo the last undone change on a page.
Call when done creating or editing. Saves the project.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | | Optional nodes you worked on. |
+30
View File
@@ -0,0 +1,30 @@
# Design from the CLI without the app
## Why
`papercraft-cli call` needs the app running with a project open. Agents on a server, in CI, or on a machine where the app is closed cannot design at all. The code that runs every tool already works without a window: `SessionHost` (`src/mcp/exec.rs:52`) holds an `Editor` and a `Session` and has an offscreen renderer (`Headless`) for screenshots and PNG export. Tests drive it today.
## Scope
1. **Offline calls.** `papercraft-cli call <tool> [JSON]` runs in-process on a `SessionHost` when the app is not reachable, or always with `--offline`. Projects come from the same store (`$PAPERCRAFT_HOME`, else the default folder). `--project <name|id|path>` opens a project first; without it, the most recently modified project is used, and `create_file` / `open_file` work as usual. Every editing call saves before exit.
2. **Safety.** When the app is reachable, plain `call` keeps going through the app (it is the one with the open file). `--offline` while the app has the same project open is refused ("PaperCraft has this project open; close it or drop --offline"), so two writers never race on one folder.
3. **Shell.** `papercraft-cli shell [--project X] [--offline]` is an interactive session that keeps one host for many calls:
* a line is `<tool> <json>` (JSON may span lines until braces balance), or a command: `:help`, `:tools`, `:schema <tool>`, `:open <project>`, `:pages`, `:page <name|id>`, `:shot [nodeId] [file]`, `:undo`, `:redo`, `:save`, `:quit`;
* `:watch on|off`: after every editing call, re-render the artboard last touched (or the page) to `preview.png` in the current folder; on iTerm2, WezTerm and kitty the image is also shown inline in the terminal;
* history in `~/.papercraft_cli_history` when a line editor is available; plain stdin otherwise (works in pipes, so `papercraft-cli shell < script.pc` runs a script).
4. **Preview without a GPU.** If no adapter is found, retry with the fallback (software) adapter; if that fails too, image tools return a clear error and every other tool still works.
5. **Docs.** `docs/agents.md`: offline use, the shell, scripts. `papercraft-cli --help` updated.
## Not in scope
* A headless MCP server (`papercraft-mcp --offline`); the same host makes it a small follow-up.
* A TUI canvas.
## Acceptance
* With the app closed: `papercraft-cli call create_file '{"name":"Demo"}'`, then `call create_artboard`, `call write_html`, `call get_screenshot -o out.png` produce a saved project and a PNG; opening the project in the app shows the design.
* With the app open on project X: `call` goes to the app; `call --offline --project X` is refused; `--offline --project Y` works.
* `papercraft-cli shell < script` runs a multi-line script and exits 0; a failing tool line exits 1 at the end and prints which line failed.
* `:watch on` writes `preview.png` after each editing call.
* Tests: offline call round trip on a temp store, refusal when a fake "app has it open" check says so, shell script parsing (multi-line JSON, commands).
* `cargo test --no-fail-fast`, wasm `cargo check --lib` and Windows check pass (the CLI binary is native only).
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+104
View File
@@ -0,0 +1,104 @@
# Variable collections, shared libraries and assets
## Why
Variables are a flat list of colors plus untyped tokens, copied into every page's `Document` and kept in step by hand (two writers fan out to every page, the remove buttons in `src/ui/confirm.rs` do not). There are no groups, no types beyond color, no aliases, and only fills can bind. Components work only on the page that holds the main: the Design tab lists the active page's components, so a `Design System` page's components are invisible everywhere else, and `create_instance` / `swap_instance` refuse other pages. Nothing can be shared between files except a one-off package import.
Goal: Figma's model, local-first. Collections with modes, slash groups, typed variables with aliases; components usable from any page; any file can publish itself as a library that other files enable and update; a proper place to see and edit all of it.
## Decisions (user, 2026-10-21)
* Libraries: **Figma-style publish**. A file publishes a versioned snapshot; consumers keep a copy and get "Updates available". Files keep working when a library is missing.
* Variables: **collections + groups + types + aliases**, number variables bindable to geometry and type.
* UI: a **Variables modal**, a **Libraries modal**, and an **Assets** side view. Design mock-ups first.
## What exists today (checked 2026-10-21)
* Storage: `Document.variables` (active colors), `modes`, `mode`, `tokens` (`src/model/document.rs:15`), one copy per page. Writers: `Tx::set_variable`, `remove_variable`, `rename_variable`, `set_token`, `remove_token`, `set_design_system`, `remove_mode`, `set_variable_in_mode`, `set_mode` (`src/history.rs`); UI `on_every_page` (`src/ui/design.rs:15`); MCP `set_variables`, `set_tokens`, `set_variable_mode`, `import_design_system`, `create_page` (`src/mcp/exec.rs`).
* Binding: `Paint::Solid { color, var: Option<String> }` holds the CSS name; the color is baked in and `apply_variable` rewrites it. Renderer, SVG and PDF read the baked color only. Tokens are substituted as text when CSS is written (`css::set_variables_in_scope`), no link kept.
* Readers: codegen (`:root`, `[data-mode]`, Tailwind, DTCG), `get_variables`, `tokens_css`, lint (`lint.rs` match and auto-link), `diff_pages`, inspector var picker.
* Components: `Node.component`, `InstanceLink { main: NodeId, overrides }`; `Editor::fixup` syncs instances on the same `Document`. `import_components` copies mains between pages with no link back.
* Project: `project.json` (version 2: v1 to v2 is a no-op manifest step; the version bump makes older apps refuse), `pages/<id>.json` (version 3, migration chain), `versions/`, `runs/`. Projects live in one store folder; relay library mode already syncs every project to every device.
* Figma import ignores variables and does not link instances.
## Model
### Variables (project level, `design.json` in the project folder)
```text
DesignSystem
collections: [Collection]
Collection { id, name, modes: [Mode { id, name }], default_mode, variables: [Variable] }
Variable { id, name: "color/brand/primary", kind: Color | Number | String | Boolean,
values: { mode_id: Literal | Alias(variable id) }, description, hidden: bool }
```
* **Groups** are the `/` segments of the name (`color/brand/primary` sits in group `color/brand`). Renaming a group renames every variable under it.
* **CSS name**: segments joined by `-`, sanitized (`--color-brand-primary`). Two variables with the same CSS name across all enabled collections are refused at write time.
* **Aliases** point to a variable of the same kind in any collection, including a library's. Cycles are refused. A value resolves through aliases per mode.
* **Active mode** is per page per collection (`PageFile.modes: {collection_id: mode_id}`, default = `default_mode`). A new page inherits the active page's choice.
* **Numbers** carry no unit; they bind as px. **Strings** substitute as text (font family, shadow). **Booleans** bind to visibility.
* **One source, one cache.** `design.json` is the only stored truth. Each page's `Document.variables` / `modes` / `mode` / `tokens` become a derived cache rebuilt from it (resolved with that page's modes, keyed by CSS name), so the renderer, codegen, lint, diff and export keep reading what they read today. Every writer goes through one design-system API that updates `design.json`, rebuilds every page's cache and re-applies bindings in one undo step per page.
* **Migration** (project version 1 → 2): the first page's `variables` + `modes` become collection `Colors` (one mode per existing mode, else `Default`); `tokens` become collection `Tokens` (a px value such as `16px` becomes Number; anything else, including unitless `1.5`, stays String). Existing fill bindings keep working because CSS names do not change. Pages keep their fields as the cache. The manifest version is 2, so an older app refuses the project ("made by a newer PaperCraft") rather than write pages without bindings and never update `design.json`.
### Bindings
* Fills keep `Paint::Solid.var` (CSS name).
* New `Node.bindings: BTreeMap<field, css name>` for `stroke` (color), `text-color`, `width`, `height`, `gap`, `padding` (all four or per side), `radius`, `font-size`, `line-height`, `opacity`, `visible` (boolean). A bound field is re-applied when the variable, an alias target or the page's mode changes, exactly like fills today. Detaching writes the resolved value and drops the binding.
* Instances inherit bindings from mains; an overridden field drops it on the instance only.
### Components across pages
* Every main gets a stable `component_key` (created on `create_component`, migrated for existing mains).
* `InstanceLink` gains `source: Option<Source>`: `None` = same page (current behaviour), `Page(page_id)`, `Library(library_id)`. `main` keeps pointing at the node id inside that source document.
* Editing a main on page A re-syncs instances on every other page whose link is `Page(A)`, in the same commit (one undo step per touched page). Deleting a main leaves its instances as they are, marked "main missing" (Assets offers Detach or Restore).
* `create_instance` and `swap_instance` accept a component from any page or enabled library (`componentId` as `pageId:num`, or `componentKey`).
* `import_components` stays (copy) but is no longer needed for reuse.
### Libraries
* **Publish** (Libraries modal, or MCP `publish_library`): writes `library.json` in the file's project folder: `{ library_id (= project id), name, description, version (counter), published (ms), collections (non-hidden variables), components (every main not named with a leading `_` or `.`, with its subtree and a 256px PNG preview) }`. A diff against the previous publish is shown first ("3 variables changed, 1 component added").
* **Available libraries** = every project in the store with a `library.json` (relay library mode makes other devices' libraries appear with no extra work).
* **Enable** in a consumer copies the snapshot to `<consumer>/libraries/<library_id>.json` and records `{library_id, version}` in `design.json`. Library collections show read-only in the Variables modal (aliasable, bindable); library components show in Assets.
* **Updates**: when the library's `version` is newer than the copy, the Libraries modal and a dot on the Assets tab say "Updates available". Review lists changed variables and components; **Update** replaces the snapshot, rebuilds caches and re-syncs `Library(id)` instances (instance overrides kept). Updates are never applied silently.
* **Missing library**: the copy keeps everything working; the modal shows "Source not found" with Disable (bindings are detached to resolved values, instances detached) after a confirm.
* **Disable** with uses left: confirm, then detach as above.
## UI
* **Variables modal** (from Design tab "Open variables", palette "Variables…", ⌘⌥V): left column collections (local, then each library, read-only) with their group tree; main area a table, one row per variable in the selected group, one column per mode; cells edit inline (swatch + hex, number, text, toggle) with an alias picker ("→ color/brand/primary"); add variable (type menu), add mode, add collection, rename and delete (confirm), search, a details pane (description, hidden from publishing, CSS name, where used count). Copy CSS / JSON / Tailwind move here.
* **Libraries modal** (Share menu "Libraries…", palette, Assets header): "This file" (publish state, description, changes since last publish, Publish), "Libraries" (available libraries with Enable toggles, version, source status), "Updates" (per library review and Update).
* **Assets side view** (replaces the Library tab icon slot, keeps My shapes as its last section): search; sections This file (grouped by page), each enabled library, My shapes; previews in a grid; click to place at the viewport centre, drag onto the canvas; context menu: Go to main, Detach all, Rename (local only). Instances whose main is missing show a warning row.
* **Design tab** slims to: modes per collection for this page, a short variables summary with "Open variables", lint. The components list moves to Assets.
* **Inspector**: every bindable field gets the variable picker (filtered by kind), a bound field shows the variable chip with Detach.
Mock-ups: `PaperCraft` design file, new page `Design libraries`, artboards `Modal / Variables`, `Modal / Libraries`, `Modal / Library updates`, `Panel / Assets` (344x1440 like the other panel states), `Inspector / Bound fields` (344x1440). Modals drawn at 2560x1440 window size and checked at 1280x800.
## MCP and CLI
* `get_variables` returns collections, groups, kinds, per-mode values and aliases (current formats keep working; DTCG nests groups).
* `set_variables` gains `collection`, `type`, `mode`, `alias`, `description`, `hidden`; old calls (color by name) still work and land in the first collection.
* New: `create_collection`, `delete_variables`, `bind_variable` (node field ← variable), `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library`.
* `get_components` lists every page and enabled library with `componentKey` and `source`.
* Every new tool is classified in `edits_doc` (the existing test enforces it).
## Not in scope (v1)
* Text styles, effect styles, grid styles (roadmap row 9; next).
* Component variants and properties (row 10).
* Per-frame mode override (mode is per page).
* Live links between files without publish.
* Figma variable import (separate item; the model now has room for it).
## Acceptance
* Migration: a project with variables, modes and tokens opens as version 2 with collections `Colors` and `Tokens`; every existing fill binding still resolves; 0.4.0 refuses a migrated project with a clear "made by a newer PaperCraft" error instead of losing data.
* Groups: renaming group `color/brand` renames its variables and every binding follows.
* Aliases resolve per mode; a cycle is refused; deleting an aliased variable asks first and detaches dependents to resolved values.
* Number bindings: changing `space/md` from 16 to 24 updates the gap of every bound frame on every page in one undo step per page; switching a page's mode updates its bound fields only.
* Removing a variable or mode from the UI affects every page (fixes today's page-only removal).
* An instance on page B of a main on page A updates when the main changes; undo on A and B each restores its side.
* Publish → enable in another project → instance and binding work; change and republish → consumer shows Updates available → Update re-syncs instances and keeps overrides; deleting the library project leaves the consumer working with "Source not found".
* Two libraries defining `--color-bg` cannot both be enabled without a clear error.
* Variables modal, Libraries modal and Assets view match their mock-ups at 1440p and 1280x800 (screenshot check).
* `cargo test --no-fail-fast`, wasm and Windows checks pass; web build loads.
+43
View File
@@ -0,0 +1,43 @@
# Tasks
Each task is independently testable. Stop on failure. Tasks in the same wave touch different files and can run in parallel; a wave starts when the previous one passes `cargo test --lib`. After each code wave a read-only Codex `gpt-6.1-sol` review; real findings get fixed before the next wave.
## Wave 0: design (parallel with wave 1)
- [x] 0. **Mock-ups** in the `PaperCraft` design file, new page `Design libraries`: `Modal / Variables`, `Modal / Libraries`, `Modal / Library updates`, `Panel / Assets` (344x1440), `Inspector / Bound fields` (344x1440). Reuse the `Design System` page variables and components. Only through the user's running app after `get_basic_info` confirms the open file is `PaperCraft`; never `open_file` in the user's app.
## Wave 1: model (parallel)
- [x] 1. **Design-system model** (new `src/variables.rs`). Types from the spec (`DesignSystem`, `Collection`, `Mode`, `Variable`, `Kind`, `Value`), ids, CSS name, group helpers (list groups, rename group), alias resolution per mode with cycle check, duplicate CSS name check, `cache_for(page modes) -> (variables, modes, mode, tokens)` producing today's `Document` fields. Tests: groups, rename group, alias chain, cycle refused, duplicate refused, cache matches today's shape.
- [x] 2. **Storage and migration** (`src/project/format.rs`, `src/project/mod.rs`, `src/session.rs`). `design.json` load/save next to `project.json`; project version 2 with a `PROJECT_STEPS` step building collections `Colors` and `Tokens` from the first page; `PageFile.modes` per collection; `component_key` for existing mains. Tests: v1 project migrates, round trip, a v2 project's page files still deserialize in the old reader shape.
- [x] 3. **Bindings field** (`src/model/node.rs`, `src/model/paint.rs`). `Node.bindings`, `InstanceLink.source`, `Node.component_key`, all `serde(default)`. Tests: old pages load; serialization skips empty fields.
## Wave 2: wiring (after 1–3; 4 then 5 and 6 in parallel)
- [x] 4. **One write path** (`src/history.rs`, `src/editor/*`). Design-system edits update the project `DesignSystem`, rebuild every page's cache and re-apply fill and field bindings, one undo step per page; replace `on_every_page` and the page-only removals in `src/ui/confirm.rs`. Per-page mode switch. Tests: number binding change on two pages; mode switch only touches its page; undo per page.
- [x] 5. **Field bindings applied** (`src/editor/structure.rs`, layout). Apply bound stroke, text colour, size, gap, padding, radius, font size, line height, opacity, visible; instance sync carries bindings; detach writes resolved values. Tests per field.
- [x] 6. **MCP and exports** (`src/mcp/exec.rs`, `src/mcp/tools.rs`, `src/mcp/codegen.rs`, `src/design_system.rs`). Extended `get_variables` / `set_variables`, `create_collection`, `delete_variables`, `bind_variable`; DTCG nests groups; old calls still work. Tests: old and new call shapes, DTCG groups, `edits_doc` classification.
## Wave 3: components across pages (after wave 2)
- [x] 7. **Cross-page instances** (`src/editor/structure.rs`, `src/mcp/exec.rs`). `Source::Page` sync on main edit, create/swap from any page, "main missing" state. Tests: edit main on A updates B; undo per page; delete main leaves instance flagged.
## Wave 4: libraries (after wave 3; 8 then 9)
- [x] 8. **Publish and enable** (new `src/libraries.rs`). `library.json` writer with previews and publish diff, available list from the store, enable copies snapshot, library collections and components resolvable (`Source::Library`), duplicate CSS name refusal. Tests: publish, enable in second project, binding and instance work, conflict refused.
- [x] 9. **Updates and missing sources** (`src/libraries.rs`). Version compare, review diff, update keeps overrides, missing library keeps working, disable detaches after confirm. MCP `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library`. Tests per path.
## Wave 5: UI (after wave 4 and the mock-ups; 10, 11, 12 in parallel, then 13)
- [x] 10. **Variables modal** (new `src/ui/variables.rs`). Screenshot check against `Modal / Variables`.
- [x] 11. **Libraries modal** (new `src/ui/libraries.rs`). Screenshot check against `Modal / Libraries` and `Modal / Library updates`.
- [x] 12. **Assets view** (`src/ui/library.rs` becomes Assets). Screenshot check against `Panel / Assets`.
- [x] 13. **Design tab and inspector** (`src/ui/design.rs`, `src/ui/inspector.rs`, palette entries). Slim Design tab; variable picker on every bindable field. Screenshot check against `Inspector / Bound fields`.
## Wave 6: finish
- [x] 14. **Docs and verify**. `docs/design-system.md`, `docs/agents.md` (new tools, count), `ROADMAP.md` rows 7, 8, 12, `PROGRESS.md`; skills `skills/papercraft*` mention collections, bindings and libraries. Full `cargo test --no-fail-fast`, wasm and Windows checks, web build loads.
## Model per task
Claude Sonnet: 0, 1, 2, 4, 5, 7, 8, 10, 11, 12. Claude Haiku: 3. Codex `gpt-6.1-sol`: 9, 13, and the reviews (task 6 moved to Sonnet: Codex hit its usage limit on 2026-10-21). Codex `gpt-6-luna`: 14.
+482 -66
View File
@@ -3,14 +3,17 @@
//! (`http://127.0.0.1:29980/mcp`, or `$PAPERCRAFT_MCP_PORT`).
use base64::Engine;
use papercraft::cli::{self, Accum, Offline, Stmt};
use papercraft::icons;
use papercraft::mcp::{self, server};
use papercraft::mcp::{self, Out, server, tools};
use papercraft::project::Store;
use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParams;
use rmcp::transport::StreamableHttpClientTransport;
use serde_json::Value;
use std::io::Read;
use serde_json::{Value, json};
use std::io::{BufRead, IsTerminal, Read, Write};
use std::process::ExitCode;
use std::time::Duration;
const USAGE: &str = "\
papercraft-cli: call the running papercraft app's tools from a shell
@@ -20,6 +23,8 @@ USAGE:
papercraft-cli schema <tool> a tool's full description and JSON input schema
papercraft-cli call <tool> [JSON|-] run a tool; JSON arguments inline, or `-` for stdin
[-o|--out FILE] where to save an image result (default: <tool>.png, or {\"out\": \"FILE\"})
[--offline] [--project NAME|ID|PATH] work without the app, see below
papercraft-cli shell interactive session; reads lines from a pipe too (see below)
papercraft-cli icons list installed and installable open icon sets
papercraft-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
papercraft-cli icons search <words> [--set S] find `set:name` references (works offline)
@@ -30,8 +35,23 @@ write_html. Sets are fetched from the npm registry with curl and tar into the ic
($PAPERCRAFT_ICONS, else the user data dir) together with their licence file.
Text results go to stdout; image results are saved to a file and the path is printed.
Exit status is 1 when the tool reports an error or the app isn't running.
Start the app and open a project first: it serves the tools while open.
Exit status is 1 when the tool reports an error.
With the app running and a project open, calls go to the app. Otherwise (app not running, or
--offline) the tools run in this process on the project store ($PAPERCRAFT_HOME, else the
default folder) and every editing call is saved. --project opens that project first; without it
the most recently modified one is used (create_file and open_file work as usual). --offline is
refused while the app has the same project open.
shell [--project X] [--offline]: one session, many calls. A line is `<tool> <json>` (JSON may
span lines until its braces close) or a command:
:help :tools :schema <tool> :open <project> :pages :page <name|id> :shot [nodeId] [file]
:undo :redo :save :watch on|off :quit
:watch re-renders the artboard last edited to preview.png after each editing call (shown inline
on iTerm2, WezTerm and kitty). Blank lines and lines starting with # are skipped. Input is plain
stdin (no line editing, no history; wrap with rlwrap if you want it). Piped input prints no
prompt and exits 1 when any line failed, listing the failed line numbers on stderr:
papercraft-cli shell < script.pc
";
fn fail(msg: impl std::fmt::Display) -> ExitCode {
@@ -184,86 +204,489 @@ fn icons_cmd(args: &[String]) -> ExitCode {
}
}
fn schema(name: &str) -> ExitCode {
fn schema(name: &str) -> bool {
let Some(t) = server::tool_list().into_iter().find(|t| t.name == name) else {
return fail(format!("no tool named {name} (see `papercraft-cli list`)"));
eprintln!("papercraft-cli: no tool named {name} (see `papercraft-cli list`)");
return false;
};
println!(
"{}",
serde_json::to_string_pretty(&serde_json::to_value(&t).unwrap_or(Value::Null))
.unwrap_or_default()
);
ExitCode::SUCCESS
true
}
async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, String> {
let out = out.or_else(|| {
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
type Client = rmcp::service::RunningService<rmcp::service::RoleClient, ()>;
/// Where tools run: the app over HTTP, or this process.
enum Backend {
Online(tokio::runtime::Runtime, Client),
Offline(Box<Offline>),
}
impl Backend {
/// The tool's output and whether it succeeded. Errors reach the caller as text, like the
/// app's own error results.
fn call(&mut self, name: &str, args: &Value) -> Result<(Vec<Out>, bool), String> {
match self {
Backend::Offline(o) => Ok(match o.call(name, args) {
Ok(r) => (r, true),
Err(e) => (vec![Out::Text(e)], false),
}),
Backend::Online(rt, client) => rt.block_on(online_call(client, name, args)),
}
}
}
async fn online_call(
client: &Client,
name: &str,
args: &Value,
) -> Result<(Vec<Out>, bool), String> {
let mut p = CallToolRequestParams::new(name.to_string());
match args {
Value::Object(m) => p.arguments = Some(m.clone()),
Value::Null => {}
_ => return Err("arguments must be a JSON object".into()),
}
let r = client.call_tool(p).await.map_err(|e| e.to_string())?;
let mut outs = Vec::new();
// Blocks are read through their JSON form, so this doesn't depend on rmcp's enum layout.
for block in r.content {
let v = serde_json::to_value(&block).map_err(|e| e.to_string())?;
outs.push(match v.get("type").and_then(Value::as_str) {
Some("text") => Out::Text(v["text"].as_str().unwrap_or("").to_string()),
Some("image") => Out::Image {
data: base64::engine::general_purpose::STANDARD
.decode(v["data"].as_str().unwrap_or(""))
.map_err(|e| format!("bad image data: {e}"))?,
mime: v["mimeType"].as_str().unwrap_or("image/png").to_string(),
},
_ => Out::Text(v.to_string()),
});
}
Ok((outs, r.is_error != Some(true)))
}
fn runtime() -> Result<tokio::runtime::Runtime, String> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())
}
fn connect(rt: &tokio::runtime::Runtime) -> Result<Client, String> {
let url = mcp::url(mcp::port());
rt.block_on(async { ().serve(StreamableHttpClientTransport::from_uri(url.clone())).await })
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))
}
fn app_reachable() -> bool {
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, mcp::port()));
TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).is_ok()
}
/// The app's `list_resources` result: `Ok(None)` when it isn't running, `Err` when it runs but
/// does not answer usably (the caller must refuse then, not guess).
fn app_items() -> Result<Option<Value>, String> {
if !app_reachable() {
return Ok(None);
}
let rt = runtime()?;
let client = connect(&rt)?;
let (outs, _) = rt.block_on(online_call(&client, "list_resources", &json!({"limit": LIST_ALL})))?;
match outs.first() {
Some(Out::Text(t)) => serde_json::from_str(t)
.map(Some)
.map_err(|e| format!("unreadable list_resources answer: {e}")),
_ => Err("empty list_resources answer".into()),
}
}
/// Large enough to list every project in one answer.
const LIST_ALL: u32 = 100_000;
/// Refuse to open `target` here while the app has it open, or when that cannot be told.
fn check_app_not_using(target: Option<&str>) -> Result<(), String> {
match app_items() {
Ok(Some(items)) => cli::refuse_if_open(&items, target),
Ok(None) => Ok(()),
Err(e) => Err(format!("cannot tell whether PaperCraft has the project open ({e}); close the app or retry")),
}
}
fn open_offline(project: Option<&str>) -> Result<Backend, String> {
let root = Store::default_root();
let store = Store::open(&root).map_err(|e| format!("{}: {e}", root.display()))?;
let mut o = Offline::new(store, project)?;
o.open_guard = Some(Box::new(|id| check_app_not_using(Some(id))));
Ok(Backend::Offline(Box::new(o)))
}
/// `check_default`: also refuse when no `--project` is given and the app has the default
/// project open (false for a lone create_file, which touches no existing project).
fn open_backend(
offline: bool,
project: Option<&str>,
check_default: bool,
) -> Result<Backend, String> {
if offline {
if project.is_some() || check_default {
check_app_not_using(project)?;
}
return open_offline(project);
}
if app_reachable() {
let rt = runtime()?;
let client = connect(&rt)?;
if let Some(p) = project {
rt.block_on(online_call(&client, "open_file", &json!({"fileId": p})))?;
}
return Ok(Backend::Online(rt, client));
}
eprintln!("papercraft-cli: app not running; working offline");
open_offline(project)
}
/// Print a tool's output: text to stdout, images to a file whose path is printed. Returns the
/// saved image bytes (for inline display).
fn emit(outs: &[Out], name: &str, out: Option<&str>) -> Result<Vec<Vec<u8>>, String> {
let mut images = Vec::new();
for o in outs {
match o {
Out::Text(t) => println!("{t}"),
Out::Image { data, .. } => {
let path = out.map_or_else(|| format!("{name}.png"), String::from);
save(&path, data)?;
println!("{path}");
images.push(data.clone());
}
}
}
Ok(images)
}
fn save(path: &str, data: &[u8]) -> Result<(), String> {
if let Some(p) = std::path::Path::new(path)
.parent()
.filter(|p| !p.as_os_str().is_empty())
{
let _ = std::fs::create_dir_all(p);
}
std::fs::write(path, data).map_err(|e| format!("{path}: {e}"))
}
fn out_arg(args: &Value, out: Option<String>) -> Option<String> {
out.or_else(|| {
args.get("out")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
});
let url = mcp::url(mcp::port());
let client = ()
.serve(StreamableHttpClientTransport::from_uri(url.clone()))
.await
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))?;
let mut p = CallToolRequestParams::new(name.clone());
match args {
Value::Object(m) => p.arguments = Some(m),
Value::Null => {}
_ => return Err("arguments must be a JSON object".into()),
}
let r = client.call_tool(p).await.map_err(|e| e.to_string())?;
// Blocks are read through their JSON form, so this doesn't depend on rmcp's enum layout.
for block in r.content {
let v = serde_json::to_value(&block).map_err(|e| e.to_string())?;
match v.get("type").and_then(Value::as_str) {
Some("text") => println!("{}", v["text"].as_str().unwrap_or("")),
Some("image") => {
let data = base64::engine::general_purpose::STANDARD
.decode(v["data"].as_str().unwrap_or(""))
.map_err(|e| format!("bad image data: {e}"))?;
let path = out.clone().unwrap_or_else(|| format!("{name}.png"));
if let Some(p) = std::path::Path::new(&path)
.parent()
.filter(|p| !p.as_os_str().is_empty())
{
let _ = std::fs::create_dir_all(p);
}
std::fs::write(&path, data).map_err(|e| format!("{path}: {e}"))?;
println!("{path}");
}
_ => println!("{v}"),
})
}
fn call_once(
name: String,
args: Value,
out: Option<String>,
offline: bool,
project: Option<String>,
) -> Result<bool, String> {
let out = out_arg(&args, out);
let mut be = open_backend(
offline,
project.as_deref(),
name != "create_file" && name != "open_file",
)?;
let (outs, ok) = be.call(&name, &args)?;
emit(&outs, &name, out.as_deref())?;
Ok(ok)
}
// ---------------------------------------------------------------------------------- shell
const SHELL_HELP: &str = "\
<tool> <json> run a tool (JSON may span lines until its braces close)
:tools tool names :schema <tool> one tool's schema
:open <project> open a project :pages pages (get_basic_info)
:page <name|id> switch page (offline only)
:shot [nodeId] [file] screenshot to file (default shot.png)
:undo :redo :save :watch on|off :quit";
const WATCH_FILE: &str = "preview.png";
struct Shell {
be: Backend,
offline: bool,
watch: bool,
last: Option<String>,
}
impl Shell {
fn show(&self, png: &[u8]) {
if !std::io::stdout().is_terminal() {
return;
}
let tp = std::env::var("TERM_PROGRAM").unwrap_or_default();
let term = std::env::var("TERM").unwrap_or_default();
if let Some(seq) = cli::inline_image(&tp, &term, png) {
print!("{seq}");
}
}
Ok(r.is_error != Some(true))
fn call(&mut self, name: &str, args: &Value, out: Option<&str>) -> bool {
let (outs, ok) = match self.be.call(name, args) {
Ok(r) => r,
Err(e) => {
eprintln!("papercraft-cli: {e}");
return false;
}
};
let images = match emit(&outs, name, out) {
Ok(i) => i,
Err(e) => {
eprintln!("papercraft-cli: {e}");
return false;
}
};
for png in &images {
self.show(png);
}
if ok && tools::edits_doc(name, args) {
self.last = match &self.be {
Backend::Offline(o) => o.last_touched.clone(),
Backend::Online(..) => cli::touched_key(args, &outs),
}
.or(self.last.take());
if self.watch {
self.preview();
}
}
ok
}
fn preview(&mut self) {
let args = match &self.last {
Some(k) => json!({"nodeId": k}),
None => Value::Null,
};
match self.be.call("get_screenshot", &args) {
Ok((outs, true)) => {
for o in outs {
if let Out::Image { data, .. } = o {
match save(WATCH_FILE, &data) {
Ok(()) => {
println!("{WATCH_FILE}");
self.show(&data);
}
Err(e) => eprintln!("papercraft-cli: {e}"),
}
}
}
}
Ok((outs, false)) => {
for o in outs {
if let Out::Text(t) = o {
eprintln!("papercraft-cli: watch: {t}");
}
}
}
Err(e) => eprintln!("papercraft-cli: watch: {e}"),
}
}
/// Run one statement. `None` means quit.
fn run(&mut self, stmt: Stmt) -> Option<bool> {
let (name, a) = match stmt {
Stmt::Call { tool, args } => {
let out = out_arg(&args, None);
return Some(self.call(&tool, &args, out.as_deref()));
}
Stmt::Cmd { name, args } => (name, args),
};
let arg = a.first().map(String::as_str);
Some(match (name.as_str(), arg) {
("quit" | "q" | "exit", _) => return None,
("help" | "h", _) => {
println!("{SHELL_HELP}");
true
}
("tools", _) => {
list();
true
}
("schema", Some(t)) => schema(t),
("open", Some(p)) => {
if self.offline
&& let Err(e) = check_app_not_using(Some(p))
{
eprintln!("papercraft-cli: {e}");
return Some(false);
}
self.call("open_file", &json!({"fileId": p}), None)
}
("pages", _) => self.call("get_basic_info", &Value::Null, None),
("page", Some(p)) => match &mut self.be {
Backend::Offline(o) => match o.switch_page(p) {
Ok(n) => {
println!("page {n}");
true
}
Err(e) => {
eprintln!("papercraft-cli: {e}");
false
}
},
Backend::Online(..) => {
eprintln!("papercraft-cli: :page works offline only; switch pages in the app");
false
}
},
("shot", _) => {
// A node id has a colon or is a number; anything else is the file.
let is_node = |s: &str| s.contains(':') || s.chars().all(|c| c.is_ascii_digit());
let (node, file) = match (a.first(), a.get(1)) {
(Some(n), f) if is_node(n) => (Some(n.clone()), f.cloned()),
(f, _) => (None, f.cloned()),
};
let args = node.map_or(Value::Null, |n| json!({"nodeId": n}));
let file = file.unwrap_or_else(|| "shot.png".into());
self.call("get_screenshot", &args, Some(&file))
}
("undo", _) => self.call("undo", &Value::Null, None),
("redo", _) => self.call("redo", &Value::Null, None),
("save", _) => self.call("finish_working_on_nodes", &Value::Null, None),
("watch", Some("on")) => {
self.watch = true;
true
}
("watch", Some("off")) => {
self.watch = false;
true
}
_ => {
eprintln!("papercraft-cli: unknown or incomplete command :{name} (try :help)");
false
}
})
}
}
fn shell(offline: bool, project: Option<String>) -> ExitCode {
let be = match open_backend(offline, project.as_deref(), true) {
Ok(b) => b,
Err(e) => return fail(e),
};
let mut sh = Shell {
be,
offline,
watch: false,
last: None,
};
let tty = std::io::stdin().is_terminal();
let mut acc = Accum::default();
let mut failed: Vec<usize> = Vec::new();
let mut n = 0;
let mut quit = false;
let mut run = |sh: &mut Shell, at: usize, s: String| {
match cli::parse_statement(&s) {
Ok(stmt) => match sh.run(stmt) {
None => return true,
Some(false) => failed.push(at),
Some(true) => {}
},
Err(e) => {
eprintln!("papercraft-cli: line {at}: {e}");
failed.push(at);
}
}
false
};
let prompt = |acc: &Accum| {
if tty {
print!(
"{}",
if acc.pending() {
"...> "
} else {
"papercraft> "
}
);
let _ = std::io::stdout().flush();
}
};
prompt(&acc);
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
n += 1;
if let Some((at, s)) = acc.push(n, &line)
&& run(&mut sh, at, s)
{
quit = true;
break;
}
prompt(&acc);
}
if !quit && let Some((at, s)) = acc.finish() {
run(&mut sh, at, s);
}
if failed.is_empty() || tty {
return ExitCode::SUCCESS;
}
eprintln!(
"papercraft-cli: failed lines: {}",
failed
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", ")
);
ExitCode::FAILURE
}
fn take_flag(args: &mut Vec<String>, long: &str, short: Option<&str>) -> Option<String> {
let eq = format!("{long}=");
let mut i = 0;
while i < args.len() {
if (args[i] == long || Some(args[i].as_str()) == short) && i + 1 < args.len() {
return Some(args.drain(i..=i + 1).nth(1).unwrap());
}
if let Some(v) = args[i].strip_prefix(&eq) {
let v = v.to_string();
args.remove(i);
return Some(v);
}
i += 1;
}
None
}
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let mut out: Option<String> = None;
let mut i = 0;
while i < args.len() {
if (args[i] == "--out" || args[i] == "-o") && i + 1 < args.len() {
out = Some(args.drain(i..=i + 1).nth(1).unwrap());
} else if let Some(v) = args[i].strip_prefix("--out=") {
let v = v.to_string();
args.remove(i);
out = Some(v);
} else {
i += 1;
}
}
let out = take_flag(&mut args, "--out", Some("-o"));
let project = take_flag(&mut args, "--project", None);
let offline = args.iter().any(|a| a == "--offline");
args.retain(|a| a != "--offline");
match args.first().map(String::as_str) {
Some("list") => list(),
Some("schema") => match args.get(1) {
Some(n) => return schema(n),
Some(n) => {
return if schema(n) {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
None => return fail("schema needs a tool name"),
},
Some("icons") => return icons_cmd(&args[1..]),
Some("--url") => println!("{}", mcp::url(mcp::port())),
Some("shell") => return shell(offline, project),
Some("call") => {
let Some(name) = args.get(1).cloned() else {
return fail("call needs a tool name");
@@ -287,14 +710,7 @@ fn main() -> ExitCode {
Err(e) => return fail(format!("arguments are not valid JSON: {e}")),
}
};
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return fail(e),
};
return match rt.block_on(call(name, value, out)) {
return match call_once(name, value, out, offline, project) {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(e) => fail(e),
+292
View File
@@ -0,0 +1,292 @@
//! The logic behind `papercraft-cli` that doesn't need a network: running tools in-process
//! when the app is closed (`--offline`), the safety check against a second writer, and parsing
//! shell scripts. The binary (`src/bin/papercraft-cli.rs`) does the I/O.
use crate::mcp::exec::{self, Host, SessionHost};
use crate::mcp::{Out, ToolResult, tools};
use crate::project::Store;
use base64::Engine;
use serde_json::{Value, json};
use std::path::Path;
/// Tools that work with no project open, so they never trigger the default-project open.
const NO_PROJECT_TOOLS: [&str; 6] = [
"create_file",
"open_file",
"list_resources",
"get_guide",
"get_font_family_info",
"search_icons",
];
/// An in-process host on a project store: the same tools as the app, saved to disk after
/// every editing call.
pub struct Offline {
pub host: SessionHost,
/// The top-level node (artboard) the last editing call touched, for `:watch`.
pub last_touched: Option<String>,
/// Checked with `open_file`'s `fileId` before it runs (the binary refuses projects the app has open).
pub open_guard: Option<Box<dyn Fn(&str) -> Result<(), String>>>,
default_tried: bool,
}
impl Offline {
/// `project` (name, id or path) is opened right away; without it the most recently modified
/// project opens on the first call that needs one.
pub fn new(store: Store, project: Option<&str>) -> Result<Offline, String> {
let mut o = Offline {
host: SessionHost::new(store),
last_touched: None,
open_guard: None,
default_tried: project.is_some(),
};
if let Some(p) = project {
exec::call(&mut o.host, "open_file", &json!({"fileId": p}))?;
}
Ok(o)
}
pub fn call(&mut self, name: &str, args: &Value) -> ToolResult {
if let ("open_file", Some(g), Some(id)) = (name, &self.open_guard, args["fileId"].as_str()) {
g(id)?;
}
if !self.default_tried && !NO_PROJECT_TOOLS.contains(&name) {
self.default_tried = true;
self.open_default();
}
let edits = tools::edits_doc(name, args);
if edits && self.host.project().is_none() {
return Err("no project is open; run create_file, then open_file".into());
}
let r = exec::call(&mut self.host, name, args)?;
if edits {
self.host.save()?;
self.last_touched = touched_key(args, &r).and_then(|k| self.artboard_of(&k));
}
Ok(r)
}
fn open_default(&mut self) {
if self.host.project().is_some() {
return;
}
if let Some(e) = self.host.store().list().first() {
let id = e.manifest.id.clone();
let _ = exec::call(&mut self.host, "open_file", &json!({"fileId": id}));
}
}
/// Make a page (id, id prefix or name) the active one.
pub fn switch_page(&mut self, key: &str) -> Result<String, String> {
let i = exec::find_page(&self.host.ed, key)?;
self.host.ed.switch_page(i);
Ok(self.host.ed.pages[i].name.clone())
}
/// The top-level ancestor of a `"<page>:<n>"` node key.
fn artboard_of(&mut self, key: &str) -> Option<String> {
let ed = &self.host.ed;
let (pfx, n) = key.split_once(':')?;
let page = exec::find_page(ed, pfx).ok()?;
let doc = ed.page_doc(page);
let mut id = crate::model::NodeId(n.parse().ok()?);
doc.get(id)?;
while let Some(p) = doc.get(id)?.parent {
id = p;
}
Some(exec::node_key(ed, page, id))
}
}
/// The node an editing call was about: from its arguments, else from its result.
pub fn touched_key(args: &Value, result: &[Out]) -> Option<String> {
let from_args = ["nodeId", "targetNodeId"]
.iter()
.find_map(|k| args[k].as_str())
.or_else(|| args["nodeIds"][0].as_str());
if let Some(k) = from_args {
return Some(k.to_string());
}
let Some(Out::Text(t)) = result.first() else {
return None;
};
let v: Value = serde_json::from_str(t).ok()?;
["id", "nodeId"]
.iter()
.find_map(|k| v[k].as_str())
.or_else(|| v["ids"][0].as_str())
.map(String::from)
}
/// `--offline` with the app running: refuse when the app has the target project open.
/// `items` is the app's `list_resources` result; `target` is `--project`, or None for the
/// default (most recently modified, the first item).
pub fn refuse_if_open(items: &Value, target: Option<&str>) -> Result<(), String> {
let list = items["items"].as_array().map(Vec::as_slice).unwrap_or(&[]);
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
let hit = match target {
None => list.first(),
Some(t) => {
let t = t.trim().trim_start_matches("/file/");
let p = |i: &Value| i["path"].as_str().map(String::from);
list.iter()
.find(|i| {
i["id"] == t
|| i["name"] == t
|| p(i).is_some_and(|p| p == t || canon(&p) == canon(t))
})
.or_else(|| {
list.iter().find(|i| {
i["name"]
.as_str()
.is_some_and(|n| n.eq_ignore_ascii_case(t))
|| p(i)
.is_some_and(|p| Path::new(&p).file_name().is_some_and(|f| f == t))
})
})
}
};
if hit.is_some_and(|i| i["open"] == true) {
return Err("PaperCraft has this project open; close it or drop --offline".into());
}
Ok(())
}
// -------------------------------------------------------------------------------- scripts
#[derive(Debug, PartialEq)]
pub enum Stmt {
Call { tool: String, args: Value },
Cmd { name: String, args: Vec<String> },
}
/// True when every `{` / `[` outside a string is closed.
pub fn balanced(s: &str) -> bool {
let (mut depth, mut in_str, mut esc) = (0i32, false, false);
for c in s.chars() {
if in_str {
match (esc, c) {
(true, _) => esc = false,
(false, '\\') => esc = true,
(false, '"') => in_str = false,
_ => {}
}
continue;
}
match c {
'"' => in_str = true,
'{' | '[' => depth += 1,
'}' | ']' => depth -= 1,
_ => {}
}
}
depth <= 0 && !in_str
}
/// Collects lines into statements: a command is one line; a tool call continues over lines
/// until its JSON closes. Blank lines and `#` comments between statements are skipped.
#[derive(Default)]
pub struct Accum {
buf: String,
start: usize,
}
impl Accum {
/// Feed one line (`line_no` counts from 1). Returns a finished statement and its first line.
pub fn push(&mut self, line_no: usize, line: &str) -> Option<(usize, String)> {
if self.buf.is_empty() {
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
return None;
}
self.start = line_no;
self.buf = t.to_string();
if t.starts_with(':') {
return Some((self.start, std::mem::take(&mut self.buf)));
}
} else {
self.buf.push('\n');
self.buf.push_str(line);
}
balanced(&self.buf).then(|| (self.start, std::mem::take(&mut self.buf)))
}
/// A statement is half-read (the shell shows a continuation prompt).
pub fn pending(&self) -> bool {
!self.buf.is_empty()
}
/// At end of input: an unfinished statement (it will fail to parse).
pub fn finish(&mut self) -> Option<(usize, String)> {
(!self.buf.is_empty()).then(|| (self.start, std::mem::take(&mut self.buf)))
}
}
pub fn parse_statement(s: &str) -> Result<Stmt, String> {
let s = s.trim();
if let Some(cmd) = s.strip_prefix(':') {
let mut w = cmd.split_whitespace().map(String::from);
let name = w.next().ok_or("empty command")?;
return Ok(Stmt::Cmd {
name,
args: w.collect(),
});
}
let (tool, rest) = s.split_once(char::is_whitespace).unwrap_or((s, ""));
let rest = rest.trim();
let args = if rest.is_empty() {
Value::Null
} else {
serde_json::from_str(rest).map_err(|e| format!("arguments are not valid JSON: {e}"))?
};
Ok(Stmt::Call {
tool: tool.into(),
args,
})
}
/// A whole script: `(first line number, parsed statement)`.
pub fn parse_script(text: &str) -> Vec<(usize, Result<Stmt, String>)> {
let mut acc = Accum::default();
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
if let Some((at, s)) = acc.push(i + 1, line) {
out.push((at, parse_statement(&s)));
}
}
if let Some((at, s)) = acc.finish() {
out.push((at, parse_statement(&s)));
}
out
}
// -------------------------------------------------------------------------------- images
const KITTY_CHUNK: usize = 4096;
/// The escape sequence that shows a PNG inline: iTerm2 / WezTerm (OSC 1337) or kitty; None
/// for any other terminal.
pub fn inline_image(term_program: &str, term: &str, png: &[u8]) -> Option<String> {
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
if ["iTerm.app", "WezTerm"].contains(&term_program) {
return Some(format!(
"\x1b]1337;File=inline=1;size={}:{b64}\x07\n",
png.len()
));
}
if term != "xterm-kitty" {
return None;
}
let chunks: Vec<&[u8]> = b64.as_bytes().chunks(KITTY_CHUNK).collect();
let mut s = String::new();
for (i, c) in chunks.iter().enumerate() {
let more = u8::from(i + 1 < chunks.len());
let head = if i == 0 { "a=T,f=100," } else { "" };
s += &format!(
"\x1b_G{head}m={more};{}\x1b\\",
std::str::from_utf8(c).unwrap_or("")
);
}
s.push('\n');
Some(s)
}
+69 -1
View File
@@ -15,6 +15,7 @@
//! | a value that is not a hex color | a token (PaperCraft colors are hex only) |
use crate::model::{Color, Document};
use crate::variables::{DesignSystem, Kind, PageModes};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
@@ -298,6 +299,50 @@ pub fn tokens_css(doc: &Document) -> String {
out
}
/// The design system as a `tokens.css` file for a page with `modes`: colors, then numbers (px),
/// strings (verbatim) and booleans, each run headed by a `/* group */` comment per variable group.
/// The comments are ignored on import, so it still round-trips.
pub fn tokens_css_system(ds: &DesignSystem, modes: &PageModes) -> String {
let cache = ds.cache_for(modes);
let mut vars: Vec<&crate::variables::Variable> = ds
.collections
.iter()
.flat_map(|c| &c.variables)
.collect();
vars.sort_by(|a, b| a.name.cmp(&b.name));
let mut out = String::from(":root {\n");
for colors in [true, false] {
let mut group: Option<&str> = None;
for v in vars.iter().filter(|v| (v.kind == Kind::Color) == colors) {
let css = crate::variables::css_name(v);
let value = match cache.variables.get(&css) {
Some(c) => c.to_hex(),
None => match cache.tokens.get(&css) {
Some(t) => clean_value(t),
None => continue,
},
};
let g = crate::variables::group_of(&v.name);
if group != Some(g) {
if !g.is_empty() {
out.push_str(&format!(" /* {} */\n", g.replace("*/", "")));
}
group = Some(g);
}
out.push_str(&format!(" --{css}: {value};\n"));
}
}
out.push_str("}\n");
for (mode, vars) in &cache.modes {
out.push_str(&format!("[data-theme=\"{}\"] {{\n", mode_name(mode)));
for (name, color) in vars {
out.push_str(&format!(" --{name}: {};\n", color.to_hex()));
}
out.push_str("}\n");
}
out
}
/// A CSS-safe custom-property name: letters, digits, `-` and `_`.
pub fn sanitize_name(name: &str) -> String {
let name = name.trim().trim_start_matches("--");
@@ -534,6 +579,29 @@ pub fn export_package_with(
dir: &Path,
doc: &Document,
meta: &Meta,
) -> Result<Vec<std::path::PathBuf>, String> {
write_package(dir, doc, meta, tokens_css(doc))
}
/// [`export_package_with`] with the tokens written from the project design system (groups,
/// numbers, strings, booleans) as the page `modes` see it.
#[cfg(not(target_arch = "wasm32"))]
pub fn export_package_system(
dir: &Path,
doc: &Document,
meta: &Meta,
ds: &DesignSystem,
modes: &PageModes,
) -> Result<Vec<std::path::PathBuf>, String> {
write_package(dir, doc, meta, tokens_css_system(ds, modes))
}
#[cfg(not(target_arch = "wasm32"))]
fn write_package(
dir: &Path,
doc: &Document,
meta: &Meta,
tokens: String,
) -> Result<Vec<std::path::PathBuf>, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let manifest = serde_json::to_string_pretty(&meta.manifest())
@@ -541,7 +609,7 @@ pub fn export_package_with(
let mut written = Vec::new();
for (name, body) in [
(MANIFEST_FILE, manifest),
(TOKENS_FILE, tokens_css(doc)),
(TOKENS_FILE, tokens),
(DESIGN_FILE, design_md(doc)),
] {
let path = dir.join(name);
+1
View File
@@ -14,6 +14,7 @@ pub enum UiEvent {
ToggleUi,
ToggleHelp,
ToggleGrid,
OpenVariables,
}
impl Editor {
+1157
View File
File diff suppressed because it is too large Load Diff
+33 -4
View File
@@ -3,6 +3,7 @@
//! It consumes platform-agnostic [`InputEvent`]s, so every interaction can be scripted in tests.
pub mod commands;
pub mod design;
pub mod hit;
pub mod input;
pub mod pages;
@@ -256,8 +257,21 @@ pub struct Editor {
pub page_groups: BTreeMap<String, Vec<String>>,
/// Page list or names changed (not tracked by undo history).
pub meta_dirty: bool,
/// The open project's design system (`design.json`).
pub design: crate::variables::DesignSystem,
/// Enabled libraries' components as documents, by library id (`libraries/<id>.json`).
pub library_docs: BTreeMap<String, Document>,
/// Folder of the open project (where `libraries/` lives), set by `Session::open`.
pub project_dir: Option<std::path::PathBuf>,
/// Auto-layout frames not re-laid out while a child is being dragged inside them.
pub layout_suspended: Vec<NodeId>,
/// Page whose document is in `doc` while `with_page` edits a parked page.
pub(crate) edit_page: Option<usize>,
/// Main components touched by the last fixup, and committed edits (page, mains) whose
/// instances on other pages still have to be re-synced.
pub(crate) touched_mains: Vec<NodeId>,
pub(crate) cross_pending: Vec<(usize, Vec<NodeId>)>,
pub(crate) cross_busy: bool,
/// Pen tool in progress.
pub pen: Option<vector_ops::PenState>,
/// Path point editing in progress.
@@ -333,7 +347,14 @@ impl Editor {
active_page: 0,
page_groups: BTreeMap::new(),
meta_dirty: false,
design: Default::default(),
library_docs: BTreeMap::new(),
project_dir: None,
layout_suspended: Vec::new(),
edit_page: None,
touched_mains: Vec::new(),
cross_pending: Vec::new(),
cross_busy: false,
pen: None,
path_edit: None,
measures: Vec::new(),
@@ -375,11 +396,16 @@ impl Editor {
pub fn commit(&mut self) {
self.fixup();
let mains = std::mem::take(&mut self.touched_mains);
if let Some((rec, key)) = self.rec.take()
&& self.history.commit(&self.doc, rec, key)
{
self.revision += 1;
if !mains.is_empty() {
self.cross_pending.push((self.edit_page_index(), mains));
}
}
self.flush_cross_pages();
}
/// Run one edit as a complete transaction.
@@ -418,8 +444,9 @@ impl Editor {
self.cancel_gesture();
self.end_text_edit();
self.commit();
if let Some(l) = self.history.undo(&mut self.doc) {
self.status = format!("Undo {l}");
if let Some(u) = self.history.undo(&mut self.doc) {
self.status = format!("Undo {}", u.label);
self.restore_project_state(u);
}
self.after_history();
}
@@ -428,8 +455,9 @@ impl Editor {
self.cancel_gesture();
self.end_text_edit();
self.commit();
if let Some(l) = self.history.redo(&mut self.doc) {
self.status = format!("Redo {l}");
if let Some(u) = self.history.redo(&mut self.doc) {
self.status = format!("Redo {}", u.label);
self.restore_project_state(u);
}
self.after_history();
}
@@ -1995,6 +2023,7 @@ impl Editor {
A::ToggleUi => self.ui_events.push(commands::UiEvent::ToggleUi),
A::ToggleHelp => self.ui_events.push(commands::UiEvent::ToggleHelp),
A::ToggleGrid => self.ui_events.push(commands::UiEvent::ToggleGrid),
A::OpenVariables => self.ui_events.push(commands::UiEvent::OpenVariables),
A::SelectNext => self.select_sibling(true),
A::SelectPrev => self.select_sibling(false),
A::SelectParent => self.select_parent(),
+145 -31
View File
@@ -4,7 +4,7 @@
use super::{Editor, Gesture};
use crate::geom::Camera;
use crate::history::History;
use crate::model::{Document, NodeId};
use crate::model::{Document, NodeId, Source};
use crate::project::{PageData, new_id};
use std::collections::HashMap;
@@ -26,6 +26,8 @@ pub struct PageSlot {
pub stash: Option<Box<PageStash>>,
/// History generation at the last save.
pub saved_gen: u64,
/// Active mode id per collection id on this page.
pub modes: crate::variables::PageModes,
}
impl PageSlot {
@@ -35,6 +37,7 @@ impl PageSlot {
name: name.into(),
stash: None,
saved_gen: 0,
modes: Default::default(),
}
}
}
@@ -77,6 +80,7 @@ impl Editor {
})
}),
saved_gen: 0,
modes: Default::default(),
})
.collect();
self.active_page = active_i;
@@ -176,6 +180,7 @@ impl Editor {
needs_fit: true,
})),
saved_gen: u64::MAX,
modes: Default::default(),
});
self.meta_dirty = true;
self.revision += 1;
@@ -192,11 +197,23 @@ impl Editor {
.unwrap_or_else(|| format!("Page {}", self.pages.len() + 1));
let name = self.unique_page_name(&base);
let mut slot = PageSlot::new(&name);
// A new page starts with the active page's modes and the cache they give.
slot.modes = self.pages[self.active_page].modes.clone();
let mut doc = Document {
name: name.clone(),
..Default::default()
};
if self.design.collections.is_empty() {
doc.restore_vars(&self.doc.var_state());
} else {
let c = self.design.cache_for(&slot.modes);
doc.variables = c.variables;
doc.modes = c.modes;
doc.mode = c.mode;
doc.tokens = c.tokens;
}
slot.stash = Some(Box::new(PageStash {
doc: Document {
name: name.clone(),
..Default::default()
},
doc,
history: History::default(),
camera: Camera::default(),
selection: Vec::new(),
@@ -225,12 +242,18 @@ impl Editor {
let group = self.page_group(&self.pages[i].id).map(str::to_string);
let name = self.unique_page_name(&format!("{} copy", src.name));
let mut slot = PageSlot::new(&name);
slot.modes = self.pages[i].modes.clone();
slot.saved_gen = u64::MAX;
let mut doc = Document {
name: name.clone(),
..src.doc
};
// A copied main is a new component, not a second source of the same key.
for n in doc.nodes.values_mut().filter(|n| n.component) {
n.component_key = Some(crate::variables::new_id());
}
slot.stash = Some(Box::new(PageStash {
doc: Document {
name: name.clone(),
..src.doc
},
doc,
history: History::default(),
camera: src.camera.unwrap_or_default(),
selection: Vec::new(),
@@ -293,32 +316,55 @@ impl Editor {
Some(removed.id)
}
/// Replaces the active page's content and design system with a saved snapshot, as one undoable
/// change. The design system goes first so the snapshot's node fills win over any linked colors.
/// Replaces the active page's content and variable values with a saved snapshot, as one
/// undoable change (on the active page; the other pages' steps carry only their cache).
///
/// The snapshot holds only the derived cache fields, so its values are written back into the
/// project design system as a design edit: colors per mode and tokens that differ from what
/// the page resolves now, and the snapshot's active mode. Every page follows. Variables the
/// snapshot lacks are kept (other pages use them) and aliases that already give the snapshot's
/// value stay aliases. If the design edit is refused (a name clash), only the content is
/// restored and the status says so.
pub fn restore_snapshot(&mut self, snap: &crate::model::Document) {
self.edit("Restore version", |tx| {
tx.set_design_system(
&snap.variables,
&snap.modes,
&snap.tokens,
snap.mode.as_deref(),
);
for root in tx.doc().root.clone() {
tx.remove(root);
}
for root in snap.root.clone() {
for (i, id) in snap.subtree(root).into_iter().enumerate() {
let Some(node) = snap.get(id).cloned() else {
continue;
};
if i == 0 {
tx.insert(node, None, None);
} else {
tx.put(node);
use crate::editor::design::{OnActive, set_color, set_token_in};
use crate::variables::Kind;
let active = self.pages[self.active_page].modes.clone();
let now = if self.design.collections.is_empty() {
crate::variables::migrate_from_document(self.page_doc(0)).cache_for(&active)
} else {
self.design.cache_for(&active)
};
let design_values = |ds: &mut crate::variables::DesignSystem| {
for (mode, vars) in &snap.modes {
for (n, c) in vars {
if now.modes.get(mode).and_then(|m| m.get(n)) != Some(c) {
set_color(ds, &active, n, Some(mode), *c)?;
}
}
}
});
if snap.modes.is_empty() {
for (n, c) in &snap.variables {
if now.variables.get(n) != Some(c) {
set_color(ds, &active, n, None, *c)?;
}
}
}
for (n, v) in &snap.tokens {
let boolean = ds.find_by_css(n).is_some_and(|(_, x)| x.kind == Kind::Boolean);
if !boolean && now.tokens.get(n) != Some(v) {
set_token_in(ds, &active, n, v, None)?;
}
}
Ok(())
};
let on_active = OnActive {
mode: snap.mode.clone(),
apply: Box::new(|tx| replace_content(tx, snap)),
};
if let Err(e) = self.edit_design_with("Restore version", design_values, Some(on_active), None) {
self.edit("Restore version", |tx| replace_content(tx, snap));
self.status = format!("Restored the page; variables kept as they are ({e})");
}
self.selection.clear();
}
@@ -404,17 +450,65 @@ impl Editor {
std::mem::swap(&mut ed.selection, &mut s.selection);
};
let saved_marker = self.saved_marker;
let outer = self.edit_page.replace(i);
swap(self, &mut s);
let r = f(self);
self.commit();
swap(self, &mut s);
self.edit_page = outer;
self.saved_marker = saved_marker;
self.pages[i].stash = Some(s);
self.revision += 1;
self.doc_stamp = super::next_stamp();
self.flush_cross_pages();
r
}
/// Re-sync instances on other pages after a main component changed: each touched page
/// gets its own undo step. Instances pick up their main from the source page's document.
pub(super) fn flush_cross_pages(&mut self) {
const MAX_ROUNDS: usize = 8; // mains that contain instances of each other
if self.cross_busy || self.edit_page.is_some() {
return;
}
self.cross_busy = true;
for _ in 0..MAX_ROUNDS {
let Some((src, mains)) = (!self.cross_pending.is_empty())
.then(|| self.cross_pending.remove(0))
else {
break;
};
let src_id = self.pages[src].id.clone();
let src_doc = self.page_doc(src).clone();
for page in (0..self.pages.len()).filter(|p| *p != src) {
let roots: Vec<NodeId> = self
.page_doc(page)
.nodes
.values()
.filter(|n| {
n.instance.as_ref().is_some_and(|l| {
matches!(&l.source, Some(Source::Page(p)) if *p == src_id)
&& mains.contains(&l.main)
})
})
.map(|n| n.id)
.collect();
if roots.is_empty() {
continue;
}
self.with_page(page, |ed| {
ed.edit("Update instances", |tx| {
for r in roots {
super::structure::sync_node(tx, r, true, Some(&src_doc));
}
})
});
}
}
self.cross_pending.clear();
self.cross_busy = false;
}
/// The document of page `i` (active or parked).
pub fn page_doc(&self, i: usize) -> &Document {
if i == self.active_page {
@@ -535,3 +629,23 @@ impl Editor {
}
}
}
/// Swap the page content for the snapshot's nodes (the variables are handled by the caller).
fn replace_content(tx: &mut crate::history::Tx, snap: &Document) {
for root in tx.doc().root.clone() {
tx.remove(root);
}
for root in snap.root.clone() {
for (i, id) in snap.subtree(root).into_iter().enumerate() {
let Some(node) = snap.get(id).cloned() else {
continue;
};
if i == 0 {
tx.insert(node, None, None);
} else {
tx.put(node);
}
}
}
}
+4
View File
@@ -59,6 +59,7 @@ pub enum Action {
ToggleUi,
ToggleHelp,
ToggleGrid,
OpenVariables,
SelectNext,
SelectPrev,
SelectParent,
@@ -164,6 +165,8 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
(Char('/'), CMD, A::ToggleHelp, "Keyboard shortcuts"),
(Char('?'), SHIFT, A::ToggleHelp, "Keyboard shortcuts"),
(Char('\''), CMD_SHIFT, A::ToggleGrid, "Show / hide dot grid"),
// Cmd+Alt+V is taken by Paste properties.
(Char('v'), CMD_ALT_SHIFT, A::OpenVariables, "Variables"),
(Tab, PLAIN, A::SelectNext, "Select next sibling"),
(Tab, SHIFT, A::SelectPrev, "Select previous sibling"),
(Enter, SHIFT, A::SelectParent, "Select parent"),
@@ -419,6 +422,7 @@ pub fn category(a: Action) -> &'static str {
| A::ZoomSelection
| A::ToggleSnapping
| A::ToggleGrid
| A::OpenVariables
| A::ToggleUi
| A::ToggleHelp
| A::NextPage
+302 -53
View File
@@ -5,7 +5,9 @@ use super::Editor;
use crate::geom::{Rect, V2, v2};
use crate::history::Tx;
use crate::layout;
use crate::model::{AutoLayout, Direction, InstanceLink, Node, NodeId, NodeKind, Sizing};
use crate::model::{
AutoLayout, Direction, Document, InstanceLink, Node, NodeId, NodeKind, Sizing, Source,
};
use std::collections::{BTreeSet, HashSet};
/// Property groups an instance can override (and that sync copies from the main component).
@@ -19,14 +21,13 @@ pub fn override_key(field: &str) -> &'static str {
match field {
"x" | "y" | "position" => "position",
"width" | "height" | "size" => "size",
"fill" | "fills" | "color" => "fills",
"fill" | "fills" | "color" | "text-color" => "fills",
"stroke" | "stroke width" | "stroke align" | "dash" | "cap" | "join" => "stroke",
"radius" | "radii" => "radius",
"opacity" | "blend" => "opacity",
"text" => "text",
"font size" | "font" | "bold" | "italic" | "align" | "line height" | "letter spacing" => {
"font"
}
"font size" | "font" | "bold" | "italic" | "align" | "line height" | "letter spacing"
| "font-size" | "line-height" => "font",
"visible" => "visible",
"name" => "name",
_ => "layout",
@@ -106,9 +107,21 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
inst.rect.w = main.rect.w;
inst.rect.h = main.rect.h;
}
// Bindings follow the main, field by field, except in overridden groups.
let fields: BTreeSet<String> = main.bindings.keys().chain(inst.bindings.keys()).cloned().collect();
for f in fields {
if ov(override_key(&f)) {
continue;
}
match main.bindings.get(&f) {
Some(v) => inst.bindings.insert(f, v.clone()),
None => inst.bindings.remove(&f),
};
}
}
/// Copy a subtree as a new instance subtree under `parent` (ids are fresh).
/// Copy a subtree as a new instance subtree under `parent` (ids are fresh). The main is on
/// the same page.
pub fn instantiate(
tx: &mut Tx,
main: NodeId,
@@ -116,23 +129,43 @@ pub fn instantiate(
index: Option<usize>,
root_rect: Option<Rect>,
) -> NodeId {
let src = tx.doc().get(main).unwrap().clone();
instantiate_from(tx, None, None, main, parent, index, root_rect)
}
/// Like [`instantiate`] for a main in another document `src` (`None` = this one), recorded
/// as `source` on every node of the instance.
pub fn instantiate_from(
tx: &mut Tx,
src: Option<&Document>,
source: Option<Source>,
main: NodeId,
parent: Option<NodeId>,
index: Option<usize>,
root_rect: Option<Rect>,
) -> NodeId {
let node = match src {
Some(d) => d.get(main),
None => tx.doc().get(main),
};
let src_node = node.unwrap().clone();
let id = tx.alloc_id();
let mut n = src.clone();
let mut n = src_node.clone();
n.id = id;
n.children = Vec::new();
n.component = false;
n.component_key = None;
n.comments.clear();
n.instance = Some(InstanceLink {
main,
overrides: Vec::new(),
source: source.clone(),
});
if let Some(r) = root_rect {
n.rect = r;
}
tx.insert(n, parent, index);
for c in src.children {
instantiate(tx, c, Some(id), None, None);
for c in src_node.children {
instantiate_from(tx, src, source.clone(), c, Some(id), None, None);
}
id
}
@@ -156,13 +189,27 @@ pub fn mark_overrides(tx: &mut Tx, id: NodeId, keys: &[&str]) {
}
}
/// Bring one instance subtree in line with its main counterpart.
fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool) {
/// Re-sync the instance subtree at `inst` from `ext` (a library snapshot), keeping overrides.
pub fn sync_library_instance(tx: &mut Tx, inst: NodeId, ext: &Document) {
sync_node(tx, inst, true, Some(ext));
}
/// Bring one instance subtree in line with its main counterpart. `ext` is the document of
/// the main's page when the link has a `source`; without it such an instance is left alone.
pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&Document>) {
let Some(n) = tx.doc().get(inst) else { return };
let Some(link) = n.instance.clone() else {
return;
};
let Some(main) = tx.doc().get(link.main).cloned() else {
let ext = ext.filter(|_| link.source.is_some());
let main = match ext {
None => tx.doc().get(link.main),
Some(d) => d.get(link.main),
};
if link.source.is_some() && ext.is_none() {
return;
}
let Some(main) = main.cloned() else {
return;
};
let mut updated = n.clone();
@@ -172,13 +219,13 @@ fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool) {
}
// Children: match by link; create missing, remove stale, follow main's order.
let kids: Vec<NodeId> = tx.doc().get(inst).unwrap().children.clone();
let mut by_main: Vec<(NodeId, NodeId)> = kids
let mut by_main: Vec<(NodeId, Option<Source>, NodeId)> = kids
.iter()
.filter_map(|k| {
tx.doc()
.get(*k)
.and_then(|c| c.instance.as_ref())
.map(|l| (l.main, *k))
.map(|l| (l.main, l.source.clone(), *k))
})
.collect();
for k in &kids {
@@ -186,23 +233,23 @@ fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool) {
.doc()
.get(*k)
.and_then(|c| c.instance.as_ref())
.is_none_or(|l| !main.children.contains(&l.main));
.is_none_or(|l| !main.children.contains(&l.main) || l.source != link.source);
if stale {
tx.remove(*k);
by_main.retain(|(_, x)| x != k);
by_main.retain(|(_, _, x)| x != k);
}
}
for (i, mc) in main.children.iter().enumerate() {
match by_main.iter().find(|(m, _)| m == mc) {
Some((_, k)) => {
match by_main.iter().find(|(m, s, _)| m == mc && *s == link.source) {
Some((_, _, k)) => {
let k = *k;
if tx.doc().children(Some(inst)).iter().position(|x| *x == k) != Some(i) {
tx.move_to_index(k, i);
}
sync_node(tx, k, false);
sync_node(tx, k, false, ext);
}
None => {
instantiate(tx, *mc, Some(inst), Some(i), None);
instantiate_from(tx, ext, link.source.clone(), *mc, Some(inst), Some(i), None);
}
}
}
@@ -213,6 +260,7 @@ impl Editor {
/// auto layout, and instances of edited components. Runs inside the transaction, so it
/// undoes together with the edit that caused it.
pub fn fixup(&mut self) {
self.touched_mains.clear();
let Some((rec, _)) = &self.rec else { return };
let touched: Vec<NodeId> = rec.touched().to_vec();
if touched.is_empty() {
@@ -253,6 +301,7 @@ impl Editor {
.copied()
.filter(|id| self.doc.get(*id).is_some_and(|n| n.component))
.collect();
self.touched_mains = comps.clone();
{
let mut tx = self.tx();
for g in &groups {
@@ -263,11 +312,15 @@ impl Editor {
.doc()
.nodes
.values()
.filter(|n| n.instance.as_ref().is_some_and(|l| l.main == *c))
.filter(|n| {
n.instance
.as_ref()
.is_some_and(|l| l.main == *c && l.source.is_none())
})
.map(|n| n.id)
.collect();
for i in instances {
sync_node(&mut tx, i, true);
sync_node(&mut tx, i, true, None);
cands.insert(i);
}
}
@@ -326,12 +379,17 @@ impl Editor {
let key = override_key(field);
let mut tx = self.tx();
for id in ids {
let is_inst = tx.doc().get(*id).is_some_and(|n| n.instance.is_some());
if is_inst
&& let Some(n) = tx.node_mut(*id)
&& let Some(l) = &mut n.instance
&& !l.overrides.iter().any(|o| o == key)
{
let Some(n) = tx.doc().get(*id) else { continue };
let Some(l) = &n.instance else { continue };
let push = !l.overrides.iter().any(|o| o == key);
let unbind = n.bindings.keys().any(|f| override_key(f) == key);
if !push && !unbind {
continue;
}
let n = tx.node_mut(*id).unwrap();
// An overridden field stops following its variable, on this instance only.
n.bindings.retain(|f, _| override_key(f) != key);
if push && let Some(l) = &mut n.instance {
l.overrides.push(key.into());
}
}
@@ -426,7 +484,9 @@ impl Editor {
.is_some_and(|n| n.is_frame() && n.instance.is_none())
{
self.edit("Create component", |tx| {
tx.node_mut(id).unwrap().component = true;
let n = tx.node_mut(id).unwrap();
n.component = true;
n.component_key.get_or_insert_with(crate::variables::new_id);
});
return;
}
@@ -436,6 +496,7 @@ impl Editor {
self.edit("Create component", |tx| {
let node = tx.node_mut(f).unwrap();
node.component = true;
node.component_key.get_or_insert_with(crate::variables::new_id);
node.name = format!("Component {}", n + 1);
});
}
@@ -479,7 +540,7 @@ impl Editor {
let roots: Vec<NodeId> = self
.top_selection()
.into_iter()
.filter(|id| self.is_instance_root(*id))
.filter(|id| self.is_instance_root(*id) || self.is_main_missing(*id))
.collect();
if roots.is_empty() {
return;
@@ -504,6 +565,7 @@ impl Editor {
.find(|a| self.is_instance_root(*a))
})
.collect();
let exts: Vec<Option<Document>> = roots.iter().map(|r| self.source_doc(*r)).collect();
self.edit("Reset overrides", |tx| {
for r in &ids {
for id in tx.doc().subtree(*r) {
@@ -512,39 +574,134 @@ impl Editor {
}
}
}
for r in roots {
sync_node(tx, r, true);
for (r, ext) in roots.into_iter().zip(&exts) {
sync_node(tx, r, true, ext.as_ref());
}
});
}
/// Index of the page the document being edited belongs to (differs from `active_page`
/// inside `with_page`).
pub fn edit_page_index(&self) -> usize {
self.edit_page.unwrap_or(self.active_page)
}
/// The page and node a link points at, when that node exists.
fn link_main(&self, l: &InstanceLink) -> Option<(usize, &Node)> {
let page = match &l.source {
None => self.edit_page_index(),
Some(Source::Page(p)) => self.pages.iter().position(|s| s.id == *p)?,
Some(Source::Library(_)) => return None,
};
let doc = if page == self.edit_page_index() {
&self.doc
} else {
&self.pages[page].stash.as_ref()?.doc
};
doc.get(l.main).map(|n| (page, n))
}
/// A clone of the document holding the main of instance node `id`, when it is another page.
pub(super) fn source_doc(&self, id: NodeId) -> Option<Document> {
let l = self.doc.get(id)?.instance.as_ref()?;
let Source::Page(_) = l.source.as_ref()? else {
return match l.source.as_ref()? {
Source::Library(id) => self.library_docs.get(id).cloned(),
Source::Page(_) => None,
};
};
let (page, _) = self.link_main(l)?;
Some(self.page_doc(page).clone())
}
fn library_main_ok(&self, lib: &str, main: NodeId) -> bool {
self.library_docs.get(lib).and_then(|d| d.get(main)).is_some_and(|m| m.component)
}
pub fn is_instance_root(&self, id: NodeId) -> bool {
self.doc.get(id).is_some_and(|n| {
n.instance
.as_ref()
.is_some_and(|l| self.doc.get(l.main).is_some_and(|m| m.component))
n.instance.as_ref().is_some_and(|l| match &l.source {
// A library instance whose library or component is gone still counts (detach, warning).
Some(Source::Library(lib)) => self.library_main_ok(lib, l.main) || self.is_main_missing(id),
_ => self.link_main(l).is_some_and(|(_, m)| m.component),
})
})
}
/// The main component an instance root points to.
/// An instance root whose main component no longer exists.
pub fn is_main_missing(&self, id: NodeId) -> bool {
self.doc.get(id).is_some_and(|n| {
let Some(l) = &n.instance else { return false };
let root = n
.parent
.and_then(|p| self.doc.get(p))
.is_none_or(|p| p.instance.is_none());
root && match &l.source {
Some(Source::Library(lib)) => !self.library_main_ok(lib, l.main),
_ => self.link_main(l).is_none_or(|(_, m)| !m.component),
}
})
}
/// Instance roots on page `page` whose main component is gone (deleted, or its page).
/// They keep their content; the UI offers Detach or Restore. Not for use inside `with_page`.
pub fn missing_mains(&self, page: usize) -> Vec<NodeId> {
let doc = self.page_doc(page);
let mut out: Vec<NodeId> = doc
.nodes
.values()
.filter(|n| {
let Some(l) = &n.instance else { return false };
let root = n
.parent
.and_then(|p| doc.get(p))
.is_none_or(|p| p.instance.is_none());
let found = match &l.source {
None => doc.get(l.main),
Some(Source::Page(p)) => self
.pages
.iter()
.position(|s| s.id == *p)
.and_then(|i| self.page_doc(i).get(l.main)),
Some(Source::Library(lib)) => return root && !self.library_main_ok(lib, l.main),
};
root && found.is_none_or(|m| !m.component)
})
.map(|n| n.id)
.collect();
out.sort();
out
}
/// The main component an instance root points to, when it is on the same page.
pub fn instance_main_root(&self, id: NodeId) -> Option<NodeId> {
let l = self.doc.get(id)?.instance.as_ref()?;
self.doc.get(l.main).filter(|m| m.component).map(|m| m.id)
if l.source.is_some() {
return None;
}
self.link_main(l).filter(|(_, m)| m.component).map(|(_, m)| m.id)
}
/// Select the main component of the selected instance.
pub fn go_to_main(&mut self) {
if let Some(m) = self
let Some(l) = self
.selection
.first()
.and_then(|id| self.instance_main_root(*id))
{
self.selection = vec![m];
if let Some(r) = self.doc.world_rect(m) {
self.camera
.fit(&r, self.viewport, 80.0, self.camera.zoom.max(1.0));
self.revision += 1;
}
.and_then(|id| self.doc.get(*id))
.and_then(|n| n.instance.clone())
else {
return;
};
let Some((page, m)) = self.link_main(&l).filter(|(_, m)| m.component).map(|(p, m)| (p, m.id))
else {
return;
};
self.switch_page(page);
self.selection = vec![m];
if let Some(r) = self.doc.world_rect(m) {
self.camera
.fit(&r, self.viewport, 80.0, self.camera.zoom.max(1.0));
self.revision += 1;
}
}
@@ -558,7 +715,7 @@ impl Editor {
{
return false;
}
self.edit("Swap instance", |tx| swap_instance_tx(tx, inst, new_main));
self.edit("Swap instance", |tx| swap_instance_tx(tx, None, None, inst, new_main));
true
}
@@ -783,16 +940,23 @@ pub fn infer_layout(rects: &[Rect], fw: f64, fh: f64) -> AutoLayout {
}
/// Swap `inst` to `new_main` inside an open transaction, matching child overrides by name/kind.
pub fn swap_instance_tx(tx: &mut Tx, inst: NodeId, new_main: NodeId) -> bool {
let Some(new_comp) = tx.doc().get(new_main).cloned() else {
pub fn swap_instance_tx(
tx: &mut Tx,
ext: Option<&Document>,
source: Option<Source>,
inst: NodeId,
new_main: NodeId,
) -> bool {
let src = ext.unwrap_or(tx.doc());
let Some(new_comp) = src.get(new_main).cloned() else {
return false;
};
if !new_comp.component {
return false;
}
let mut target_by_name: Vec<(String, &'static str, NodeId)> = Vec::new();
for cid in tx.doc().subtree(new_main).into_iter().skip(1) {
if let Some(cn) = tx.doc().get(cid) {
for cid in src.subtree(new_main).into_iter().skip(1) {
if let Some(cn) = src.get(cid) {
target_by_name.push((cn.name.clone(), cn.kind.type_name(), cid));
}
}
@@ -814,10 +978,15 @@ pub fn swap_instance_tx(tx: &mut Tx, inst: NodeId, new_main: NodeId) -> bool {
link.main = tid;
}
}
// Unmatched nodes are removed by the sync below; they must not keep the old source.
if let Some(link) = tx.node_mut(k).and_then(|n| n.instance.as_mut()) {
link.source = source.clone();
}
}
if let Some(root) = tx.node_mut(inst) {
if let Some(link) = &mut root.instance {
link.main = new_main;
link.source = source;
}
if root
.instance
@@ -827,6 +996,86 @@ pub fn swap_instance_tx(tx: &mut Tx, inst: NodeId, new_main: NodeId) -> bool {
root.name = new_comp.name;
}
}
sync_node(tx, inst, true);
sync_node(tx, inst, true, ext);
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Paint;
/// Two pages; a component on page 0 with a text child, an instance of it on page 1.
fn setup() -> (Editor, NodeId, NodeId) {
let mut e = Editor::default();
e.add_page_in_background(None);
let comp = e.add_node(
Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)),
None,
);
e.selection = vec![comp];
e.create_component();
let src = e.doc.clone();
let source = Some(Source::Page(e.pages[0].id.clone()));
let inst = e.with_page(1, |e| {
e.edit("i", |tx| instantiate_from(tx, Some(&src), source, comp, None, None, None))
});
(e, comp, inst)
}
fn red(e: &Editor, page: usize, id: NodeId) -> bool {
matches!(e.page_doc(page).get(id).unwrap().style.fills.first(),
Some(Paint::Solid { color, .. }) if color.r == 255 && color.b == 0)
}
#[test]
fn main_edit_updates_other_page_with_undo_per_page() {
let (mut e, comp, inst) = setup();
assert!(e.doc.get(comp).unwrap().component_key.is_some());
assert!(e.page_doc(1).get(inst).unwrap().component_key.is_none());
e.edit("red", |tx| {
tx.node_mut(comp).unwrap().style.fills = vec![Paint::solid(crate::model::Color::rgb(255, 0, 0))];
});
assert!(red(&e, 1, inst), "page B follows the main");
e.undo();
assert!(!red(&e, 0, comp), "A restored");
assert!(red(&e, 1, inst), "B untouched by undo on A");
e.with_page(1, |e| e.undo());
assert!(!red(&e, 1, inst), "undo on B restores B");
}
#[test]
fn deleting_main_keeps_instance_and_lists_it() {
let (mut e, comp, inst) = setup();
assert!(e.missing_mains(1).is_empty());
e.edit("del", |tx| tx.remove(comp));
assert!(e.page_doc(1).get(inst).is_some());
assert_eq!(e.missing_mains(1), vec![inst]);
}
#[test]
fn sync_replaces_child_links_with_a_different_source() {
let mut e = Editor::default();
let comp = e.add_node(
Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)),
None,
);
let kid = e.add_node(
Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 5., 5.)),
Some(comp),
);
e.selection = vec![comp];
e.create_component();
let inst = e.edit("i", |tx| instantiate(tx, comp, None, None, None));
let old_kid = e.doc.get(inst).unwrap().children[0];
e.edit("stale", |tx| {
tx.node_mut(old_kid).unwrap().instance.as_mut().unwrap().source = Some(Source::Page("gone".into()));
});
e.edit("sync", |tx| sync_node(tx, inst, true, None));
let kids = e.doc.get(inst).unwrap().children.clone();
assert_eq!(kids.len(), 1);
assert_ne!(kids[0], old_kid, "stale-source child replaced");
let l = e.doc.get(kids[0]).unwrap().instance.clone().unwrap();
assert_eq!((l.main, l.source), (kid, None));
}
}
+114 -8
View File
@@ -6,7 +6,8 @@
use crate::geom::V2;
use crate::model::{Color, Document, Node, NodeId, VarState};
use std::collections::{BTreeMap, HashMap};
use crate::variables::{Cache, DesignSystem, PageModes};
use std::collections::{BTreeMap, BTreeSet, HashMap};
const MAX_DEPTH: usize = 500;
@@ -23,11 +24,44 @@ pub struct Change {
entries: Vec<Entry>,
root: Option<(Vec<NodeId>, Vec<NodeId>)>,
variables: Option<(VarState, VarState)>,
/// Project design system before/after. Every page's step of a design edit carries it.
design: Option<(Box<DesignSystem>, Box<DesignSystem>)>,
/// The page's active mode per collection before/after (`Editor::set_page_mode`).
page_modes: Option<(PageModes, PageModes)>,
next_id: (u64, u64),
coalesce_key: Option<String>,
}
/// Project-level state a step carries besides the page document; the editor puts it back.
#[derive(Debug, Default)]
pub struct Undone {
pub label: String,
/// The design system the step leads to, and the one it started from. The editor moves
/// `Editor::design` only when it still equals `design_from` (nothing newer replaced it).
pub design: Option<DesignSystem>,
pub design_from: Option<DesignSystem>,
pub page_modes: Option<PageModes>,
}
impl Change {
fn undone(&self, forward: bool) -> Undone {
Undone {
label: self.label.clone(),
design: self
.design
.as_ref()
.map(|(b, a)| (**if forward { a } else { b }).clone()),
design_from: self
.design
.as_ref()
.map(|(b, a)| (**if forward { b } else { a }).clone()),
page_modes: self
.page_modes
.as_ref()
.map(|(b, a)| (if forward { a } else { b }).clone()),
}
}
fn apply(&self, doc: &mut Document, forward: bool) {
for e in &self.entries {
let state = if forward { &e.after } else { &e.before };
@@ -66,6 +100,8 @@ pub struct Recording {
order: Vec<NodeId>,
root_before: Option<Vec<NodeId>>,
variables_before: Option<VarState>,
pub(crate) design: Option<(DesignSystem, DesignSystem)>,
pub(crate) page_modes: Option<(PageModes, PageModes)>,
next_id_before: u64,
}
@@ -304,6 +340,54 @@ impl<'a> Tx<'a> {
fills
}
/// Replace the page's variable cache with `cache` (derived from the project design system)
/// and bring the fills that follow variables in step: `renames` (old CSS name to new) move
/// links, a variable that is gone stops being followed (fill keeps its color), every other
/// followed fill takes the new color. Returns fills whose color changed, per variable.
pub fn set_cache(
&mut self,
cache: &Cache,
renames: &BTreeMap<String, String>,
) -> BTreeMap<String, usize> {
self.snapshot_variables();
let had: BTreeSet<String> = self.doc.variables.keys().cloned().collect();
let users: Vec<NodeId> = self
.doc
.nodes
.values()
.filter(|n| n.style.fills.iter().any(|p| p.var().is_some()))
.map(|n| n.id)
.collect();
let mut fills = BTreeMap::new();
for id in users {
let Some(n) = self.node_mut(id) else { continue };
for p in &mut n.style.fills {
let Some(old) = p.var().map(str::to_string) else {
continue;
};
let name = renames.get(&old).cloned().unwrap_or_else(|| old.clone());
let Some(color) = cache.variables.get(&name).copied() else {
if had.contains(&old) {
p.unlink_var();
}
continue;
};
if let crate::model::Paint::Solid { color: c, var } = p {
*var = Some(name.clone());
if *c != color {
*c = color;
*fills.entry(name).or_insert(0) += 1;
}
}
}
}
self.doc.variables.clone_from(&cache.variables);
self.doc.modes.clone_from(&cache.modes);
self.doc.mode.clone_from(&cache.mode);
self.doc.tokens.clone_from(&cache.tokens);
fills
}
/// Move every fill following `name` to `color`.
fn apply_variable(&mut self, name: &str, color: Color) -> usize {
let users: Vec<NodeId> = self
@@ -444,7 +528,17 @@ impl History {
.variables_before
.filter(|b| *b != doc.var_state())
.map(|b| (b, doc.var_state()));
if entries.is_empty() && root.is_none() && variables.is_none() {
let design = rec
.design
.filter(|(b, a)| b != a)
.map(|(b, a)| (Box::new(b), Box::new(a)));
let page_modes = rec.page_modes.filter(|(b, a)| b != a);
if entries.is_empty()
&& root.is_none()
&& variables.is_none()
&& design.is_none()
&& page_modes.is_none()
{
return false;
}
let change = Change {
@@ -452,6 +546,8 @@ impl History {
entries,
root,
variables,
design,
page_modes,
next_id: (rec.next_id_before, doc.next_id),
coalesce_key,
};
@@ -471,22 +567,22 @@ impl History {
true
}
pub fn undo(&mut self, doc: &mut Document) -> Option<String> {
pub fn undo(&mut self, doc: &mut Document) -> Option<Undone> {
let c = self.undo.pop()?;
c.apply(doc, false);
self.generation += 1;
let label = c.label.clone();
let u = c.undone(false);
self.redo.push(c);
Some(label)
Some(u)
}
pub fn redo(&mut self, doc: &mut Document) -> Option<String> {
pub fn redo(&mut self, doc: &mut Document) -> Option<Undone> {
let c = self.redo.pop()?;
c.apply(doc, true);
self.generation += 1;
let label = c.label.clone();
let u = c.undone(true);
self.undo.push(c);
Some(label)
Some(u)
}
pub fn generation(&self) -> u64 {
@@ -539,6 +635,16 @@ fn merge(older: &mut Change, newer: Change) {
(v @ None, Some(nv)) => *v = Some(nv),
_ => {}
}
match (&mut older.design, newer.design) {
(Some((_, after)), Some((_, new_after))) => *after = new_after,
(d @ None, Some(nd)) => *d = Some(nd),
_ => {}
}
match (&mut older.page_modes, newer.page_modes) {
(Some((_, after)), Some((_, new_after))) => *after = new_after,
(m @ None, Some(nm)) => *m = Some(nm),
_ => {}
}
older.next_id.1 = newer.next_id.1;
}
+4
View File
@@ -3,6 +3,8 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod app;
pub mod assets;
#[cfg(not(target_arch = "wasm32"))]
pub mod cli;
pub mod connector;
pub mod demo;
pub mod design_system;
@@ -13,6 +15,7 @@ pub mod icons;
pub mod ink;
pub mod io;
pub mod layout;
pub mod libraries;
pub mod library;
pub mod lottie;
#[cfg(target_os = "macos")]
@@ -29,6 +32,7 @@ pub mod theme;
pub mod ui;
#[cfg(not(target_arch = "wasm32"))]
pub mod update;
pub mod variables;
pub mod vector;
pub mod runs;
pub mod versions;
+747
View File
@@ -0,0 +1,747 @@
//! Design libraries: a project publishes its variables and components as `library.json`; other
//! projects enable a snapshot (`libraries/<id>.json`) and use them read-only.
//!
//! v1 limits: an instance inside a published component that points at another component
//! (another page, or outside the component) is published as a plain copy. Node ids in the
//! snapshot are derived from `(component key, original id)`, so they are unique across pages
//! and stay the same when the library is republished.
use crate::editor::Editor;
use crate::model::{Document, Node, NodeId, Source};
use crate::project::{Project, Store};
use crate::variables::{self, Collection, EnabledLibrary, PageModes, Value, stable_id};
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub const LIBRARY_FILE: &str = "library.json";
pub const LIBRARIES_DIR: &str = "libraries";
/// Longest side of a component preview, in px.
const PREVIEW_PX: f64 = 256.0;
/// Node ids stay below 2^53 so they survive a JSON round trip in any reader.
const ID_MASK: u64 = (1 << 53) - 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Published {
pub library_id: String,
pub name: String,
#[serde(default)]
pub description: String,
pub version: u32,
/// Unix ms.
pub published: u64,
pub collections: Vec<Collection>,
pub components: Vec<PublishedComponent>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PublishedComponent {
pub key: String,
pub name: String,
pub page_name: String,
/// The main's subtree, parent first.
pub nodes: Vec<Node>,
pub root: NodeId,
/// 256px PNG, base64. `None` when published without a GPU.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_png: Option<String>,
}
/// An available library, as listed by [`available`].
#[derive(Debug, Clone, PartialEq)]
pub struct LibrarySummary {
pub library_id: String,
pub name: String,
pub description: String,
pub version: u32,
pub published: u64,
pub variables: usize,
pub components: usize,
/// The library's project folder.
pub dir: PathBuf,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PublishDiff {
pub variables_added: Vec<String>,
pub variables_changed: Vec<String>,
pub variables_removed: Vec<String>,
pub components_added: Vec<String>,
pub components_changed: Vec<String>,
pub components_removed: Vec<String>,
}
impl PublishDiff {
pub fn is_empty(&self) -> bool {
*self == PublishDiff::default()
}
}
impl std::fmt::Display for PublishDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_empty() {
return write!(f, "no changes");
}
let parts = [
("variable", "added", self.variables_added.len()),
("variable", "changed", self.variables_changed.len()),
("variable", "removed", self.variables_removed.len()),
("component", "added", self.components_added.len()),
("component", "changed", self.components_changed.len()),
("component", "removed", self.components_removed.len()),
];
let s: Vec<String> = parts
.iter()
.filter(|p| p.2 > 0)
.map(|(what, how, n)| format!("{n} {what}{} {how}", if *n == 1 { "" } else { "s" }))
.collect();
write!(f, "{}", s.join(", "))
}
}
// ------------------------------------------------------------------------------ files
fn read_published(path: &Path) -> Result<Published, String> {
let s = crate::vfs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
serde_json::from_str(&s).map_err(|e| format!("{}: {e}", path.display()))
}
fn write_json<T: Serialize>(path: &Path, v: &T) -> Result<(), String> {
if let Some(d) = path.parent() {
crate::vfs::create_dir_all(d).map_err(|e| e.to_string())?;
}
let s = serde_json::to_string(v).map_err(|e| e.to_string())?;
crate::io::atomic::write(path, s.as_bytes()).map_err(|e| e.to_string())
}
fn previous(project_dir: &Path) -> Option<Published> {
read_published(&project_dir.join(LIBRARY_FILE)).ok()
}
impl Published {
/// The snapshot's components as one document (roots in publish order), what `Library(id)`
/// instances sync from.
pub fn to_document(&self) -> Document {
let mut doc = Document {
name: self.name.clone(),
..Document::default()
};
for c in &self.components {
doc.root.push(c.root);
for n in &c.nodes {
doc.nodes.insert(n.id, n.clone());
}
}
doc.next_id = doc.nodes.keys().map(|i| i.0).max().unwrap_or(0) + 1;
doc
}
}
/// Every `libraries/*.json` of a project as documents by library id. Unreadable files are
/// skipped (the next enable or update rewrites them).
pub fn load_docs(project_dir: &Path) -> BTreeMap<String, Document> {
let mut out = BTreeMap::new();
let Ok(rd) = crate::vfs::read_dir(project_dir.join(LIBRARIES_DIR)) else {
return out;
};
for e in rd.flatten() {
let p = e.path();
if p.extension().is_none_or(|x| x != "json") {
continue;
}
match read_published(&p) {
Ok(l) => {
out.insert(l.library_id.clone(), l.to_document());
}
Err(e) => log::warn!("library snapshot skipped: {e}"),
}
}
out
}
/// Documents of the enabled libraries only (a snapshot file may outlive a disable, for undo).
pub fn load_enabled_docs(project_dir: &Path, enabled: &[EnabledLibrary]) -> BTreeMap<String, Document> {
let mut docs = load_docs(project_dir);
docs.retain(|id, _| enabled.iter().any(|l| l.library_id == *id));
docs
}
/// Delete snapshot files no enabled library references (left by disable so undo could bring
/// the library back).
pub fn sweep_orphans(project_dir: &Path, enabled: &[EnabledLibrary]) {
let Ok(rd) = crate::vfs::read_dir(project_dir.join(LIBRARIES_DIR)) else {
return;
};
for e in rd.flatten() {
let p = e.path();
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
if p.extension().is_some_and(|x| x == "json") && !enabled.iter().any(|l| l.library_id == stem) {
let _ = crate::vfs::remove_file(&p);
}
}
}
// ------------------------------------------------------------------------------ build
fn snapshot_id(key: &str, id: NodeId) -> NodeId {
let h = stable_id(&[key, &id.0.to_string()]);
NodeId((u64::from_str_radix(&h, 16).unwrap_or(1) & ID_MASK).max(1))
}
fn published_collections(ed: &Editor, library_id: &str) -> Vec<Collection> {
let ds = &ed.design;
let kept: BTreeSet<&str> = ds
.collections
.iter()
.filter(|c| c.library.is_none())
.flat_map(|c| &c.variables)
.filter(|v| !v.hidden)
.map(|v| v.id.as_str())
.collect();
let var_id = |id: &str| stable_id(&[library_id, "var", id]);
let mut out = Vec::new();
for c in ds.collections.iter().filter(|c| c.library.is_none()) {
let mut c2 = c.clone();
c2.library = None;
c2.id = stable_id(&[library_id, "col", &c.id]);
c2.variables.retain(|v| !v.hidden);
for v in &mut c2.variables {
for (mode, val) in &mut v.values {
if let Value::Alias(t) = val {
if kept.contains(t.as_str()) {
*t = var_id(t);
} else {
// Target is hidden or from another library: publish what it resolves to.
let modes: PageModes = [(c.id.clone(), mode.clone())].into();
*val = ds.resolve(&v.id, &modes).unwrap_or_else(|_| v.kind.default_value());
}
}
}
v.id = var_id(&v.id);
}
if !c2.variables.is_empty() {
out.push(c2);
}
}
out
}
fn component_nodes(doc: &Document, root: NodeId, key: &str) -> Vec<Node> {
let ids = doc.subtree(root);
let inside: BTreeSet<NodeId> = ids.iter().copied().collect();
ids.iter()
.filter_map(|i| doc.get(*i))
.map(|n| {
let mut n = n.clone();
n.id = snapshot_id(key, n.id);
n.parent = n.parent.filter(|p| inside.contains(p)).map(|p| snapshot_id(key, p));
n.children = n.children.iter().map(|c| snapshot_id(key, *c)).collect();
n.link = None;
n.comments.clear();
match &mut n.instance {
Some(l) if l.source.is_none() && inside.contains(&l.main) => l.main = snapshot_id(key, l.main),
_ => n.instance = None,
}
if let crate::model::NodeKind::Connector { start, end, .. } = &mut n.kind {
for b in [start, end] {
if let Some(t) = b.as_mut().filter(|t| inside.contains(&t.node)) {
t.node = snapshot_id(key, t.node);
} else {
*b = None;
}
}
}
n
})
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn preview(gpu: &crate::render::gpu::Headless, doc: &Document, root: NodeId) -> Option<String> {
let r = doc.world_rect(root)?;
let scale = (PREVIEW_PX / r.w.max(r.h).max(1.0)).min(2.0);
let img = crate::io::png::render(gpu, doc, Some(&[root]), scale, None).ok()?;
let png = crate::io::export::encode_png(&img).ok()?;
Some(base64::engine::general_purpose::STANDARD.encode(png))
}
fn build(
project_dir: &Path,
ed: &Editor,
description: &str,
gpu: Option<&crate::render::gpu::Headless>,
) -> Result<(Published, Option<Published>), String> {
let manifest = Project::open(project_dir).map_err(|e| e.to_string())?.manifest;
let prev = previous(project_dir);
let mut components = Vec::new();
let mut seen = BTreeSet::new();
for i in 0..ed.pages.len() {
let page_name = &ed.pages[i].name;
if page_name.starts_with(['_', '.']) {
continue;
}
let doc = ed.page_doc(i);
let mut mains: Vec<&Node> = doc
.nodes
.values()
.filter(|n| n.component && !n.name.starts_with(['_', '.']))
.filter(|n| !doc.ancestors(n.id).iter().any(|a| doc.get(*a).is_some_and(|a| a.component)))
.collect();
mains.sort_by_key(|n| n.id);
for m in mains {
let Some(mut key) = m.component_key.clone() else { continue };
// Duplicate keys (copies made before keys were refreshed): the later one is rekeyed.
if !seen.insert(key.clone()) {
key = stable_id(&[&key, "dup", &page_name.to_string(), &m.id.0.to_string()]);
seen.insert(key.clone());
}
#[cfg(not(target_arch = "wasm32"))]
let preview_png = gpu.and_then(|g| preview(g, doc, m.id));
#[cfg(target_arch = "wasm32")]
let preview_png = {
let _ = gpu;
None
};
components.push(PublishedComponent {
name: m.name.clone(),
page_name: page_name.clone(),
nodes: component_nodes(doc, m.id, &key),
root: snapshot_id(&key, m.id),
preview_png,
key,
});
}
}
let description = if description.is_empty() {
prev.as_ref().map(|p| p.description.clone()).unwrap_or_default()
} else {
description.to_string()
};
let next = Published {
library_id: manifest.id.clone(),
name: manifest.name,
description,
version: prev.as_ref().map_or(1, |p| p.version + 1),
published: crate::project::now_ms(),
collections: published_collections(ed, &manifest.id),
components,
};
Ok((next, prev))
}
fn diff(prev: Option<&Published>, next: &Published) -> PublishDiff {
let vars = |p: &Published| -> BTreeMap<String, Vec<(String, Value)>> {
p.collections
.iter()
.flat_map(|c| &c.variables)
.map(|v| {
let mut vals: Vec<(String, Value)> =
v.values.iter().map(|(m, x)| (m.clone(), x.clone())).collect();
vals.insert(0, (format!("{:?}", v.kind), Value::String(v.description.clone())));
(v.name.clone(), vals)
})
.collect()
};
fn comps(p: &Published) -> BTreeMap<String, &PublishedComponent> {
p.components.iter().map(|c| (c.key.clone(), c)).collect()
}
let empty = Published {
library_id: String::new(),
name: String::new(),
description: String::new(),
version: 0,
published: 0,
collections: Vec::new(),
components: Vec::new(),
};
let prev = prev.unwrap_or(&empty);
let mut d = PublishDiff::default();
let (pv, nv) = (vars(prev), vars(next));
for (k, v) in &nv {
match pv.get(k) {
None => d.variables_added.push(k.clone()),
Some(o) if o != v => d.variables_changed.push(k.clone()),
_ => {}
}
}
d.variables_removed = pv.keys().filter(|k| !nv.contains_key(*k)).cloned().collect();
let (pc, nc) = (comps(prev), comps(next));
for (k, c) in &nc {
match pc.get(k) {
None => d.components_added.push(c.name.clone()),
Some(o) if (&o.name, &o.nodes) != (&c.name, &c.nodes) => {
d.components_changed.push(c.name.clone())
}
_ => {}
}
}
d.components_removed = pc
.iter()
.filter(|(k, _)| !nc.contains_key(*k))
.map(|(_, c)| c.name.clone())
.collect();
d
}
// ------------------------------------------------------------------------------ public API
/// Publish the open project as a library: `<project_dir>/library.json`, version = previous + 1.
/// No previews (they need a GPU); see [`publish_with_previews`].
pub fn publish(project_dir: &Path, ed: &Editor, description: &str) -> Result<Published, String> {
publish_with_previews(project_dir, ed, description, None)
}
/// [`publish`] with 256px component previews rendered on `gpu` (skipped on wasm, where the
/// GPU read-back cannot block).
pub fn publish_with_previews(
project_dir: &Path,
ed: &Editor,
description: &str,
gpu: Option<&crate::render::gpu::Headless>,
) -> Result<Published, String> {
let (next, _) = build(project_dir, ed, description, gpu)?;
write_json(&project_dir.join(LIBRARY_FILE), &next)?;
Ok(next)
}
/// What publishing now would change against the existing `library.json`.
pub fn publish_diff(project_dir: &Path, ed: &Editor) -> Result<PublishDiff, String> {
let (next, prev) = build(project_dir, ed, "", None)?;
Ok(diff(prev.as_ref(), &next))
}
/// Projects in the store that have a `library.json`, except `exclude` (the open project).
pub fn available(store: &Store, exclude: Option<&Path>) -> Vec<LibrarySummary> {
#[derive(Deserialize)]
struct Header {
library_id: String,
name: String,
#[serde(default)]
description: String,
version: u32,
published: u64,
collections: Vec<Collection>,
components: Vec<serde::de::IgnoredAny>,
}
store
.list()
.into_iter()
.filter(|e| exclude != Some(e.dir.as_path()))
.filter_map(|e| {
let s = crate::vfs::read_to_string(e.dir.join(LIBRARY_FILE)).ok()?;
let h: Header = serde_json::from_str(&s).ok()?;
Some(LibrarySummary {
variables: h.collections.iter().map(|c| c.variables.len()).sum(),
components: h.components.len(),
library_id: h.library_id,
name: h.name,
description: h.description,
version: h.version,
published: h.published,
dir: e.dir,
})
})
.collect()
}
/// Enable `library` in the project at `consumer_dir`: copy the snapshot to
/// `libraries/<id>.json`, add its collections read-only, record the version, make its
/// components placeable. Refused (nothing changes) when a CSS name is already taken.
pub fn enable(consumer_dir: &Path, ed: &mut Editor, library: &LibrarySummary) -> Result<(), String> {
let snap = read_published(&library.dir.join(LIBRARY_FILE))?;
let id = snap.library_id.clone();
if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || id.is_empty() {
return Err(format!("invalid library id {id:?}"));
}
let own = Project::open(consumer_dir).map_err(|e| e.to_string())?.manifest.id;
if own == id {
return Err("a project cannot use its own library".into());
}
if ed.design.libraries.iter().any(|l| l.library_id == id) {
return Err(format!("{} is already enabled", snap.name));
}
let taken: BTreeSet<String> = ed.design.collections.iter().flat_map(|c| &c.variables).map(variables::css_name).collect();
let clash: BTreeSet<String> = snap
.collections
.iter()
.flat_map(|c| &c.variables)
.map(variables::css_name)
.filter(|n| taken.contains(n))
.collect();
if !clash.is_empty() {
let names: Vec<String> = clash.iter().map(|n| format!("--{n}")).collect();
return Err(format!(
"cannot enable {}: CSS name(s) already defined: {}",
snap.name,
names.join(", ")
));
}
let file = consumer_dir.join(LIBRARIES_DIR).join(format!("{id}.json"));
write_json(&file, &snap)?;
let added = snap.collections.iter().cloned().map(|mut c| {
c.library = Some(id.clone());
c
});
let r = ed.edit_design("Enable library", |ds| {
ds.collections.extend(added);
ds.libraries.push(EnabledLibrary {
library_id: id.clone(),
version: u64::from(snap.version),
});
Ok(())
});
if let Err(e) = r {
let _ = crate::vfs::remove_file(&file);
return Err(e.to_string());
}
ed.library_docs.insert(id, snap.to_document());
ed.meta_dirty = true;
Ok(())
}
/// One enabled library against its source, as listed by [`status`].
#[derive(Debug, Clone, PartialEq)]
pub struct EnabledStatus {
pub library_id: String,
pub name: String,
pub local_version: u32,
/// `None`: the source project (or its `library.json`) is gone; the copy keeps working.
pub source_version: Option<u32>,
pub update_available: bool,
}
/// What [`disable`] detached.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DisableReport {
pub fills: usize,
pub bindings: usize,
pub instances: usize,
}
fn source(store: &Store, consumer_dir: &Path, library_id: &str) -> Option<LibrarySummary> {
available(store, Some(consumer_dir)).into_iter().find(|l| l.library_id == library_id)
}
fn snapshot_path(consumer_dir: &Path, library_id: &str) -> PathBuf {
consumer_dir.join(LIBRARIES_DIR).join(format!("{library_id}.json"))
}
/// Every enabled library of the open project against its source in the store.
pub fn status(store: &Store, consumer_dir: &Path, ed: &Editor) -> Vec<EnabledStatus> {
ed.design
.libraries
.iter()
.map(|l| {
let src = source(store, consumer_dir, &l.library_id);
let local = u32::try_from(l.version).unwrap_or(u32::MAX);
EnabledStatus {
library_id: l.library_id.clone(),
name: ed.library_docs.get(&l.library_id).map_or_else(|| l.library_id.clone(), |d| d.name.clone()),
local_version: local,
source_version: src.as_ref().map(|s| s.version),
update_available: src.is_some_and(|s| s.version > local),
}
})
.collect()
}
/// What updating `library_id` would change: the consumer's copy against the source's current
/// `library.json`.
pub fn update_diff(store: &Store, consumer_dir: &Path, library_id: &str) -> Result<PublishDiff, String> {
let src = source(store, consumer_dir, library_id).ok_or("library source not found")?;
let local = read_published(&snapshot_path(consumer_dir, library_id))?;
Ok(diff(Some(&local), &read_published(&src.dir.join(LIBRARY_FILE))?))
}
/// Replace the copy of `library_id` with the source's current publish: snapshot file, read-only
/// collections, version, and every `Library(id)` instance on every page (overrides kept), one undo
/// step per page. Refused with no change when the new collections break the design (CSS name
/// clash, a local alias to a removed variable).
pub fn update(store: &Store, consumer_dir: &Path, ed: &mut Editor, library_id: &str) -> Result<(), String> {
if !ed.design.libraries.iter().any(|l| l.library_id == library_id) {
return Err(format!("library {library_id} is not enabled"));
}
let src = source(store, consumer_dir, library_id).ok_or("library source not found")?;
let snap = read_published(&src.dir.join(LIBRARY_FILE))?;
let doc = snap.to_document();
let id = library_id.to_string();
let added = snap.collections.iter().cloned().map(|mut c| {
c.library = Some(id.clone());
c
});
let sync = |tx: &mut crate::history::Tx| {
let roots: Vec<NodeId> = tx
.doc()
.nodes
.values()
.filter(|n| {
n.instance.as_ref().is_some_and(|l| {
l.source == Some(Source::Library(id.clone())) && doc.get(l.main).is_some_and(|m| m.component)
})
})
.map(|n| n.id)
.collect();
for r in roots {
crate::editor::structure::sync_library_instance(tx, r, &doc);
}
};
let path = snapshot_path(consumer_dir, library_id);
let old_file = crate::vfs::read_to_string(&path).ok();
write_json(&path, &snap)?;
let r = ed.edit_design_each(
"Update library",
|ds| {
ds.collections.retain(|c| c.library.as_deref() != Some(&id));
ds.collections.extend(added);
if let Some(l) = ds.libraries.iter_mut().find(|l| l.library_id == id) {
l.version = u64::from(snap.version);
}
Ok(())
},
&sync,
);
if let Err(e) = r {
match old_file {
Some(s) => {
let _ = crate::io::atomic::write(&path, s.as_bytes());
}
None => {
let _ = crate::vfs::remove_file(&path);
}
}
return Err(e.to_string());
}
ed.library_docs.insert(id, snap.to_document());
ed.meta_dirty = true;
Ok(())
}
/// Stop using `library_id`: bound fills and fields keep their current value and lose the binding,
/// local variables aliasing it keep the resolved value, its instances become plain copies; then
/// its collections and the design entry go (the snapshot file stays until the next open, for undo). One undo step per page.
pub fn disable(_consumer_dir: &Path, ed: &mut Editor, library_id: &str) -> Result<DisableReport, String> {
if !ed.design.libraries.iter().any(|l| l.library_id == library_id) {
return Err(format!("library {library_id} is not enabled"));
}
let own: BTreeSet<String> = ed
.design
.collections
.iter()
.filter(|c| c.library.as_deref() == Some(library_id))
.flat_map(|c| &c.variables)
.map(variables::css_name)
.collect();
let (fills, bindings, instances) = (Cell::new(0), Cell::new(0), Cell::new(0));
let detach = |tx: &mut crate::history::Tx| {
let ids: Vec<NodeId> = tx.doc().nodes.keys().copied().collect();
for id in ids {
let n = &tx.doc().nodes[&id];
let lib = n.instance.as_ref().is_some_and(|l| l.source == Some(Source::Library(library_id.into())));
let bound = n.bindings.values().any(|v| own.contains(v))
|| n.style.fills.iter().any(|p| p.var().is_some_and(|v| own.contains(v)));
if !lib && !bound {
continue;
}
let Some(n) = tx.node_mut(id) else { continue };
if lib {
n.instance = None;
instances.set(instances.get() + 1);
}
for p in &mut n.style.fills {
if p.var().is_some_and(|v| own.contains(v)) {
p.unlink_var();
fills.set(fills.get() + 1);
}
}
let before = n.bindings.len();
n.bindings.retain(|_, v| !own.contains(v));
bindings.set(bindings.get() + before - n.bindings.len());
}
};
ed.edit_design_each(
"Disable library",
|ds| {
// Local variables aliasing the library keep what it resolved to, per mode.
let mut literals = Vec::new();
for c in ds.collections.iter().filter(|c| c.library.is_none()) {
for v in &c.variables {
for (mode, x) in &v.values {
let Value::Alias(t) = x else { continue };
let from_lib = ds
.collections
.iter()
.any(|c| c.library.as_deref() == Some(library_id) && c.variables.iter().any(|v| v.id == *t));
if from_lib {
let modes: PageModes = [(c.id.clone(), mode.clone())].into();
literals.push((v.id.clone(), mode.clone(), ds.resolve(&v.id, &modes).unwrap_or_else(|_| v.kind.default_value())));
}
}
}
}
for (vid, mode, lit) in literals {
for v in ds.collections.iter_mut().flat_map(|c| &mut c.variables).filter(|v| v.id == vid) {
v.values.insert(mode.clone(), lit.clone());
}
}
ds.collections.retain(|c| c.library.as_deref() != Some(library_id));
ds.libraries.retain(|l| l.library_id != library_id);
Ok(())
},
&detach,
)
.map_err(|e| e.to_string())?;
// The snapshot file stays so undo can bring the library back; the next open sweeps it.
ed.library_docs.remove(library_id);
ed.meta_dirty = true;
Ok(DisableReport { fills: fills.get(), bindings: bindings.get(), instances: instances.get() })
}
impl Editor {
/// The enabled library holding a component with `key`: `(library id, root node in its snapshot)`.
pub fn find_library_component(&self, key: &str) -> Option<(String, NodeId)> {
self.library_docs.iter().find_map(|(lib, doc)| {
doc.nodes
.values()
.find(|n| n.component && n.component_key.as_deref() == Some(key))
.map(|n| (lib.clone(), n.id))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::{Rect, V2};
use crate::model::{Binding, InstanceLink, NodeKind};
#[test]
fn component_nodes_remaps_inner_links_and_drops_outside_ones() {
let frame = |id: u64| Node::new(NodeId(id), NodeKind::Frame { clip: false }, Rect::new(0., 0., 10., 10.));
let mut doc = Document::default();
let (mut root, mut inner, mut line) = (frame(1), frame(2), frame(3));
let bind = |n: u64| Some(Binding { node: NodeId(n), fixed: V2::ZERO, inside: false });
line.kind = NodeKind::Connector {
points: vec![V2::ZERO, V2::ZERO],
start: bind(2),
end: bind(99),
route: Default::default(),
start_head: Default::default(),
end_head: Default::default(),
};
inner.instance = Some(InstanceLink { main: NodeId(1), overrides: vec![], source: None });
root.children = vec![NodeId(2), NodeId(3)];
inner.parent = Some(NodeId(1));
line.parent = Some(NodeId(1));
for n in [root, inner, line] {
doc.nodes.insert(n.id, n);
}
let out = component_nodes(&doc, NodeId(1), "k");
let by = |id: u64| out.iter().find(|n| n.id == snapshot_id("k", NodeId(id))).unwrap();
assert_eq!(by(2).instance.as_ref().unwrap().main, snapshot_id("k", NodeId(1)));
let NodeKind::Connector { start, end, .. } = &by(3).kind else { panic!() };
assert_eq!(start.unwrap().node, snapshot_id("k", NodeId(2)));
assert!(end.is_none());
}
}
+104
View File
@@ -4,6 +4,7 @@ use super::css;
use crate::model::{
Align, AutoLayout, Direction, Document, Node, NodeId, NodeKind, Paint, Sizing, TextAlign,
};
use crate::variables::{DesignSystem, Kind, PageModes, Value as DsValue};
use serde_json::{Map, Value, json};
fn parent_layout(doc: &Document, n: &Node) -> Option<AutoLayout> {
@@ -586,6 +587,25 @@ pub fn to_html_css(doc: &Document, root: NodeId) -> String {
format!("<style>\n{css_out}</style>\n{markup}")
}
/// Variables and tokens as `:root` custom properties plus one `[data-theme]` block per mode.
pub fn export_variables_css(doc: &Document) -> String {
let body: String = doc
.variables
.iter()
.map(|(k, c)| format!(" --{k}: {};\n", c.to_hex()))
.chain(doc.tokens.iter().map(|(k, v)| format!(" --{k}: {v};\n")))
.collect();
let mut out = format!(":root {{\n{body}}}\n");
for (mode, vars) in &doc.modes {
out.push_str(&format!("[data-theme=\"{mode}\"] {{\n"));
for (k, c) in vars {
out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
}
out.push_str("}\n");
}
out
}
/// Export document variables and tokens as a Tailwind v4 `@theme` block plus mode overrides.
pub fn export_variables_tailwind(doc: &Document) -> String {
let mut out = String::from("@theme {\n");
@@ -652,6 +672,89 @@ pub fn export_variables_dtcg(doc: &Document) -> Value {
Value::Object(out)
}
/// The project design system as DTCG JSON: groups (`/` in a name) nest, aliases are `{a.b}`
/// references, extra modes sit in `$extensions.modes`. `$value` follows the page's `modes`.
pub fn export_dtcg_system(ds: &DesignSystem, modes: &PageModes) -> Value {
let reference = |id: &str| {
let name = ds.find(id).map_or("", |(_, v)| v.name.as_str());
format!("{{{}}}", name.replace('/', "."))
};
let literal = |v: &DsValue, kind: Kind| match v {
DsValue::Color(c) => json!(c.to_hex()),
DsValue::Number(n) if kind == Kind::Number => json!(format!("{n}px")),
DsValue::Number(n) => json!(n),
DsValue::String(s) => json!(s),
DsValue::Boolean(b) => json!(b),
DsValue::Alias(id) => json!(reference(id)),
};
let mut out = Map::new();
for c in &ds.collections {
for v in &c.variables {
let active = modes.get(&c.id).unwrap_or(&c.default_mode);
let Some(raw) = v.values.get(active).or_else(|| v.values.get(&c.default_mode)) else {
continue;
};
let ty = match (v.kind, raw) {
(Kind::Color, _) => "color",
(Kind::Number, _) => "dimension",
(Kind::Boolean, _) => "boolean",
(Kind::String, DsValue::String(s)) if is_dimension_token(s) => "dimension",
(Kind::String, _) => "string",
};
let mut entry = Map::new();
entry.insert("$type".into(), json!(ty));
entry.insert("$value".into(), literal(raw, v.kind));
if !v.description.is_empty() {
entry.insert("$description".into(), json!(v.description));
}
if c.modes.len() > 1 {
let per: Map<String, Value> = c
.modes
.iter()
.filter_map(|m| Some((m.name.clone(), literal(v.values.get(&m.id)?, v.kind))))
.collect();
entry.insert("$extensions".into(), json!({ "modes": per }));
}
insert_nested(&mut out, &v.name, entry);
}
}
Value::Object(out)
}
/// Put `entry` at the `/` path of `name`; when a path segment is already a token, the entry
/// goes in flat under its full name instead.
fn insert_nested(root: &mut Map<String, Value>, name: &str, entry: Map<String, Value>) {
let segs: Vec<&str> = name.split('/').filter(|s| !s.is_empty()).collect();
let Some((leaf, groups)) = segs.split_last() else {
return;
};
// A group that is already a token, or a leaf already taken, sends the entry in flat.
let mut probe = Some(&*root);
for g in groups {
probe = match probe.and_then(|m| m.get(*g)) {
None => None,
Some(Value::Object(m)) if !m.contains_key("$value") => Some(m),
Some(_) => {
root.insert(name.replace('/', "-"), Value::Object(entry));
return;
}
};
}
if probe.is_some_and(|m| m.contains_key(*leaf)) {
root.insert(name.replace('/', "-"), Value::Object(entry));
return;
}
let mut cur = root;
for g in groups {
cur = cur
.entry(g.to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("checked above");
}
cur.insert(leaf.to_string(), Value::Object(entry));
}
fn pascal_ident(name: &str) -> String {
let mut out = String::new();
let mut cap = true;
@@ -1692,6 +1795,7 @@ mod tests {
frame.instance = Some(InstanceLink {
main: comp_id,
overrides: Vec::new(),
source: None,
});
frame.layout = Some(AutoLayout {
direction: Direction::Vertical,
+1
View File
@@ -737,6 +737,7 @@ mod tests {
doc.nodes.get_mut(&root).unwrap().instance = Some(InstanceLink {
main: comp_id,
overrides: Vec::new(),
source: None,
});
let drift_code = r#"
+654 -133
View File
@@ -9,14 +9,15 @@
use super::css::{self, Context, Styles};
use super::html::{self, Element, Html};
use super::{Out, ToolResult, tools};
use crate::editor::Editor;
use crate::editor::{Editor, design};
use crate::geom::{Rect, V2, v2};
use crate::history::Tx;
use crate::model::{
AutoLayout, Binding, Color, Direction, Document, Head, ImageMode, Node, NodeId, NodeKind,
Paint, PathPoint, Route, Sizing, SubPath, TextAlign,
Paint, PathPoint, Route, Sizing, Source, SubPath, TextAlign,
};
use crate::project::{Project, Store};
use crate::variables::{self, DesignSystem, Kind, Value as DsVal};
use crate::render::gpu::Headless;
use crate::session::Session;
use serde_json::{Map, Value, json};
@@ -260,6 +261,12 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"create_file" => create_file(host, &a),
"get_font_family_info" => font_info(&a),
"get_guide" => guide(&a),
"publish_library" | "list_libraries" | "enable_library" | "update_library"
| "disable_library" => {
check_file(host, &a)?;
host.editor().commit_pending();
library_tool(host, name, &a)
}
_ => {
check_file(host, &a)?;
let ed = host.editor();
@@ -299,6 +306,8 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"set_variables" => set_variables(ed, &a),
"set_variable_mode" => set_variable_mode(ed, &a),
"set_tokens" => set_tokens(ed, &a),
"create_collection" => create_collection(ed, &a),
"delete_variables" => delete_variables(ed, &a),
"import_design_system" => import_design_system(host, &a),
"export_design_system" => export_design_system(host, &a),
"get_components" => get_components(ed, &a),
@@ -306,6 +315,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"create_component" => create_component(ed, &a),
"create_instance" => create_instance(ed, &a),
"swap_instance" => swap_instance(ed, &a),
"bind_variable" => bind_variable(ed, &a),
"detach_instance" => detach_instance(ed, &a),
"diff_frames" => diff_frames(ed, &a),
"search_icons" => search_icons(&a),
@@ -444,9 +454,8 @@ fn create_file(host: &mut dyn Host, a: &Args) -> ToolResult {
}
fn create_page(ed: &mut Editor, a: &Args) -> ToolResult {
let vars = ed.page_doc(ed.active_page).var_state();
// The new page copies the active page's modes and cache (`insert_empty_page`).
let i = ed.add_page_in_background(a.str("name"));
ed.with_page(i, |ed| ed.doc.restore_vars(&vars));
text(json!({"pageId": ed.pages[i].id, "name": ed.pages[i].name}))
}
@@ -2727,7 +2736,14 @@ fn clone_subtree_from_doc(
.iter()
.filter_map(|x| map.get(x).copied())
.collect();
if let Some(l) = &mut c.instance {
if c.component && c.component_key.is_some() {
c.component_key = Some(crate::variables::new_id());
}
// Only a same-page link can point into the copied subtree; cross-page and library links
// keep their main.
if let Some(l) = &mut c.instance
&& l.source.is_none()
{
if let Some(m) = map.get(&l.main) {
l.main = *m;
} else if !tx.doc().get(l.main).is_some_and(|mn| mn.component) {
@@ -2780,6 +2796,9 @@ fn clone_subtree(
let mut c = n.clone();
c.id = map[&n.id];
c.children = n.children.iter().map(|x| map[x]).collect();
if c.component && c.component_key.is_some() {
c.component_key = Some(crate::variables::new_id());
}
if let NodeKind::Connector { start, end, .. } = &mut c.kind {
for b in [start, end].into_iter().flatten() {
if let Some(nn) = map.get(&b.node) {
@@ -3328,25 +3347,13 @@ fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let doc = ed.page_doc(page);
match a.str("format") {
Some("css") => {
let body: String = doc
.variables
.iter()
.map(|(k, c)| format!(" --{k}: {};\n", c.to_hex()))
.chain(doc.tokens.iter().map(|(k, v)| format!(" --{k}: {v};\n")))
.collect();
let mut out = format!(":root {{\n{body}}}\n");
for (mode, vars) in &doc.modes {
out.push_str(&format!("[data-theme=\"{mode}\"] {{\n"));
for (k, c) in vars {
out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
}
out.push_str("}\n");
}
return Ok(vec![Out::Text(out)]);
}
Some("css") => return Ok(vec![Out::Text(super::codegen::export_variables_css(doc))]),
Some("json") => {
return text(super::codegen::export_variables_dtcg(doc));
let ds = design_of(ed);
return text(super::codegen::export_dtcg_system(
&ds,
&ed.pages[page].modes,
));
}
Some("tailwind") => {
return Ok(vec![Out::Text(super::codegen::export_variables_tailwind(
@@ -3369,55 +3376,389 @@ fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
v["mode"] = json!(doc.mode);
v["modes"] = json!(doc.modes.keys().collect::<Vec<_>>());
}
let ds = design_of(ed);
let page_modes = &ed.pages[page].modes;
v["collections"] = ds
.collections
.iter()
.map(|c| collection_json(&ds, c))
.collect();
v["pageModes"] = ds
.collections
.iter()
.map(|c| {
let active = page_modes.get(&c.id).unwrap_or(&c.default_mode);
let name = c.modes.iter().find(|m| &m.id == active).map(|m| &m.name);
(c.name.clone(), json!(name))
})
.collect::<Map<String, Value>>()
.into();
text(v)
}
/// The project design system; a project that never went through the session reads as its first
/// page does, like `edit_design`.
fn design_of(ed: &Editor) -> DesignSystem {
if ed.design.collections.is_empty() {
return variables::migrate_from_document(ed.page_doc(0));
}
ed.design.clone()
}
fn collection_json(ds: &DesignSystem, c: &variables::Collection) -> Value {
let value = |v: &DsVal| match v {
DsVal::Color(c) => json!(c.to_hex()),
DsVal::Number(n) => json!(n),
DsVal::String(s) => json!(s),
DsVal::Boolean(b) => json!(b),
DsVal::Alias(id) => json!({"alias": ds.find(id).map(|(_, t)| t.name.clone())}),
};
let vars: Vec<Value> = c
.variables
.iter()
.map(|v| {
let values: Map<String, Value> = c
.modes
.iter()
.filter_map(|m| Some((m.name.clone(), value(v.values.get(&m.id)?))))
.collect();
json!({"id": v.id, "name": v.name, "cssName": variables::css_name(v),
"group": variables::group_of(&v.name), "type": v.kind,
"values": values, "description": v.description, "hidden": v.hidden})
})
.collect();
let default = c.modes.iter().find(|m| m.id == c.default_mode).map(|m| &m.name);
json!({"id": c.id, "name": c.name, "library": c.library,
"modes": c.modes.iter().map(|m| &m.name).collect::<Vec<_>>(),
"defaultMode": default, "variables": vars})
}
/// One `set_variables` entry after parsing.
struct VarSpec {
name: String,
kind: Option<Kind>,
value: Option<DsVal>,
alias: Option<String>,
collection: Option<String>,
mode: Option<String>,
description: Option<String>,
hidden: Option<bool>,
/// The old shape: a plain color by name.
plain_color: Option<Color>,
}
fn parse_kind(s: &str) -> Result<Kind, String> {
match s {
"color" => Ok(Kind::Color),
"number" => Ok(Kind::Number),
"string" => Ok(Kind::String),
"boolean" => Ok(Kind::Boolean),
_ => Err(format!("unknown type `{s}` (color, number, string, boolean)")),
}
}
fn parse_value(name: &str, kind: Option<Kind>, v: &Value) -> Result<(Kind, DsVal), String> {
let bad = |k: &str| format!("variable `{name}`: `{v}` is not a {k}");
match (kind, v) {
(Some(Kind::Color) | None, Value::String(s)) if css::color(s).is_some() => {
Ok((Kind::Color, DsVal::Color(css::color(s).unwrap())))
}
(Some(Kind::Color), _) => Err(bad("color")),
(Some(Kind::Number) | None, Value::Number(n)) => {
Ok((Kind::Number, DsVal::Number(n.as_f64().unwrap_or_default())))
}
(Some(Kind::Number), Value::String(s)) => s
.trim()
.trim_end_matches("px")
.trim()
.parse()
.map(|n| (Kind::Number, DsVal::Number(n)))
.map_err(|_| bad("number")),
(Some(Kind::Number), _) => Err(bad("number")),
(Some(Kind::String), Value::String(s)) => Ok((Kind::String, DsVal::String(s.clone()))),
(Some(Kind::String), _) => Err(bad("string")),
(Some(Kind::Boolean) | None, Value::Bool(b)) => Ok((Kind::Boolean, DsVal::Boolean(*b))),
(Some(Kind::Boolean), _) => Err(bad("boolean")),
(None, _) => Err(bad("color (pass type for other kinds)")),
}
}
fn parse_var_name(name: &str) -> Result<String, String> {
let name = name.trim().trim_start_matches("--").trim_matches('/').to_string();
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/'))
{
return Err(format!(
"variable name `{name}` may only use letters, digits, -, _ and / (groups)"
));
}
Ok(name)
}
fn parse_spec(name: &str, entry: &Value, a: &Args) -> Result<VarSpec, String> {
let name = parse_var_name(name)?;
let obj = entry.as_object();
let field = |k: &str| {
obj.and_then(|o| o.get(k))
.or_else(|| a.get(k))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let kind = field("type").as_deref().map(parse_kind).transpose()?;
let alias = obj
.and_then(|o| o.get("alias"))
.and_then(Value::as_str)
.map(|s| s.trim().trim_start_matches("--").to_string());
let raw = match obj {
Some(o) => o.get("value"),
None => Some(entry),
};
let (kind, value) = match (raw, &alias) {
(Some(v), None) => {
let (k, v) = parse_value(&name, kind, v)?;
(Some(k), Some(v))
}
(Some(_), Some(_)) => return Err(format!("variable `{name}`: give a value or an alias, not both")),
(None, _) => (kind, None),
};
let description = obj
.and_then(|o| o.get("description"))
.and_then(Value::as_str)
.map(str::to_string);
let hidden = obj.and_then(|o| o.get("hidden")).and_then(Value::as_bool);
let collection = field("collection");
let mode = field("mode");
let plain_color = match (&value, obj, &collection) {
(Some(DsVal::Color(c)), None, None) if a.get("type").is_none() => Some(*c),
_ => None,
};
Ok(VarSpec {
name,
kind,
value,
alias,
collection,
mode,
description,
hidden,
plain_color,
})
}
/// `(collection id, variable id, kind)` of a variable by name or CSS name.
fn find_var(ds: &DesignSystem, name: &str) -> Option<(String, String, Kind)> {
let css = crate::design_system::sanitize_name(name);
ds.collections.iter().find_map(|c| {
c.variables
.iter()
.find(|v| v.name == name || variables::css_name(v) == css)
.map(|v| (c.id.clone(), v.id.clone(), v.kind))
})
}
fn mode_for(ds: &mut DesignSystem, cid: &str, name: &str) -> Result<String, variables::Error> {
let c = ds
.collection(cid)
.ok_or_else(|| variables::Error::NotFound(cid.into()))?;
if let Some(m) = c.modes.iter().find(|m| m.name == name || m.id == name) {
return Ok(m.id.clone());
}
// A collection with only its unnamed `Default` mode adopts the first name defined.
if c.modes.len() == 1 && c.modes[0].name == variables::DEFAULT_MODE && c.library.is_none() {
let id = c.modes[0].id.clone();
if let Some(c) = ds.collections.iter_mut().find(|c| c.id == cid) {
c.modes[0].name = name.into();
}
return Ok(id);
}
ds.add_mode(cid, name)
}
fn set_entry(
ds: &mut DesignSystem,
active: &variables::PageModes,
s: &VarSpec,
) -> Result<(), variables::Error> {
use variables::Error;
if let Some(c) = s.plain_color {
return design::set_color(ds, active, &s.name, s.mode.as_deref(), c);
}
let target = match &s.alias {
Some(t) => Some(find_var(ds, t).ok_or_else(|| Error::NotFound(t.clone()))?),
None => None,
};
let existing = find_var(ds, &s.name);
let kind = s
.kind
.or(existing.as_ref().map(|e| e.2))
.or(target.as_ref().map(|t| t.2))
.ok_or_else(|| Error::Missing(s.name.clone()))?;
let cid = match (&s.collection, &existing) {
(Some(c), _) => match ds.collections.iter().find(|x| &x.id == c || &x.name == c) {
Some(x) => x.id.clone(),
None => ds.add_collection(c),
},
(None, Some(e)) => e.0.clone(),
(None, None) if kind == Kind::Color => design::color_collection(ds),
(None, None) => match ds
.collections
.iter()
.find(|c| c.library.is_none() && c.name == variables::TOKENS_COLLECTION)
{
Some(c) => c.id.clone(),
None => ds.add_collection(variables::TOKENS_COLLECTION),
},
};
let (vid, fresh) = match existing.filter(|e| e.0 == cid) {
Some(e) if e.2 != kind => return Err(Error::KindMismatch(s.name.clone())),
Some(e) => (e.1, false),
None => (ds.add_variable(&cid, &s.name, kind)?, true),
};
let value = match (&s.value, &target) {
(Some(v), _) => Some(v.clone()),
(None, Some(t)) => Some(DsVal::Alias(t.1.clone())),
(None, None) => None,
};
if let Some(value) = value {
let mid = match &s.mode {
Some(m) => mode_for(ds, &cid, m)?,
None => {
let c = ds.collection(&cid).expect("collection exists");
active
.get(&cid)
.filter(|m| c.modes.iter().any(|x| &x.id == *m))
.unwrap_or(&c.default_mode)
.clone()
}
};
if fresh {
let all: Vec<String> = ds.collection(&cid).unwrap().modes.iter().map(|m| m.id.clone()).collect();
for m in all {
ds.set_value(&vid, &m, value.clone())?;
}
}
ds.set_value(&vid, &mid, value)?;
}
if s.description.is_some() || s.hidden.is_some() {
let c = ds.collections.iter_mut().find(|c| c.id == cid).unwrap();
if c.library.is_some() {
return Err(Error::ReadOnly(c.name.clone()));
}
let v = c.variables.iter_mut().find(|v| v.id == vid).unwrap();
if let Some(d) = &s.description {
v.description = d.clone();
}
if let Some(h) = s.hidden {
v.hidden = h;
}
}
Ok(())
}
fn set_variables(ed: &mut Editor, a: &Args) -> ToolResult {
let vars = a
.get("variables")
.and_then(Value::as_object)
.filter(|m| !m.is_empty())
.ok_or("`variables` must be an object like {\"accent\": \"#cba6f7\"}")?;
let mut parsed: Vec<(String, Color)> = Vec::new();
for (name, v) in vars {
let name = name.trim().trim_start_matches("--").to_string();
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!(
"variable name `{name}` may only use letters, digits, - and _"
));
}
let c = v
.as_str()
.and_then(css::color)
.ok_or_else(|| format!("variable `{name}`: `{v}` is not a color"))?;
parsed.push((name, c));
}
let mode = a.str("mode").filter(|m| !m.is_empty());
let mut updated: Map<String, Value> = Map::new();
for page in 0..ed.pages.len() {
let counts = ed.with_page(page, |ed| {
ed.edit_variables("Set variables", |tx| {
parsed
.iter()
.map(|(n, c)| match mode {
Some(m) => tx.set_variable_in_mode(m, n, *c),
None => tx.set_variable(n, *c),
})
.collect::<Vec<_>>()
})
});
for ((name, _), n) in parsed.iter().zip(counts) {
let e = updated.entry(name.clone()).or_insert(json!(0));
*e = json!(e.as_u64().unwrap_or(0) + n as u64);
}
}
let mut specs = vars
.iter()
.map(|(n, v)| parse_spec(n, v, a))
.collect::<Result<Vec<_>, _>>()?;
// Aliases last, so they may point at a variable defined in the same call.
specs.sort_by_key(|s| s.alias.is_some());
let active = ed.pages[ed.active_page].modes.clone();
let ((), fills) = ed
.edit_design("Set variables", |ds| {
specs.iter().try_for_each(|s| set_entry(ds, &active, s))
})
.map_err(|e| e.to_string())?;
let updated: Map<String, Value> = specs
.iter()
.map(|s| {
let css = crate::design_system::sanitize_name(&s.name);
(s.name.clone(), json!(fills.get(&css).copied().unwrap_or(0)))
})
.collect();
text(json!({"fillsUpdated": updated, "pages": ed.pages.len()}))
}
fn create_collection(ed: &mut Editor, a: &Args) -> ToolResult {
let name = a.req_str("name")?.trim().to_string();
if name.is_empty() {
return Err("`name` is empty".into());
}
let modes: Vec<String> = a
.get("modes")
.and_then(Value::as_array)
.map(|v| v.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
let (id, _) = ed
.edit_design("Create collection", |ds| {
if ds.collections.iter().any(|c| c.library.is_none() && c.name == name) {
return Err(variables::Error::Duplicate(format!("collection {name}")));
}
let id = ds.add_collection(&name);
if let Some((first, rest)) = modes.split_first() {
let c = ds.collections.iter_mut().find(|c| c.id == id).unwrap();
c.modes[0].name = first.clone();
for m in rest {
ds.add_mode(&id, m)?;
}
}
Ok(id)
})
.map_err(|e| e.to_string())?;
let c = ed.design.collection(&id).expect("just created");
text(json!({"id": id, "name": c.name, "modes": c.modes.iter().map(|m| &m.name).collect::<Vec<_>>()}))
}
fn delete_variables(ed: &mut Editor, a: &Args) -> ToolResult {
let names: Vec<String> = a
.get("names")
.and_then(Value::as_array)
.map(|v| v.iter().filter_map(Value::as_str).map(str::to_string).collect())
.filter(|v: &Vec<String>| !v.is_empty())
.ok_or("`names` must be an array of variable names")?;
let force = a.bool("force").unwrap_or(false);
let ds = design_of(ed);
let ids = names
.iter()
.map(|n| {
find_var(&ds, n.trim().trim_start_matches("--"))
.map(|e| e.1)
.ok_or_else(|| format!("no variable `{n}`"))
})
.collect::<Result<Vec<_>, _>>()?;
if !force {
for (n, id) in names.iter().zip(&ids) {
// Dependents that go in the same call do not count.
let mut deps = ds.dependents(id);
deps.retain(|d| !names.contains(d));
if !deps.is_empty() {
return Err(format!(
"`{n}` is aliased by {}; pass force to detach them to their resolved values",
deps.join(", ")
));
}
}
}
ed.edit_design("Delete variables", |ds| {
for id in &ids {
// Gone already when the same variable was named twice.
if ds.find(id).is_some() {
ds.remove_variable(id, true)?;
}
}
Ok(())
})
.map_err(|e| e.to_string())?;
text(json!({"deleted": names.len()}))
}
fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let (page, roots) = match a.str("nodeId").filter(|n| !n.is_empty() && *n != "root") {
@@ -3494,15 +3835,12 @@ fn set_tokens(ed: &mut Editor, a: &Args) -> ToolResult {
}
parsed.push((name, value));
}
for page in 0..ed.pages.len() {
ed.with_page(page, |ed| {
ed.edit_variables("Set tokens", |tx| {
for (n, v) in &parsed {
tx.set_token(n, v);
}
})
});
}
ed.edit_design("Set tokens", |ds| {
parsed
.iter()
.try_for_each(|(n, v)| design::set_token(ds, n, v))
})
.map_err(|e| e.to_string())?;
text(
json!({"tokens": parsed.len(), "use": "var(--name) in any style value, e.g. padding: var(--spacing-md); substituted when written"}),
)
@@ -3517,32 +3855,61 @@ fn set_variable_mode(ed: &mut Editor, a: &Args) -> ToolResult {
known.join(", ")
));
}
// Project-wide like before: every page switches (the Design tab switches one page).
let (cid, mid) = design::find_mode(&ed.design, &mode).ok_or(format!("no mode `{mode}`"))?;
let mut fills = 0;
for page in 0..ed.pages.len() {
fills += ed.with_page(page, |ed| {
ed.edit_variables("Set variable mode", |tx| tx.set_mode(&mode))
});
fills += ed
.set_page_mode_on(page, &cid, &mid)
.map_err(|e| e.to_string())?;
}
text(json!({"mode": mode, "fillsUpdated": fills}))
}
fn get_components(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let doc = ed.page_doc(page);
let mut comps: Vec<&Node> = doc.nodes.values().filter(|n| n.component).collect();
comps.sort_by_key(|n| n.id);
let items: Vec<Value> = comps
.iter()
.map(|c| {
let uses = doc
.nodes
.values()
.filter(|n| n.instance.as_ref().is_some_and(|l| l.main == c.id) && n.id != c.id)
.count();
json!({"id": node_key(ed, page, c.id), "name": c.name,
"width": r2(c.rect.w), "height": r2(c.rect.h), "instances": uses})
})
.collect();
let only = a.str("pageId").filter(|p| !p.is_empty()).map(|p| find_page(ed, p)).transpose()?;
// Instances anywhere, by the page and node of their main.
let mut uses: HashMap<(usize, NodeId), usize> = HashMap::new();
for page in 0..ed.pages.len() {
for n in ed.page_doc(page).nodes.values() {
let Some(l) = &n.instance else { continue };
let main_page = match &l.source {
None => page,
Some(Source::Page(p)) => match ed.pages.iter().position(|s| s.id == *p) {
Some(i) => i,
None => continue,
},
Some(Source::Library(_)) => continue,
};
if n.id != l.main || main_page != page {
*uses.entry((main_page, l.main)).or_default() += 1;
}
}
}
let mut items: Vec<Value> = Vec::new();
for page in (0..ed.pages.len()).filter(|p| only.is_none_or(|o| o == *p)) {
let doc = ed.page_doc(page);
let mut comps: Vec<&Node> = doc.nodes.values().filter(|n| n.component).collect();
comps.sort_by_key(|n| n.id);
for c in comps {
let n = uses.get(&(page, c.id)).copied().unwrap_or(0);
items.push(json!({"id": node_key(ed, page, c.id), "name": c.name,
"componentKey": c.component_key, "pageId": ed.pages[page].id,
"pageName": ed.pages[page].name,
"width": r2(c.rect.w), "height": r2(c.rect.h), "uses": n, "instances": n}));
}
}
if only.is_none() {
for (lib, doc) in &ed.library_docs {
let mut comps: Vec<&Node> = doc.nodes.values().filter(|n| n.component).collect();
comps.sort_by_key(|n| n.id);
for c in comps {
items.push(json!({"name": c.name, "componentKey": c.component_key,
"source": {"library": lib, "name": doc.name},
"width": r2(c.rect.w), "height": r2(c.rect.h)}));
}
}
}
text(json!({"components": items}))
}
@@ -3678,7 +4045,12 @@ fn import_components(ed: &mut Editor, a: &Args) -> ToolResult {
.filter_map(|k| map.get(k).copied())
.collect();
c.comments.clear();
if let Some(l) = &mut c.instance {
if c.component && c.component_key.is_some() {
c.component_key = Some(crate::variables::new_id());
}
if let Some(l) = &mut c.instance
&& l.source.is_none()
{
match map.get(&l.main) {
Some(m) => l.main = *m,
None => c.instance = None,
@@ -3730,6 +4102,7 @@ fn create_component(ed: &mut Editor, a: &Args) -> ToolResult {
ed.edit("Create component", |tx| {
let n = tx.node_mut(id).unwrap();
n.component = true;
n.component_key.get_or_insert_with(crate::variables::new_id);
if let Some(name) = name {
n.name = name;
}
@@ -3740,16 +4113,49 @@ fn create_component(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"id": node_key(ed, page, id), "name": name, "component": true}))
}
/// The main component a call names, on any page: `componentId` ("pageId:num") or
/// `componentKey` (the stable key from get_components).
fn find_component(ed: &Editor, a: &Args, default_page: usize) -> Result<(usize, NodeId), String> {
if let Some(key) = a.str("componentKey").filter(|k| !k.is_empty()) {
return (0..ed.pages.len())
.find_map(|p| {
ed.page_doc(p)
.nodes
.values()
.find(|n| n.component && n.component_key.as_deref() == Some(key))
.map(|n| (p, n.id))
})
.ok_or_else(|| format!("no component with componentKey `{key}`"));
}
let (page, id) = resolve(ed, a.req_str("componentId")?, default_page)?;
if !ed.page_doc(page).get(id).is_some_and(|n| n.component) {
return Err(format!(
"{} is not a component (see create_component)",
ed.page_doc(page).get(id).map_or("", |n| n.name.as_str())
));
}
Ok((page, id))
}
fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
let (page, comp) = resolve(ed, a.req_str("componentId")?, page_arg(ed, a)?)?;
let parent = match a.str("parentId") {
None | Some("") | Some("root") => None,
let def = page_arg(ed, a)?;
// A componentKey that no page owns may name a component of an enabled library.
let lib = a
.str("componentKey")
.filter(|_| find_component(ed, a, def).is_err())
.and_then(|k| ed.find_library_component(k));
let (cpage, comp) = match &lib {
Some((_, id)) => (def, *id),
None => find_component(ed, a, def)?,
};
let (page, parent) = match a.str("parentId") {
None | Some("") | Some("root") => (def, None),
Some(p) => {
let (pp, id) = resolve(ed, p, page)?;
if pp != page {
return Err("the parent must be on the same page as the component".into());
let (pp, id) = resolve(ed, p, def)?;
if a.str("pageId").is_some_and(|s| !s.is_empty()) && pp != def {
return Err("the parent must be on the page given by pageId".into());
}
Some(id)
(pp, Some(id))
}
};
let index = a.num("index").map(|i| i.max(0.0) as usize);
@@ -3757,16 +4163,20 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
let name = a
.str("name")
.map(|n| n.chars().take(50).collect::<String>());
let id = ed.with_page(page, |ed| {
let main = ed.doc.get(comp).ok_or("component not found")?;
if !main.component {
return Err(format!(
"{} is not a component (see create_component)",
main.name
));
let (src_doc, source) = match &lib {
Some((l, _)) => (ed.library_docs.get(l).cloned(), Some(Source::Library(l.clone()))),
None => {
let d = (cpage != page).then(|| ed.page_doc(cpage).clone());
let s = d.as_ref().map(|_| Source::Page(ed.pages[cpage].id.clone()));
(d, s)
}
let size = (main.rect.w, main.rect.h);
Ok(ed.edit("Create instance", |tx| {
};
let id = ed.with_page(page, |ed| {
let size = {
let m = src_doc.as_ref().unwrap_or(&ed.doc).get(comp).ok_or("component not found")?;
(m.rect.w, m.rect.h)
};
Ok::<_, String>(ed.edit("Create instance", |tx| {
let rect = match (parent, at) {
(None, Some((x, y))) => Some(Rect::new(x, y, size.0, size.1)),
(None, None) => {
@@ -3778,12 +4188,24 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
.filter_map(|r| tx.doc().world_rect(*r))
.map(|r| r.right())
.fold(f64::MIN, f64::max);
let y = tx.doc().world_rect(comp).map_or(0.0, |r| r.y);
let y = src_doc
.as_ref()
.unwrap_or(tx.doc())
.world_rect(comp)
.map_or(0.0, |r| r.y);
Some(Rect::new(right.max(0.0) + 80.0, y, size.0, size.1))
}
_ => None,
};
let id = crate::editor::structure::instantiate(tx, comp, parent, index, rect);
let id = crate::editor::structure::instantiate_from(
tx,
src_doc.as_ref(),
source,
comp,
parent,
index,
rect,
);
if let Some(name) = name {
tx.node_mut(id).unwrap().name = name;
}
@@ -3812,25 +4234,41 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let (page, inst) = resolve(ed, a.req_str("nodeId")?, def)?;
let (comp_page, new_main) = resolve(ed, a.req_str("componentId")?, page)?;
if page != comp_page {
return Err("instance and component must be on the same page".into());
}
let lib = a
.str("componentKey")
.filter(|_| find_component(ed, a, page).is_err())
.and_then(|k| ed.find_library_component(k));
let (cpage, new_main) = match &lib {
Some((_, id)) => (page, *id),
None => find_component(ed, a, page)?,
};
let (src_doc, source) = match &lib {
Some((l, _)) => (ed.library_docs.get(l).cloned(), Some(Source::Library(l.clone()))),
None => {
let d = (cpage != page).then(|| ed.page_doc(cpage).clone());
let s = d.as_ref().map(|_| Source::Page(ed.pages[cpage].id.clone()));
(d, s)
}
};
ed.with_page(page, |ed| {
if !ed.is_instance_root(inst) {
// An instance root: linked, and not itself inside an instance. A missing main is fine,
// so a swap restores such an instance.
let n = ed.doc.get(inst).ok_or("node not found")?;
let is_root = n.instance.is_some()
&& n.parent
.and_then(|p| ed.doc.get(p))
.is_none_or(|p| p.instance.is_none());
if !is_root {
return Err(format!(
"`{}` is not a component instance root",
a.req_str("nodeId").unwrap_or_default()
));
}
if !ed.doc.get(new_main).is_some_and(|m| m.component) {
return Err(format!(
"`{}` is not a main component",
a.req_str("componentId").unwrap_or_default()
));
if cpage == page && lib.is_none() && (inst == new_main || ed.doc.is_ancestor(inst, new_main)) {
return Err("could not swap instance to that component".into());
}
let ok = ed.edit("Swap instance", |tx| {
crate::editor::structure::swap_instance_tx(tx, inst, new_main)
crate::editor::structure::swap_instance_tx(tx, src_doc.as_ref(), source, inst, new_main)
});
if !ok {
return Err("could not swap instance to that component".into());
@@ -3839,11 +4277,29 @@ fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
})?;
text(json!({
"id": node_key(ed, page, inst),
"componentId": node_key(ed, page, new_main),
"componentId": if lib.is_some() { Value::Null } else { json!(node_key(ed, cpage, new_main)) },
"swapped": true,
}))
}
fn bind_variable(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let keys = a.strs("nodeIds")?;
let (page, ids) = resolve_all(ed, &keys, def)?;
let field = a.req_str("field")?;
let css = match a.str("variable").filter(|v| !v.is_empty()) {
None => None,
Some(name) => {
let ds = design_of(ed);
let (_, vid, _) = find_var(&ds, name.trim().trim_start_matches("--"))
.ok_or_else(|| format!("no variable `{name}`"))?;
Some(variables::css_name(ds.find(&vid).unwrap().1))
}
};
ed.with_page(page, |ed| ed.bind(&ids, field, css.as_deref()))?;
text(json!({"nodes": ids.len(), "field": field, "variable": css}))
}
fn detach_instance(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let keys = match a.strs("nodeIds") {
@@ -3898,19 +4354,18 @@ fn import_design_system(host: &mut dyn Host, a: &Args) -> ToolResult {
if let Some(p) = a.str("pageId").filter(|p| !p.is_empty()) {
find_page(ed, p)?;
}
let mut fills = 0;
for page in 0..ed.pages.len() {
fills += ed.with_page(page, |ed| {
ed.edit_variables("Import design system", |tx| {
tx.set_design_system(
&pkg.tokens.variables,
&pkg.tokens.modes,
&pkg.tokens.tokens,
None,
)
})
});
}
let (_, per_var) = ed
.edit_design("Import design system", |ds| {
design::replace_system(
ds,
&pkg.tokens.variables,
&pkg.tokens.modes,
&pkg.tokens.tokens,
);
Ok(())
})
.map_err(|e| e.to_string())?;
let fills: usize = per_var.values().sum();
text(json!({
"package": {"id": pkg.manifest.id, "name": pkg.manifest.name,
"category": pkg.manifest.category, "description": pkg.manifest.description},
@@ -3941,7 +4396,9 @@ fn export_design_system(host: &mut dyn Host, a: &Args) -> ToolResult {
if let Some(name) = project {
meta.name = name;
}
let written = crate::design_system::export_package_with(&dir, &doc, &meta)?;
let ds = design_of(ed);
let written =
crate::design_system::export_package_system(&dir, &doc, &meta, &ds, &ed.pages[page].modes)?;
text(json!({
"path": dir,
"files": written,
@@ -3956,6 +4413,70 @@ fn export_design_system(_: &mut dyn Host, _: &Args) -> ToolResult {
Err(NO_DISK.into())
}
// ------------------------------------------------------------------------------ libraries
fn library_tool(host: &mut dyn Host, name: &str, a: &Args) -> ToolResult {
use crate::libraries as lib;
let Some((dir, project_id)) = host.project().map(|p| (p.dir.clone(), p.manifest.id.clone())) else {
return Err("libraries need a saved project (create_file, then open_file)".into());
};
let store = host.store().clone();
let summary = |l: &lib::LibrarySummary, enabled: bool| {
json!({"libraryId": l.library_id, "name": l.name, "description": l.description,
"version": l.version, "variables": l.variables, "components": l.components,
"enabled": enabled})
};
let r = match name {
"publish_library" => {
let gpu = host.gpu();
let ed = host.editor();
let changes = lib::publish_diff(&dir, ed)?;
let p = lib::publish_with_previews(&dir, ed, a.str("description").unwrap_or(""), gpu.as_ref())?;
json!({"libraryId": p.library_id, "name": p.name, "version": p.version,
"components": p.components.len(), "changes": changes.to_string()})
}
"list_libraries" => {
let ed = host.editor();
let enabled = lib::status(&store, &dir, ed);
let available: Vec<Value> = lib::available(&store, Some(&dir))
.iter()
.map(|l| summary(l, enabled.iter().any(|e| e.library_id == l.library_id)))
.collect();
let enabled: Vec<Value> = enabled
.iter()
.map(|e| json!({"libraryId": e.library_id, "name": e.name, "version": e.local_version,
"sourceVersion": e.source_version, "sourceFound": e.source_version.is_some(),
"updateAvailable": e.update_available}))
.collect();
return text(json!({"libraryId": project_id, "available": available, "enabled": enabled}));
}
"enable_library" => {
let id = a.req_str("libraryId")?;
let l = lib::available(&store, Some(&dir))
.into_iter()
.find(|l| l.library_id == id || l.name == id)
.ok_or_else(|| format!("no available library `{id}` (see list_libraries)"))?;
lib::enable(&dir, host.editor(), &l)?;
json!({"libraryId": l.library_id, "name": l.name, "version": l.version,
"variables": l.variables, "components": l.components})
}
"update_library" => {
let id = a.req_str("libraryId")?;
let changes = lib::update_diff(&store, &dir, id)?;
lib::update(&store, &dir, host.editor(), id)?;
json!({"libraryId": id, "changes": changes.to_string()})
}
_ => {
let id = a.req_str("libraryId")?;
let r = lib::disable(&dir, host.editor(), id)?;
json!({"libraryId": id, "detachedFills": r.fills, "detachedBindings": r.bindings,
"detachedInstances": r.instances})
}
};
host.save()?;
text(r)
}
// ------------------------------------------------------------------------------ icons
fn search_icons(a: &Args) -> ToolResult {
+104 -15
View File
@@ -506,10 +506,14 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"get_variables",
"Color variables of the page's project (name -> hex). Fills that follow a variable \
were written with `var(--name)`; set_variables moves all of them at once. \
`format` css returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), \
json a W3C DTCG design-tokens object, and tailwind a Tailwind v4 `@theme` block.",
"Variables of the page's project. Default JSON: `variables` (name -> hex), `tokens`, and \
`collections` (id, name, modes, defaultMode, variables with id, name, cssName, group, \
type color|number|string|boolean, values per mode name, aliases as {\"alias\": name}, \
description, hidden) plus `pageModes` (active mode per collection on this page). Fills \
that follow a variable were written with `var(--name)`; set_variables moves all of them \
at once. `format` css returns a `:root { --name: #hex }` block (plus `[data-theme]` rules \
for modes), json a W3C DTCG object (groups nest on `/`, aliases are {group.name}), and \
tailwind a Tailwind v4 `@theme` block.",
json!({"pageId": page_id(),
"format": {"type": "string", "enum": ["css", "json", "tailwind"]}}),
&[],
@@ -517,18 +521,42 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"set_variables",
"Define or change color variables (names like `surface`, `accent-text`), on every \
page. Use them as `var(--name)` in write_html / update_styles / create_artboard \
"Define or change variables (names like `surface`, `accent-text`, or grouped \
`color/brand/primary`), on every page. Plain {\"name\": \"#hex\"} defines a color. An \
entry may be an object {value, type, collection, mode, alias, description, hidden}: \
`type` color|number|string|boolean (numbers bind as px), `collection` a name or id \
(created if missing), `alias` the name of another variable of the same type. Use them as `var(--name)` in write_html / update_styles / create_artboard \
colors (backgroundColor, background, color) instead of hex literals, so a theme \
change is one call. Fills already following a changed variable are updated. \
Variables apply to fills and text color; strokes and gradients keep fixed colors.",
json!({"variables": {"type": "object", "propertyNames": {"type": "string"},
"additionalProperties": {"type": "string"},
"description": "{\"accent\": \"#cba6f7\", \"surface\": \"#313244\"}"},
"mode": {"type": "string", "description": "Store the values for this mode (e.g. \"Light\", \"Dark\"); they apply when it is the active mode. The first mode you define becomes active."}}),
"description": "{\"accent\": \"#cba6f7\"} or {\"space/md\": {\"value\": 16, \"type\": \"number\"}, \"accent-hover\": {\"alias\": \"accent\"}}"},
"mode": {"type": "string", "description": "Store the values for this mode (e.g. \"Light\", \"Dark\"); they apply when it is the active mode. The first mode you define becomes active."},
"collection": {"type": "string", "description": "Default collection (name or id) for entries that name none."},
"type": {"type": "string", "enum": ["color", "number", "string", "boolean"], "description": "Default type for entries that name none."}}),
&["variables"],
false,
),
tool(
"create_collection",
"Create a variable collection (a set of variables with modes), e.g. `Brand` with \
modes [\"Light\", \"Dark\"]. Without `modes` it has one mode, `Default`. Then fill it with \
set_variables {collection}.",
json!({"name": {"type": "string"},
"modes": {"type": "array", "items": {"type": "string"}}}),
&["name"],
false,
),
tool(
"delete_variables",
"Delete variables by name. A variable another one aliases is refused (the error names \
the dependents) unless `force` is true, which gives the dependents its resolved value.",
json!({"names": {"type": "array", "items": {"type": "string"}},
"force": {"type": "boolean"}}),
&["names"],
false,
),
tool(
"set_tokens",
"Define non-color design tokens (spacing, radius, font size, shadows, widths), on every \
@@ -575,11 +603,54 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"get_components",
"Main components on a page (id, name, size) with how many instances each has.",
"Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), \
`pageId`, `pageName`, size, and `uses` (instances across all pages).",
json!({"pageId": page_id()}),
&[],
true,
),
tool(
"publish_library",
"Publish this project's variables (not hidden) and components (not named with a leading \
`_` or `.`) as a library other projects can enable. Writes library.json with the next \
version and returns what changed since the last publish.",
json!({"description": {"type": "string", "description": "What the library is for (kept from the last publish when omitted)."}}),
&[],
false,
),
tool(
"list_libraries",
"Libraries other projects published (`available`, with `enabled`) and the ones this \
project uses (`enabled`: version, sourceVersion, sourceFound, updateAvailable).",
json!({}),
&[],
true,
),
tool(
"enable_library",
"Use a published library: its variables become read-only, bindable and aliasable, and \
its components can be placed with create_instance componentKey. Refused when a CSS \
name is already defined here.",
json!({"libraryId": {"type": "string", "description": "From list_libraries."}}),
&["libraryId"],
false,
),
tool(
"update_library",
"Apply the library's newest publish to this project: variables and components are \
replaced and every instance of it re-synced, overrides kept. Returns the changes.",
json!({"libraryId": {"type": "string"}}),
&["libraryId"],
false,
),
tool(
"disable_library",
"Stop using a library: bindings to its variables are detached (values kept), its \
instances become plain frames, its variables and components leave this project.",
json!({"libraryId": {"type": "string"}}),
&["libraryId"],
false,
),
tool(
"import_components",
"Copy the main components anywhere on `fromPageId` (including inside artboards, \
@@ -597,7 +668,7 @@ pub fn definitions() -> Vec<Value> {
"create_component",
"Turn a frame or artboard into a main component. Build reusable parts (button, row, \
chip, tab) once as components, then place them with create_instance or \
<x-paper-instance>. Components and instances live on one page.",
<x-paper-instance>. Instances may live on any page and follow the main.",
json!({"nodeId": {"type": "string", "description": "The frame to convert."},
"name": {"type": "string", "description": "Optional new layer name."},
"pageId": page_id()}),
@@ -609,23 +680,39 @@ pub fn definitions() -> Vec<Value> {
"Place an instance of a component. Edit text and styles on the instance's descendants \
with set_text_content / update_styles (they become overrides); edits to the main \
component flow to every instance. Returns the instance id and its text layers.",
json!({"componentId": {"type": "string"},
json!({"componentId": {"type": "string", "description": "A main on any page (\"pageId:num\")."},
"componentKey": {"type": "string", "description": "Instead of componentId: the key from get_components."},
"parentId": {"type": "string", "description": "Parent frame; omit or \"root\" for the page."},
"index": {"type": "number", "description": "Position among the parent's children."},
"x": {"type": "number"}, "y": {"type": "number"},
"name": {"type": "string"},
"pageId": page_id()}),
&["componentId"],
&[],
false,
),
tool(
"swap_instance",
"Swap an instance root to point at a different main component on the same page, \
"Swap an instance root to point at a different main component (any page), \
preserving overrides on descendants matched by name and layer type.",
json!({"nodeId": node_id("The instance root node to swap."),
"componentId": node_id("The new main component id."),
"componentKey": {"type": "string", "description": "Instead of componentId: a key from get_components, including a library component."},
"pageId": page_id()}),
&["nodeId", "componentId"],
&["nodeId"],
false,
),
tool(
"bind_variable",
"Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, \
width, height, gap, padding, padding-top/right/bottom/left, radius, font-size, \
line-height, opacity, visible. `variable` is a name like color/brand/primary or its \
css name; null or omitted detaches (the current value stays). The value applies at \
once; on an instance the field becomes an override.",
json!({"nodeIds": ids("Layers to bind."),
"field": {"type": "string"},
"variable": {"type": ["string", "null"]},
"pageId": page_id()}),
&["nodeIds", "field"],
false,
),
tool(
@@ -814,13 +901,15 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
| "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
| "get_computed_styles" | "get_fill_image" | "find_nodes" | "get_font_family_info"
| "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
| "diff_frames" => false,
| "diff_frames" | "publish_library" | "list_libraries" => false,
"select_nodes" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
| "create_file" | "finish_working_on_nodes" => false,
"lint_design" => args["fix"] == true,
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html"
| "create_shape" | "update_styles" | "set_text_content" | "rename_nodes"
| "add_feedback" | "resolve_feedback" | "set_variables" | "set_tokens"
| "create_collection" | "delete_variables" | "bind_variable"
| "enable_library" | "update_library" | "disable_library"
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes"
| "duplicate_nodes" | "move_nodes" | "undo" | "redo" => true,
+24
View File
@@ -5,6 +5,7 @@ use super::paint::{
};
use crate::geom::{Rect, V2};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
use std::fmt;
#[derive(
@@ -420,6 +421,14 @@ pub struct Node {
/// Prototype link: clicking this layer in Present mode jumps to this frame.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<NodeId>,
/// Variable bindings by field name (`stroke`, `text-color`, `width`, `height`, `gap`,
/// `padding`, `padding-top`/`right`/`bottom`/`left`, `radius`, `font-size`, `line-height`,
/// `opacity`, `visible`) mapped to the variable's CSS name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bindings: BTreeMap<String, String>,
/// Stable key of a main component (set on `create_component`), used by instances on other pages.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component_key: Option<String>,
}
/// A note attached to a layer: what to change, from the user to an agent or back.
@@ -522,6 +531,8 @@ impl Node {
instance: None,
comments: Vec::new(),
link: None,
bindings: BTreeMap::new(),
component_key: None,
}
}
@@ -600,6 +611,19 @@ impl Node {
mod tests {
use super::*;
#[test]
fn node_without_binding_fields_loads_and_default_skips_them() {
let n = Node::new(NodeId(1), NodeKind::Rect, Rect::new(0.0, 0.0, 10.0, 10.0));
let v = serde_json::to_value(&n).unwrap();
assert!(v.get("bindings").is_none());
assert!(v.get("component_key").is_none());
let back: Node = serde_json::from_value(v).unwrap();
assert!(back.bindings.is_empty());
assert_eq!(back.component_key, None);
let link: InstanceLink = serde_json::from_str(r#"{"main":3}"#).unwrap();
assert_eq!(link.source, None);
}
#[test]
fn color_hex_roundtrip() {
for c in [Color::rgb(1, 2, 255), Color::rgba(0x12, 0x34, 0x56, 0x78)] {
+12
View File
@@ -632,6 +632,18 @@ pub struct InstanceLink {
/// Properties overridden on this node (not synced from the main component).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overrides: Vec<String>,
/// Where the main lives. `None` = same page as this instance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<Source>,
}
/// The document holding a main component, when it is not the instance's own page.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Source {
/// Another page of the same project, by page id.
Page(String),
/// An enabled library, by library id.
Library(String),
}
#[cfg(test)]
+45 -9
View File
@@ -6,6 +6,7 @@
//! unit-testable on its own.
use crate::geom::Camera;
use crate::variables::PageModes;
use crate::model::{Color, Document, Node, NodeId};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -16,7 +17,13 @@ pub const PAGE_FORMAT: &str = "paper-rs-page";
/// The single-file format of papercraft 0.1 (`.paper-rs`), version 1. It migrates to a page.
pub const LEGACY_FORMAT: &str = "paper-rs";
pub const PROJECT_VERSION: u32 = 1;
/// v1: pages only. v2: `design.json` is the source of truth for variables and pages carry
/// bindings, component keys and sources. An older app would write pages without those and never
/// update `design.json`, so it must refuse a v2 project (`TooNew`) instead of losing data.
pub const PROJECT_VERSION: u32 = 2;
/// Project migrations, indexed by source version: `PROJECT_STEPS[0]` turns v1 into v2. The
/// manifest itself does not change; the design system is built on load from the pages.
const PROJECT_STEPS: &[fn(Value) -> Value] = &[|v| v];
/// v1: legacy single file. v2: page file inside a project. v3: fill layers (`fills`).
pub const PAGE_VERSION: u32 = 3;
@@ -37,7 +44,7 @@ impl std::fmt::Display for FormatError {
FormatError::WrongFormat(s) => write!(f, "not a papercraft file (format {s:?})"),
FormatError::TooNew { version, supported } => write!(
f,
"file version {version} is newer than this app supports ({supported})"
"made by a newer PaperCraft (file version {version}, this app reads up to {supported}); update PaperCraft to open it"
),
FormatError::Invalid(s) => write!(f, "invalid document: {s}"),
}
@@ -104,7 +111,7 @@ impl Manifest {
pub fn from_json(s: &str) -> Result<Manifest, FormatError> {
let v: Value = serde_json::from_str(s).map_err(FormatError::Json)?;
let v = migrate(v, PROJECT_FORMAT, PROJECT_VERSION, &[], &[])?;
let v = migrate(v, PROJECT_FORMAT, PROJECT_VERSION, &[], PROJECT_STEPS)?;
serde_json::from_value(v).map_err(FormatError::Json)
}
}
@@ -139,6 +146,9 @@ struct PageFile {
mode: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
tokens: BTreeMap<String, String>,
/// Active mode id per collection id.
#[serde(default, rename = "collectionModes", skip_serializing_if = "BTreeMap::is_empty")]
modes_by_collection: BTreeMap<String, String>,
}
/// Page migrations, indexed by source version: `PAGE_STEPS[0]` turns v1 into v2.
@@ -176,6 +186,11 @@ fn page_v2_to_v3(mut v: Value) -> Value {
}
pub fn page_to_json(p: &PageData) -> String {
page_to_json_modes(p, &PageModes::new())
}
/// A page file with its active mode per collection.
pub fn page_to_json_modes(p: &PageData, modes: &PageModes) -> String {
let mut nodes: Vec<Node> = p.doc.nodes.values().cloned().collect();
nodes.sort_by_key(|n| n.id);
let f = PageFile {
@@ -191,12 +206,18 @@ pub fn page_to_json(p: &PageData) -> String {
modes: p.doc.modes.clone(),
mode: p.doc.mode.clone(),
tokens: p.doc.tokens.clone(),
modes_by_collection: modes.clone(),
};
serde_json::to_string_pretty(&f).expect("page serializes")
}
/// Parse a page file (or a legacy `.paper-rs` document) of any supported version.
pub fn page_from_json(s: &str) -> Result<PageData, FormatError> {
page_from_json_modes(s).map(|(p, _)| p)
}
/// Like [`page_from_json`], plus the page's active mode per collection.
pub fn page_from_json_modes(s: &str) -> Result<(PageData, PageModes), FormatError> {
let v: Value = serde_json::from_str(s).map_err(FormatError::Json)?;
let v = migrate(v, PAGE_FORMAT, PAGE_VERSION, PAGE_ALIASES, PAGE_STEPS)?;
let f: PageFile = serde_json::from_value(v).map_err(FormatError::Json)?;
@@ -213,12 +234,15 @@ pub fn page_from_json(s: &str) -> Result<PageData, FormatError> {
tokens: f.tokens,
};
doc.validate().map_err(FormatError::Invalid)?;
Ok(PageData {
id: f.id,
name: f.name,
doc,
camera: f.camera,
})
Ok((
PageData {
id: f.id,
name: f.name,
doc,
camera: f.camera,
},
f.modes_by_collection,
))
}
/// Check the format tag and run the migration chain up to `current`.
@@ -346,6 +370,18 @@ mod tests {
assert!(page_to_json(&p).contains("\"version\": 3"));
}
#[test]
fn v1_manifest_migrates_to_v2_and_v3_is_refused() {
let v1 = r#"{"format":"paper-rs-project","version":1,"id":"a","name":"N","created":1,
"modified":2,"pages":[{"id":"p","name":"Page 1"}]}"#;
assert_eq!(Manifest::from_json(v1).unwrap().version, PROJECT_VERSION);
assert_eq!(PROJECT_VERSION, 2);
// What the old 0.4.0 reader (supports 1) does with a v2 file: TooNew.
let err = Manifest::from_json(&v1.replace("\"version\":1", "\"version\":3")).unwrap_err();
assert!(matches!(err, FormatError::TooNew { version: 3, supported: 2 }));
assert!(err.to_string().contains("made by a newer PaperCraft"));
}
#[test]
fn manifest_roundtrip() {
let m = Manifest::new("Hello");
+185 -17
View File
@@ -5,6 +5,7 @@
//! ```text
//! <root>/<Project name>/
//! project.json manifest: id, name, timestamps, ordered page list
//! design.json the project's design system (collections of variables)
//! pages/<page-id>.json one infinite canvas per page
//! assets/<hash>.<ext> images used by image fills
//! thumbnail.png preview for the home screen (written by the app)
@@ -17,9 +18,11 @@ pub mod format;
use crate::io::atomic;
pub use format::{FormatError, Manifest, PageData, PageRef, new_id, now_ms};
use crate::variables::{COLORS_COLLECTION, DesignSystem, PageModes};
use std::path::{Path, PathBuf};
pub const MANIFEST: &str = "project.json";
pub const DESIGN: &str = "design.json";
pub const PAGES_DIR: &str = "pages";
pub const THUMBNAIL: &str = "thumbnail.png";
pub const ASSETS_DIR: &str = "assets";
@@ -57,6 +60,40 @@ pub struct Project {
pub manifest: Manifest,
}
/// The first page's variables, modes and tokens plus whatever only later pages have (pages
/// can diverge: removals were page-only). The first page wins on a name conflict.
fn union_doc(pages: &[PageData]) -> crate::model::Document {
let mut out = pages.first().map(|p| p.doc.clone()).unwrap_or_default();
for p in pages.iter().skip(1) {
for (k, c) in &p.doc.variables {
out.variables.entry(k.clone()).or_insert(*c);
}
for (mode, vars) in &p.doc.modes {
let m = out.modes.entry(mode.clone()).or_default();
for (k, c) in vars {
m.entry(k.clone()).or_insert(*c);
}
}
for (k, v) in &p.doc.tokens {
out.tokens.entry(k.clone()).or_insert(v.clone());
}
}
out
}
/// What [`Project::load_all`] read.
#[derive(Debug, Clone)]
pub struct Loaded {
pub design: DesignSystem,
pub pages: Vec<PageData>,
/// Active mode per collection, per page (same order as `pages`).
pub modes: Vec<PageModes>,
/// Pages whose file differs from what was loaded (cache rebuilt, keys assigned).
pub changed: Vec<usize>,
/// `design.json` did not exist: it was built from the first page.
pub design_new: bool,
}
/// File-system safe folder name for a project name.
pub fn folder_name(name: &str) -> String {
let s: String = name
@@ -231,6 +268,8 @@ impl Store {
copy_dir(dir, &target)?;
// A copy is its own project, not another editor of the shared one.
crate::vfs::remove_file(target.join(crate::sync::SHARE_FILE)).ok();
// Nor a published library: its `library.json` still carries the source's library id.
crate::vfs::remove_file(target.join(crate::libraries::LIBRARY_FILE)).ok();
let mut p = Project::open(&target)?;
p.manifest.id = new_id();
p.manifest.name = name;
@@ -357,34 +396,125 @@ impl Project {
Ok(p)
}
/// Load every page listed in the manifest. A page whose file is missing comes back empty
/// (e.g. after a crash between creating it and its first save).
/// Every page, with its cache rebuilt from the design system.
pub fn load_pages(&self) -> Result<Vec<PageData>, FormatError> {
self.manifest
.pages
.iter()
.map(|r| match self.load_page(&r.id) {
Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(PageData {
Ok(self.load_all()?.pages)
}
pub fn design_path(&self) -> PathBuf {
self.dir.join(DESIGN)
}
/// `design.json`, or `None` when the project has none yet.
pub fn load_design(&self) -> Result<Option<DesignSystem>, FormatError> {
match crate::vfs::read_to_string(self.design_path()) {
Ok(s) => Ok(Some(serde_json::from_str(&s).map_err(FormatError::Json)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn save_design(&self, design: &DesignSystem) -> Result<(), FormatError> {
let s = serde_json::to_string_pretty(design).expect("design system serializes");
atomic::write(&self.design_path(), s.as_bytes())?;
Ok(())
}
/// Load the design system and every page listed in the manifest. A page whose file is
/// missing comes back empty (e.g. after a crash between creating it and its first save).
/// A project without `design.json` gets one built from the first page; pages get the
/// derived variable cache and every main component a key. Nothing is written here:
/// `Loaded::changed` and `Loaded::design_new` say what the next save must write.
pub fn load_all(&self) -> Result<Loaded, FormatError> {
let mut pages = Vec::new();
let mut modes = Vec::new();
for r in &self.manifest.pages {
let (mut p, m) = match crate::vfs::read_to_string(self.page_path(&r.id))
.map_err(FormatError::from)
.and_then(|s| format::page_from_json_modes(&s))
{
Ok(x) => x,
Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => (
PageData {
id: r.id.clone(),
name: r.name.clone(),
doc: crate::model::Document {
name: r.name.clone(),
..Default::default()
},
doc: crate::model::Document::default(),
camera: None,
})
},
PageModes::new(),
),
Err(e) => return Err(e),
};
// The manifest owns page names (renames touch only the manifest).
p.name = r.name.clone();
p.doc.name = r.name.clone();
pages.push(p);
modes.push(m);
}
let stored = match self.load_design() {
Ok(d) => d,
// A damaged design.json must not lock the project: keep the bad file for recovery
// and rebuild from the page caches (which hold every value). Next save rewrites it.
Err(FormatError::Json(e)) => {
let backup = self.dir.join(format!("{DESIGN}.broken-{}", now_ms()));
log::warn!("{DESIGN} is malformed ({e}); saved as {backup:?}, rebuilding");
crate::vfs::rename(self.design_path(), backup)?;
None
}
Err(e) => return Err(e),
};
let design_new = stored.is_none();
let design = stored.unwrap_or_else(|| {
crate::variables::migrate_from_document(&union_doc(&pages))
});
let mut changed = Vec::new();
for (i, (p, m)) in pages.iter_mut().zip(&mut modes).enumerate() {
// A page without per-collection modes keeps the mode its old `mode` field names.
if m.is_empty()
&& let Some(name) = &p.doc.mode
&& let Some(c) = design.collections.iter().find(|c| c.name == COLORS_COLLECTION)
&& let Some(mode) = c.modes.iter().find(|x| &x.name == name)
{
m.insert(c.id.clone(), mode.id.clone());
}
let cache = design.cache_for(m);
let mut dirty = p.doc.variables != cache.variables
|| p.doc.modes != cache.modes
|| p.doc.mode != cache.mode
|| p.doc.tokens != cache.tokens;
p.doc.variables = cache.variables;
p.doc.modes = cache.modes;
p.doc.mode = cache.mode;
p.doc.tokens = cache.tokens;
for n in p.doc.nodes.values_mut() {
if n.component && n.component_key.is_none() {
// From the page and node ids, so every device agrees on the key.
n.component_key = Some(format!("{}:{}", p.id, n.id.0));
dirty = true;
}
other => other,
})
.collect()
}
if dirty {
changed.push(i);
}
}
Ok(Loaded {
design,
pages,
modes,
changed,
design_new,
})
}
pub fn save_page(&self, page: &PageData) -> Result<(), FormatError> {
self.save_page_modes(page, &PageModes::new())
}
pub fn save_page_modes(&self, page: &PageData, modes: &PageModes) -> Result<(), FormatError> {
crate::vfs::create_dir_all(self.dir.join(PAGES_DIR))?;
atomic::write(
&self.page_path(&page.id),
format::page_to_json(page).as_bytes(),
format::page_to_json_modes(page, modes).as_bytes(),
)?;
Ok(())
}
@@ -479,6 +609,44 @@ mod tests {
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
fn malformed_design_json_is_backed_up_and_rebuilt_from_pages() {
let s = temp_store();
let (p, mut page) = s.create("P").unwrap();
page.doc.variables.insert("bg".into(), crate::model::Color::rgb(1, 2, 3));
p.save_page(&page).unwrap();
crate::vfs::write(p.design_path(), "{ not json").unwrap();
let loaded = p.load_all().unwrap();
assert!(loaded.design_new, "rebuilt design is written by the next save");
assert!(loaded.design.find_by_css("bg").is_some());
let backups: Vec<_> = std::fs::read_dir(&p.dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("design.json.broken-"))
.collect();
assert_eq!(backups.len(), 1);
assert_eq!(std::fs::read_to_string(backups[0].path()).unwrap(), "{ not json");
assert!(!p.design_path().exists());
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
fn migrated_component_keys_are_stable_across_loads() {
let s = temp_store();
let (p, mut page) = s.create("P").unwrap();
let id = page.doc.alloc_id();
let mut n = crate::model::Node::new(id, crate::model::NodeKind::Frame { clip: false }, crate::geom::Rect::new(0., 0., 5., 5.));
n.component = true;
page.doc.nodes.insert(id, n);
page.doc.root.push(id);
p.save_page(&page).unwrap();
let key = |l: &Loaded| l.pages[0].doc.get(id).unwrap().component_key.clone();
let (a, b) = (p.load_all().unwrap(), p.load_all().unwrap());
assert_eq!(key(&a), key(&b));
assert_eq!(key(&a), Some(format!("{}:{}", page.id, id.0)));
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
fn folder_names_are_safe() {
assert_eq!(folder_name("a/b\\c"), "a-b-c");
+12 -7
View File
@@ -736,13 +736,18 @@ impl Headless {
pub fn new() -> Result<Headless, String> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
force_fallback_adapter: false,
compatible_surface: None,
..Default::default()
}))
.map_err(|e| format!("no GPU adapter available: {e}"))?;
// No hardware adapter (a server, CI): retry with the software one.
let request = |fallback| {
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
force_fallback_adapter: fallback,
compatible_surface: None,
..Default::default()
}))
};
let adapter = request(false)
.or_else(|_| request(true))
.map_err(|e| format!("no GPU adapter available: {e}"))?;
let adapter_info = adapter.get_info();
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("papercraft headless"),
+23 -10
View File
@@ -605,6 +605,7 @@ fn finish_title(ed: &Editor, args: &Value) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::design;
use crate::model::Document;
fn temp() -> PathBuf {
@@ -654,21 +655,26 @@ mod tests {
None,
);
}
ed.edit("Design", |tx| {
tx.set_variable_in_mode("Light", "accent", Color::rgb(200, 0, 0));
tx.set_variable_in_mode("Dark", "accent", Color::rgb(0, 0, 200));
tx.set_token("gap", "8px");
});
// Variables are project-wide: every page shows the same values.
let none = crate::variables::PageModes::new();
ed.edit_design("Design", |ds| {
design::set_color(ds, &none, "accent", Some("Light"), Color::rgb(200, 0, 0))?;
design::set_color(ds, &none, "accent", Some("Dark"), Color::rgb(0, 0, 200))?;
design::set_token(ds, "gap", "8px")
})
.unwrap();
before.push(ed.page_data(i).doc);
r.pages.push(snapshot_page(&dir, &ed, i));
let root = ed.doc.root[0];
ed.edit("Agent changes", |tx| {
tx.node_mut(root).unwrap().name = "Agent".into();
tx.move_to_index(root, 1);
tx.set_variable("accent", Color::rgb(0, 200, 0));
tx.set_mode("Dark");
tx.set_token("gap", "16px");
});
ed.edit_design("Agent design", |ds| {
design::set_color(ds, &none, "accent", None, Color::rgb(0, 200, 0))?;
design::set_token(ds, "gap", "16px")
})
.unwrap();
after.push(ed.doc.clone());
ed.rename_page(i, "Renamed");
}
@@ -677,13 +683,20 @@ mod tests {
assert_eq!(ed.active_page_id(), active);
assert_eq!(r.state, RunState::Reverted);
assert_eq!(list(&dir), vec![r]);
// The design is shared, so the restored variables are the snapshots' (identical here).
for i in 0..2 {
ed.switch_page(i);
assert_content(&ed.doc, &before[i]);
assert_eq!(ed.doc.var_state(), before[0].var_state());
}
for i in 0..2 {
ed.switch_page(i);
assert_eq!(ed.doc.nodes, before[i].nodes);
assert_eq!(ed.doc.root, before[i].root);
assert_eq!(ed.page_name(), before[i].name);
assert_eq!(ed.doc.name, before[i].name);
ed.undo();
assert_content(&ed.doc, &after[i]);
assert_eq!(ed.doc.nodes, after[i].nodes);
assert_eq!(ed.doc.root, after[i].root);
}
}
+175 -4
View File
@@ -24,6 +24,8 @@ pub struct Session {
pub sync: crate::sync::Sync,
sync_checked: Option<Instant>,
dirty_since: Option<Instant>,
/// `design.json` as last read or written (`None` = no file yet).
design_disk: Option<crate::variables::DesignSystem>,
thumb_stale: bool,
last_thumb: Option<Instant>,
}
@@ -38,6 +40,7 @@ impl Session {
sync,
sync_checked: None,
dirty_since: None,
design_disk: None,
thumb_stale: false,
last_thumb: None,
}
@@ -54,8 +57,15 @@ impl Session {
pub fn open(&mut self, ed: &mut Editor, dir: &Path) -> Result<(), FormatError> {
let p = Project::open(dir)?;
crate::assets::load_dir(&dir.join(crate::project::ASSETS_DIR));
let pages = p.load_pages()?;
ed.open_pages(pages, p.manifest.active_page.as_deref());
let loaded = p.load_all()?;
ed.open_pages(loaded.pages, p.manifest.active_page.as_deref());
ed.design = loaded.design;
crate::libraries::sweep_orphans(dir, &ed.design.libraries);
ed.library_docs = crate::libraries::load_docs(dir);
ed.project_dir = Some(dir.to_path_buf());
for (slot, modes) in ed.pages.iter_mut().zip(loaded.modes) {
slot.modes = modes;
}
// The groups of the opened project (an old project has none).
ed.page_groups = p.manifest.page_groups.clone();
ed.sync_page_group_order();
@@ -63,6 +73,12 @@ impl Session {
for i in 0..ed.pages.len() {
ed.mark_page_saved(i);
}
// Pages migrated on load (cache rebuilt, component keys) are written by the next save.
for &i in &loaded.changed {
ed.pages[i].saved_gen = u64::MAX;
}
self.design_disk = (!loaded.design_new).then(|| ed.design.clone());
ed.meta_dirty = loaded.design_new && !ed.design.collections.is_empty();
self.project = Some(p);
self.dirty_since = None;
self.thumb_stale = false;
@@ -196,6 +212,18 @@ impl Session {
};
ed.commit_pending();
let dirty = ed.dirty_pages();
// design.json goes first: a crash after it leaves pages whose cache is stale, which load
// rebuilds from the design; the other order would leave page fields newer than the truth.
if self.design_disk.as_ref() != Some(&ed.design) {
if self.design_disk.is_none() {
// First design.json: the manifest turns v2 before it exists, so an old app
// refuses the project rather than editing pages behind the design's back.
p.manifest.version = crate::project::format::PROJECT_VERSION;
p.write_manifest()?;
}
p.save_design(&ed.design)?;
self.design_disk = Some(ed.design.clone());
}
for &i in &dirty {
let data = ed.page_data(i);
// Images first, so a page never references a file that isn't there.
@@ -203,7 +231,7 @@ impl Session {
&p.dir.join(crate::project::ASSETS_DIR),
crate::assets::used_by(&data.doc),
)?;
p.save_page(&data)?;
p.save_page_modes(&data, &ed.pages[i].modes)?;
ed.mark_page_saved(i);
}
let pages: Vec<PageRef> = ed
@@ -239,7 +267,7 @@ impl Session {
self.save(ed)?;
if let Some(p) = &self.project {
// The camera isn't tracked by history; persist the active page's view.
p.save_page(&ed.page_data(ed.active_page))?;
p.save_page_modes(&ed.page_data(ed.active_page), &ed.pages[ed.active_page].modes)?;
}
Ok(())
}
@@ -418,4 +446,147 @@ mod tests {
);
crate::vfs::remove_dir_all(&s.root).ok();
}
const V1_PAGE: &str = r##"{"format":"paper-rs-page","version":3,"id":"pg1","name":"Page 1",
"next_id":3,"root":[1,2],"nodes":[
{"id":1,"name":"Main","type":"frame","rect":{"x":0,"y":0,"w":5,"h":5},"component":true},
{"id":2,"name":"Plain","type":"rect","rect":{"x":0,"y":0,"w":5,"h":5}}],
"variables":{"bg":"#000000"},
"modes":{"Light":{"bg":"#ffffff"},"Dark":{"bg":"#000000"}},
"mode":"Dark","tokens":{"space-md":"16px","font":"Inter"}}"##;
fn v1_project(root: &Store, page: &str) -> std::path::PathBuf {
let dir = root.root.join("Old");
crate::vfs::create_dir_all(dir.join("pages")).unwrap();
let mut m = crate::project::Manifest::new("Old");
m.pages[0].id = "pg1".into();
m.active_page = Some("pg1".into());
m.version = 1;
crate::vfs::write(dir.join("project.json"), m.to_json()).unwrap();
crate::vfs::write(dir.join("pages/pg1.json"), page).unwrap();
dir
}
#[test]
fn v1_project_migrates_to_design_json() {
let s = store();
let dir = v1_project(&s, V1_PAGE);
let orig = crate::project::format::page_from_json(V1_PAGE).unwrap().doc;
let mut sess = Session::new(s.clone());
let mut ed = Editor::default();
sess.open(&mut ed, &dir).unwrap();
assert!(!dir.join("design.json").exists(), "written on next save");
let cols = &ed.design.collections;
assert_eq!(cols[0].name, "Colors");
assert_eq!(cols[0].modes.len(), 2);
let dark = cols[0].modes.iter().find(|m| m.name == "Dark").unwrap();
assert_eq!(cols[0].default_mode, dark.id);
assert_eq!(cols[1].name, "Tokens");
let doc = &ed.doc;
assert_eq!(doc.variables, orig.variables);
assert_eq!(doc.modes, orig.modes);
assert_eq!(doc.mode, orig.mode);
assert_eq!(doc.tokens, orig.tokens);
let key = doc.get(NodeId(1)).unwrap().component_key.clone().unwrap();
assert!(doc.get(NodeId(2)).unwrap().component_key.is_none());
assert!(ed.project_dirty());
sess.save(&mut ed).unwrap();
assert!(dir.join("design.json").is_file());
let design = ed.design.clone();
let mut sess2 = Session::new(s.clone());
let mut ed2 = Editor::default();
sess2.open(&mut ed2, &dir).unwrap();
assert_eq!(ed2.design, design);
assert_eq!(ed2.pages[0].modes, ed.pages[0].modes);
assert_eq!(ed2.doc.get(NodeId(1)).unwrap().component_key, Some(key));
assert_eq!(ed2.doc.variables, orig.variables);
assert_eq!(ed2.doc.mode, orig.mode);
assert!(!ed2.project_dirty());
let raw = crate::vfs::read_to_string(dir.join("project.json")).unwrap();
// The manifest is rewritten at version 2 so 0.4.0 refuses the project instead of
// writing pages without bindings and never updating design.json.
assert!(raw.contains("\"version\": 2"));
// An older app reads the page files directly and still sees the cache fields.
let old = crate::project::format::page_from_json(
&crate::vfs::read_to_string(dir.join("pages/pg1.json")).unwrap(),
)
.unwrap();
assert_eq!(old.doc.variables, orig.variables);
assert_eq!(old.doc.modes, orig.modes);
assert_eq!(old.doc.tokens, orig.tokens);
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
fn design_json_is_written_before_pages() {
let s = store();
let dir = v1_project(&s, V1_PAGE);
let mut sess = Session::new(s.clone());
let mut ed = Editor::default();
sess.open(&mut ed, &dir).unwrap();
let red = crate::model::Color::rgb(255, 0, 0);
ed.edit_design("Add", |ds| {
crate::editor::design::set_color(ds, &Default::default(), "brand", None, red)
})
.unwrap();
// Page writes fail: `pages` is now a file.
crate::vfs::remove_dir_all(dir.join("pages")).unwrap();
crate::vfs::write(dir.join("pages"), "x").unwrap();
assert!(sess.save(&mut ed).is_err());
let on_disk = Project::open(&dir).unwrap().load_design().unwrap().unwrap();
assert!(on_disk.find_by_css("brand").is_some(), "design saved before the page failed");
// The manifest turned v2 with it.
let raw = crate::vfs::read_to_string(dir.join("project.json")).unwrap();
assert!(raw.contains("\"version\": 2"));
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
fn project_without_variables_gets_empty_design() {
let s = store();
let dir = v1_project(
&s,
r#"{"format":"paper-rs-page","version":3,"id":"pg1","name":"P","next_id":1,"root":[],"nodes":[]}"#,
);
let mut sess = Session::new(s.clone());
let mut ed = Editor::default();
sess.open(&mut ed, &dir).unwrap();
assert!(ed.design.collections.is_empty());
assert!(!ed.project_dirty());
sess.save(&mut ed).unwrap();
assert!(dir.join("design.json").is_file());
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
fn migration_unions_diverged_pages() {
let s = store();
let dir = v1_project(&s, V1_PAGE);
let mut m = Project::open(&dir).unwrap().manifest;
m.pages.push(crate::project::PageRef { id: "pg2".into(), name: "Two".into() });
crate::vfs::write(dir.join("project.json"), m.to_json()).unwrap();
crate::vfs::write(
dir.join("pages/pg2.json"),
r##"{"format":"paper-rs-page","version":3,"id":"pg2","name":"Two","next_id":1,"root":[],"nodes":[],
"variables":{"bg":"#123456","accent":"#ff0000"},
"modes":{"Light":{"bg":"#abcdef","accent":"#ff0000"}},
"tokens":{"space-md":"99px","radius":"4px"}}"##,
)
.unwrap();
let mut sess = Session::new(s.clone());
let mut ed = Editor::default();
sess.open(&mut ed, &dir).unwrap();
let (c, t) = (&ed.design.collections[0], &ed.design.collections[1]);
let var = |n: &str| c.variables.iter().find(|v| v.name == n).unwrap();
let mode = |n: &str| c.modes.iter().find(|m| m.name == n).unwrap().id.clone();
use crate::variables::Value;
assert_eq!(var("bg").values[&mode("Light")], Value::Color(crate::model::Color::rgb(255, 255, 255)));
assert!(var("accent").values.contains_key(&mode("Dark")));
let tok = |n: &str| t.variables.iter().find(|v| v.name == n).map(|v| v.values.values().next().cloned());
assert_eq!(tok("space-md"), Some(Some(Value::Number(16.0))));
assert!(tok("radius").is_some());
assert!(ed.project_dirty());
crate::vfs::remove_dir_all(&s.root).ok();
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Variable bindings in the inspector: one picker (grouped by collection then group, filtered by
//! kind, with search), the bind button, and the chip a bound field shows instead of its input.
use crate::editor::Editor;
use crate::model::{Color, NodeId};
use crate::theme::{self, Icon};
use crate::variables::{self, DesignSystem, Kind, PageModes, Value};
use egui::{Align2, Rect, RichText, Sense, Ui, vec2};
pub const BIND_W: f32 = 20.0;
const POPUP_W: f32 = 280.0;
const ROW_H: f32 = 28.0;
const LIST_MAX_H: f32 = 300.0;
pub struct PickItem {
pub css: String,
/// Name inside its group.
pub name: String,
/// Current value as text (hex, number, true/false).
pub value: String,
pub color: Option<Color>,
}
pub struct PickGroup {
pub collection: String,
pub library: bool,
/// "" = variables with no group.
pub group: String,
pub items: Vec<PickItem>,
}
fn value_text(v: &Value) -> (String, Option<Color>) {
match v {
Value::Color(c) => (c.to_hex(), Some(*c)),
Value::Number(n) => (format!("{}", (n * 100.0).round() / 100.0), None),
Value::Boolean(b) => (b.to_string(), None),
Value::String(s) => (s.clone(), None),
Value::Alias(_) => (String::new(), None),
}
}
/// Variables of `kind` whose name matches `query`, grouped by collection then group.
pub fn pick_list(ds: &DesignSystem, modes: &PageModes, kind: Kind, query: &str) -> Vec<PickGroup> {
let needle = query.trim().to_lowercase();
let mut out = Vec::new();
for c in &ds.collections {
let mut groups: std::collections::BTreeMap<&str, Vec<PickItem>> = Default::default();
for v in c.variables.iter().filter(|v| {
v.kind == kind && (needle.is_empty() || v.name.to_lowercase().contains(&needle))
}) {
let group = variables::group_of(&v.name);
let (value, color) = ds.resolve(&v.id, modes).map(|x| value_text(&x)).unwrap_or_default();
groups.entry(group).or_default().push(PickItem {
css: variables::css_name(v),
name: v.name.strip_prefix(group).map_or(&*v.name, |n| n.trim_start_matches('/')).into(),
value,
color,
});
}
out.extend(groups.into_iter().map(|(g, items)| PickGroup {
collection: c.name.clone(),
library: c.library.is_some(),
group: g.into(),
items,
}));
}
out
}
fn kind_label(kind: Kind) -> &'static str {
match kind {
Kind::Color => "color",
Kind::Number => "number",
Kind::Boolean => "boolean",
Kind::String => "text",
}
}
/// Display name (`group/name`) of the variable with CSS name `css`.
pub fn display_name(ds: &DesignSystem, css: &str) -> String {
ds.find_by_css(css).map_or_else(|| css.to_string(), |(_, v)| v.name.clone())
}
/// The popup body: search plus the grouped list. Returns the picked CSS name.
fn picker(ui: &mut Ui, ed: &Editor, kind: Kind, current: Option<&str>) -> Option<String> {
let p = theme::pal(ui);
let id = ui.make_persistent_id("bind-search");
let mut query = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_default();
ui.set_width(POPUP_W);
ui.add(
egui::TextEdit::singleline(&mut query)
.hint_text(format!("Search {} variables", kind_label(kind)))
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
ui.data_mut(|d| d.insert_temp(id, query.clone()));
let list = pick_list(&ed.design, &ed.pages[ed.active_page].modes, kind, &query);
let count: usize = list.iter().map(|g| g.items.len()).sum();
ui.label(
RichText::new(format!("{count} {} variables", kind_label(kind)))
.size(theme::SECONDARY)
.color(p.tertiary),
);
let mut picked = None;
egui::ScrollArea::vertical().max_height(LIST_MAX_H).show(ui, |ui| {
if list.is_empty() {
ui.label(RichText::new("No variables of this type").color(p.secondary));
}
let mut last: Option<&str> = None;
for g in &list {
if g.library && last != Some(g.collection.as_str()) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(12.0, 14.0), Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Lock, p.secondary);
ui.label(RichText::new(&g.collection).font(theme::semibold(theme::SECONDARY)).color(p.secondary));
});
}
last = Some(g.collection.as_str());
if !g.group.is_empty() {
ui.label(RichText::new(&g.group).font(theme::semibold(theme::SECONDARY)).color(p.tertiary));
}
for it in &g.items {
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click());
let on = current == Some(it.css.as_str());
let hover = crate::ui::motion::hover(ui.ctx(), r.id.with("pick"), &r);
let fill = if on { p.field } else { p.hover.gamma_multiply(hover) };
ui.painter().rect_filled(rect, theme::RADIUS_MD, fill);
let mark = Rect::from_center_size(rect.left_center() + vec2(14.0, 0.0), vec2(14.0, 14.0));
match it.color {
Some(c) => {
ui.painter().rect_filled(mark, 3.0, super::c32(c));
}
None => {
ui.painter().text(mark.center(), Align2::CENTER_CENTER, "#", theme::semibold(theme::BODY), p.secondary);
}
}
ui.painter().text(
rect.left_center() + vec2(30.0, 0.0),
Align2::LEFT_CENTER,
&it.name,
if on { theme::semibold(theme::BODY) } else { egui::FontId::proportional(theme::BODY) },
p.text,
);
ui.painter().text(
rect.right_center() - vec2(if on { 28.0 } else { 10.0 }, 0.0),
Align2::RIGHT_CENTER,
&it.value,
egui::FontId::proportional(theme::SECONDARY),
p.tertiary,
);
if on {
ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::CENTER_CENTER, "✓", egui::FontId::proportional(theme::BODY), theme::accent());
}
if r.clicked() {
picked = Some(it.css.clone());
ui.close();
}
}
}
});
picked
}
fn open_picker(resp: &egui::Response, ed: &Editor, kind: Kind, current: Option<&str>) -> Option<String> {
egui::Popup::menu(resp)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(POPUP_W)
.show(|ui| picker(ui, ed, kind, current))
.and_then(|r| r.inner)
}
/// The bind icon button next to an unbound field. Returns the picked CSS name.
pub fn bind_button(ui: &mut Ui, ed: &Editor, kind: Kind) -> Option<String> {
let r = theme::icon_button(ui, Icon::Diamond, BIND_W, false).on_hover_text("Bind to a variable");
open_picker(&r, ed, kind, None)
}
/// A painted detach cross; true when clicked.
pub fn cross(ui: &mut Ui, rect: Rect) -> bool {
let r = ui.interact(rect, ui.id().with("detach"), Sense::click()).on_hover_text("Detach variable");
let c = rect.center();
let ink = if r.hovered() { theme::pal(ui).text } else { theme::pal(ui).secondary };
for (a, b) in [((-3.5, -3.5), (3.5, 3.5)), ((-3.5, 3.5), (3.5, -3.5))] {
ui.painter().line_segment([c + vec2(a.0, a.1), c + vec2(b.0, b.1)], egui::Stroke::new(1.4, ink));
}
r.clicked()
}
/// What a bound field's chip asks for: `Some(None)` detach, `Some(Some(css))` rebind.
pub type ChipAction = Option<Option<String>>;
/// The field box of a bound field: a chip with the variable's `group/name`, the current value,
/// and a detach cross. Clicking the chip opens the picker to switch variable.
pub fn bound_chip(ui: &mut Ui, ed: &Editor, kind: Kind, css: &str, value: &str, width: f32) -> ChipAction {
let p = theme::pal(ui);
let (rect, resp) = ui.allocate_exact_size(vec2(width, theme::FIELD_H), Sense::click());
ui.painter().rect_filled(rect, 5.0, ui.visuals().widgets.inactive.bg_fill);
let x = Rect::from_center_size(rect.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let detach = cross(ui, x);
let mut right = x.left() - 4.0;
if !value.is_empty() {
let g = ui.painter().layout_no_wrap(value.into(), egui::FontId::proportional(theme::SECONDARY + 1.0), p.secondary);
ui.painter().galley(egui::pos2(right - g.size().x, rect.center().y - g.size().y / 2.0), g.clone(), p.secondary);
right -= g.size().x + 6.0;
}
let label = format!("{}{}", if kind == Kind::Number { "# " } else { "" }, display_name(&ed.design, css));
let g = ui.painter().layout_no_wrap(label, theme::semibold(theme::SECONDARY + 1.0), theme::accent());
let chip = Rect::from_min_max(
rect.left_top() + vec2(4.0, 4.0),
egui::pos2((rect.left() + 4.0 + g.size().x + 16.0).min(right), rect.bottom() - 4.0),
);
ui.painter().rect_filled(chip, 5.0, theme::accent().gamma_multiply(0.18));
ui.painter().with_clip_rect(chip.shrink2(vec2(6.0, 0.0))).galley(
egui::pos2(chip.left() + 8.0, chip.center().y - g.size().y / 2.0),
g,
theme::accent(),
);
resp.clone().on_hover_text(display_name(&ed.design, css));
if detach {
return Some(None);
}
open_picker(&resp, ed, kind, Some(css)).map(Some)
}
/// The variable all of `ids` have bound to `field`, if they agree.
pub fn shared_binding(ed: &Editor, ids: &[NodeId], field: &str) -> Option<String> {
let mut it = ids.iter().map(|id| ed.doc.get(*id).and_then(|n| n.bindings.get(field)));
let first = it.next()??;
it.all(|b| b == Some(first)).then(|| first.clone())
}
/// Apply a chip or button result to `field` of `ids`; `None` (nothing happened) does nothing.
pub fn apply(ed: &mut Editor, ids: &[NodeId], field: &str, action: ChipAction) {
let Some(css) = action else { return };
if let Err(e) = ed.bind(ids, field, css.as_deref()) {
ed.status = e;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ds() -> DesignSystem {
let mut ds = DesignSystem::default();
let c = ds.add_collection("Tokens");
for (n, k) in [("space/md", Kind::Number), ("space/sm", Kind::Number), ("radius/sm", Kind::Number), ("flag", Kind::Boolean), ("color/bg", Kind::Color)] {
ds.add_variable(&c, n, k).unwrap();
}
let mut lib = ds.collections[0].clone();
lib.id = "lib".into();
lib.name = "Brand Kit".into();
lib.library = Some("l1".into());
lib.variables.retain(|v| v.name == "space/sm");
lib.variables[0].name = "brand/tight".into();
lib.variables[0].id = "libvar".into();
ds.collections.push(lib);
ds
}
#[test]
fn list_is_grouped_and_kind_filtered() {
let ds = ds();
let l = pick_list(&ds, &PageModes::new(), Kind::Number, "");
let heads: Vec<_> = l.iter().map(|g| (g.collection.as_str(), g.group.as_str(), g.items.len(), g.library)).collect();
assert_eq!(heads, [("Tokens", "radius", 1, false), ("Tokens", "space", 2, false), ("Brand Kit", "brand", 1, true)]);
assert_eq!(l[1].items[0].name, "md");
assert_eq!(l[1].items[0].css, "space-md");
assert!(pick_list(&ds, &PageModes::new(), Kind::Boolean, "").iter().all(|g| g.items.len() == 1 && g.group.is_empty()));
assert_eq!(pick_list(&ds, &PageModes::new(), Kind::Color, "").len(), 1);
let q = pick_list(&ds, &PageModes::new(), Kind::Number, "TIGHT");
assert_eq!(q.len(), 1);
assert_eq!(q[0].items[0].css, "brand-tight");
}
}
+10 -9
View File
@@ -177,15 +177,16 @@ pub(super) fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
ed.remove_page_group(&name);
}
action => {
for page in 0..ed.pages.len() {
ed.with_page(page, |ed| {
ed.edit_variables("Remove design token", |tx| match &action {
Removal::Mode(name) => tx.remove_mode(name),
Removal::Variable(name) => tx.remove_variable(name),
Removal::Token(name) => tx.remove_token(name),
_ => unreachable!(),
})
});
// One design-system edit: every page loses it, not just the active one.
let r = ed.edit_design("Remove design token", |ds| match &action {
Removal::Mode(name) => crate::editor::design::remove_mode_by_name(ds, name),
Removal::Variable(name) | Removal::Token(name) => {
crate::editor::design::remove_by_css(ds, name)
}
_ => unreachable!(),
});
if let Err(e) = r {
ed.status = e.to_string();
}
}
},
+28 -363
View File
@@ -1,37 +1,12 @@
//! The Design tab: the project's color variables (edit one and every fill following it moves),
//! variable modes (themes), and the page's components.
//! The Design tab: this page's variable modes, a variables summary, feedback and lint. Variables
//! are edited in the Variables modal, components live in Assets.
use super::UiState;
use crate::editor::Editor;
use crate::model::{Color, NodeId};
use crate::model::NodeId;
use crate::theme;
use egui::{RichText, Ui};
fn c32(c: Color) -> egui::Color32 {
egui::Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
}
/// Run `f` as one undoable edit on every page, so variables stay in step across the project.
fn on_every_page(ed: &mut Editor, label: &str, f: impl Fn(&mut crate::history::Tx)) {
for page in 0..ed.pages.len() {
ed.with_page(page, |ed| ed.edit_variables(label, |tx| f(tx)));
}
}
fn sanitize_name(s: &str) -> String {
s.trim()
.trim_start_matches("--")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'-'
}
})
.collect()
}
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
let heading = |ui: &mut Ui, t: &str| {
@@ -96,346 +71,36 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
);
}
}
// ---- modes
heading(ui, "Modes");
let modes: Vec<String> = ed.doc.modes.keys().cloned().collect();
let active = ed.doc.mode.clone();
if !modes.is_empty() {
ui.horizontal_wrapped(|ui| {
for m in &modes {
let on = active.as_deref() == Some(m);
let chip = super::kit::chip(ui, m, on).on_hover_text("Right-click to delete");
if chip.clicked() && !on {
let m = m.clone();
on_every_page(ed, "Set variable mode", |tx| {
tx.set_mode(&m);
});
// ---- modes per collection, for this page
let multi: Vec<_> = ed.design.collections.iter().filter(|c| c.modes.len() > 1).cloned().collect();
if !multi.is_empty() {
heading(ui, "Modes on this page");
}
for c in multi {
let active = ed.pages[ed.active_page].modes.get(&c.id).unwrap_or(&c.default_mode);
let cur = c.modes.iter().position(|m| &m.id == active).unwrap_or(0);
let opts: Vec<(usize, &str)> = c.modes.iter().enumerate().map(|(i, m)| (i, m.name.as_str())).collect();
ui.horizontal(|ui| {
ui.label(RichText::new(&c.name).size(theme::SECONDARY + 1.0).color(p.secondary));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(i) = super::kit::select(ui, cur, &opts, 120.0)
&& let Err(e) = ed.set_page_mode(&c.id, &c.modes[i].id)
{
ed.status = e.to_string();
}
chip.context_menu(|ui| {
if super::icon_menu_item(ui, crate::theme::Icon::Trash, "Delete mode").clicked() {
super::confirm::request(st, ed, super::confirm::Removal::Mode(m.clone()));
ui.close();
}
});
}
});
});
}
ui.horizontal(|ui| {
let w = (ui.available_width() - 44.0).max(60.0);
let r = ui.add(
egui::TextEdit::singleline(&mut st.mode_draft)
.hint_text("New mode (e.g. Dark)")
.desired_width(w)
.margin(crate::theme::INPUT_MARGIN),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let mode_name = st.mode_draft.trim().to_string();
if (crate::theme::small_button(ui, "Add").clicked() || enter)
&& !mode_name.is_empty()
{
st.mode_draft.clear();
let snapshot: Vec<(String, Color)> = ed
.doc
.variables
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
on_every_page(ed, "Add variable mode", |tx| {
tx.doc.modes.entry(mode_name.clone()).or_default();
for (k, v) in &snapshot {
tx.set_variable_in_mode(&mode_name, k, *v);
}
tx.set_mode(&mode_name);
});
}
});
// ---- variables
heading(ui, "Variables");
if ed.doc.variables.is_empty() {
ui.label(
RichText::new("No variables yet. Agents add them with\nset_variables; fills use var(--name).")
.size(theme::SECONDARY)
.color(p.secondary),
);
}
let vars: Vec<(String, Color)> = ed
.doc
.variables
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
for (name, color) in vars {
ui.horizontal(|ui| {
let mut c = c32(color);
if ui.color_edit_button_srgba(&mut c).changed() {
let new = Color::rgba(c.r(), c.g(), c.b(), c.a());
if new != color {
let (n, mode) = (name.clone(), ed.doc.mode.clone());
on_every_page(ed, "Change variable", |tx| {
match &mode {
Some(m) => tx.set_variable_in_mode(m, &n, new),
None => tx.set_variable(&n, new),
};
});
}
}
let is_renaming = st
.var_renaming
.as_ref()
.is_some_and(|(old, _)| old == &name);
if is_renaming {
let (_, mut draft) = st.var_renaming.clone().unwrap();
let r = ui.add(
egui::TextEdit::singleline(&mut draft)
.desired_width(90.0)
.margin(crate::theme::INPUT_MARGIN),
);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
st.var_renaming = None;
} else if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let new = sanitize_name(&draft);
let old = name.clone();
st.var_renaming = None;
if !new.is_empty() && new != old {
on_every_page(ed, "Rename variable", |tx| {
tx.rename_variable(&old, &new);
});
}
} else {
st.var_renaming = Some((name.clone(), draft));
}
} else {
let lbl = ui.add(
egui::Label::new(
RichText::new(format!("--{name}"))
.monospace()
.size(theme::SECONDARY + 1.0),
)
.sense(egui::Sense::click()),
);
if lbl.on_hover_text("Click to rename").clicked() {
st.var_renaming = Some((name.clone(), name.clone()));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::icon_button(ui, theme::Icon::Trash, 20.0, false)
.on_hover_text("Delete (fills keep their color)")
.clicked()
{
super::confirm::request(st, ed, super::confirm::Removal::Variable(name.clone()));
}
ui.label(RichText::new(color.to_hex()).monospace().weak());
});
});
}
ui.horizontal(|ui| {
let w = (ui.available_width() - 44.0).max(60.0);
let r = ui.add(
egui::TextEdit::singleline(&mut st.variable_draft)
.hint_text("new-variable")
.desired_width(w)
.margin(crate::theme::INPUT_MARGIN),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let name = sanitize_name(&st.variable_draft);
if (crate::theme::small_button(ui, "Add").clicked() || enter) && !name.is_empty() {
st.variable_draft.clear();
let base = Color::rgb(0x80, 0x80, 0x80);
on_every_page(ed, "Add variable", |tx| {
tx.set_variable(&name, base);
});
}
});
if !ed.doc.variables.is_empty() || !ed.doc.tokens.is_empty() {
ui.horizontal(|ui| {
if crate::theme::small_button(ui, "Copy CSS")
.on_hover_text("Copy variables and tokens as CSS custom properties")
.clicked()
{
let mut lines = vec![":root {".to_string()];
for (k, v) in &ed.doc.variables {
lines.push(format!(" --{k}: {};", v.to_hex()));
}
for (k, v) in &ed.doc.tokens {
lines.push(format!(" --{k}: {v};"));
}
lines.push("}".to_string());
for (mode, map) in &ed.doc.modes {
lines.push(format!("[data-theme=\"{mode}\"] {{"));
for (k, v) in map {
lines.push(format!(" --{k}: {};", v.to_hex()));
}
lines.push("}".to_string());
}
ui.ctx().copy_text(lines.join("\n"));
ed.status = "Copied tokens to clipboard".into();
}
if crate::theme::small_button(ui, "Copy JSON")
.on_hover_text("Copy variables and tokens in W3C DTCG JSON format")
.clicked()
{
let mut root = serde_json::Map::new();
for (k, v) in &ed.doc.variables {
root.insert(
k.clone(),
serde_json::json!({ "$type": "color", "$value": v.to_hex() }),
);
}
for (k, v) in &ed.doc.tokens {
root.insert(
k.clone(),
serde_json::json!({ "$type": "dimension", "$value": v }),
);
}
if let Ok(json) =
serde_json::to_string_pretty(&serde_json::Value::Object(root))
{
ui.ctx().copy_text(json);
ed.status = "Copied tokens to clipboard".into();
}
}
});
}
// ---- tokens
heading(ui, "Tokens");
let tokens: Vec<(String, String)> = ed
.doc
.tokens
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (k, v) in tokens {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("--{k}")).monospace().size(theme::SECONDARY));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::icon_button(ui, theme::Icon::Trash, 20.0, false)
.on_hover_text("Delete token")
.clicked()
{
super::confirm::request(st, ed, super::confirm::Removal::Token(k.clone()));
}
ui.label(RichText::new(&v).monospace().weak().size(theme::SECONDARY));
});
});
}
ui.horizontal(|ui| {
let avail = (ui.available_width() - 48.0).max(80.0);
let r1 = ui.add(
egui::TextEdit::singleline(&mut st.token_name_draft)
.hint_text("spacing-md")
.desired_width(avail * 0.55)
.margin(crate::theme::INPUT_MARGIN),
);
let r2 = ui.add(
egui::TextEdit::singleline(&mut st.token_val_draft)
.hint_text("16px")
.desired_width(avail * 0.45)
.margin(crate::theme::INPUT_MARGIN),
);
let enter = (r1.lost_focus() || r2.lost_focus())
&& ui.input(|i| i.key_pressed(egui::Key::Enter));
let name = sanitize_name(&st.token_name_draft);
let val = st.token_val_draft.trim().to_string();
if (crate::theme::small_button(ui, "Add").clicked() || enter)
&& !name.is_empty()
&& !val.is_empty()
{
st.token_name_draft.clear();
st.token_val_draft.clear();
on_every_page(ed, "Set token", |tx| tx.set_token(&name, &val));
}
});
// ---- components
heading(ui, "Components");
let mut comps: Vec<(NodeId, String, f64, f64, usize)> = ed
.doc
.nodes
.values()
.filter(|n| n.component)
.map(|n| {
let uses = ed
.doc
.nodes
.values()
.filter(|i| i.id != n.id && i.instance.as_ref().is_some_and(|l| l.main == n.id))
.count();
(n.id, n.name.clone(), n.rect.w, n.rect.h, uses)
})
.collect();
comps.sort_by_key(|c| c.0);
if comps.is_empty() {
ui.label(
RichText::new("Select a frame and press ⌘⌥K,\nor use create_component.")
.size(theme::SECONDARY)
.color(p.secondary),
);
}
if comps.len() > 3 || !st.component_filter.is_empty() {
ui.add(
egui::TextEdit::singleline(&mut st.component_filter)
.hint_text("Filter components…")
.desired_width(f32::INFINITY)
.margin(crate::theme::INPUT_MARGIN),
);
ui.add_space(2.0);
}
if let Some(inst_id) = ed
.selection
.first()
.copied()
.filter(|id| ed.is_instance_root(*id))
{
let cur_main = ed.instance_main_root(inst_id);
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new("Instance")
.size(theme::SECONDARY)
.color(p.secondary),
);
let opts: Vec<(Option<NodeId>, &str)> = comps
.iter()
.map(|(cid, cname, _, _, _)| (Some(*cid), cname.as_str()))
.collect();
if let Some(Some(cid)) = super::kit::select(ui, cur_main, &opts, 120.0) {
ed.swap_instance(inst_id, cid);
}
if crate::theme::small_button(ui, "Reset").clicked() {
ed.reset_overrides();
}
if crate::theme::small_button(ui, "Detach").clicked() {
ed.detach_instance();
}
});
}
let needle = st.component_filter.trim().to_lowercase();
for (id, name, w, h, uses) in comps {
if !needle.is_empty() && !name.to_lowercase().contains(&needle) {
continue;
}
ui.horizontal(|ui| {
ui.label(RichText::new(&name).font(theme::medium(theme::BODY)));
ui.label(
RichText::new(format!("{w:.0}×{h:.0} · {uses}×"))
.weak()
.size(theme::SECONDARY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "Insert")
.on_hover_text("Place an instance in view")
.clicked()
{
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
ed.create_instance(id, at);
}
if crate::theme::small_button(ui, "Go to")
.on_hover_text("Select the main component")
.clicked()
{
ed.selection = vec![id];
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
}
});
});
let vars: usize = ed.design.collections.iter().map(|c| c.variables.len()).sum();
ui.label(
RichText::new(format!("{vars} variables in {} collections", ed.design.collections.len()))
.size(theme::SECONDARY + 1.0)
.color(p.secondary),
);
if super::kit::button(ui, super::kit::ButtonKind::Secondary, None, "Open variables").clicked() {
st.variables.open = true;
}
// ---- design lint
let issues = crate::mcp::lint::lint(
+145 -106
View File
@@ -11,6 +11,8 @@ use crate::model::{
NodeKind, Paint, Sizing, Stop, StrokeAlign, TextAlign,
};
use crate::theme;
use super::bind::BIND_W;
use crate::variables::Kind;
use egui::{RichText, Ui};
const LABEL_W: f32 = 64.0;
@@ -126,6 +128,7 @@ fn color_field(
ed: &mut Editor,
c: Color,
bound: Option<&str>,
detach: &mut bool,
width: f32,
) -> Option<Color> {
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, theme::FIELD_H), egui::Sense::hover());
@@ -149,19 +152,23 @@ fn color_field(
egui::pos2(rect.right() - 8.0, rect.bottom()),
);
if let Some(v) = bound {
let x = egui::Rect::from_center_size(rect.right_center() - egui::vec2(14.0, 0.0), egui::Vec2::splat(16.0));
*detach = super::bind::cross(ui, x);
let name = super::bind::display_name(&ed.design, v);
let text_rect = egui::Rect::from_min_max(text_rect.min, egui::pos2(x.left() - 4.0, rect.bottom()));
let mut label_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect));
label_ui.set_clip_rect(text_rect.intersect(ui.clip_rect()));
label_ui
.add_sized(
text_rect.size(),
egui::Label::new(
RichText::new(format!("--{v}"))
.size(theme::SECONDARY + 1.0)
.color(theme::pal(ui).secondary),
RichText::new(&name)
.font(theme::semibold(theme::SECONDARY + 1.0))
.color(theme::accent()),
)
.truncate(),
)
.on_hover_text(format!("--{v}"));
.on_hover_text(name);
return out;
}
let id = ui.id().with("hex");
@@ -327,6 +334,53 @@ fn field(
out
}
/// [`field`] with a variable affordance: a bind button beside it, or, when every selected layer
/// binds `bfield` to the same variable, the variable's chip in its place.
#[allow(clippy::too_many_arguments)]
fn bound_field(
ui: &mut Ui,
ed: &mut Editor,
ids: &[NodeId],
bfield: &str,
prefix: &str,
suffix: &str,
value: f64,
speed: f64,
range: std::ops::RangeInclusive<f64>,
width: f32,
) -> Option<f64> {
if let Some(css) = super::bind::shared_binding(ed, ids, bfield) {
let shown = format!("{}{suffix}", fmt_num(value));
let act = super::bind::bound_chip(ui, ed, Kind::Number, &css, &shown, width);
super::bind::apply(ed, ids, bfield, act);
return None;
}
let w = (width - BIND_W - ui.spacing().item_spacing.x).max(36.0);
let out = field(ui, ed, prefix, suffix, value, speed, range, w);
if let Some(css) = super::bind::bind_button(ui, ed, Kind::Number) {
super::bind::apply(ed, ids, bfield, Some(Some(css)));
}
out
}
/// A row for a field that has no room for a bind button: label, then the bind button or chip.
fn bind_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], bfield: &str, label: &str, kind: Kind, shown: &str) {
let bound = super::bind::shared_binding(ed, ids, bfield);
row(ui, label, |ui| match bound {
Some(css) => {
let w = ui.available_width();
let act = super::bind::bound_chip(ui, ed, kind, &css, shown, w);
super::bind::apply(ed, ids, bfield, act);
}
None => {
if let Some(css) = super::bind::bind_button(ui, ed, kind) {
super::bind::apply(ed, ids, bfield, Some(Some(css)));
}
ui.label(RichText::new("Bind to a variable").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
}
});
}
fn fmt_num(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
@@ -1136,14 +1190,14 @@ fn geometry(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
});
ui.horizontal(|ui| {
let w = half(ui);
if let Some(v) = num_field(ui, ed, "W ", r.w, 1.0, 0.0..=1e6, w) {
if let Some(v) = bound_field(ui, ed, ids, "width", "W ", "", r.w, 1.0, 0.0..=1e6, w) {
resize(ed, ids[0], v2(v, r.h));
}
// Disabling the Ui itself (not `add_enabled_ui`, whose scope shifts the cell).
if matches!(first.kind, NodeKind::Text { .. }) {
ui.disable();
}
if let Some(v) = num_field(ui, ed, "H ", r.h, 1.0, 0.0..=1e6, w) {
if let Some(v) = bound_field(ui, ed, ids, "height", "H ", "", r.h, 1.0, 0.0..=1e6, w) {
resize(ed, ids[0], v2(r.w, v));
}
});
@@ -1193,10 +1247,13 @@ fn geometry(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
let sp = ui.spacing().item_spacing.x;
let room = ui.available_width() - 32.0;
if !per_corner {
if let Some(v) = num_field(
if let Some(v) = bound_field(
ui,
ed,
ids,
"radius",
"Radius ",
"",
first.style.corner_radius,
0.5,
0.0..=1e4,
@@ -1388,7 +1445,7 @@ fn auto_layout(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.add_space(6.0);
ui.add_enabled_ui(!l.gap_auto, |ui| {
let room = (ui.available_width() - 34.0).max(60.0);
if let Some(v) = num_field(ui, ed, "Gap ", l.gap, 0.5, -1e4..=1e4, room) {
if let Some(v) = bound_field(ui, ed, ids, "gap", "Gap ", "", l.gap, 0.5, -1e4..=1e4, room) {
set(ed, &|l| l.gap = v);
}
});
@@ -1399,7 +1456,8 @@ fn auto_layout(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.horizontal(|ui| {
let cw = ((ui.available_width() - 3.0 * ui.spacing().item_spacing.x) / 4.0).max(36.0);
for (i, p) in ["T ", "R ", "B ", "L "].iter().enumerate() {
if let Some(v) = num_field(ui, ed, p, l.padding[i], 0.5, 0.0..=1e4, cw) {
let side = ["padding-top", "padding-right", "padding-bottom", "padding-left"][i];
if let Some(v) = bound_field(ui, ed, ids, side, p, "", l.padding[i], 0.5, 0.0..=1e4, cw) {
set(ed, &|l| l.padding[i] = v);
}
}
@@ -1518,6 +1576,9 @@ fn text_section(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
});
}
});
bind_row(ui, ed, ids, "font-size", "Size", Kind::Number, &fmt_num(*font_size));
bind_row(ui, ed, ids, "line-height", "Line height", Kind::Number, &fmt_num(first.text_style.line_height.unwrap_or(0.0)));
bind_row(ui, ed, ids, "text-color", "Color", Kind::Color, "");
if ids.len() == 1 && ed.text_edit.is_none() {
let NodeKind::Text { text, .. } = &first.kind else {
return;
@@ -1597,63 +1658,24 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
}
match p {
Paint::Solid { color, var } => {
let has_vars = !ed.doc.variables.is_empty() || var.is_some();
let btn_label = if var.is_some() { "var" } else { "--" };
let spacing = ui.spacing().item_spacing.x;
let variable_width = if has_vars {
ui.painter()
.layout_no_wrap(
btn_label.into(),
egui::TextStyle::Button.resolve(ui.style()),
ui.visuals().text_color(),
)
.size()
.x
+ 2.0 * ui.spacing().button_padding.x
+ spacing
} else {
0.0
};
let w = ui.available_width() - variable_width - 24.0 - spacing;
if let Some(c) = color_field(ui, ed, *color, var.as_deref(), w.max(40.0)) {
let bind_w = if var.is_some() { 0.0 } else { BIND_W + spacing };
let w = ui.available_width() - bind_w - 24.0 - spacing;
let mut detach = false;
if let Some(c) = color_field(ui, ed, *color, var.as_deref(), &mut detach, w.max(40.0)) {
set(ed, Paint::solid(c));
}
if has_vars {
let vars: Vec<(String, Color)> = ed
.doc
.variables
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
ui.menu_button(btn_label, |ui| {
for (vname, vcol) in &vars {
let active = var.as_deref() == Some(vname.as_str());
if ui
.selectable_label(active, format!("--{vname} ({})", vcol.to_hex()))
.clicked()
{
set(ed, Paint::solid_var(*vcol, vname));
ui.close();
}
}
if var.is_some() {
if !vars.is_empty() {
ui.separator();
}
if super::icon_menu_item(
ui,
crate::theme::Icon::Minus,
"Unlink variable",
)
.clicked()
{
let mut unlinked = p.clone();
unlinked.unlink_var();
set(ed, unlinked);
ui.close();
}
}
});
let mut picked = None;
if detach {
let mut unlinked = p.clone();
unlinked.unlink_var();
set(ed, unlinked);
} else if var.is_none() {
picked = super::bind::bind_button(ui, ed, Kind::Color);
}
if let Some(css) = picked {
let c = ed.doc.variables.get(&css).copied().unwrap_or(*color);
set(ed, Paint::solid_var(c, &css));
}
}
Paint::Image {
@@ -1776,6 +1798,9 @@ fn stroke(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if s.stroke.is_some() {
if crate::theme::icon_button(ui, crate::theme::Icon::Minus, 24.0, false).clicked() {
if super::bind::shared_binding(ed, ids, "stroke").is_some() {
super::bind::apply(ed, ids, "stroke", Some(None));
}
apply(ed, "stroke", ids, |n| n.style.stroke = None);
}
} else if crate::theme::icon_button(ui, crate::theme::Icon::Plus, 24.0, false).clicked()
@@ -1786,9 +1811,21 @@ fn stroke(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
});
let Some(c) = s.stroke else { return };
let full = ui.available_width();
if let Some(nc) = color_field(ui, ed, c, None, full) {
apply(ed, "stroke", ids, |n| n.style.stroke = Some(nc));
}
let bound = super::bind::shared_binding(ed, ids, "stroke");
let mut detach = false;
let w = if bound.is_some() { full } else { full - BIND_W - ui.spacing().item_spacing.x };
ui.horizontal(|ui| {
if let Some(nc) = color_field(ui, ed, c, bound.as_deref(), &mut detach, w) {
apply(ed, "stroke", ids, |n| n.style.stroke = Some(nc));
}
if detach {
super::bind::apply(ed, ids, "stroke", Some(None));
} else if bound.is_none()
&& let Some(css) = super::bind::bind_button(ui, ed, Kind::Color)
{
super::bind::apply(ed, ids, "stroke", Some(Some(css)));
}
});
ui.horizontal(|ui| {
let w = half(ui);
if let Some(v) = num_field(ui, ed, "W ", s.stroke_width, 0.1, 0.0..=500.0, w) {
@@ -1904,44 +1941,59 @@ fn layer(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
apply(ed, "blend", ids, |n| n.style.blend = v);
}
let op = first.style.opacity * 100.0;
if let Some(v) = field(ui, ed, "", "%", op, 0.5, 0.0..=100.0, w) {
if let Some(v) = bound_field(ui, ed, ids, "opacity", "", "%", op, 0.5, 0.0..=100.0, w) {
apply(ed, "opacity", ids, |n| n.style.opacity = v / 100.0);
}
});
bind_row(ui, ed, ids, "visible", "Visible", Kind::Boolean, &first.visible.to_string());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bound_color_field_keeps_following_control_outside_field() {
let ctx = egui::Context::default();
super::super::setup_style(&ctx);
fn bound_ed() -> (Editor, NodeId, NodeId) {
let mut ed = Editor::default();
let mut next = egui::Rect::NOTHING;
let mut field_left = 0.0;
let mut output = ctx.run_ui(Default::default(), |ui| {
theme::figma_fields(ui);
ui.horizontal(|ui| {
field_left = ui.next_widget_position().x;
color_field(ui, &mut ed, Color::BLACK, Some("surface-2"), 100.0);
next = ui.menu_button("var", |_| {}).response.rect;
});
});
output.textures_delta.clear();
assert!(
next.left() >= field_left + 100.0,
"next control overlaps color field: {next:?}"
);
let label = output.shapes.iter().find(|shape| {
matches!(&shape.shape, egui::epaint::Shape::Text(t) if t.galley.text().starts_with("--surface"))
}).expect("bound color label");
assert!(
label.clip_rect.right() <= field_left + 100.0,
"bound label paints beyond field"
);
ed.edit_design("t", |ds| {
let c = ds.add_collection("Tokens");
ds.add_variable(&c, "space/md", Kind::Number)?;
Ok(())
})
.unwrap();
let mk = |ed: &mut Editor, bind: bool| {
let id = ed.add_node(
Node::new(NodeId(0), NodeKind::Frame { clip: false }, crate::geom::Rect::new(0.0, 0.0, 50.0, 30.0)),
None,
);
if bind {
ed.bind(&[id], "radius", Some("space-md")).unwrap();
}
id
};
let (a, b) = (mk(&mut ed, true), mk(&mut ed, false));
(ed, a, b)
}
#[test]
fn inspector_draws_bound_and_unbound_node() {
for pick in [0, 1] {
let ctx = egui::Context::default();
super::super::setup_style(&ctx);
let (mut ed, a, b) = bound_ed();
ed.selection = vec![[a, b][pick]];
let mut st = UiState::default();
// Two frames: a bound chip must not detach itself while idle.
let mut out = ctx.run_ui(Default::default(), |ui| panel(ui, &mut ed, &mut st));
out.textures_delta.clear();
let mut out = ctx.run_ui(Default::default(), |ui| panel(ui, &mut ed, &mut st));
out.textures_delta.clear();
let chip = out.shapes.iter().any(|s| {
matches!(&s.shape, egui::epaint::Shape::Text(t) if t.galley.text().contains("space/md"))
});
assert_eq!(chip, pick == 0, "chip shown only for the bound node");
}
}
#[test]
fn bound_fill_row_fits_narrow_inspector() {
for width in [192.0, 232.0, 392.0] {
@@ -1967,19 +2019,6 @@ mod tests {
row_width <= width + 0.1,
"fill row {row_width} overflows inspector {width}"
);
let mut variable = None;
let mut binding = None;
for shape in &output.shapes {
if let egui::epaint::Shape::Text(t) = &shape.shape {
if t.galley.text() == "var" {
variable = Some(t.galley.rect.translate(t.pos.to_vec2()));
}
if t.galley.text().starts_with("--surface") {
binding = Some(shape.clip_rect);
}
}
}
assert!(binding.unwrap().right() < variable.unwrap().left());
}
}
}
+4 -1
View File
@@ -125,11 +125,14 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
}
/// A chip with its own ink (for example `p.success` for Accepted); None is the idle grey.
/// Height of [`chip`], so rows that hold one can size around it.
pub const CHIP_H: f32 = 22.0;
pub fn chip_ink(ui: &mut Ui, label: &str, ink: Option<Color32>) -> Response {
let p = theme::pal(ui);
let font = theme::semibold(theme::SECONDARY);
let w = galley_width(ui, label, font.clone()) + 20.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click());
let (rect, resp) = ui.allocate_exact_size(vec2(w, CHIP_H), Sense::click());
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("chip"), &resp);
ui.painter().rect_filled(
rect,
+712
View File
@@ -0,0 +1,712 @@
//! The Libraries modal: publish this file, enable libraries from other projects, review updates.
use super::{UiState, confirm, home::relative_time, kit};
use crate::editor::Editor;
use crate::libraries::{self, EnabledStatus, LibrarySummary, PublishDiff, Published};
use crate::project::{Store, now_ms};
use crate::theme::{self, Icon};
use crate::variables::{Collection, Value, Variable};
use egui::{Color32, RichText, Ui};
use std::collections::HashMap;
use std::path::Path;
const MODAL_W: f32 = 720.0;
const MODAL_H: f32 = 560.0;
const CARD_RADIUS: u8 = 16;
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tab {
#[default]
File,
Libraries,
Updates,
}
/// A variable value as the Updates tab shows it.
#[derive(Debug, Clone, PartialEq)]
pub enum Shown {
None,
Color(Color32, String),
Text(String),
}
struct VarRow {
name: String,
before: Shown,
after: Shown,
/// "Added", "Changed" or "Removed".
how: &'static str,
}
struct UpdateInfo {
id: String,
name: String,
from: u32,
to: u32,
published: u64,
vars: Vec<VarRow>,
comps: Vec<(String, &'static str)>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RowState {
Available,
Enabled,
UpdateAvailable,
Missing,
}
struct Row {
id: String,
name: String,
version: u32,
components: usize,
variables: usize,
state: RowState,
source: Option<LibrarySummary>,
}
/// What the modal shows, read from disk when it opens and after each action.
#[derive(Default)]
struct Scan {
rows: Vec<Row>,
updates: Vec<UpdateInfo>,
previous: Option<(u32, u64)>,
diff: Option<PublishDiff>,
components: usize,
variables: usize,
hidden: usize,
}
enum Act {
Publish,
Enable(String),
AskDisable(String),
Disable(String),
Update(String),
Skip(String, u32),
}
#[derive(Default)]
pub struct LibrariesUi {
pub open: bool,
was_open: bool,
tab: Tab,
search: String,
description: String,
scan: Scan,
/// Library id -> source version the user skipped this session.
skipped: HashMap<String, u32>,
confirm_disable: Option<String>,
error: Option<String>,
note: Option<String>,
}
/// The value of `v` in the collection's default mode (or its first value).
pub fn shown(c: &Collection, v: &Variable) -> Shown {
match v.values.get(&c.default_mode).or_else(|| v.values.values().next()) {
Some(Value::Color(x)) => Shown::Color(super::c32(*x), x.to_hex()),
Some(Value::Number(n)) => Shown::Text(format!("{n}")),
Some(Value::String(s)) => Shown::Text(s.clone()),
Some(Value::Boolean(b)) => Shown::Text(b.to_string()),
Some(Value::Alias(_)) => Shown::Text("alias".into()),
None => Shown::None,
}
}
fn find<'a>(cols: impl Iterator<Item = &'a Collection>, name: &str) -> Shown {
for c in cols {
if let Some(v) = c.variables.iter().find(|v| v.name == name) {
return shown(c, v);
}
}
Shown::None
}
/// Mains that publishing would include.
fn count_components(ed: &Editor) -> usize {
(0..ed.pages.len())
.filter(|&i| !ed.pages[i].name.starts_with(['_', '.']))
.map(|i| {
let doc = ed.page_doc(i);
doc.nodes
.values()
.filter(|n| n.component && n.component_key.is_some() && !n.name.starts_with(['_', '.']))
.filter(|n| !doc.ancestors(n.id).iter().any(|a| doc.get(*a).is_some_and(|a| a.component)))
.count()
})
.sum()
}
fn scan(ed: &Editor, dir: &Path) -> Scan {
let mut s = Scan::default();
let Some(root) = dir.parent() else { return s };
let store = Store { root: root.to_path_buf() };
s.components = count_components(ed);
let local = ed.design.collections.iter().filter(|c| c.library.is_none());
for v in local.flat_map(|c| &c.variables) {
if v.hidden {
s.hidden += 1;
} else {
s.variables += 1;
}
}
s.previous = crate::vfs::read_to_string(dir.join(libraries::LIBRARY_FILE))
.ok()
.and_then(|t| serde_json::from_str::<Published>(&t).ok())
.map(|p| (p.version, p.published));
s.diff = libraries::publish_diff(dir, ed).ok();
let available = libraries::available(&store, Some(dir));
let status = libraries::status(&store, dir, ed);
for st in &status {
s.rows.push(enabled_row(ed, st, &available));
if st.update_available {
s.updates.extend(update_info(ed, &store, dir, st, &available));
}
}
for l in available.iter().filter(|l| !status.iter().any(|st| st.library_id == l.library_id)) {
s.rows.push(Row {
id: l.library_id.clone(),
name: l.name.clone(),
version: l.version,
components: l.components,
variables: l.variables,
state: RowState::Available,
source: Some(l.clone()),
});
}
s
}
fn enabled_row(ed: &Editor, st: &EnabledStatus, available: &[LibrarySummary]) -> Row {
let state = match (st.source_version, st.update_available) {
(None, _) => RowState::Missing,
(Some(_), true) => RowState::UpdateAvailable,
(Some(_), false) => RowState::Enabled,
};
Row {
id: st.library_id.clone(),
name: st.name.clone(),
version: st.local_version,
components: ed.library_docs.get(&st.library_id).map_or(0, |d| d.root.len()),
variables: ed
.design
.collections
.iter()
.filter(|c| c.library.as_deref() == Some(&st.library_id))
.map(|c| c.variables.len())
.sum(),
state,
source: available.iter().find(|l| l.library_id == st.library_id).cloned(),
}
}
fn update_info(
ed: &Editor,
store: &Store,
dir: &Path,
st: &EnabledStatus,
available: &[LibrarySummary],
) -> Option<UpdateInfo> {
let d = libraries::update_diff(store, dir, &st.library_id).ok()?;
let src = available.iter().find(|l| l.library_id == st.library_id)?;
let new: Published = serde_json::from_str(&crate::vfs::read_to_string(src.dir.join(libraries::LIBRARY_FILE)).ok()?).ok()?;
let own = || ed.design.collections.iter().filter(|c| c.library.as_deref() == Some(&st.library_id));
let mut vars = Vec::new();
for (names, how) in [
(&d.variables_changed, "Changed"),
(&d.variables_added, "Added"),
(&d.variables_removed, "Removed"),
] {
vars.extend(names.iter().map(|n| VarRow {
name: n.clone(),
before: find(own(), n),
after: find(new.collections.iter(), n),
how,
}));
}
let mut comps = Vec::new();
for (names, how) in [
(&d.components_changed, "Changed"),
(&d.components_added, "Added"),
(&d.components_removed, "Removed"),
] {
comps.extend(names.iter().map(|n| (n.clone(), how)));
}
Some(UpdateInfo {
id: st.library_id.clone(),
name: st.name.clone(),
from: st.local_version,
to: src.version,
published: src.published,
vars,
comps,
})
}
impl LibrariesUi {
fn refresh(&mut self, ed: &Editor, dir: Option<&Path>) {
self.scan = dir.map(|d| scan(ed, d)).unwrap_or_default();
}
fn visible_updates(&self) -> impl Iterator<Item = &UpdateInfo> {
self.scan.updates.iter().filter(|u| self.skipped.get(&u.id) != Some(&u.to))
}
}
pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
let mut lib = std::mem::take(&mut st.libraries);
let dir = st.project_dir.clone();
if lib.open {
if !lib.was_open {
lib.error = None;
lib.note = None;
lib.refresh(ed, dir.as_deref());
lib.description = lib.scan.previous.map_or_else(String::new, |_| {
dir.as_deref()
.and_then(|d| crate::vfs::read_to_string(d.join(libraries::LIBRARY_FILE)).ok())
.and_then(|t| serde_json::from_str::<Published>(&t).ok())
.map(|p| p.description)
.unwrap_or_default()
});
}
draw(ctx, ed, st, &mut lib, dir.as_deref());
}
lib.was_open = lib.open;
st.libraries = lib;
}
fn draw(ctx: &egui::Context, ed: &mut Editor, st: &UiState, lib: &mut LibrariesUi, dir: Option<&Path>) {
let p = theme::pal_from_ctx(ctx);
let mut act = None;
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(24.0).inner_margin(24);
let modal = egui::Modal::new(egui::Id::new("libraries-modal")).frame(frame).show(ctx, |ui| {
ui.set_width(MODAL_W.min(ctx.content_rect().width() - 96.0));
if lib.tab != Tab::File {
ui.set_height(MODAL_H.min(ctx.content_rect().height() - 96.0));
}
ui.spacing_mut().item_spacing.y = 8.0;
header(ui, lib);
ui.add_space(8.0);
match lib.tab {
Tab::File => act = file_tab(ui, st, lib, true),
Tab::Libraries => act = libraries_tab(ui, st, lib),
Tab::Updates => act = updates_tab(ui, lib),
}
});
let asking = lib.confirm_disable.clone();
if let Some(id) = asking {
let name = lib.scan.rows.iter().find(|r| r.id == id).map_or(id.clone(), |r| r.name.clone());
let msg = format!(
"Disable {name}? Fills and fields bound to its variables keep their current value but lose the binding, \
and its component instances become plain copies."
);
match confirm::dialog(ctx, "libraries-disable", "Disable library?", &msg, "Disable") {
Some(true) => act = Some(Act::Disable(id)),
Some(false) => lib.confirm_disable = None,
None => {}
}
} else if modal.should_close() || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
lib.open = false;
}
if let Some(a) = act {
apply(a, ed, lib, dir);
}
}
fn apply(act: Act, ed: &mut Editor, lib: &mut LibrariesUi, dir: Option<&Path>) {
lib.error = None;
lib.note = None;
let Some(dir) = dir else { return };
let store = dir.parent().map(|r| Store { root: r.to_path_buf() });
let result: Result<Option<String>, String> = match act {
Act::Publish => libraries::publish(dir, ed, lib.description.trim()).map(|p| Some(format!("Published v{}", p.version))),
Act::Enable(id) => match lib.scan.rows.iter().find(|r| r.id == id).and_then(|r| r.source.clone()) {
Some(src) => libraries::enable(dir, ed, &src).map(|()| None),
None => Err("library source not found".into()),
},
Act::AskDisable(id) => {
lib.confirm_disable = Some(id);
return;
}
Act::Disable(id) => {
lib.confirm_disable = None;
libraries::disable(dir, ed, &id).map(|r| {
Some(format!(
"Disabled: {} fills, {} bindings and {} instances detached",
r.fills, r.bindings, r.instances
))
})
}
Act::Update(id) => match &store {
Some(s) => libraries::update(s, dir, ed, &id).map(|()| Some("Library updated".into())),
None => Err("library source not found".into()),
},
Act::Skip(id, v) => {
lib.skipped.insert(id, v);
return;
}
};
match result {
Ok(note) => lib.note = note,
Err(e) => lib.error = Some(e),
}
lib.refresh(ed, Some(dir));
}
fn header(ui: &mut Ui, lib: &mut LibrariesUi) {
let n = lib.visible_updates().count();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Library, theme::accent());
ui.label(RichText::new("Libraries").font(theme::semibold(20.0)));
ui.add_space(8.0);
let updates = if n > 0 { format!("Updates · {n}") } else { "Updates".to_string() };
for (tab, label) in [(Tab::File, "This file"), (Tab::Libraries, "Libraries"), (Tab::Updates, updates.as_str())] {
if kit::chip(ui, label, lib.tab == tab).clicked() {
lib.tab = tab;
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if kit::close_button(ui).clicked() {
lib.open = false;
}
});
});
}
fn count(n: usize, unit: &str) -> String {
format!("{n} {unit}{}", if n == 1 { "" } else { "s" })
}
fn card(ui: &Ui) -> egui::Frame {
egui::Frame::new()
.fill(theme::pal(ui).window)
.corner_radius(CARD_RADIUS)
.inner_margin(egui::Margin::symmetric(12, 10))
}
fn status_line(ui: &mut Ui, lib: &LibrariesUi) {
let p = theme::pal(ui);
if let Some(e) = &lib.error {
ui.label(RichText::new(e).size(theme::SECONDARY).color(p.danger));
}
if let Some(n) = &lib.note {
ui.label(RichText::new(n).size(theme::SECONDARY).color(p.success));
}
}
fn file_tab(ui: &mut Ui, st: &UiState, lib: &mut LibrariesUi, full: bool) -> Option<Act> {
let p = theme::pal(ui);
let mut act = None;
let next = lib.scan.previous.map_or(1, |(v, _)| v + 1);
card(ui).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| {
ui.label(RichText::new(format!("This file · {}", st.project_name)).font(theme::semibold(theme::BODY)));
let chip = lib.scan.previous.map_or("Not published".to_string(), |(v, _)| format!("v{v} published"));
kit::chip_ink(ui, &chip, Some(theme::accent()));
});
let age = lib.scan.previous.map(|(_, t)| format!("Last published {}.", relative_time(t, now_ms())));
let diff = lib.scan.diff.as_ref().filter(|d| !d.is_empty()).map(|d| format!("{d} since."));
let line = [age, diff].into_iter().flatten().collect::<Vec<_>>().join(" ");
let line = if line.is_empty() { "Nothing to publish yet.".to_string() } else { line };
ui.label(RichText::new(line).size(theme::SECONDARY).color(p.secondary));
ui.horizontal(|ui| {
kit::chip(ui, &count(lib.scan.components, "component"), false);
kit::chip(ui, &count(lib.scan.variables, "variable"), false);
kit::chip(ui, &format!("{} hidden", lib.scan.hidden), false);
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let enabled = st.project_dir.is_some();
if ui
.add_enabled_ui(enabled, |ui| kit::button(ui, kit::ButtonKind::Primary, Some(Icon::Export), &format!("Publish v{next}")))
.inner
.clicked()
{
act = Some(Act::Publish);
}
});
});
});
if full {
ui.add_space(4.0);
kit::section_header(ui, "Description", None);
ui.add(
egui::TextEdit::multiline(&mut lib.description)
.hint_text("What this library is for")
.desired_rows(3)
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
ui.label(
RichText::new("Hidden variables and components named with a leading _ or . are not published.")
.size(theme::SECONDARY)
.color(p.tertiary),
);
}
status_line(ui, lib);
act
}
fn libraries_tab(ui: &mut Ui, st: &UiState, lib: &mut LibrariesUi) -> Option<Act> {
let p = theme::pal(ui);
let mut act = file_tab(ui, st, lib, false);
ui.add_space(6.0);
ui.horizontal(|ui| {
kit::section_header(ui, "Available libraries", None);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add(
egui::TextEdit::singleline(&mut lib.search)
.hint_text("Search libraries")
.desired_width(200.0)
.margin(theme::INPUT_MARGIN),
);
});
});
let q = lib.search.to_lowercase();
egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 6.0;
let mut any = false;
for r in lib.scan.rows.iter().filter(|r| r.name.to_lowercase().contains(&q)) {
any = true;
card(ui).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
kit::badge(ui, Icon::Library, theme::accent(), 36.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
ui.spacing_mut().interact_size.y = 16.0;
ui.horizontal(|ui| {
ui.label(RichText::new(&r.name).font(theme::semibold(theme::BODY)));
match r.state {
RowState::UpdateAvailable => kit::chip_ink(ui, "Update available", Some(theme::accent())),
RowState::Enabled => kit::chip(ui, "Enabled", false),
RowState::Missing => kit::chip_ink(ui, "Missing", Some(p.warning)),
RowState::Available => return,
};
});
let counts = format!("v{} · {} · {}", r.version, count(r.components, "component"), count(r.variables, "variable"));
ui.label(RichText::new(counts).size(theme::SECONDARY).color(p.secondary));
let (dot, text) = match r.state {
RowState::Missing => (p.warning, "Source not found · copy still works"),
RowState::UpdateAvailable => (p.success, "Source found · update ready"),
RowState::Enabled => (p.success, "Source found · up to date"),
RowState::Available => (p.success, "Source found"),
};
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover());
ui.painter().circle_filled(rect.center(), 3.0, dot);
ui.label(RichText::new(text).size(theme::SECONDARY).color(p.tertiary));
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if r.state == RowState::Missing {
if kit::button(ui, kit::ButtonKind::Secondary, None, "Disable").clicked() {
act = Some(Act::AskDisable(r.id.clone()));
}
return;
}
let on = r.state != RowState::Available;
let mut v = on;
if kit::toggle(ui, &mut v).changed() {
act = Some(if on { Act::AskDisable(r.id.clone()) } else { Act::Enable(r.id.clone()) });
}
});
});
});
}
if !any {
ui.label(
RichText::new("No libraries yet. Publish another project, then enable it here.")
.size(theme::SECONDARY)
.color(p.secondary),
);
}
});
act
}
fn swatch(ui: &mut Ui, s: &Shown) {
let p = theme::pal(ui);
match s {
Shown::None => {
ui.label(RichText::new("none").size(theme::BODY).color(p.secondary));
}
Shown::Text(t) => {
ui.label(RichText::new(t).size(theme::BODY));
}
Shown::Color(c, hex) => {
let (r, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover());
ui.painter().rect_filled(r, 4.0, *c);
ui.label(RichText::new(hex.to_uppercase()).size(theme::BODY));
}
}
}
fn updates_tab(ui: &mut Ui, lib: &mut LibrariesUi) -> Option<Act> {
let p = theme::pal(ui);
let mut act = None;
let updates: Vec<&UpdateInfo> = lib.visible_updates().collect();
if updates.is_empty() {
kit::empty_state(ui, Icon::Library, theme::accent(), "Everything is up to date", "", |_| {});
status_line(ui, lib);
return None;
}
egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
for u in updates {
ui.horizontal(|ui| {
kit::badge(ui, Icon::Library, theme::accent(), 36.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.label(RichText::new(&u.name).font(theme::semibold(15.0)));
kit::chip_ink(ui, &format!("v{} → v{}", u.from, u.to), Some(theme::accent()));
});
let t = format!("Published {} · source found", relative_time(u.published, now_ms()));
ui.label(RichText::new(t).size(theme::SECONDARY).color(p.secondary));
});
});
ui.add_space(4.0);
kit::section_header(ui, &format!("Variables · {} changed", u.vars.len()), None);
if !u.vars.is_empty() {
card(ui).show(ui, |ui| {
ui.set_width(ui.available_width());
for r in &u.vars {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(190.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(190.0);
ui.add(egui::Label::new(RichText::new(&r.name).font(theme::semibold(theme::BODY))).truncate());
});
ui.allocate_ui_with_layout(egui::vec2(110.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(110.0);
swatch(ui, &r.before);
});
let (a, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), a, Icon::ArrowRight, p.secondary);
if r.how == "Removed" {
ui.label(RichText::new("removed").size(theme::BODY).color(p.secondary));
} else {
swatch(ui, &r.after);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
chip_how(ui, r.how);
});
});
}
});
}
ui.add_space(4.0);
let added = u.comps.iter().filter(|c| c.1 == "Added").count();
let changed = u.comps.iter().filter(|c| c.1 == "Changed").count();
let removed = u.comps.len() - added - changed;
let mut title = format!("Components · {added} added · {changed} changed");
if removed > 0 {
title += &format!(" · {removed} removed");
}
kit::section_header(ui, &title, None);
ui.horizontal_wrapped(|ui| {
for (name, how) in &u.comps {
card(ui).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(name).font(theme::semibold(theme::SECONDARY)));
chip_how(ui, how);
});
});
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let (a, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), a, Icon::Question, p.secondary);
ui.label(
RichText::new("Instance overrides are kept. Nothing changes until you press Update.")
.size(theme::SECONDARY)
.color(p.secondary),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if kit::button(ui, kit::ButtonKind::Primary, Some(Icon::Restore), &format!("Update to v{}", u.to)).clicked() {
act = Some(Act::Update(u.id.clone()));
}
if kit::button(ui, kit::ButtonKind::Secondary, None, "Skip").clicked() {
act = Some(Act::Skip(u.id.clone(), u.to));
}
});
});
ui.add_space(8.0);
}
status_line(ui, lib);
});
act
}
fn chip_how(ui: &mut Ui, how: &str) {
let p = theme::pal(ui);
let ink = match how {
"Added" => p.success,
"Removed" => p.danger,
_ => theme::accent(),
};
kit::chip_ink(ui, how, Some(ink));
}
#[cfg(test)]
mod tests {
use super::*;
fn render(lib: &mut LibrariesUi, ed: &mut Editor, st: &mut UiState) {
let ctx = egui::Context::default();
super::super::setup_style(&ctx);
for _ in 0..2 {
st.libraries = std::mem::take(lib);
let mut out = ctx.run_ui(Default::default(), |ui| show(ui.ctx(), ed, st));
out.textures_delta.clear();
*lib = std::mem::take(&mut st.libraries);
}
}
#[test]
fn each_tab_renders() {
let root = std::env::temp_dir().join(format!("papercraft-libui-{:016x}", fastrand::u64(..)));
let store = Store::open(root).unwrap();
let (project, _) = store.create("Kit").unwrap();
for dir in [None, Some(project.dir.clone())] {
for tab in [Tab::File, Tab::Libraries, Tab::Updates] {
let mut ed = Editor::default();
let mut st = UiState { project_dir: dir.clone(), project_name: "Kit".into(), ..UiState::default() };
let mut lib = LibrariesUi { open: true, tab, ..LibrariesUi::default() };
render(&mut lib, &mut ed, &mut st);
assert!(lib.open);
}
}
}
#[test]
fn shown_formats_values() {
let c = Collection {
id: "c".into(),
name: "C".into(),
modes: vec![],
default_mode: "m".into(),
variables: vec![],
library: None,
};
let var = |v: Value| Variable {
id: "v".into(),
name: "v".into(),
kind: crate::variables::Kind::Number,
values: [("m".to_string(), v)].into(),
description: String::new(),
hidden: false,
};
assert_eq!(shown(&c, &var(Value::Number(16.0))), Shown::Text("16".into()));
let s = shown(&c, &var(Value::Color(crate::model::Color::rgb(1, 2, 3))));
assert!(matches!(s, Shown::Color(_, h) if h == "#010203"));
}
}
+798 -163
View File
@@ -1,197 +1,832 @@
//! The Library tab: reusable shapes with vector previews; click to insert, add the selection,
//! rename or delete items.
//! The Assets side view: every main component of this file (grouped by page), the components of
//! each enabled library, and the personal shape library ("My shapes"). Click a tile to place it;
//! tiles do not drag onto the canvas (neither did the shape library).
use super::UiState;
use super::{UiState, kit};
use crate::editor::Editor;
use crate::geom::{Rect, V2, v2};
use crate::library::{LibItem, Library};
use crate::libraries::EnabledStatus;
use crate::model::{Document, NodeId, Source};
use crate::theme::{self, Icon};
use egui::{Color32, RichText, Ui};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::rc::Rc;
#[derive(Default)]
pub struct LibraryUi {
pub library: Library,
/// The library changed and should be saved.
pub dirty: bool,
/// Tile being renamed `(tile key, draft)`.
pub renaming: Option<(String, String)>,
previews: HashMap<String, (usize, Option<egui::TextureHandle>)>,
/// Search box text.
pub filter: String,
/// Sections the user folded.
collapsed: HashSet<String>,
/// Enabled libraries against their sources, refreshed whenever the panel opens.
status: Vec<EnabledStatus>,
seen: Option<u64>,
/// `(revision stamp, preview fingerprint, texture)` per tile.
previews: HashMap<String, (u64, u64, Option<egui::TextureHandle>)>,
/// `list_assets` / `missing_mains` results, rebuilt when the editor revision moves.
assets: Option<(Stamp, String, Rc<AssetList>)>,
missing: Option<(Stamp, Rc<Vec<NodeId>>)>,
}
/// Narrowest a tile gets; tiles grow to share the row.
const TILE: f32 = 92.0;
const TILE_GAP: f32 = 8.0;
/// What the cached asset lists depend on besides the filter.
type Stamp = (u64, usize, usize, usize);
/// Rasterises an item (anti-aliased, 256 px square, transparent) for its tile.
fn preview_image(item: &LibItem) -> Option<egui::ColorImage> {
const PX: u32 = 256;
let doc = item.doc();
let svg = crate::io::svg::export(&doc, None, None)?;
fn stamp(ed: &Editor) -> Stamp {
(ed.revision, ed.active_page, ed.pages.len(), ed.library_docs.len())
}
impl LibraryUi {
fn assets(&mut self, ed: &Editor, filter: &str) -> Rc<AssetList> {
let st = stamp(ed);
match &self.assets {
Some((s, f, l)) if *s == st && f == filter => l.clone(),
_ => {
let l = Rc::new(list_assets(ed, filter));
self.assets = Some((st, filter.to_string(), l.clone()));
l
}
}
}
fn missing(&mut self, ed: &Editor) -> Rc<Vec<NodeId>> {
let st = stamp(ed);
match &self.missing {
Some((s, l)) if *s == st => l.clone(),
_ => {
let l = Rc::new(ed.missing_mains(ed.active_page));
self.missing = Some((st, l.clone()));
l
}
}
}
}
/// Where a component lives.
#[derive(Clone, Debug, PartialEq)]
pub enum Origin {
/// A page of this file (index).
Page(usize),
/// An enabled library (id).
Library(String),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Asset {
pub origin: Origin,
pub id: NodeId,
pub key: String,
pub name: String,
}
#[derive(Debug, Default, PartialEq)]
pub struct AssetList {
/// `(page name, components)`, pages in order, empty pages left out.
pub pages: Vec<(String, Vec<Asset>)>,
/// `(library id, library name, components)` for every enabled library.
pub libraries: Vec<(String, String, Vec<Asset>)>,
}
impl AssetList {
pub fn this_file(&self) -> usize {
self.pages.iter().map(|p| p.1.len()).sum()
}
}
fn mains(doc: &Document, origin: &Origin, needle: &str) -> Vec<Asset> {
let mut out: Vec<Asset> = doc
.nodes
.values()
.filter(|n| n.component && n.instance.is_none())
.filter(|n| needle.is_empty() || n.name.to_lowercase().contains(needle))
.map(|n| Asset {
origin: origin.clone(),
id: n.id,
key: n.component_key.clone().unwrap_or_else(|| n.id.0.to_string()),
name: n.name.clone(),
})
.collect();
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then(a.id.cmp(&b.id)));
out
}
/// The components the Assets view shows, filtered by a case-insensitive name search.
pub fn list_assets(ed: &Editor, filter: &str) -> AssetList {
let needle = filter.trim().to_lowercase();
let pages = (0..ed.pages.len())
.map(|i| (ed.pages[i].name.clone(), mains(ed.page_doc(i), &Origin::Page(i), &needle)))
.filter(|(_, v)| !v.is_empty())
.collect();
let libraries = ed
.design
.libraries
.iter()
.filter_map(|l| {
let doc = ed.library_docs.get(&l.library_id)?;
let o = Origin::Library(l.library_id.clone());
Some((l.library_id.clone(), doc.name.clone(), mains(doc, &o, &needle)))
})
.filter(|(_, _, v)| needle.is_empty() || !v.is_empty())
.collect();
AssetList { pages, libraries }
}
const OTHER_GROUP: &str = "Other";
/// `"A / B / C"` -> `(Some("A"), "B / C")`; a name without ` / ` has no group.
pub fn split_name(name: &str) -> (Option<&str>, &str) {
match name.split_once(" / ") {
Some((g, rest)) if !g.trim().is_empty() => (Some(g.trim()), rest),
_ => (None, name),
}
}
/// Components grouped by the part of the name before the first ` / `, groups by name,
/// names without a group under "Other" last.
pub fn group_assets(assets: &[Asset]) -> Vec<(String, Vec<Asset>)> {
let mut out: Vec<(String, Vec<Asset>)> = Vec::new();
for a in assets {
let g = split_name(&a.name).0.unwrap_or(OTHER_GROUP);
match out.iter_mut().find(|(n, _)| n == g) {
Some((_, v)) => v.push(a.clone()),
None => out.push((g.to_string(), vec![a.clone()])),
}
}
out.sort_by_key(|(n, _)| (n == OTHER_GROUP, n.to_lowercase()));
out
}
fn group_head(ui: &mut Ui, lib: &mut LibraryUi, key: &str, name: &str, count: usize, force_open: bool) -> bool {
let p = theme::pal(ui);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 22.0), egui::Sense::click());
let open = force_open || !lib.collapsed.contains(key);
if resp.clicked() && !force_open {
if open {
lib.collapsed.insert(key.to_string());
} else {
lib.collapsed.remove(key);
}
}
let chevron = if open { Icon::ChevronDown } else { Icon::ChevronRight };
let c = rect.left_center() + egui::vec2(7.0, 0.0);
theme::paint_icon(ui.painter(), egui::Rect::from_center_size(c, egui::vec2(12.0, 12.0)), chevron, p.secondary);
let g = ui.painter().text(
rect.left_center() + egui::vec2(20.0, 0.0),
egui::Align2::LEFT_CENTER,
name,
theme::semibold(theme::BODY + 1.0),
p.text,
);
ui.painter().text(
egui::pos2(g.right() + 6.0, rect.center().y),
egui::Align2::LEFT_CENTER,
count.to_string(),
egui::FontId::proportional(theme::SECONDARY),
p.tertiary,
);
open
}
fn grouped_grid(
ui: &mut Ui,
ed: &Editor,
lib: &mut LibraryUi,
section: &str,
assets: &[Asset],
searching: bool,
acts: &mut Vec<Act>,
) {
for (g, items) in group_assets(assets) {
let key = format!("{section}|{g}");
if group_head(ui, lib, &key, &g, items.len(), searching) {
ui.add_space(2.0);
asset_grid(ui, ed, lib, &items, acts);
}
}
}
fn asset_doc<'a>(ed: &'a Editor, a: &Asset) -> Option<&'a Document> {
match &a.origin {
Origin::Page(i) => Some(ed.page_doc(*i)),
Origin::Library(l) => ed.library_docs.get(l),
}
}
/// A component with exactly this name, preferring this page, then other pages, then libraries.
fn find_named(ed: &Editor, name: &str) -> Option<Asset> {
let pages = std::iter::once(ed.active_page).chain((0..ed.pages.len()).filter(|i| *i != ed.active_page));
let from_pages = pages.map(|i| mains(ed.page_doc(i), &Origin::Page(i), &name.to_lowercase()));
let from_libs = ed
.library_docs
.iter()
.map(|(l, d)| mains(d, &Origin::Library(l.clone()), &name.to_lowercase()));
from_pages.chain(from_libs).flatten().find(|a| a.name == name)
}
/// The subtree of component `id` as a standalone document, for previews.
fn component_doc(src: &Document, id: NodeId) -> Document {
let mut d = Document::default();
for i in src.subtree(id) {
let Some(mut n) = src.get(i).cloned() else { continue };
if i == id {
n.parent = None;
}
d.next_id = d.next_id.max(i.0 + 1);
d.nodes.insert(i, n);
}
d.root = vec![id];
d
}
/// The source document, source link and size for placing `a`.
fn source_of(ed: &Editor, a: &Asset) -> Option<(Option<Document>, Option<Source>, V2)> {
let doc = asset_doc(ed, a)?;
let m = doc.get(a.id)?;
let size = v2(m.rect.w, m.rect.h);
Some(match &a.origin {
Origin::Page(i) if *i == ed.active_page => (None, None, size),
Origin::Page(i) => (Some(doc.clone()), Some(Source::Page(ed.pages[*i].id.clone())), size),
Origin::Library(l) => (Some(doc.clone()), Some(Source::Library(l.clone())), size),
})
}
/// Place an instance of `a` on the active page, centred on `at` (world), and select it.
pub fn place(ed: &mut Editor, a: &Asset, at: V2) -> Option<NodeId> {
let (src, source, size) = source_of(ed, a)?;
let rect = Rect::new(at.x - size.x / 2.0, at.y - size.y / 2.0, size.x, size.y);
let id = ed.edit("Create instance", |tx| {
crate::editor::structure::instantiate_from(tx, src.as_ref(), source, a.id, None, None, Some(rect))
});
ed.selection = vec![id];
Some(id)
}
/// Point instance root `inst` (active page) at component `a`.
fn restore(ed: &mut Editor, inst: NodeId, a: &Asset) {
let Some((src, source, _)) = source_of(ed, a) else { return };
ed.edit("Swap instance", |tx| {
crate::editor::structure::swap_instance_tx(tx, src.as_ref(), source, inst, a.id)
});
}
fn go_to_main(ed: &mut Editor, a: &Asset) {
let Origin::Page(i) = a.origin else { return };
ed.switch_page(i);
ed.selection = vec![a.id];
if let Some(r) = ed.doc.world_rect(a.id) {
ed.camera.fit(&r, ed.viewport, 80.0, ed.camera.zoom.max(1.0));
ed.revision += 1;
}
}
fn rename(ed: &mut Editor, a: &Asset, name: String) {
let Origin::Page(i) = a.origin else { return };
ed.with_page(i, |ed| {
ed.edit("Rename component", |tx| {
if let Some(n) = tx.node_mut(a.id) {
n.name = name;
}
})
});
}
const TILE_PX: (u32, u32) = (268, 168);
const TILE_GAP: f32 = 8.0;
const TILE_RATIO: f32 = 0.63;
const MAX_PREVIEW_SCALE: f32 = 2.0;
const PREVIEW_MARGIN_PX: f32 = 28.0;
/// Rasterises a document (anti-aliased, transparent) to fit a tile.
fn preview_image(doc: &Document) -> Option<egui::ColorImage> {
let (pw, ph) = TILE_PX;
let svg = crate::io::svg::export(doc, None, None)?;
let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).ok()?;
let (w, h) = (tree.size().width(), tree.size().height());
let scale = ((PX as f32 - 40.0) / w.max(h).max(1.0)).min(3.0);
let mut pixmap = resvg::tiny_skia::Pixmap::new(PX, PX)?;
let scale = ((pw as f32 - PREVIEW_MARGIN_PX) / w.max(1.0))
.min((ph as f32 - PREVIEW_MARGIN_PX) / h.max(1.0))
.min(MAX_PREVIEW_SCALE);
let mut pixmap = resvg::tiny_skia::Pixmap::new(pw, ph)?;
let transform = resvg::tiny_skia::Transform::from_translate(
(PX as f32 - w * scale) / 2.0,
(PX as f32 - h * scale) / 2.0,
(pw as f32 - w * scale) / 2.0,
(ph as f32 - h * scale) / 2.0,
)
.pre_scale(scale, scale);
resvg::render(&tree, transform, &mut pixmap.as_mut());
Some(egui::ColorImage::from_rgba_premultiplied(
[PX as usize, PX as usize],
pixmap.data(),
))
Some(egui::ColorImage::from_rgba_premultiplied([pw as usize, ph as usize], pixmap.data()))
}
enum Act {
Place(Asset),
Go(Asset),
Rename(Asset, String),
Detach(NodeId),
Restore(NodeId, Asset),
InsertShape(LibItem),
RenameShape(String, String),
RemoveShape(String),
AddSelection,
Close,
}
fn text_action(ui: &mut Ui, label: &str) -> egui::Response {
let font = theme::semibold(theme::BODY);
let color = if ui.is_enabled() { theme::accent() } else { theme::pal(ui).tertiary };
let g = ui.painter().layout_no_wrap(label.into(), font, color);
let (rect, resp) = ui.allocate_exact_size(g.size(), egui::Sense::click());
ui.painter().galley(rect.min, g, color);
resp
}
/// Foldable section title: chevron, small caps title, then `right` aligned at the end.
/// Returns whether the section is open.
fn section_head(
ui: &mut Ui,
lib: &mut LibraryUi,
id: &str,
icon: Option<Icon>,
title: &str,
right: impl FnOnce(&Ui, egui::Rect),
) -> bool {
let p = theme::pal(ui);
ui.add_space(4.0);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
let open = !lib.collapsed.contains(id);
if resp.clicked() {
if open {
lib.collapsed.insert(id.to_string());
} else {
lib.collapsed.remove(id);
}
}
let at = rect.left_center();
let chevron = if open { Icon::ChevronDown } else { Icon::ChevronRight };
let icon_rect = |x: f32| egui::Rect::from_center_size(egui::pos2(at.x + x, at.y), egui::vec2(14.0, 14.0));
let mut x = 7.0;
if let Some(i) = icon {
theme::paint_icon(ui.painter(), icon_rect(7.0), i, p.secondary);
} else {
theme::paint_icon(ui.painter(), icon_rect(7.0), chevron, p.secondary);
}
x += 14.0;
ui.painter().text(
egui::pos2(at.x + x, at.y),
egui::Align2::LEFT_CENTER,
title.to_uppercase(),
theme::semibold(theme::SECONDARY),
p.secondary,
);
right(ui, rect);
open
}
fn right_text(ui: &Ui, rect: egui::Rect, text: &str, color: Color32) -> egui::Rect {
ui.painter().text(
rect.right_center() - egui::vec2(2.0, 0.0),
egui::Align2::RIGHT_CENTER,
text,
egui::FontId::proportional(theme::BODY),
color,
)
}
/// A preview tile: rounded cell with the image, centred, and the name below.
fn tile(ui: &mut Ui, w: f32, tex: Option<&egui::TextureHandle>) -> egui::Response {
let p = theme::pal(ui);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(w, (w * TILE_RATIO).round()), egui::Sense::click());
let bg = if resp.hovered() { p.hover } else { p.control };
ui.painter().rect_filled(rect, 12.0, bg);
if let Some(t) = tex {
ui.painter().image(
t.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
}
resp
}
/// A preview texture rebuilt only when `fp` (computed once per `rev`, visible tiles only) changes.
fn cached(
ui: &Ui,
lib: &mut LibraryUi,
key: String,
rev: u64,
visible: bool,
fp: impl FnOnce() -> u64,
build: impl FnOnce() -> Option<egui::ColorImage>,
) -> Option<egui::TextureHandle> {
let e = lib.previews.entry(key.clone()).or_insert((u64::MAX, u64::MAX, None));
if e.0 != rev && visible {
let fp = fp();
if e.1 != fp || e.2.is_none() {
let tex = build().map(|img| ui.ctx().load_texture(format!("asset-{key}"), img, egui::TextureOptions::LINEAR));
*e = (rev, fp, tex);
} else {
e.0 = rev;
}
}
e.2.clone()
}
/// Hash of the component's subtree: edits elsewhere on the page leave it unchanged.
fn subtree_fingerprint(doc: &Document, id: NodeId) -> u64 {
let nodes: Vec<&crate::model::Node> = doc.subtree(id).into_iter().filter_map(|i| doc.get(i)).collect();
let mut h = std::collections::hash_map::DefaultHasher::new();
serde_json::to_vec(&nodes).unwrap_or_default().hash(&mut h);
h.finish()
}
/// The name under a tile, or its rename box. Returns the new name once editing ends.
fn name_label(ui: &mut Ui, lib: &mut LibraryUi, key: &str, name: &str, w: f32) -> Option<String> {
if lib.renaming.as_ref().is_some_and(|(k, _)| k == key) {
let (_, draft) = lib.renaming.as_mut().unwrap();
let r = ui.add(
egui::TextEdit::singleline(draft)
.margin(theme::INPUT_MARGIN)
.desired_width(w),
);
r.request_focus();
if r.lost_focus() {
return lib.renaming.take().map(|r| r.1);
}
return None;
}
ui.add(egui::Label::new(RichText::new(name).size(theme::SECONDARY)).truncate());
None
}
fn asset_grid(
ui: &mut Ui,
ed: &Editor,
lib: &mut LibraryUi,
assets: &[Asset],
acts: &mut Vec<Act>,
) {
let w = ((ui.available_width() - TILE_GAP) / 2.0).floor();
for row in assets.chunks(2) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = TILE_GAP;
for a in row {
ui.vertical(|ui| {
ui.set_width(w);
ui.spacing_mut().item_spacing.y = 4.0;
let tile_key = format!("{:?}|{}", a.origin, a.id.0);
let (rev, doc) = match &a.origin {
Origin::Page(_) => (ed.revision, asset_doc(ed, a)),
Origin::Library(l) => (
ed.design.libraries.iter().find(|x| x.library_id == *l).map_or(0, |x| x.version),
asset_doc(ed, a),
),
};
let tex_key = format!("{tile_key}|{}", a.key);
let (rect, _) = ui.allocate_exact_size(egui::Vec2::ZERO, egui::Sense::hover());
let visible = ui.clip_rect().intersects(egui::Rect::from_min_size(rect.min, egui::vec2(w, w * TILE_RATIO)));
let fp = || doc.map_or(rev, |d| match a.origin {
Origin::Page(_) => subtree_fingerprint(d, a.id),
Origin::Library(_) => rev,
});
let tex = cached(ui, lib, tex_key, rev, visible, fp, || preview_image(&component_doc(doc?, a.id)));
let resp = tile(ui, w, tex.as_ref());
if let Some(n) = name_label(ui, lib, &tile_key, split_name(&a.name).1, w) {
acts.push(Act::Rename(a.clone(), n));
}
if resp.clicked() {
acts.push(Act::Place(a.clone()));
}
resp.on_hover_text("Click to place an instance").context_menu(|ui| {
if let Origin::Page(_) = a.origin {
if super::icon_menu_item(ui, Icon::Component, "Go to main").clicked() {
acts.push(Act::Go(a.clone()));
ui.close();
}
if super::icon_menu_item(ui, Icon::Text, "Rename").clicked() {
lib.renaming = Some((tile_key.clone(), a.name.clone()));
ui.close();
}
}
if super::icon_menu_item(ui, Icon::Plus, "Place instance").clicked() {
acts.push(Act::Place(a.clone()));
ui.close();
}
});
});
}
});
ui.add_space(6.0);
}
}
fn shape_grid(ui: &mut Ui, lib: &mut LibraryUi, acts: &mut Vec<Act>) {
let w = ((ui.available_width() - TILE_GAP) / 2.0).floor();
let items = lib.library.items.clone();
for row in items.chunks(2) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = TILE_GAP;
for it in row {
ui.vertical(|ui| {
ui.set_width(w);
ui.spacing_mut().item_spacing.y = 4.0;
let fp = (it.nodes.len() ^ (it.name.len() << 16)) as u64;
let key = format!("shape|{}", it.id);
let tex = cached(ui, lib, key.clone(), fp, true, || fp, || preview_image(&it.doc()));
let resp = tile(ui, w, tex.as_ref());
if let Some(n) = name_label(ui, lib, &it.id, &it.name, w) {
acts.push(Act::RenameShape(it.id.clone(), n));
}
if resp.clicked() {
acts.push(Act::InsertShape(it.clone()));
}
resp.on_hover_text("Click to insert").context_menu(|ui| {
if super::icon_menu_item(ui, Icon::Plus, "Insert").clicked() {
acts.push(Act::InsertShape(it.clone()));
ui.close();
}
if super::icon_menu_item(ui, Icon::Text, "Rename").clicked() {
lib.renaming = Some((it.id.clone(), it.name.clone()));
ui.close();
}
if super::icon_menu_item(ui, Icon::Trash, "Remove from library").clicked() {
acts.push(Act::RemoveShape(it.id.clone()));
ui.close();
}
});
});
}
});
ui.add_space(6.0);
}
}
/// Re-reads the enabled libraries' status when the panel was not drawn on the previous frame.
fn refresh_status(ui: &Ui, ed: &Editor, st: &mut UiState) {
let now = ui.ctx().cumulative_frame_nr();
if st.library.seen.is_some_and(|s| s + 1 >= now) {
st.library.seen = Some(now);
return;
}
st.library.seen = Some(now);
st.library.status = st
.project_dir
.as_deref()
.and_then(|d| {
let store = crate::project::Store::open(d.parent()?).ok()?;
Some(crate::libraries::status(&store, d, ed))
})
.unwrap_or_default();
}
fn header(ui: &mut Ui, st: &mut UiState, acts: &mut Vec<Act>) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Component, theme::accent());
ui.label(RichText::new("Assets").font(theme::semibold(15.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if kit::close_button(ui).clicked() {
acts.push(Act::Close);
}
if theme::icon_button(ui, Icon::Library, 28.0, st.libraries_open)
.on_hover_text("Libraries")
.clicked()
{
st.libraries_open = true;
}
});
});
ui.add_space(6.0);
ui.add(
egui::TextEdit::singleline(&mut st.library.filter)
.hint_text("Search assets")
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
ui.add_space(6.0);
}
fn missing_rows(ui: &mut Ui, ed: &Editor, missing: &[NodeId], acts: &mut Vec<Act>) {
let p = theme::pal(ui);
for &inst in missing {
let name = ed.doc.get(inst).map_or_else(String::new, |n| n.name.clone());
let target = find_named(ed, &name);
egui::Frame::new()
.fill(p.field)
.corner_radius(theme::RADIUS_PANEL)
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("!").font(theme::semibold(16.0)).color(p.warning));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.label(RichText::new("Main missing").font(theme::semibold(theme::BODY)));
ui.add(egui::Label::new(RichText::new(&name).size(theme::SECONDARY).color(p.secondary)).truncate());
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 12.0;
ui.add_enabled_ui(target.is_some(), |ui| {
if text_action(ui, "Restore").on_hover_text("Point at the component with this name").clicked()
&& let Some(t) = &target
{
acts.push(Act::Restore(inst, t.clone()));
}
});
if text_action(ui, "Detach").on_hover_text("Turn into a plain frame").clicked() {
acts.push(Act::Detach(inst));
}
});
});
});
ui.add_space(6.0);
}
}
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
let lib = &mut st.library;
let mut acts = Vec::new();
refresh_status(ui, ed, st);
header(ui, st, &mut acts);
let filter = st.library.filter.trim().to_lowercase();
let list = st.library.assets(ed, &filter);
let missing = st.library.missing(ed);
let shapes: Vec<LibItem> = st
.library
.library
.items
.iter()
.filter(|i| filter.is_empty() || i.name.to_lowercase().contains(&filter))
.cloned()
.collect();
let can_add = !ed.top_selection().is_empty();
ui.add_enabled_ui(can_add, |ui| {
if super::kit::button_wide(
ui,
super::kit::ButtonKind::Secondary,
Some(Icon::Plus),
"Add selection",
)
.on_hover_text("Save the selected layers as a reusable item")
.clicked()
{
let ids = ed.top_selection();
let name = ids
.first()
.and_then(|id| ed.doc.get(*id))
.map_or("Item".to_string(), |n| n.name.clone());
if let Some(item) = LibItem::from_selection(&ed.doc, &ids, &name) {
lib.library.items.push(item);
lib.dirty = true;
}
}
});
ui.add_space(8.0);
if lib.library.items.is_empty() {
ui.add_space(24.0);
super::kit::empty_state(
ui,
Icon::Library,
theme::accent(),
"Library is empty",
"Select layers and choose “Add selection” to keep them here.",
|_| {},
);
return;
}
let avail = ui.available_width() - 8.0;
let cols = ((avail + TILE_GAP) / (TILE + TILE_GAP)).floor().max(1.0) as usize;
let tile = ((avail - TILE_GAP * (cols as f32 - 1.0)) / cols as f32).floor();
let items = lib.library.items.clone();
let mut remove = None;
let lib = &mut st.library;
let p = theme::pal(ui);
let nothing = list.this_file() == 0
&& list.libraries.iter().all(|l| l.2.is_empty())
&& shapes.is_empty();
egui::ScrollArea::vertical()
.id_salt("library")
.id_salt("assets")
.auto_shrink(false)
.show(ui, |ui| {
crate::theme::figma_fields(ui);
for row in items.chunks(cols) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = TILE_GAP;
for it in row {
ui.vertical(|ui| {
ui.set_width(tile);
let (rect, resp) = ui
.allocate_exact_size(egui::vec2(tile, tile), egui::Sense::click());
let bg = if resp.hovered() { p.hover } else { p.control };
ui.painter().rect_filled(rect, 14.0, bg);
let key = it.id.clone();
let fp = it.nodes.len() ^ (it.name.len() << 16);
let entry = lib
.previews
.entry(key.clone())
.or_insert((usize::MAX, None));
if entry.0 != fp {
let tex = preview_image(it).map(|img| {
ui.ctx().load_texture(
format!("lib-{key}"),
img,
egui::TextureOptions::LINEAR,
)
});
*entry = (fp, tex);
}
if let Some(tex) = &entry.1 {
ui.painter().image(
tex.id(),
rect.shrink(4.0),
egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, 1.0),
),
Color32::WHITE,
);
}
let renaming =
lib.renaming.as_ref().is_some_and(|(id, _)| *id == it.id);
if renaming {
let (_, name) = lib.renaming.as_mut().unwrap();
let r = ui.add(
egui::TextEdit::singleline(name)
.margin(crate::theme::INPUT_MARGIN)
.desired_width(tile),
);
r.request_focus();
if r.lost_focus() {
let (id, name) = lib.renaming.take().unwrap();
if let Some(x) =
lib.library.items.iter_mut().find(|x| x.id == id)
{
x.name = name;
lib.dirty = true;
}
}
} else {
ui.add(
egui::Label::new(
RichText::new(&it.name).size(theme::SECONDARY),
)
.truncate(),
);
}
if resp.clicked() {
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
ed.insert_library_item(it, at);
}
resp.on_hover_text("Click to insert").context_menu(|ui| {
if super::icon_menu_item(ui, crate::theme::Icon::Plus, "Insert")
.clicked()
{
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
ed.insert_library_item(it, at);
ui.close();
}
if super::icon_menu_item(ui, crate::theme::Icon::Text, "Rename")
.clicked()
{
lib.renaming = Some((it.id.clone(), it.name.clone()));
ui.close();
}
if super::icon_menu_item(
ui,
crate::theme::Icon::Trash,
"Remove from library",
)
.clicked()
{
remove = Some(it.id.clone());
ui.close();
}
});
});
theme::figma_fields(ui);
missing_rows(ui, ed, &missing, &mut acts);
if nothing {
ui.add_space(24.0);
let (title, body) = if filter.is_empty() {
("No assets yet", "Make a component, enable a library, or add layers to My shapes.")
} else {
("No matches", "Nothing in this file, your libraries or My shapes has that name.")
};
kit::empty_state(ui, Icon::Component, theme::accent(), title, body, |_| {});
}
if list.this_file() > 0 {
let n = list.this_file();
let open = section_head(ui, lib, "file", None, "This file", |ui, r| {
right_text(ui, r, &n.to_string(), p.tertiary);
});
for (name, assets) in list.pages.iter().filter(|_| open) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(name).font(theme::semibold(theme::BODY + 2.0)));
ui.label(RichText::new(assets.len().to_string()).size(theme::SECONDARY).color(p.tertiary));
});
grouped_grid(ui, ed, lib, &format!("page:{name}"), assets, !filter.is_empty(), &mut acts);
}
}
for (id, name, assets) in &list.libraries {
let update = lib.status.iter().any(|s| s.library_id == *id && s.update_available);
let open = section_head(ui, lib, id, Some(Icon::Library), name, |ui, r| {
if update {
let x = right_text(ui, r, "Update", theme::accent()).left() - 8.0;
ui.painter().circle_filled(egui::pos2(x, r.center().y), 3.5, theme::accent());
} else {
right_text(ui, r, &assets.len().to_string(), p.tertiary);
}
});
ui.add_space(6.0);
if !open {
continue;
}
if assets.is_empty() {
ui.label(RichText::new("No components").size(theme::SECONDARY).color(p.tertiary));
}
grouped_grid(ui, ed, lib, id, assets, !filter.is_empty(), &mut acts);
}
if !shapes.is_empty() || filter.is_empty() {
let n = shapes.len();
let open = section_head(ui, lib, "shapes", None, "My shapes", |ui, r| {
right_text(ui, r, &n.to_string(), p.tertiary);
});
if open {
ui.add_space(4.0);
ui.add_enabled_ui(can_add, |ui| {
if kit::button_wide(ui, kit::ButtonKind::Secondary, Some(Icon::Plus), "Add selection")
.on_hover_text("Save the selected layers as a reusable item")
.clicked()
{
acts.push(Act::AddSelection);
}
});
ui.add_space(8.0);
shape_grid(ui, lib, &mut acts);
}
}
});
if let Some(id) = remove {
lib.library.items.retain(|x| x.id != id);
lib.dirty = true;
for act in acts {
match act {
Act::Place(a) => {
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
place(ed, &a, at);
}
Act::Go(a) => go_to_main(ed, &a),
Act::Rename(a, name) => rename(ed, &a, name),
Act::Detach(id) => {
ed.selection = vec![id];
ed.detach_instance();
}
Act::Restore(inst, a) => restore(ed, inst, &a),
Act::InsertShape(it) => {
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
ed.insert_library_item(&it, at);
}
Act::RenameShape(id, name) => {
if let Some(x) = st.library.library.items.iter_mut().find(|x| x.id == id) {
x.name = name;
st.library.dirty = true;
}
}
Act::RemoveShape(id) => {
st.library.library.items.retain(|x| x.id != id);
st.library.dirty = true;
}
Act::AddSelection => {
let ids = ed.top_selection();
let name = ids
.first()
.and_then(|id| ed.doc.get(*id))
.map_or("Item".to_string(), |n| n.name.clone());
if let Some(item) = LibItem::from_selection(&ed.doc, &ids, &name) {
st.library.library.items.push(item);
st.library.dirty = true;
}
}
Act::Close => st.show_library = false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Node, NodeKind};
fn comp(ed: &mut Editor, name: &str) -> NodeId {
let id = ed.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 30.)), None);
ed.selection = vec![id];
ed.create_component();
ed.edit("n", |tx| tx.node_mut(id).unwrap().name = name.into());
id
}
fn asset(name: &str) -> Asset {
Asset { origin: Origin::Page(0), id: NodeId(1), key: String::new(), name: name.into() }
}
#[test]
fn lists_pages_and_libraries() {
let mut ed = Editor::default();
comp(&mut ed, "Button");
comp(&mut ed, "Card");
ed.add_page_in_background(None);
ed.with_page(1, |e| comp(e, "Toggle"));
let mut lib = Document::default();
lib.name = "Kit".into();
let mut n = Node::new(NodeId(1), NodeKind::Frame { clip: false }, Rect::new(0., 0., 10., 10.));
n.name = "Logo".into();
n.component = true;
n.component_key = Some("k".into());
lib.nodes.insert(n.id, n);
ed.library_docs.insert("kit".into(), lib);
ed.design.libraries.push(crate::variables::EnabledLibrary { library_id: "kit".into(), version: 1 });
let l = list_assets(&ed, "");
assert_eq!(l.this_file(), 3);
assert_eq!(l.pages.len(), 2);
assert_eq!(l.pages[0].1.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Button", "Card"]);
assert_eq!(l.libraries[0].1, "Kit");
assert_eq!(l.libraries[0].2[0].name, "Logo");
let g = group_assets(&[
asset("Zed / A / B"),
asset("Plain"),
asset("Button / Primary"),
asset("Button / Secondary"),
]);
assert_eq!(g.iter().map(|x| x.0.as_str()).collect::<Vec<_>>(), ["Button", "Zed", "Other"]);
assert_eq!(g[0].1.len(), 2);
assert_eq!(split_name("Zed / A / B"), (Some("Zed"), "A / B"));
assert_eq!(split_name("Plain"), (None, "Plain"));
let f = list_assets(&ed, "tog");
assert_eq!((f.this_file(), f.libraries.len()), (1, 0));
}
let _ = Icon::Library;
}
+64 -30
View File
@@ -2,6 +2,7 @@
//! projects home screen.
pub mod agent;
pub mod bind;
pub mod confirm;
pub mod design;
pub mod home;
@@ -13,7 +14,9 @@ pub mod motion;
pub mod pages;
pub mod palette;
pub mod panels;
pub mod libraries;
pub mod settings;
pub mod variables;
#[cfg(not(target_arch = "wasm32"))]
pub mod update;
@@ -80,6 +83,8 @@ pub struct UiState {
pub show_design: bool,
pub palette: palette::PaletteUi,
pub settings: settings::SettingsUi,
pub variables: variables::VariablesUi,
pub libraries: libraries::LibrariesUi,
/// All panels hidden (⌘\\): just the canvas.
pub hide_ui: bool,
/// Present mode: only the canvas, with a presenter bar to step through the page's frames.
@@ -104,18 +109,8 @@ pub struct UiState {
pub feedback_draft: String,
/// Text in the layers panel's search box; only matching layers (and their parents) show.
pub layer_filter: String,
/// Name typed into the Design tab's new-variable box.
pub variable_draft: String,
/// Name typed into the Design tab's new-mode box.
pub mode_draft: String,
/// Name typed into the Design tab's new-token name box.
pub token_name_draft: String,
/// Value typed into the Design tab's new-token value box.
pub token_val_draft: String,
/// Filter text for the Design tab's components list.
pub component_filter: String,
/// Variable currently being renamed inline `(old_name, draft_new_name)`.
pub var_renaming: Option<(String, String)>,
/// The Libraries modal is open (Assets header button).
pub libraries_open: bool,
/// The last tool an agent ran and when, for the toolbar's activity hint.
pub agent: Option<(String, web_time::Instant)>,
/// Recent agent tool calls, oldest first (the Agent view).
@@ -211,6 +206,13 @@ fn account_footer(ui: &mut Ui, st: &mut UiState) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ui.set_max_width((ui.available_width() - 44.0).max(40.0));
// A child column starts at the top of the row while the avatar is centred in it;
// pad the column so its two lines centre on the avatar.
let block = ui.fonts_mut(|f| {
f.row_height(&theme::semibold(theme::BODY))
+ f.row_height(&egui::FontId::proportional(theme::SECONDARY))
});
ui.add_space((r.center().y - block / 2.0 - ui.cursor().top()).max(0.0));
ui.add(
egui::Label::new(RichText::new(name).font(theme::semibold(theme::BODY))).truncate(),
);
@@ -636,6 +638,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
UiEvent::ToggleUi => st.hide_ui = !st.hide_ui,
UiEvent::ToggleHelp => st.show_help = !st.show_help,
UiEvent::ToggleGrid => st.show_grid_off = !st.show_grid_off,
UiEvent::OpenVariables => st.variables.open = !st.variables.open,
}
}
if st.run_open.is_some() {
@@ -776,6 +779,8 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
help(ui.ctx(), st, canvas);
}
settings::show(ui.ctx(), st);
variables::show(ui.ctx(), ed, st);
libraries::show(ui.ctx(), ed, st);
#[cfg(not(target_arch = "wasm32"))]
st.updater.show(ui.ctx());
confirm::show(ui.ctx(), ed, st);
@@ -829,10 +834,23 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.separator();
ui.add_space(6.0);
}
if theme::icon_button(ui, theme::Icon::Grid, 26.0, false)
.on_hover_text("All projects")
.clicked()
{
// The title column sits at the top of the bar while the bar centres single widgets, so
// place the home icon on the column's centre (measured last frame) instead.
let column_id = egui::Id::new("title-column-centre");
let cy = ui
.ctx()
.data(|d| d.get_temp::<f32>(column_id))
.unwrap_or(ui.max_rect().center().y);
let slot = egui::Rect::from_center_size(
egui::pos2(ui.cursor().left() + 13.0, cy),
egui::vec2(26.0, 26.0),
);
let home = ui
.scope_builder(egui::UiBuilder::new().max_rect(slot), |ui| {
theme::icon_button(ui, theme::Icon::Grid, 26.0, false)
})
.inner;
if home.on_hover_text("All projects").clicked() {
ed.requests.push(crate::editor::AppRequest::Home);
}
ui.add_space(4.0);
@@ -841,7 +859,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
} else {
st.project_name.clone()
};
ui.vertical(|ui| {
let column = ui.vertical(|ui| {
// Leave room for the zoom pill and icons on the right; long titles truncate.
ui.set_max_width(
(ui.available_width()
@@ -855,7 +873,6 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.max(40.0),
);
ui.spacing_mut().item_spacing.y = 0.0;
ui.add_space(if st.native_chrome { 6.0 } else { 1.0 });
let state = if !st.save_state.is_empty() {
st.save_state.clone()
} else if ed.is_dirty() {
@@ -863,18 +880,31 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
} else {
String::new()
};
ui.horizontal(|ui| {
ui.add(egui::Label::new(title_text(title)).truncate());
if !state.is_empty() {
kit::chip(ui, &state, state == "Saved");
}
});
// The text, not its row: the row is button height with the title low in it.
let top = ui
.horizontal(|ui| {
let t = ui.add(egui::Label::new(title_text(title)).truncate());
if !state.is_empty() {
kit::chip(ui, &state, state == "Saved");
}
t.rect.top()
})
.inner;
let sub = ed.page_name().to_string();
ui.add(
egui::Label::new(RichText::new(sub).size(theme::SECONDARY).color(p.secondary))
.truncate(),
);
let bottom = ui
.add(
egui::Label::new(RichText::new(sub).size(theme::SECONDARY).color(p.secondary))
.truncate(),
)
.rect
.bottom();
(top + bottom) / 2.0
});
let centre = column.inner;
if (centre - cy).abs() > 0.5 {
ui.ctx().data_mut(|d| d.insert_temp(column_id, centre));
ui.ctx().request_repaint();
}
agent_pill(ui, st, !tight);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
@@ -892,6 +922,10 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.close();
}
}
if icon_menu_item(ui, Icon::Library, "Libraries…").clicked() {
st.libraries.open = true;
ui.close();
}
});
ui.add_space(6.0);
if let Some(a) = &st.agent_link {
@@ -1140,7 +1174,7 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
fn side_tabs(ui: &mut Ui, st: &mut UiState) {
const TABS: [(i32, &str); 6] = [
(0, "Layers"),
(1, "Library"),
(1, "Assets"),
(2, "Design"),
(3, "History"),
(4, "Notes"),
@@ -1192,7 +1226,7 @@ fn side_tabs(ui: &mut Ui, st: &mut UiState) {
0 => {
lottie_icon::paint_hover(ui, resp.id, "layers", icon_box, tint, hovered)
}
1 => theme::paint_icon(ui.painter(), icon_box, Icon::Library, tint),
1 => theme::paint_icon(ui.painter(), icon_box, Icon::Component, tint),
2 => lottie_icon::paint_hover(
ui,
resp.id,
+16
View File
@@ -36,6 +36,10 @@ pub enum Command {
Scale(f32),
/// Open the settings dialog.
Settings,
/// Open the Variables modal.
Variables,
/// Open the Libraries modal.
Libraries,
/// Back to the projects home screen.
Home,
/// Check for a newer PaperCraft.
@@ -187,6 +191,16 @@ pub fn entries(ed: &Editor, st: &UiState) -> Vec<Entry> {
});
}
}
out.push(Entry {
label: "Libraries…".into(),
detail: "Publish and enable".into(),
cmd: Command::Libraries,
});
out.push(Entry {
label: "Variables…".into(),
detail: "Design".into(),
cmd: Command::Variables,
});
out.push(Entry {
label: "Settings…".into(),
detail: "Preferences".into(),
@@ -297,6 +311,8 @@ pub fn run(ed: &mut Editor, st: &mut UiState, cmd: Command) {
st.show_design = false;
}
Command::Settings => st.settings.open = true,
Command::Variables => st.variables.open = true,
Command::Libraries => st.libraries.open = true,
// Needs the egui context; handled where the palette is drawn.
Command::Theme(_) | Command::Scale(_) => {}
}
+1361
View File
File diff suppressed because it is too large Load Diff
+974
View File
@@ -0,0 +1,974 @@
//! The project-level design system: collections of typed variables with modes, groups and aliases.
//! Pure model. `cache_for` derives the per-page `Document` fields the rest of the app reads.
use crate::design_system::sanitize_name;
use crate::model::Color;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const DEFAULT_MODE: &str = "Default";
pub const COLORS_COLLECTION: &str = "Colors";
pub const TOKENS_COLLECTION: &str = "Tokens";
/// Active mode id per collection id (missing = the collection's default mode).
pub type PageModes = BTreeMap<String, String>;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DesignSystem {
#[serde(default)]
pub collections: Vec<Collection>,
#[serde(default)]
pub libraries: Vec<EnabledLibrary>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnabledLibrary {
pub library_id: String,
pub version: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Collection {
pub id: String,
pub name: String,
pub modes: Vec<Mode>,
pub default_mode: String,
#[serde(default)]
pub variables: Vec<Variable>,
/// `Some(library id)` = read-only copy from a library.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub library: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Mode {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Color,
Number,
String,
Boolean,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Value {
Color(Color),
Number(f64),
String(String),
Boolean(bool),
/// Variable id.
Alias(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Variable {
pub id: String,
/// Slash path: `color/brand/primary`.
pub name: String,
pub kind: Kind,
/// mode id -> value.
#[serde(default)]
pub values: BTreeMap<String, Value>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub hidden: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
NotFound(String),
ReadOnly(String),
KindMismatch(String),
Cycle(String),
/// Two variables share this CSS name.
Duplicate(String),
/// Alias target(s) still referencing the variable (names).
InUse(Vec<String>),
/// No value in the mode and none in the default.
Missing(String),
LastMode,
/// Empty name or empty `/` segment (`a//b`, `/a`, `a/`).
InvalidName(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NotFound(s) => write!(f, "not found: {s}"),
Error::ReadOnly(s) => write!(f, "collection {s} is read-only"),
Error::KindMismatch(s) => write!(f, "kind mismatch: {s}"),
Error::Cycle(s) => write!(f, "alias cycle through {s}"),
Error::Duplicate(s) => write!(f, "duplicate CSS name --{s}"),
Error::InUse(v) => write!(f, "aliased by {}", v.join(", ")),
Error::Missing(s) => write!(f, "no value for {s}"),
Error::LastMode => write!(f, "a collection needs at least one mode"),
Error::InvalidName(s) => write!(f, "invalid variable name {s:?} (empty group segment)"),
}
}
}
impl std::error::Error for Error {}
pub fn new_id() -> String {
format!("{:08x}", fastrand::u32(..))
}
/// An id derived from `parts` (FNV-1a, 64 bit): the same input gives the same id on every
/// device. Migration uses it so one v1 project migrated on two devices gets one design system.
pub fn stable_id(parts: &[&str]) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in parts.iter().flat_map(|p| p.bytes().chain([0xff])) {
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
}
format!("{h:016x}")
}
impl Value {
pub fn kind(&self) -> Option<Kind> {
match self {
Value::Color(_) => Some(Kind::Color),
Value::Number(_) => Some(Kind::Number),
Value::String(_) => Some(Kind::String),
Value::Boolean(_) => Some(Kind::Boolean),
Value::Alias(_) => None,
}
}
}
impl Kind {
pub fn default_value(self) -> Value {
match self {
Kind::Color => Value::Color(Color::rgb(0, 0, 0)),
Kind::Number => Value::Number(0.0),
Kind::String => Value::String(String::new()),
Kind::Boolean => Value::Boolean(false),
}
}
}
// ------------------------------------------------------------------------------ names, groups
/// `color/brand/primary` -> `color-brand-primary` (no leading `--`).
pub fn css_name(v: &Variable) -> String {
sanitize_name(&v.name.replace('/', "-"))
}
/// `color/brand/primary` -> `color/brand`; no slash -> "".
pub fn group_of(name: &str) -> &str {
name.rsplit_once('/').map_or("", |(g, _)| g)
}
/// Every group path (all prefixes), sorted and unique.
pub fn groups(c: &Collection) -> Vec<String> {
let mut out = BTreeSet::new();
for v in &c.variables {
// Prefixes end at each `/` (a char boundary) and at the end; empty segments are skipped.
let g = group_of(&v.name);
let ends = g.match_indices('/').map(|(i, _)| i).chain([g.len()]);
for end in ends {
let p = &g[..end];
if !p.is_empty() && !p.ends_with('/') && !p.split('/').any(str::is_empty) {
out.insert(p.to_string());
}
}
}
out.into_iter().collect()
}
fn under(name: &str, group: &str) -> bool {
name.strip_prefix(group).is_some_and(|r| r.starts_with('/'))
}
// ------------------------------------------------------------------------------ lookup
impl DesignSystem {
pub fn collection(&self, id: &str) -> Option<&Collection> {
self.collections.iter().find(|c| c.id == id)
}
fn local_mut(&mut self, id: &str) -> Result<&mut Collection, Error> {
let c = self
.collections
.iter_mut()
.find(|c| c.id == id)
.ok_or_else(|| Error::NotFound(id.into()))?;
if c.library.is_some() {
return Err(Error::ReadOnly(c.name.clone()));
}
Ok(c)
}
/// The variable and its collection.
pub fn find(&self, var_id: &str) -> Option<(&Collection, &Variable)> {
self.collections
.iter()
.find_map(|c| c.variables.iter().find(|v| v.id == var_id).map(|v| (c, v)))
}
pub fn find_by_css(&self, css: &str) -> Option<(&Collection, &Variable)> {
let css = sanitize_name(css);
self.collections.iter().find_map(|c| {
c.variables
.iter()
.find(|v| css_name(v) == css)
.map(|v| (c, v))
})
}
fn all(&self) -> impl Iterator<Item = (&Collection, &Variable)> {
self.collections
.iter()
.flat_map(|c| c.variables.iter().map(move |v| (c, v)))
}
fn var_mut(&mut self, var_id: &str) -> Result<(&mut Collection, usize), Error> {
let c = self
.collections
.iter_mut()
.find(|c| c.variables.iter().any(|v| v.id == var_id))
.ok_or_else(|| Error::NotFound(var_id.into()))?;
if c.library.is_some() {
return Err(Error::ReadOnly(c.name.clone()));
}
let i = c.variables.iter().position(|v| v.id == var_id).unwrap();
Ok((c, i))
}
/// Names of variables that alias `var_id` in any mode.
pub fn dependents(&self, var_id: &str) -> Vec<String> {
self.all()
.filter(|(_, v)| {
v.values
.values()
.any(|x| matches!(x, Value::Alias(t) if t == var_id))
})
.map(|(_, v)| v.name.clone())
.collect()
}
// -------------------------------------------------------------------------- validation
/// Duplicate CSS names, dangling or mismatched aliases, alias cycles.
pub fn validate(&self) -> Result<(), Error> {
let mut seen = BTreeSet::new();
for (_, v) in self.all() {
if v.name.split('/').any(|s| s.trim().is_empty()) {
return Err(Error::InvalidName(v.name.clone()));
}
let css = css_name(v);
if !seen.insert(css.clone()) {
return Err(Error::Duplicate(css));
}
for x in v.values.values() {
let Value::Alias(t) = x else { continue };
let (_, target) = self.find(t).ok_or_else(|| Error::NotFound(t.clone()))?;
if target.kind != v.kind {
return Err(Error::KindMismatch(v.name.clone()));
}
if self.reaches(t, &v.id) {
return Err(Error::Cycle(v.name.clone()));
}
}
}
Ok(())
}
/// True when `from` is `to` or aliases it through any chain of modes.
fn reaches(&self, from: &str, to: &str) -> bool {
let mut stack = vec![from.to_string()];
let mut seen = BTreeSet::new();
while let Some(id) = stack.pop() {
if id == to {
return true;
}
if !seen.insert(id.clone()) {
continue;
}
let Some((_, v)) = self.find(&id) else {
continue;
};
for x in v.values.values() {
if let Value::Alias(t) = x {
stack.push(t.clone());
}
}
}
false
}
// -------------------------------------------------------------------------- edits
pub fn add_collection(&mut self, name: &str) -> String {
let mode = Mode {
id: new_id(),
name: DEFAULT_MODE.into(),
};
let id = new_id();
self.collections.push(Collection {
id: id.clone(),
name: name.into(),
default_mode: mode.id.clone(),
modes: vec![mode],
variables: Vec::new(),
library: None,
});
id
}
pub fn add_variable(
&mut self,
collection_id: &str,
name: &str,
kind: Kind,
) -> Result<String, Error> {
let c = self.local_mut(collection_id)?;
let v = Variable {
id: new_id(),
name: name.into(),
kind,
values: c
.modes
.iter()
.map(|m| (m.id.clone(), kind.default_value()))
.collect(),
description: String::new(),
hidden: false,
};
let id = v.id.clone();
c.variables.push(v);
if let Err(e) = self.validate() {
self.local_mut(collection_id)?.variables.pop();
return Err(e);
}
Ok(id)
}
/// Removes the variable. Aliased and not `force`: refused with the dependents. With `force`,
/// dependents keep the resolved literal. Returns the dependents either way.
pub fn remove_variable(&mut self, var_id: &str, force: bool) -> Result<Vec<String>, Error> {
self.var_mut(var_id)?;
let deps = self.dependents(var_id);
if !deps.is_empty() && !force {
return Err(Error::InUse(deps));
}
// Each dependent mode keeps what the target resolved to in that mode (same collection:
// the same mode; another collection: its default).
let mut literals = Vec::new();
for (c, v) in self.all() {
for (mode, x) in &v.values {
if matches!(x, Value::Alias(t) if t == var_id) {
let modes = PageModes::from([(c.id.clone(), mode.clone())]);
literals.push((v.id.clone(), mode.clone(), self.resolve(var_id, &modes).ok()));
}
}
}
for (vid, mode, literal) in literals {
for c in &mut self.collections {
for v in c.variables.iter_mut().filter(|v| v.id == vid) {
let lit = literal.clone().unwrap_or_else(|| v.kind.default_value());
v.values.insert(mode.clone(), lit);
}
}
}
let (c, i) = self.var_mut(var_id)?;
c.variables.remove(i);
Ok(deps)
}
pub fn rename_variable(&mut self, var_id: &str, name: &str) -> Result<(), Error> {
let (c, i) = self.var_mut(var_id)?;
let old = std::mem::replace(&mut c.variables[i].name, name.into());
if let Err(e) = self.validate() {
self.var_mut(var_id)?.0.variables[i].name = old;
return Err(e);
}
Ok(())
}
/// Renames group `old` (and subgroups) to `new`; returns how many variables moved.
pub fn rename_group(
&mut self,
collection_id: &str,
old: &str,
new: &str,
) -> Result<usize, Error> {
let before = self.clone();
let c = self.local_mut(collection_id)?;
let mut n = 0;
for v in &mut c.variables {
if v.name == old || under(&v.name, old) {
v.name = format!("{new}{}", &v.name[old.len()..]);
n += 1;
}
}
if let Err(e) = self.validate() {
*self = before;
return Err(e);
}
Ok(n)
}
/// Sets a literal or alias for one mode. Kind must match; alias must not make a cycle.
pub fn set_value(&mut self, var_id: &str, mode_id: &str, value: Value) -> Result<(), Error> {
let (c, i) = self.var_mut(var_id)?;
let v = &mut c.variables[i];
if value.kind().is_some_and(|k| k != v.kind) {
return Err(Error::KindMismatch(v.name.clone()));
}
if !c.modes.iter().any(|m| m.id == mode_id) {
return Err(Error::NotFound(mode_id.into()));
}
let old = c.variables[i].values.insert(mode_id.into(), value);
if let Err(e) = self.validate() {
let (c, i) = self.var_mut(var_id)?;
match old {
Some(o) => c.variables[i].values.insert(mode_id.into(), o),
None => c.variables[i].values.remove(mode_id),
};
return Err(e);
}
Ok(())
}
/// New mode; each variable copies its default-mode value.
pub fn add_mode(&mut self, collection_id: &str, name: &str) -> Result<String, Error> {
let c = self.local_mut(collection_id)?;
let id = new_id();
for v in &mut c.variables {
let seed = v
.values
.get(&c.default_mode)
.cloned()
.unwrap_or_else(|| v.kind.default_value());
v.values.insert(id.clone(), seed);
}
c.modes.push(Mode {
id: id.clone(),
name: name.into(),
});
Ok(id)
}
/// Drops the mode and every value in it.
pub fn remove_mode(&mut self, collection_id: &str, mode_id: &str) -> Result<(), Error> {
let c = self.local_mut(collection_id)?;
if c.modes.len() < 2 {
return Err(Error::LastMode);
}
let i = c
.modes
.iter()
.position(|m| m.id == mode_id)
.ok_or_else(|| Error::NotFound(mode_id.into()))?;
c.modes.remove(i);
for v in &mut c.variables {
v.values.remove(mode_id);
}
if c.default_mode == mode_id {
c.default_mode = c.modes[0].id.clone();
}
Ok(())
}
// -------------------------------------------------------------------------- resolve
fn active_mode<'a>(c: &'a Collection, modes: &'a PageModes) -> &'a str {
modes
.get(&c.id)
.filter(|m| c.modes.iter().any(|x| &x.id == *m))
.unwrap_or(&c.default_mode)
}
/// The literal value of a variable, following aliases. Each variable resolves in its own
/// collection's active mode (or default).
pub fn resolve(&self, var_id: &str, modes: &PageModes) -> Result<Value, Error> {
let mut id = var_id.to_string();
let mut seen = BTreeSet::new();
loop {
if !seen.insert(id.clone()) {
return Err(Error::Cycle(id));
}
let (c, v) = self.find(&id).ok_or_else(|| Error::NotFound(id.clone()))?;
let value = v
.values
.get(Self::active_mode(c, modes))
.or_else(|| v.values.get(&c.default_mode))
.ok_or_else(|| Error::Missing(v.name.clone()))?;
match value {
Value::Alias(t) => id = t.clone(),
lit => return Ok(lit.clone()),
}
}
}
// -------------------------------------------------------------------------- cache
/// Today's `Document` fields (`variables`, `modes`, `mode`, `tokens`) for a page, keyed by CSS
/// name. `modes`/`mode` describe the first collection with color variables when it has more
/// than one mode.
pub fn cache_for(&self, page_modes: &PageModes) -> Cache {
let mut out = Cache::default();
for (_, v) in self.all() {
let Ok(value) = self.resolve(&v.id, page_modes) else {
continue;
};
let css = css_name(v);
match value {
Value::Color(c) => {
out.variables.insert(css, c);
}
Value::Number(n) => {
out.tokens.insert(css, format!("{n}px"));
}
Value::String(s) => {
out.tokens.insert(css, s);
}
Value::Boolean(b) => {
out.bools.insert(css, b);
}
Value::Alias(_) => {}
}
}
let Some(c) = self
.collections
.iter()
.find(|c| c.variables.iter().any(|v| v.kind == Kind::Color))
.filter(|c| c.modes.len() > 1)
else {
return out;
};
for m in &c.modes {
let mut one = page_modes.clone();
one.insert(c.id.clone(), m.id.clone());
let colors = c
.variables
.iter()
.filter(|v| v.kind == Kind::Color)
.filter_map(|v| match self.resolve(&v.id, &one) {
Ok(Value::Color(col)) => Some((css_name(v), col)),
_ => None,
})
.collect();
out.modes.insert(m.name.clone(), colors);
}
let active = Self::active_mode(c, page_modes);
out.mode = c
.modes
.iter()
.find(|m| m.id == active)
.map(|m| m.name.clone());
out
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Cache {
pub variables: BTreeMap<String, Color>,
pub modes: BTreeMap<String, BTreeMap<String, Color>>,
pub mode: Option<String>,
pub tokens: BTreeMap<String, String>,
/// Boolean variables by CSS name. Not a page field: they bind to `visible` and are never
/// written into `tokens` (they are not CSS values).
pub bools: BTreeMap<String, bool>,
}
// ------------------------------------------------------------------------------ migration
/// `16px` -> 16. A unitless value (a line-height such as `1.5`) stays a string, since numbers
/// come back out as px.
fn parse_number(s: &str) -> Option<f64> {
s.trim().strip_suffix("px")?.trim().parse().ok()
}
/// Ids come from names (`stable_id`), so migrating the same project on two devices agrees.
pub fn migrate_from_document(doc: &crate::model::Document) -> DesignSystem {
let mut ds = DesignSystem::default();
let collection = |name: &str, modes: Vec<Mode>, default_mode: String| Collection {
id: stable_id(&["collection", name]),
name: name.into(),
modes,
default_mode,
variables: Vec::new(),
library: None,
};
let mode = |coll: &str, name: &str| Mode {
id: stable_id(&["mode", coll, name]),
name: name.into(),
};
let mut taken = BTreeSet::new();
if !doc.variables.is_empty() || !doc.modes.is_empty() {
let mut modes: Vec<Mode> = doc
.modes
.keys()
.map(|n| mode(COLORS_COLLECTION, n))
.collect();
if modes.is_empty() {
modes.push(mode(COLORS_COLLECTION, DEFAULT_MODE));
}
let active = doc
.mode
.as_ref()
.and_then(|n| modes.iter().find(|m| &m.name == n))
.unwrap_or(&modes[0])
.id
.clone();
let mut c = collection(COLORS_COLLECTION, modes, active);
let names: BTreeSet<&String> = doc
.variables
.keys()
.chain(doc.modes.values().flat_map(|m| m.keys()))
.collect();
for name in names {
let values = if doc.modes.is_empty() {
doc.variables
.get(name)
.map(|col| (c.modes[0].id.clone(), Value::Color(*col)))
.into_iter()
.collect()
} else {
c.modes
.iter()
.filter_map(|m| {
let col = doc.modes[&m.name]
.get(name)
.or_else(|| doc.variables.get(name))?;
Some((m.id.clone(), Value::Color(*col)))
})
.collect()
};
let v = Variable {
id: stable_id(&["var", COLORS_COLLECTION, name]),
name: name.clone(),
kind: Kind::Color,
values,
description: String::new(),
hidden: false,
};
taken.insert(css_name(&v));
c.variables.push(v);
}
ds.collections.push(c);
}
if !doc.tokens.is_empty() {
let m = mode(TOKENS_COLLECTION, DEFAULT_MODE);
let mut c = collection(TOKENS_COLLECTION, vec![m.clone()], m.id.clone());
for (name, raw) in &doc.tokens {
let (kind, value) = match parse_number(raw) {
Some(n) => (Kind::Number, Value::Number(n)),
None => (Kind::String, Value::String(raw.clone())),
};
// A color and a token may share a CSS name in an old page; the token yields.
let mut name = name.clone();
while taken.contains(&sanitize_name(&name.replace('/', "-"))) {
name.push_str("-token");
}
let v = Variable {
id: stable_id(&["var", TOKENS_COLLECTION, &name]),
name,
kind,
values: BTreeMap::from([(m.id.clone(), value)]),
description: String::new(),
hidden: false,
};
taken.insert(css_name(&v));
c.variables.push(v);
}
ds.collections.push(c);
}
ds
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Document;
fn red() -> Color {
Color::rgb(255, 0, 0)
}
fn ds_with(names: &[&str]) -> (DesignSystem, String) {
let mut ds = DesignSystem::default();
let c = ds.add_collection("C");
for n in names {
ds.add_variable(&c, n, Kind::Color).unwrap();
}
(ds, c)
}
fn id_of(ds: &DesignSystem, name: &str) -> String {
ds.all().find(|(_, v)| v.name == name).unwrap().1.id.clone()
}
#[test]
fn groups_and_rename() {
let (mut ds, c) = ds_with(&[
"color/brand/primary",
"color/brand/accent",
"color/bg",
"solo",
]);
assert_eq!(groups(ds.collection(&c).unwrap()), ["color", "color/brand"]);
assert_eq!(ds.rename_group(&c, "color/brand", "palette").unwrap(), 2);
assert!(ds.find_by_css("palette-brand-primary").is_none());
assert!(ds.find_by_css("palette-primary").is_some());
assert!(ds.find_by_css("color-bg").is_some());
assert_eq!(group_of("a/b/c"), "a/b");
assert_eq!(group_of("solo"), "");
}
#[test]
fn rename_group_duplicate_refused_and_reverted() {
let (mut ds, c) = ds_with(&["a/x", "b/x"]);
let before = ds.clone();
assert!(matches!(
ds.rename_group(&c, "a", "b"),
Err(Error::Duplicate(_))
));
assert_eq!(ds, before);
}
#[test]
fn alias_chain_across_collections_per_mode() {
let (mut ds, c1) = ds_with(&["base"]);
let c2 = ds.add_collection("Theme");
let dark = ds.add_mode(&c2, "Dark").unwrap();
let light = ds.collection(&c2).unwrap().default_mode.clone();
let base = id_of(&ds, "base");
let m1 = ds.collection(&c1).unwrap().default_mode.clone();
ds.set_value(&base, &m1, Value::Color(red())).unwrap();
let mid = ds.add_variable(&c2, "mid", Kind::Color).unwrap();
ds.set_value(&mid, &light, Value::Alias(base.clone()))
.unwrap();
ds.set_value(&mid, &dark, Value::Color(Color::rgb(0, 0, 255)))
.unwrap();
let top = ds.add_variable(&c1, "top", Kind::Color).unwrap();
ds.set_value(&top, &m1, Value::Alias(mid.clone())).unwrap();
assert_eq!(
ds.resolve(&top, &PageModes::new()).unwrap(),
Value::Color(red())
);
let modes = PageModes::from([(c2.clone(), dark)]);
assert_eq!(
ds.resolve(&top, &modes).unwrap(),
Value::Color(Color::rgb(0, 0, 255))
);
}
#[test]
fn cycle_and_kind_refused() {
let (mut ds, c) = ds_with(&["a", "b"]);
let (a, b) = (id_of(&ds, "a"), id_of(&ds, "b"));
let m = ds.collection(&c).unwrap().default_mode.clone();
ds.set_value(&a, &m, Value::Alias(b.clone())).unwrap();
assert!(matches!(
ds.set_value(&b, &m, Value::Alias(a.clone())),
Err(Error::Cycle(_))
));
assert!(matches!(
ds.set_value(&a, &m, Value::Alias(a.clone())),
Err(Error::Cycle(_))
));
assert!(matches!(
ds.set_value(&b, &m, Value::Number(1.0)),
Err(Error::KindMismatch(_))
));
let n = ds.add_variable(&c, "n", Kind::Number).unwrap();
assert!(matches!(
ds.set_value(&a, &m, Value::Alias(n)),
Err(Error::KindMismatch(_))
));
}
#[test]
fn duplicate_css_refused() {
let (mut ds, c) = ds_with(&["a/b"]);
assert!(matches!(
ds.add_variable(&c, "a-b", Kind::Number),
Err(Error::Duplicate(_))
));
let c2 = ds.add_collection("Other");
assert!(matches!(
ds.add_variable(&c2, "a/b", Kind::Color),
Err(Error::Duplicate(_))
));
assert_eq!(ds.all().count(), 1);
}
#[test]
fn remove_aliased_reports_dependents() {
let (mut ds, c) = ds_with(&["base", "user"]);
let (base, user) = (id_of(&ds, "base"), id_of(&ds, "user"));
let m = ds.collection(&c).unwrap().default_mode.clone();
ds.set_value(&base, &m, Value::Color(red())).unwrap();
ds.set_value(&user, &m, Value::Alias(base.clone())).unwrap();
assert_eq!(
ds.remove_variable(&base, false),
Err(Error::InUse(vec!["user".into()]))
);
assert_eq!(
ds.remove_variable(&base, true).unwrap(),
vec!["user".to_string()]
);
assert_eq!(
ds.resolve(&user, &PageModes::new()).unwrap(),
Value::Color(red())
);
assert!(ds.find(&base).is_none());
}
#[test]
fn modes_add_remove() {
let (mut ds, c) = ds_with(&["a"]);
let a = id_of(&ds, "a");
let d = ds.add_mode(&c, "Dark").unwrap();
assert!(ds.find(&a).unwrap().1.values.contains_key(&d));
ds.remove_mode(&c, &d).unwrap();
assert!(!ds.find(&a).unwrap().1.values.contains_key(&d));
let only = ds.collection(&c).unwrap().default_mode.clone();
assert_eq!(ds.remove_mode(&c, &only), Err(Error::LastMode));
}
#[test]
fn library_collection_read_only() {
let (mut ds, c) = ds_with(&["a"]);
ds.collections[0].library = Some("lib".into());
assert!(matches!(
ds.add_variable(&c, "b", Kind::Color),
Err(Error::ReadOnly(_))
));
}
#[test]
fn cache_shape() {
let mut ds = DesignSystem::default();
let c = ds.add_collection("Colors");
let light = ds.collection(&c).unwrap().default_mode.clone();
let dark = ds.add_mode(&c, "Dark").unwrap();
let bg = ds.add_variable(&c, "color/bg", Kind::Color).unwrap();
ds.set_value(&bg, &light, Value::Color(Color::rgb(255, 255, 255)))
.unwrap();
ds.set_value(&bg, &dark, Value::Color(Color::rgb(0, 0, 0)))
.unwrap();
let t = ds.add_collection("Tokens");
let sp = ds.add_variable(&t, "space/md", Kind::Number).unwrap();
let tm = ds.collection(&t).unwrap().default_mode.clone();
ds.set_value(&sp, &tm, Value::Number(16.0)).unwrap();
let f = ds.add_variable(&t, "font", Kind::String).unwrap();
ds.set_value(&f, &tm, Value::String("Inter".into()))
.unwrap();
let b = ds.add_variable(&t, "flag", Kind::Boolean).unwrap();
ds.set_value(&b, &tm, Value::Boolean(true)).unwrap();
let cache = ds.cache_for(&PageModes::from([(c.clone(), dark)]));
assert_eq!(cache.variables["color-bg"], Color::rgb(0, 0, 0));
assert_eq!(
cache.modes["Default"]["color-bg"],
Color::rgb(255, 255, 255)
);
assert_eq!(cache.modes["Dark"]["color-bg"], Color::rgb(0, 0, 0));
assert_eq!(cache.mode.as_deref(), Some("Dark"));
assert_eq!(cache.tokens["space-md"], "16px");
assert_eq!(cache.tokens["font"], "Inter");
assert!(!cache.tokens.contains_key("flag"), "booleans are not tokens");
assert_eq!(cache.bools["flag"], true);
assert_eq!(
ds.cache_for(&PageModes::new()).mode.as_deref(),
Some("Default")
);
}
fn round_trip(doc: &Document) {
let ds = migrate_from_document(doc);
ds.validate().unwrap();
let c = ds.cache_for(&PageModes::new());
assert_eq!(c.variables, doc.variables);
assert_eq!(c.modes, doc.modes);
assert_eq!(c.mode, doc.mode);
assert_eq!(c.tokens, doc.tokens);
}
#[test]
fn migration_round_trip() {
let mut doc = Document::default();
round_trip(&doc);
doc.variables.insert("bg".into(), Color::rgb(255, 255, 255));
doc.tokens.insert("space-md".into(), "16px".into());
doc.tokens
.insert("shadow-sm".into(), "0 1px 2px rgba(0,0,0,.06)".into());
round_trip(&doc);
doc.modes.insert(
"Light".into(),
BTreeMap::from([("bg".into(), Color::rgb(255, 255, 255))]),
);
doc.modes.insert(
"Dark".into(),
BTreeMap::from([("bg".into(), Color::rgb(0, 0, 0))]),
);
doc.mode = Some("Light".into());
round_trip(&doc);
doc.mode = Some("Dark".into());
doc.variables.insert("bg".into(), Color::rgb(0, 0, 0));
round_trip(&doc);
let ds = migrate_from_document(&doc);
assert_eq!(ds.collections[0].name, COLORS_COLLECTION);
assert_eq!(ds.collections[1].name, TOKENS_COLLECTION);
assert_eq!(ds.collections[1].variables[1].kind, Kind::Number);
}
#[test]
fn group_names_with_multibyte_and_empty_segments() {
let mut ds = DesignSystem::default();
let c = ds.add_collection("C");
ds.add_variable(&c, "é/ü/x", Kind::Color).unwrap();
assert_eq!(groups(ds.collection(&c).unwrap()), ["é", "é/ü"]);
for bad in ["a//b", "/a", "a/", "", "é//x"] {
assert!(
matches!(ds.add_variable(&c, bad, Kind::Color), Err(Error::InvalidName(_))),
"{bad:?}"
);
}
// A stored bad name (hand-edited design.json) must not panic the group list.
ds.collections[0].variables[0].name = "é//ü//x".into();
assert_eq!(groups(ds.collection(&c).unwrap()), ["é"]);
}
#[test]
fn force_remove_resolves_per_dependent_mode() {
let mut ds = DesignSystem::default();
let c = ds.add_collection("C");
let light = ds.collection(&c).unwrap().default_mode.clone();
let dark = ds.add_mode(&c, "Dark").unwrap();
let base = ds.add_variable(&c, "base", Kind::Color).unwrap();
let user = ds.add_variable(&c, "user", Kind::Color).unwrap();
ds.set_value(&base, &light, Value::Color(red())).unwrap();
ds.set_value(&base, &dark, Value::Color(Color::rgb(0, 0, 255))).unwrap();
ds.set_value(&user, &light, Value::Alias(base.clone())).unwrap();
ds.set_value(&user, &dark, Value::Alias(base.clone())).unwrap();
ds.remove_variable(&base, true).unwrap();
let v = &ds.find(&user).unwrap().1.values;
assert_eq!(v[&light], Value::Color(red()));
assert_eq!(v[&dark], Value::Color(Color::rgb(0, 0, 255)));
}
#[test]
fn migration_ids_are_deterministic_and_dedupe_color_token_clash() {
let mut doc = Document::default();
doc.variables.insert("brand".into(), red());
doc.tokens.insert("brand".into(), "4px".into());
doc.tokens.insert("space".into(), "8px".into());
let (a, b) = (migrate_from_document(&doc), migrate_from_document(&doc));
assert_eq!(a, b, "same project, same ids on every device");
a.validate().unwrap();
assert!(a.find_by_css("brand-token").is_some());
assert_eq!(a.find_by_css("brand").unwrap().1.kind, Kind::Color);
assert_ne!(stable_id(&["a", "bc"]), stable_id(&["ab", "c"]));
}
}
+51
View File
@@ -0,0 +1,51 @@
//! The Assets view draws with components on two pages and a library, and placing works.
use egui::{Pos2, Rect, Vec2};
use papercraft::editor::Editor;
use papercraft::geom::{Rect as R, v2};
use papercraft::model::{Document, Node, NodeId, NodeKind};
use papercraft::ui::{self, UiState, library};
fn comp(e: &mut Editor, name: &str) -> NodeId {
let id = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, R::new(0., 0., 60., 30.)), None);
e.selection = vec![id];
e.create_component();
e.edit("n", |tx| tx.node_mut(id).unwrap().name = name.into());
id
}
#[test]
fn assets_panel_draws_and_places() {
let ctx = egui::Context::default();
ui::setup_style(&ctx);
let mut ed = Editor::default();
let button = comp(&mut ed, "Button");
ed.add_page_in_background(None);
ed.with_page(1, |e| comp(e, "Toggle"));
let mut doc = Document::default();
doc.name = "Kit".into();
let mut n = Node::new(NodeId(1), NodeKind::Frame { clip: false }, R::new(0., 0., 10., 10.));
n.name = "Logo".into();
n.component = true;
n.component_key = Some("k".into());
doc.nodes.insert(n.id, n);
ed.library_docs.insert("kit".into(), doc);
ed.design.libraries.push(papercraft::variables::EnabledLibrary { library_id: "kit".into(), version: 1 });
let mut st = UiState::default();
st.show_library = true;
for _ in 0..3 {
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(344.0, 1440.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| library::panel(ui, &mut ed, &mut st));
out.textures_delta.clear();
}
let list = library::list_assets(&ed, "");
let toggle = list.pages[1].1[0].clone();
let before = ed.doc.nodes.len();
assert!(library::place(&mut ed, &toggle, v2(100., 100.)).is_some());
assert!(library::place(&mut ed, &list.libraries[0].2[0], v2(0., 0.)).is_some());
assert_eq!(ed.doc.nodes.len(), before + 2);
assert!(ed.doc.get(button).is_some_and(|b| b.component));
}
+178
View File
@@ -0,0 +1,178 @@
//! `papercraft-cli` without the app: in-process calls on a temp store, the "app has it open"
//! refusal, and shell script parsing.
use papercraft::cli::{Offline, Stmt, parse_script, refuse_if_open};
use papercraft::mcp::Out;
use papercraft::project::Store;
use serde_json::{Value, json};
fn temp_store(tag: &str) -> Store {
let dir = std::env::temp_dir().join(format!("pc-cli-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Store::open(dir).unwrap()
}
fn json_of(outs: Vec<Out>) -> Value {
let Some(Out::Text(t)) = outs.into_iter().next() else {
panic!("no text result")
};
serde_json::from_str(&t).unwrap()
}
#[test]
fn offline_round_trip_saves_and_reopens() {
let store = temp_store("rt");
let root = store.root.clone();
let mut o = Offline::new(store, None).unwrap();
let file = json_of(o.call("create_file", &json!({"name": "Demo"})).unwrap());
let id = file["fileId"].as_str().unwrap().to_string();
o.call("open_file", &json!({"fileId": id})).unwrap();
let ab = json_of(
o.call(
"create_artboard",
&json!({"name": "Card", "styles": {"width": "200px", "height": "100px"}}),
)
.unwrap(),
);
let ab_id = ab["id"].as_str().unwrap().to_string();
o.call(
"write_html",
&json!({"targetNodeId": ab_id, "mode": "insert-children",
"html": "<div layer-name=\"Title\" style=\"font-size:20px\">Hi</div>"}),
)
.unwrap();
assert_eq!(o.last_touched.as_deref(), Some(ab_id.as_str()));
// A fresh host on the same folder: the default project is the one just saved.
let mut again = Offline::new(Store::open(&root).unwrap(), None).unwrap();
let tree = again
.call("get_tree_summary", &json!({"nodeId": ab_id}))
.unwrap();
let Some(Out::Text(t)) = tree.first() else {
panic!("no tree")
};
assert!(t.contains("Card") && t.contains("Title"), "{t}");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn offline_edit_without_project_is_an_error() {
let mut o = Offline::new(temp_store("none"), None).unwrap();
let e = o.call("create_artboard", &json!({})).unwrap_err();
assert!(e.contains("no project is open"), "{e}");
}
#[test]
fn refuses_when_app_has_project_open() {
let items = json!({"items": [
{"id": "a1", "name": "Alpha", "path": "/p/Alpha", "open": true},
{"id": "b2", "name": "Beta", "path": "/p/Beta", "open": false},
]});
let e = refuse_if_open(&items, Some("Alpha")).unwrap_err();
assert!(e.contains("PaperCraft has this project open"), "{e}");
assert!(refuse_if_open(&items, Some("a1")).is_err());
assert!(refuse_if_open(&items, Some("/p/Alpha")).is_err());
assert!(
refuse_if_open(&items, None).is_err(),
"default is the first item"
);
assert!(refuse_if_open(&items, Some("beta")).is_ok());
assert!(refuse_if_open(&items, Some("Unknown")).is_ok());
assert!(refuse_if_open(&json!({"items": []}), None).is_ok());
}
#[test]
fn script_parsing() {
let script = r#"
# comment
create_file {"name": "a}b"}
write_html {
"html": "<div style=\"x\">{ not a brace }</div>",
"list": [1, 2]
}
:shot 1:2 out.png
get_basic_info
bad {"x":
"#;
let s = parse_script(script);
let lines: Vec<usize> = s.iter().map(|(n, _)| *n).collect();
assert_eq!(lines, [3, 5, 9, 10, 11]);
match &s[0].1 {
Ok(Stmt::Call { tool, args }) => {
assert_eq!(tool, "create_file");
assert_eq!(args["name"], "a}b");
}
o => panic!("{o:?}"),
}
match &s[1].1 {
Ok(Stmt::Call { tool, args }) => {
assert_eq!(tool, "write_html");
assert_eq!(args["list"][1], 2);
assert!(args["html"].as_str().unwrap().contains("{ not a brace }"));
}
o => panic!("{o:?}"),
}
assert_eq!(
s[2].1,
Ok(Stmt::Cmd {
name: "shot".into(),
args: vec!["1:2".into(), "out.png".into()]
})
);
assert_eq!(
s[3].1,
Ok(Stmt::Call {
tool: "get_basic_info".into(),
args: Value::Null
})
);
assert!(s[4].1.is_err(), "unterminated JSON fails at its first line");
}
#[test]
fn offline_screenshot_when_gpu_exists() {
if papercraft::render::gpu::Headless::new().is_err() {
return;
}
let store = temp_store("shot");
let root = store.root.clone();
let mut o = Offline::new(store, None).unwrap();
let id = json_of(o.call("create_file", &json!({"name": "S"})).unwrap())["fileId"]
.as_str()
.unwrap()
.to_string();
o.call("open_file", &json!({"fileId": id})).unwrap();
o.call(
"create_artboard",
&json!({"styles": {"width": "80px", "height": "60px", "backgroundColor": "#336699"}}),
)
.unwrap();
let r = o.call("get_screenshot", &Value::Null).unwrap();
assert!(
r.iter()
.any(|x| matches!(x, Out::Image { data, .. } if data.starts_with(b"\x89PNG")))
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn open_file_tool_goes_through_the_open_guard() {
let store = temp_store("guard");
let root = store.root.clone();
let mut o = Offline::new(store, None).unwrap();
o.open_guard = Some(Box::new(|id| Err(format!("app has {id} open"))));
let e = o.call("open_file", &json!({"fileId": "X"})).unwrap_err();
assert_eq!(e, "app has X open");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn refusal_compares_canonical_paths() {
let dir = std::env::temp_dir().join(format!("pc-canon-{}", std::process::id()));
std::fs::create_dir_all(dir.join("p")).unwrap();
let via_dots = dir.join("p").join("..").join("p");
let items = json!({"items": [{"id": "a", "name": "A", "path": dir.join("p"), "open": true}]});
assert!(refuse_if_open(&items, Some(via_dots.to_str().unwrap())).is_err());
let _ = std::fs::remove_dir_all(dir);
}
+118
View File
@@ -0,0 +1,118 @@
//! Components used across pages through the MCP tools, and variable binding.
use papercraft::mcp::{Out, exec};
use papercraft::model::NodeId;
use papercraft::project::Store;
use serde_json::{Value, json};
fn host() -> exec::SessionHost {
let store =
Store::open(std::env::temp_dir().join(format!("papercraft-xp-{:016x}", fastrand::u64(..))))
.unwrap();
let mut h = exec::SessionHost::new(store);
h.session.create(&mut h.ed, "Cross").unwrap();
h
}
fn try_call(h: &mut exec::SessionHost, name: &str, args: Value) -> Result<Value, String> {
exec::call(h, name, &args).map(|o| match o.into_iter().next() {
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)),
_ => Value::Null,
})
}
fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
try_call(h, name, args).unwrap_or_else(|e| panic!("{name} failed: {e}"))
}
fn num(id: &Value) -> NodeId {
NodeId(id.as_str().unwrap().rsplit(':').next().unwrap().parse().unwrap())
}
/// Page A with component "Pill", page B empty. Returns (A id, B id, component id).
fn setup(h: &mut exec::SessionHost) -> (Value, Value, Value) {
let a = call(h, "get_basic_info", json!({}))["pages"][0]["id"].clone();
let b = call(h, "create_page", json!({"name": "B"}))["pageId"].clone();
let ab = call(h, "create_artboard", json!({"pageId": a, "name": "Pill",
"styles": {"width": "100px", "height": "40px", "background": "#ff0000"}}));
call(h, "create_component", json!({"pageId": a, "nodeId": ab["id"]}));
(a, b, ab["id"].clone())
}
fn bg_red(h: &exec::SessionHost, page: usize, id: &Value) -> bool {
use papercraft::model::Paint;
matches!(h.ed.page_doc(page).get(num(id)).unwrap().style.fills.first(),
Some(Paint::Solid { color, .. }) if color.r == 255 && color.b == 0)
}
#[test]
fn instance_on_another_page_follows_main_and_survives_deletion() {
let mut h = host();
let (a, b, comp) = setup(&mut h);
let inst = call(&mut h, "create_instance", json!({"pageId": b, "componentId": comp}));
assert!(bg_red(&h, 1, &inst["id"]));
call(&mut h, "update_styles", json!({"pageId": a, "updates": [{"nodeIds": [comp], "styles": {"background": "#0000ff"}}]}));
assert!(!bg_red(&h, 1, &inst["id"]), "B follows A");
let comps = call(&mut h, "get_components", json!({}));
let c = &comps["components"][0];
assert_eq!(c["uses"], 1, "{comps}");
assert_eq!(c["pageName"], "Pill".replace("Pill", &h.ed.pages[0].name));
assert!(c["componentKey"].is_string());
call(&mut h, "delete_nodes", json!({"pageId": a, "nodeIds": [comp]}));
assert!(h.ed.page_doc(1).get(num(&inst["id"])).is_some());
assert_eq!(h.ed.missing_mains(1), vec![num(&inst["id"])]);
}
#[test]
fn create_and_swap_by_component_key_across_pages() {
let mut h = host();
let (a, b, comp) = setup(&mut h);
let key = call(&mut h, "get_components", json!({}))["components"][0]["componentKey"].clone();
let i1 = call(&mut h, "create_instance", json!({"pageId": b, "componentKey": key}));
// A second component on A; swap B's instance to it by key.
let ab = call(&mut h, "create_artboard", json!({"pageId": a, "name": "Other",
"styles": {"width": "10px", "height": "10px", "background": "#00ff00"}}));
call(&mut h, "create_component", json!({"pageId": a, "nodeId": ab["id"]}));
let comps = call(&mut h, "get_components", json!({"pageId": a}));
let other = comps["components"].as_array().unwrap().iter()
.find(|c| c["id"] == ab["id"]).unwrap()["componentKey"].clone();
assert_ne!(other, key);
call(&mut h, "swap_instance", json!({"pageId": b, "nodeId": i1["id"], "componentKey": other}));
assert!(!bg_red(&h, 1, &i1["id"]));
assert!(try_call(&mut h, "create_instance", json!({"pageId": b, "componentKey": "nope"})).is_err());
let _ = comp;
}
#[test]
fn bind_variable_binds_and_detaches() {
let mut h = host();
let (_a, _b, comp) = setup(&mut h);
call(&mut h, "set_tokens", json!({"tokens": {"space-md": "24px"}}));
let child = call(&mut h, "create_artboard", json!({"name": "F",
"styles": {"width": "10px", "height": "10px", "display": "flex"}}));
call(&mut h, "bind_variable", json!({"nodeIds": [child["id"]], "field": "gap", "variable": "space-md"}));
let n = h.ed.doc.get(num(&child["id"])).unwrap();
assert_eq!(n.bindings["gap"], "space-md");
call(&mut h, "bind_variable", json!({"nodeIds": [child["id"]], "field": "gap", "variable": null}));
assert!(h.ed.doc.get(num(&child["id"])).unwrap().bindings.is_empty());
assert!(try_call(&mut h, "bind_variable", json!({"nodeIds": [child["id"]], "field": "gap", "variable": "zzz"})).is_err());
let _ = comp;
}
#[test]
fn duplicating_keeps_cross_page_links_and_gives_copied_mains_fresh_keys() {
let mut h = host();
let (_a, b, comp) = setup(&mut h);
let inst = call(&mut h, "create_instance", json!({"pageId": b, "componentId": comp}));
let d = call(&mut h, "duplicate_nodes", json!({"pageId": b, "nodes": [{"id": inst["id"]}]}));
let copy = num(&d["duplicates"][0]["newId"]);
let src = h.ed.page_doc(1).get(num(&inst["id"])).unwrap().instance.clone().unwrap();
let l = h.ed.page_doc(1).get(copy).unwrap().instance.clone().expect("link kept");
assert_eq!((l.main, l.source.clone()), (src.main, src.source), "cross-page link untouched");
let d = call(&mut h, "duplicate_nodes", json!({"nodes": [{"id": comp}]}));
let _ = d;
let keys: Vec<String> = h.ed.page_doc(0).nodes.values().filter_map(|n| n.component_key.clone()).collect();
assert_eq!(keys.len(), 2);
assert_ne!(keys[0], keys[1], "copied main gets its own key");
}
+206
View File
@@ -725,3 +725,209 @@ fn changing_variable_modes_preserves_existing_frame_geometry() {
Color::rgb(0xcb, 0xa6, 0xf7)
);
}
#[test]
fn old_variable_and_token_calls_land_in_collections() {
let mut h = host();
call(
&mut h,
"set_tokens",
json!({"tokens": {"spacing-md": "16px", "font": "Inter", "gap": 4}}),
);
call(
&mut h,
"set_variables",
json!({"variables": {"accent": "#cba6f7"}}),
);
let ds = &h.ed.design;
let col = |n: &str| ds.collections.iter().find(|c| c.name == n).unwrap();
assert!(col("Colors").variables.iter().any(|v| v.name == "accent"));
let kind = |n: &str| {
col("Tokens")
.variables
.iter()
.find(|v| v.name == n)
.unwrap()
.kind
};
assert_eq!(kind("spacing-md"), papercraft::variables::Kind::Number);
assert_eq!(kind("gap"), papercraft::variables::Kind::Number);
assert_eq!(kind("font"), papercraft::variables::Kind::String);
assert_eq!(h.ed.doc.tokens["spacing-md"], "16px");
assert_eq!(h.ed.doc.tokens["font"], "Inter");
// A second page follows, and a new page copies the active page's modes.
for (mode, hex) in [("Light", "#eeeeee"), ("Dark", "#111111")] {
call(
&mut h,
"set_variables",
json!({"mode": mode, "variables": {"accent": hex}}),
);
}
let (cid, dark) = papercraft::editor::design::find_mode(&h.ed.design, "Dark").unwrap();
h.ed.set_page_mode(&cid, &dark).unwrap();
let p2 = call(&mut h, "create_page", json!({"name": "Second"}));
let i = h.ed.pages.iter().position(|p| p.id == p2["pageId"]).unwrap();
assert_eq!(h.ed.pages[i].modes, h.ed.pages[h.ed.active_page].modes);
assert_eq!(h.ed.page_doc(i).mode.as_deref(), Some("Dark"));
assert_eq!(h.ed.page_doc(i).variables["accent"], Color::rgb(0x11, 0x11, 0x11));
}
fn try_call(h: &mut exec::SessionHost, name: &str, args: Value) -> Result<Value, String> {
exec::call(h, name, &args).map(|outs| match outs.into_iter().next() {
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)),
_ => Value::Null,
})
}
#[test]
fn set_variables_new_shapes_and_get_variables_collections() {
let mut h = host();
call(&mut h, "create_collection", json!({"name": "Brand", "modes": ["Light", "Dark"]}));
assert!(try_call(&mut h, "create_collection", json!({"name": "Brand"})).is_err());
call(
&mut h,
"set_variables",
json!({"collection": "Brand", "variables": {
"color/brand/primary": {"value": "#ff0000", "description": "Main"},
"color/brand/hover": {"alias": "color/brand/primary", "hidden": true},
"space/md": {"value": 16, "type": "number", "collection": "Sizes"},
"flag": {"value": true},
"font": {"value": "Inter", "type": "string"},
}}),
);
// Old shape still lands in the first color collection.
call(&mut h, "set_variables", json!({"variables": {"accent": "#00ff00"}}));
let v = call(&mut h, "get_variables", json!({}));
let cols = v["collections"].as_array().unwrap();
let brand = cols.iter().find(|c| c["name"] == "Brand").unwrap();
assert_eq!(brand["modes"], json!(["Light", "Dark"]));
assert_eq!(brand["defaultMode"], "Light");
let var = |n: &str| {
cols.iter()
.flat_map(|c| c["variables"].as_array().unwrap())
.find(|x| x["name"] == n)
.unwrap_or_else(|| panic!("no {n}"))
.clone()
};
let p = var("color/brand/primary");
assert_eq!(p["cssName"], "color-brand-primary");
assert_eq!(p["group"], "color/brand");
assert_eq!(p["type"], "color");
assert_eq!(p["values"]["Dark"], "#ff0000");
assert_eq!(p["description"], "Main");
let hover = var("color/brand/hover");
assert_eq!(hover["values"]["Light"], json!({"alias": "color/brand/primary"}));
assert_eq!(hover["hidden"], true);
assert_eq!(var("space/md")["type"], "number");
assert_eq!(var("space/md")["values"]["Default"], 16.0);
assert!(cols.iter().any(|c| c["name"] == "Sizes"));
assert_eq!(var("flag")["type"], "boolean");
assert_eq!(var("font")["type"], "string");
assert!(v["variables"]["accent"].is_string());
assert_eq!(v["pageModes"]["Brand"], "Light");
// Existing keys stay; the alias resolves into the cache.
assert_eq!(v["variables"]["color-brand-hover"], "#ff0000");
assert_eq!(h.ed.doc.tokens["space-md"], "16px");
// A wrong kind or an alias cycle is refused and changes nothing.
assert!(try_call(&mut h, "set_variables", json!({"variables": {"space/md": "#fff"}})).is_err());
assert!(
try_call(
&mut h,
"set_variables",
json!({"variables": {"color/brand/primary": {"alias": "color/brand/hover"}}})
)
.is_err()
);
// Old color-only error text stays.
let e = try_call(&mut h, "set_variables", json!({"variables": {"x": "nope"}})).unwrap_err();
assert!(e.contains("is not a color"), "{e}");
}
#[test]
fn dtcg_nests_groups_and_references_aliases() {
let mut h = host();
call(
&mut h,
"set_variables",
json!({"variables": {
"color/brand/primary": "#ff0000",
"color/brand/hover": {"alias": "color/brand/primary"},
"space/md": {"value": 16, "type": "number"},
"enabled": {"value": true},
"plain": "#000000",
}}),
);
let t = call(&mut h, "get_variables", json!({"format": "json"}));
assert_eq!(t["color"]["brand"]["primary"]["$type"], "color");
assert_eq!(t["color"]["brand"]["primary"]["$value"], "#ff0000");
assert_eq!(t["color"]["brand"]["hover"]["$value"], "{color.brand.primary}");
assert_eq!(t["space"]["md"]["$type"], "dimension");
assert_eq!(t["space"]["md"]["$value"], "16px");
assert_eq!(t["enabled"]["$type"], "boolean");
assert_eq!(t["enabled"]["$value"], true);
assert_eq!(t["plain"]["$value"], "#000000");
// css keeps today's flat shape: colors, numbers as px.
let css = call(&mut h, "get_variables", json!({"format": "css"}));
let css = css.as_str().unwrap();
assert!(css.contains("--color-brand-primary: #ff0000;"), "{css}");
assert!(css.contains("--space-md: 16px;"), "{css}");
}
#[test]
fn delete_variables_needs_force_when_aliased() {
let mut h = host();
call(
&mut h,
"set_variables",
json!({"variables": {"base": "#112233", "alt": {"alias": "base"}}}),
);
let e = try_call(&mut h, "delete_variables", json!({"names": ["base"]})).unwrap_err();
assert!(e.contains("alt"), "{e}");
assert!(h.ed.design.find_by_css("base").is_some());
// Deleting both in one call is fine without force.
call(&mut h, "delete_variables", json!({"names": ["base", "alt"]}));
assert!(h.ed.design.find_by_css("base").is_none());
assert!(h.ed.design.find_by_css("alt").is_none());
call(
&mut h,
"set_variables",
json!({"variables": {"base": "#112233", "alt": {"alias": "base"}}}),
);
call(&mut h, "delete_variables", json!({"names": ["base"], "force": true}));
let (_, alt) = h.ed.design.find_by_css("alt").unwrap();
assert!(matches!(
alt.values.values().next(),
Some(papercraft::variables::Value::Color(_))
));
assert!(try_call(&mut h, "delete_variables", json!({"names": ["ghost"]})).is_err());
}
#[test]
fn export_design_system_writes_groups_and_round_trips() {
let mut h = host();
call(
&mut h,
"set_variables",
json!({"variables": {
"color/brand/primary": "#ff0000",
"space/md": {"value": 16, "type": "number"},
"font": {"value": "Inter", "type": "string"},
"on": {"value": true},
}}),
);
let dir = std::env::temp_dir().join(format!("papercraft-ds-pkg-{:016x}", fastrand::u64(..)));
call(&mut h, "export_design_system", json!({"path": dir}));
let css = std::fs::read_to_string(dir.join("tokens.css")).unwrap();
assert!(css.contains("/* color/brand */"), "{css}");
assert!(css.contains("--color-brand-primary: #ff0000;"));
assert!(css.contains("--space-md: 16px;"));
assert!(css.contains("--font: Inter;"));
assert!(!css.contains("--on:"), "booleans are not CSS tokens");
let mut h2 = host();
call(&mut h2, "import_design_system", json!({"path": dir}));
let doc = h2.ed.doc.clone();
assert_eq!(doc.variables["color-brand-primary"], Color::rgb(255, 0, 0));
assert_eq!(doc.tokens["space-md"], "16px");
assert_eq!(doc.tokens["font"], "Inter");
}
+370
View File
@@ -0,0 +1,370 @@
//! Publishing a project as a library and using it from another project.
use papercraft::libraries::{self, LibrarySummary};
use papercraft::mcp::{Out, exec};
use papercraft::model::{NodeId, Paint};
use papercraft::project::Store;
use serde_json::{Value, json};
use std::path::PathBuf;
fn host() -> exec::SessionHost {
let root = std::env::temp_dir().join(format!("papercraft-lib-{:016x}", fastrand::u64(..)));
exec::SessionHost::new(Store::open(root).unwrap())
}
fn try_call(h: &mut exec::SessionHost, name: &str, args: Value) -> Result<Value, String> {
exec::call(h, name, &args).map(|o| match o.into_iter().next() {
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)),
_ => Value::Null,
})
}
fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
try_call(h, name, args).unwrap_or_else(|e| panic!("{name} failed: {e}"))
}
fn num(id: &Value) -> NodeId {
NodeId(id.as_str().unwrap().rsplit(':').next().unwrap().parse().unwrap())
}
fn dir(h: &exec::SessionHost) -> PathBuf {
h.session.project.as_ref().unwrap().dir.clone()
}
/// Project "Kit" with variable `brand` and component "Pill" (red), saved. Returns its key.
fn make_kit(h: &mut exec::SessionHost, brand: &str) -> String {
h.session.create(&mut h.ed, "Kit").unwrap();
call(h, "set_variables", json!({"variables": {"brand": brand}}));
let ab = call(h, "create_artboard", json!({"name": "Pill",
"styles": {"width": "100px", "height": "40px", "backgroundColor": "var(--brand)"}}));
call(h, "create_component", json!({"nodeId": ab["id"]}));
h.session.save(&mut h.ed).unwrap();
let c = call(h, "get_components", json!({}));
c["components"][0]["componentKey"].as_str().unwrap().to_string()
}
fn kit_summary(h: &exec::SessionHost) -> LibrarySummary {
libraries::available(&h.session.store, None)
.into_iter()
.find(|l| l.name == "Kit")
.unwrap()
}
#[test]
fn publish_twice_increments_version_and_diffs() {
let mut h = host();
make_kit(&mut h, "#ff0000");
let d = dir(&h);
assert!(!libraries::publish_diff(&d, &h.ed).unwrap().is_empty());
let p1 = libraries::publish(&d, &h.ed, "Kit v1").unwrap();
assert_eq!((p1.version, p1.components.len()), (1, 1));
assert!(libraries::publish_diff(&d, &h.ed).unwrap().is_empty());
call(&mut h, "set_variables", json!({"variables": {"brand": "#00ff00", "extra": "#0000ff"}}));
let diff = libraries::publish_diff(&d, &h.ed).unwrap();
assert_eq!(diff.variables_changed, ["brand"]);
assert_eq!(diff.variables_added, ["extra"]);
// The component's bound fill color follows the variable, so it changed too.
assert_eq!(diff.to_string(), "1 variable added, 1 variable changed, 1 component changed");
let p2 = libraries::publish(&d, &h.ed, "").unwrap();
assert_eq!((p2.version, p2.description.as_str()), (2, "Kit v1"));
assert_eq!(p1.components[0].root, p2.components[0].root, "ids are stable");
// The library itself is not offered to itself.
assert!(libraries::available(&h.session.store, Some(&d)).is_empty());
}
#[test]
fn enable_in_second_project_binds_and_places_components() {
let mut h = host();
let key = make_kit(&mut h, "#ff0000");
libraries::publish(&dir(&h), &h.ed, "").unwrap();
h.session.create(&mut h.ed, "App").unwrap();
let app = dir(&h);
let lib = kit_summary(&h);
libraries::enable(&app, &mut h.ed, &lib).unwrap();
let col = h.ed.design.collections.iter().find(|c| c.library.is_some()).unwrap();
assert_eq!(col.library.as_deref(), Some(lib.library_id.as_str()));
assert!(app.join("libraries").join(format!("{}.json", lib.library_id)).is_file());
assert_eq!(h.ed.design.libraries[0].version, 1);
// Library collections are read-only.
let id = h.ed.design.collections.iter().find(|c| c.library.is_some()).unwrap().id.clone();
assert!(h.ed.edit_design("x", |ds| ds.add_mode(&id, "Dark").map(|_| ())).is_err());
// A fill bound to the library variable resolves.
let ab = call(&mut h, "create_artboard", json!({"name": "Card",
"styles": {"width": "50px", "height": "50px", "backgroundColor": "var(--brand)"}}));
match h.ed.doc.get(num(&ab["id"])).unwrap().style.fills.last() {
Some(Paint::Solid { color, var }) => {
assert_eq!((color.r, color.g), (255, 0));
assert_eq!(var.as_deref(), Some("brand"));
}
o => panic!("{o:?}"),
}
// A library component instance places and renders its nodes.
let inst = call(&mut h, "create_instance", json!({"componentKey": key}));
let n = h.ed.doc.get(num(&inst["id"])).unwrap();
assert!(matches!(n.instance.as_ref().unwrap().source, Some(papercraft::model::Source::Library(_))));
assert_eq!((n.rect.w, n.rect.h), (100.0, 40.0));
assert!(h.ed.is_instance_root(n.id));
let svg = papercraft::io::svg::export(&h.ed.doc, Some(&[n.id]), None).unwrap();
assert!(svg.contains("<rect") || svg.contains("<path"), "{svg}");
// Reset overrides resyncs from the snapshot without losing the instance.
let nid = n.id;
h.ed.selection = vec![nid];
h.ed.reset_overrides();
assert!(h.ed.doc.get(nid).unwrap().instance.is_some());
// Snapshot and design survive a save and reopen.
h.session.save(&mut h.ed).unwrap();
let design = h.ed.design.clone();
h.session.create(&mut h.ed, "Other").unwrap();
assert!(h.ed.library_docs.is_empty());
h.session.open(&mut h.ed, &app).unwrap();
assert_eq!(h.ed.design, design);
assert!(h.ed.find_library_component(&key).is_some());
let again = call(&mut h, "create_instance", json!({"componentKey": key}));
assert!(h.ed.doc.get(num(&again["id"])).is_some());
}
#[test]
fn local_css_name_conflict_is_refused() {
let mut h = host();
make_kit(&mut h, "#ff0000");
libraries::publish(&dir(&h), &h.ed, "").unwrap();
h.session.create(&mut h.ed, "App").unwrap();
call(&mut h, "set_variables", json!({"variables": {"brand": "#123456"}}));
let before = h.ed.design.clone();
let lib = kit_summary(&h);
let e = libraries::enable(&dir(&h), &mut h.ed, &lib).unwrap_err();
assert!(e.contains("--brand"), "{e}");
assert_eq!(h.ed.design, before);
assert!(h.ed.library_docs.is_empty());
assert!(!dir(&h).join("libraries").join(format!("{}.json", lib.library_id)).exists());
}
fn fill_rgb(h: &exec::SessionHost, id: NodeId) -> (u8, u8, u8, Option<String>) {
match h.ed.doc.get(id).unwrap().style.fills.last() {
Some(Paint::Solid { color, var }) => (color.r, color.g, color.b, var.clone()),
o => panic!("{o:?}"),
}
}
/// Kit published (red), App enabled it with a bound artboard and an instance whose opacity is
/// overridden. Returns (kit dir, app dir, key, bound artboard, instance).
fn kit_and_app(h: &mut exec::SessionHost) -> (PathBuf, PathBuf, String, NodeId, NodeId) {
let key = make_kit(h, "#ff0000");
let kit = dir(h);
libraries::publish(&kit, &h.ed, "").unwrap();
h.session.create(&mut h.ed, "App").unwrap();
let app = dir(h);
let lib = kit_summary(h);
libraries::enable(&app, &mut h.ed, &lib).unwrap();
let ab = call(h, "create_artboard", json!({"name": "Card",
"styles": {"width": "50px", "height": "50px", "backgroundColor": "var(--brand)"}}));
let inst = call(h, "create_instance", json!({"componentKey": key}));
call(h, "update_styles", json!({"updates": [{"nodeIds": [inst["id"]], "styles": {"opacity": "0.5"}}]}));
h.session.save(&mut h.ed).unwrap();
(kit, app, key, num(&ab["id"]), num(&inst["id"]))
}
#[test]
fn update_after_republish_keeps_overrides() {
let mut h = host();
let (kit, app, _key, card, inst) = kit_and_app(&mut h);
let lib = kit_summary(&h);
let st = libraries::status(&h.session.store, &app, &h.ed);
assert_eq!((st[0].local_version, st[0].source_version, st[0].update_available), (1, Some(1), false));
assert_eq!(st[0].name, "Kit");
// Change the library: variable color and component size, republish.
h.session.open(&mut h.ed, &kit).unwrap();
call(&mut h, "set_variables", json!({"variables": {"brand": "#00ff00"}}));
let c = call(&mut h, "get_components", json!({}));
call(&mut h, "update_styles", json!({"updates": [{"nodeIds": [c["components"][0]["id"]], "styles": {"width": "120px"}}]}));
libraries::publish(&kit, &h.ed, "").unwrap();
h.session.save(&mut h.ed).unwrap();
h.session.open(&mut h.ed, &app).unwrap();
let st = libraries::status(&h.session.store, &app, &h.ed);
assert_eq!((st[0].source_version, st[0].update_available), (Some(2), true));
let d = libraries::update_diff(&h.session.store, &app, &lib.library_id).unwrap();
assert_eq!((d.variables_changed.len(), d.components_changed.len()), (1, 1));
assert_eq!(fill_rgb(&h, card).0, 255, "no silent update");
libraries::update(&h.session.store, &app, &mut h.ed, &lib.library_id).unwrap();
assert_eq!(fill_rgb(&h, card), (0, 255, 0, Some("brand".into())));
let n = h.ed.doc.get(inst).unwrap();
assert_eq!((n.rect.w, n.style.opacity), (120.0, 0.5), "size follows, override kept");
assert_eq!(fill_rgb(&h, inst).0, 0);
assert_eq!(h.ed.design.libraries[0].version, 2);
let st = libraries::status(&h.session.store, &app, &h.ed);
assert!(!st[0].update_available);
h.ed.undo();
assert_eq!(h.ed.doc.get(inst).unwrap().rect.w, 100.0, "one undo step restores the page");
h.ed.redo();
// Persisted: reopen keeps the new copy.
h.session.save(&mut h.ed).unwrap();
h.session.open(&mut h.ed, &app).unwrap();
assert_eq!(h.ed.design.libraries[0].version, 2);
assert_eq!(h.ed.library_docs.len(), 1);
}
#[test]
fn update_refused_on_clash_changes_nothing() {
let mut h = host();
let (kit, app, _key, _card, _inst) = kit_and_app(&mut h);
let lib = kit_summary(&h);
h.session.open(&mut h.ed, &kit).unwrap();
call(&mut h, "set_variables", json!({"variables": {"accent": "#0000ff"}}));
libraries::publish(&kit, &h.ed, "").unwrap();
h.session.save(&mut h.ed).unwrap();
h.session.open(&mut h.ed, &app).unwrap();
call(&mut h, "set_variables", json!({"variables": {"accent": "#123456"}}));
let before = h.ed.design.clone();
let file = app.join("libraries").join(format!("{}.json", lib.library_id));
let bytes = std::fs::read(&file).unwrap();
let e = libraries::update(&h.session.store, &app, &mut h.ed, &lib.library_id).unwrap_err();
assert!(e.contains("accent"), "{e}");
assert_eq!(h.ed.design, before);
assert_eq!(std::fs::read(&file).unwrap(), bytes, "snapshot file restored on refusal");
}
#[test]
fn missing_source_keeps_working_then_disable_detaches() {
let mut h = host();
let (kit, app, key, card, inst) = kit_and_app(&mut h);
let lib = kit_summary(&h);
// A field binding to a library variable would need a Number variable; use a local alias too.
call(&mut h, "set_variables", json!({"variables": {"mine": {"alias": "brand"}}}));
h.session.save(&mut h.ed).unwrap();
h.session.create(&mut h.ed, "Elsewhere").unwrap();
std::fs::remove_dir_all(&kit).unwrap();
h.session.open(&mut h.ed, &app).unwrap();
let st = libraries::status(&h.session.store, &app, &h.ed);
assert_eq!((st[0].source_version, st[0].update_available), (None, false));
assert_eq!(fill_rgb(&h, card), (255, 0, 0, Some("brand".into())));
assert!(h.ed.is_instance_root(inst));
let again = call(&mut h, "create_instance", json!({"componentKey": key}));
assert!(h.ed.doc.get(num(&again["id"])).is_some());
assert!(libraries::update(&h.session.store, &app, &mut h.ed, &lib.library_id).is_err());
let r = libraries::disable(&app, &mut h.ed, &lib.library_id).unwrap();
assert!(r.fills >= 2 && r.instances == 2, "{r:?}");
assert_eq!(fill_rgb(&h, card), (255, 0, 0, None), "value kept, binding gone");
let n = h.ed.doc.get(inst).unwrap();
assert!(n.instance.is_none());
assert_eq!(n.style.opacity, 0.5);
assert!(h.ed.design.libraries.is_empty());
assert!(h.ed.design.collections.iter().all(|c| c.library.is_none()));
assert!(h.ed.library_docs.is_empty());
// The snapshot stays for undo; the next open sweeps it (see the undo test).
// The local alias kept the resolved color.
let v = call(&mut h, "get_variables", json!({}));
assert!(v.to_string().contains("#ff0000") || v.to_string().contains("#FF0000"), "{v}");
// Undo restores the library on that page.
h.ed.undo();
assert_eq!(fill_rgb(&h, card).3.as_deref(), Some("brand"));
}
#[test]
fn mcp_library_round_trip() {
let mut h = host();
let key = make_kit(&mut h, "#ff0000");
let kit = dir(&h);
let p = call(&mut h, "publish_library", json!({"description": "Kit"}));
assert_eq!(p["version"], 1);
assert!(p["changes"].as_str().unwrap().contains("added"));
let lid = p["libraryId"].as_str().unwrap().to_string();
assert!(call(&mut h, "list_libraries", json!({}))["available"].as_array().unwrap().is_empty());
h.session.create(&mut h.ed, "App").unwrap();
let l = call(&mut h, "list_libraries", json!({}));
assert_eq!((l["available"][0]["libraryId"].as_str(), l["available"][0]["enabled"].as_bool()), (Some(lid.as_str()), Some(false)));
call(&mut h, "enable_library", json!({"libraryId": lid}));
let l = call(&mut h, "list_libraries", json!({}));
assert_eq!(l["enabled"][0]["updateAvailable"], false);
let c = call(&mut h, "get_components", json!({}));
let lc = c["components"].as_array().unwrap().iter().find(|c| c["source"]["library"] == lid.as_str()).unwrap();
assert_eq!(lc["componentKey"], key.as_str());
assert_eq!(lc["source"]["name"], "Kit");
// swap_instance accepts a library key: swap a local frame's instance? Use a fresh instance pair.
let inst = call(&mut h, "create_instance", json!({"componentKey": key}));
let sw = call(&mut h, "swap_instance", json!({"nodeId": inst["id"], "componentKey": key}));
assert_eq!(sw["swapped"], true);
// Republish in Kit, update in App.
h.session.save(&mut h.ed).unwrap();
h.session.open(&mut h.ed, &kit).unwrap();
call(&mut h, "set_variables", json!({"variables": {"brand": "#0000ff"}}));
assert_eq!(call(&mut h, "publish_library", json!({}))["version"], 2);
let app = dir_of(&h, "App");
h.session.open(&mut h.ed, &app).unwrap();
assert_eq!(call(&mut h, "list_libraries", json!({}))["enabled"][0]["updateAvailable"], true);
let u = call(&mut h, "update_library", json!({"libraryId": lid}));
assert!(u["changes"].as_str().unwrap().contains("changed"));
assert_eq!(h.ed.design.libraries[0].version, 2);
let d = call(&mut h, "disable_library", json!({"libraryId": lid}));
assert!(d["detachedInstances"].as_u64().unwrap() >= 1);
assert!(try_call(&mut h, "enable_library", json!({"libraryId": "nope"})).is_err());
assert!(call(&mut h, "list_libraries", json!({}))["enabled"].as_array().unwrap().is_empty());
}
fn dir_of(h: &exec::SessionHost, name: &str) -> PathBuf {
h.session.store.list().into_iter().find(|e| e.manifest.name == name).unwrap().dir
}
fn snapshot_file(app: &std::path::Path, lib: &LibrarySummary) -> PathBuf {
app.join("libraries").join(format!("{}.json", lib.library_id))
}
#[test]
fn undo_disable_restores_library_components_and_open_sweeps_orphans() {
let mut h = host();
let (_kit, app, key, _card, _inst) = kit_and_app(&mut h);
let lib = kit_summary(&h);
libraries::disable(&app, &mut h.ed, &lib.library_id).unwrap();
assert!(h.ed.library_docs.is_empty());
assert!(snapshot_file(&app, &lib).exists(), "kept so undo can reload it");
h.ed.undo();
assert!(h.ed.find_library_component(&key).is_some(), "components are back after undo");
h.ed.redo();
h.session.save(&mut h.ed).unwrap();
h.session.open(&mut h.ed, &app).unwrap();
assert!(!snapshot_file(&app, &lib).exists(), "orphan swept on open");
}
#[test]
fn duplicate_project_is_not_a_published_library() {
let mut h = host();
make_kit(&mut h, "#ff0000");
let kit = dir(&h);
libraries::publish(&kit, &h.ed, "").unwrap();
let copy = h.session.store.duplicate(&kit).unwrap();
assert!(!copy.join(libraries::LIBRARY_FILE).exists());
}
#[test]
fn publish_survives_duplicate_component_keys() {
let mut h = host();
make_kit(&mut h, "#ff0000");
let c = call(&mut h, "get_components", json!({}));
let id = c["components"][0]["id"].clone();
call(&mut h, "duplicate_nodes", json!({"nodes": [{"id": id}]}));
let p = libraries::publish(&dir(&h), &h.ed, "").unwrap();
assert_eq!(p.components.len(), 2);
assert_ne!(p.components[0].key, p.components[1].key);
assert_eq!(p.to_document().root.len(), 2);
assert_eq!(p.to_document().nodes.len(), p.components.iter().map(|c| c.nodes.len()).sum::<usize>());
}
#[test]
fn library_instance_with_missing_library_is_main_missing_and_detachable() {
let mut h = host();
let (_kit, _app, _key, _card, inst) = kit_and_app(&mut h);
assert!(!h.ed.is_main_missing(inst));
h.ed.library_docs.clear();
assert!(h.ed.is_main_missing(inst) && h.ed.is_instance_root(inst));
assert_eq!(h.ed.missing_mains(h.ed.active_page), vec![inst]);
h.ed.selection = vec![inst];
h.ed.detach_instance();
assert!(h.ed.doc.get(inst).unwrap().instance.is_none());
}
+9 -12
View File
@@ -285,18 +285,15 @@ fn group_menu_moves_pages_and_removal_keeps_them() {
fn design_removals_require_confirmation_and_apply_to_all_pages() {
for kind in ["mode", "variable", "token"] {
let mut h = Harness::new();
for page in 0..h.ed.pages.len() {
h.ed.with_page(page, |ed| {
ed.edit("Setup", |tx| {
tx.set_variable_in_mode(
"Mocha",
"accent",
papercraft::model::Color::rgb(1, 2, 3),
);
tx.set_token("space-1", "4px");
})
});
}
// Two modes (a collection keeps at least one), a variable and a token, project-wide.
h.ed.edit_design("Setup", |ds| {
let none = Default::default();
let accent = papercraft::model::Color::rgb(1, 2, 3);
papercraft::editor::design::set_color(ds, &none, "accent", Some("Mocha"), accent)?;
papercraft::editor::design::set_color(ds, &none, "accent", Some("Latte"), accent)?;
papercraft::editor::design::set_token(ds, "space-1", "4px")
})
.unwrap();
let action = || match kind {
"mode" => ui::confirm::Removal::Mode("Mocha".into()),
"variable" => ui::confirm::Removal::Variable("accent".into()),
+48
View File
@@ -0,0 +1,48 @@
//! The Variables modal draws without panicking for a design with two collections, at both
//! window sizes, with a variable selected, an alias, a library collection and a confirm open.
use egui::{Pos2, Rect, Vec2};
use papercraft::editor::Editor;
use papercraft::ui::{self, UiState};
use papercraft::variables::{Kind, Value};
fn frame(ctx: &egui::Context, size: Vec2, st: &mut UiState, ed: &mut Editor) {
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
ui::draw(ui, ed, st);
});
out.textures_delta.clear();
}
#[test]
fn variables_modal_renders_two_collections() {
let ctx = egui::Context::default();
ui::setup_style(&ctx);
let mut ed = Editor::default();
ed.edit_design("seed", |ds| {
let colors = ds.add_collection("Colors");
let dark = ds.add_mode(&colors, "Dark")?;
let primary = ds.add_variable(&colors, "color/brand/primary", Kind::Color)?;
let accent = ds.add_variable(&colors, "color/brand/accent", Kind::Color)?;
let mode = ds.collection(&colors).unwrap().default_mode.clone();
ds.set_value(&accent, &mode, Value::Alias(primary))?;
ds.set_value(&accent, &dark, Value::Color(papercraft::model::Color::rgb(1, 2, 3)))?;
let tokens = ds.add_collection("Tokens");
ds.add_variable(&tokens, "space/md", Kind::Number)?;
ds.add_variable(&tokens, "flag/compact", Kind::Boolean)?;
ds.add_variable(&tokens, "font/family", Kind::String)?;
Ok(())
})
.unwrap();
let mut st = UiState::default();
st.variables.open = true;
for size in [Vec2::new(2560.0, 1440.0), Vec2::new(1280.0, 800.0)] {
for _ in 0..3 {
frame(&ctx, size, &mut st, &mut ed);
}
}
assert!(st.variables.open);
}