diff --git a/.cargo/config.toml b/.cargo/config.toml index a81092ec..ccd3f2be 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,3 +9,9 @@ rustflags = [ [env] CARGO_WORKSPACE_DIR = { value = "", relative = true } + +[alias] +# Refill the generated regions of the repository's markdown from the registries. +# `cargo sync-docs -- --check` reports drift without writing; `tests/docs_md.rs` +# asserts the same thing, so CI catches a stale region either way. +sync-docs = "run --quiet -p darkly --bin sync-docs" diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 3f9d71ea..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,289 +0,0 @@ -# Darkly — Agent Guidelines - -Darkly is a web-based, gpu-native paint program written in Rust, Svelte and Typescript, leveraging WebAssembly and WebGPU. - -This document exists to keep Darkly **minimal, elegant, and proper**. The best code is the code never written; nearly all the principles below are in support of this core principle. - -## Architecture - -Darkly's Rust core (`crates/darkly/`) is platform-agnostic — document, brush engine, GPU compositor, undo, and `DarklyEngine` itself, all with zero platform dependencies. A WASM bridge (`frontend/wasm/`) wraps the engine for the browser. The frontend's `Engine` transport (`frontend/src/engine/`) issues typed requests by kind (`send`/`post`) over a single id→promise FIFO; the wasm `DarklyHandle` enqueues them — borrowing nothing — and resolves them on a scheduled drain or at frame time. Payloads cross the boundary as `serde_wasm_bindgen` values (with raw `bytes` alongside for binary responses). A process-level `DarklySession` owns one wgpu device and hands out one `DarklyHandle` per canvas (`createHandle`); the multi-tab editor runs N handles on the one shared device. - -State splits three ways: - -- **Document** — authoritative, undoable, serializable. Layer tree, modifiers (mask / selection), canvas size. Reasoning about it requires no GPU. -- **Session** — transient editor state on `DarklyEngine`. Active tool, view transform, in-flight stroke, undo stack. -- **Compositor** — derived realization. GPU textures, pipelines, render caches. Always rebuildable from the document on the next frame. - -Data flows downhill: document → compositor, session → compositor. Never upward. Bulk pixel data (layer pixels, mask pixels) is the principled exception — GPU-authoritative because it's huge and the GPU is where it's used. - -**Runtime stack** — pointer event to pixel: - -```mermaid -flowchart LR - User[Pointer / keyboard] - Svelte[Svelte UI
frontend/src/] - Transport[Engine transport
frontend/src/engine/
id→promise request/response] - Handle[DarklyHandle
frontend/wasm/
enqueue → drain / render] - Core[DarklyEngine
crates/darkly/] - WGPU[wgpu] - Canvas[WebGPU canvas] - - User --> Svelte - Svelte <-->|send / post requests by kind| Transport - Transport <-->|enqueue + drain / render| Handle - Handle --> Core - Core --> WGPU - WGPU --> Canvas -``` - -**Repo layout** — `★` marks modular subsystems (drop a new file with `pub fn register()`; `build.rs` discovers it — no central registration to touch): - -``` -crates/darkly/src/ - document/ Authoritative model (layer tree, canvas, ...) - layer_kinds/ ★ group, raster, vector, void, filter - filters/ ★ mask, selection - engine/ DarklyEngine — session + per-domain dispatch - (painting, rendering, load/save, export, - floating, flatten, merge, undo_dispatch, …) - gpu/ Compositor, ping-pong blend, regions, readback - blend_modes/ ★ normal, multiply, hue, color_burn, … - veils/ ★ post-process effects (rainy_glass, VHS, painting, …) - voids/ ★ procedural fill sources (camera, noise, …) - brush/ Stroke engine + node-graph brush engine, GPU - compute pipelines, WGSL compilation, brush - bundles + import. Files: stroke_engine, eval, - pipeline, composite_pipeline, gpu_context, - wgsl_compile, bundle, library, save_points, - preview_renderer, checkpoint_ring, … - nodes/ ★ graph nodes — input, math, color, shape, - modulation, output terminals - stabilizers/ ★ stroke stabilizers (laplacian, …) - import/ brush-bundle importers (krita) - config/ - sections/ ★ schema sections (canvas, input, ui, …) - presets/ ★ bundled presets (gimp, krita, photoshop) - tools/ ★ brush, fill, gradient, colorpicker, - select (rect/ellipse/lasso/polygon/magic_wand), transform - undo/ Per-domain undoable ops (layer, modifier, property, - selection, gpu_region, compound) - format/ Save/load — zip container, manifest, registry I/O - nodegraph/ Generic node-graph (graph, compiler, layout) -frontend/wasm/ WASM bridge (wasm-bindgen) — single API surface -frontend/src/ Svelte UI -``` - -### Coordinate Systems - -**If you're touching anything with x/y coordinates, read -[`docs/coordinate-systems.md`](docs/coordinate-systems.md) first.** Darkly moves -a pixel through several frames (screen → plane → window-local → layer-local), and -a value carried into the wrong frame is the single most recurring class of bug -here — invisible until the canvas is cropped. The doc covers the frames, their -authority, how to convert between them, and the pitfalls that have bitten us. - -### Brush Preview & Overlays - -**If you're touching the brush hover preview or on-canvas overlays, read -[`docs/brush-preview-and-overlays.md`](docs/brush-preview-and-overlays.md) -first.** Brushes compile to two shader variants (stroke + cursor-preview), -`setOverlay` is single-slot, and preview swaps stroke-only bindings for -fallbacks — the model that makes hover-feedback bugs hard to see otherwise. - -### Hotkey & Config Presets - -Darkly's settings use a three-layer resolution order: `user → overlay (krita/ps/gimp) → defaults`. Placement rule is documented in [`crates/darkly/presets/defaults.yaml`](crates/darkly/presets/defaults.yaml)'s header; host-editor reference hotkeys live in [`docs/*-default-hotkeys.md`](docs/). - -## DRY Principle - -Don't Repeat Yourself — and interpret this broadly. If two pieces of code aren't identical but follow a similar enough pattern that they could be generalized, they should be. This applies across modules, layers (Rust, WASM bridge, JS), and systems. - -**Search before writing.** To ensure what you're about to write doesn't have siblings somewhere else in the codebase, grep for similar functionality. By doing this, you may discover overlap that can be extracted into a shared component (DRYify opportunity), or even better, that what you need is already written, and can simply be imported. - -**Place functionality where it generalizes.** Before writing logic, ask: "where does this belong so that it works for all cases, not just this one?" If a behavior applies to any tool, it belongs in the tool system's generic hooks — not inside one specific tool. If a behavior applies to any async operation, it belongs in the async completion pipeline — not special-cased at one call site. Putting the right logic in the right architectural layer eliminates the need to repeat it, and prevents future features from having to rediscover where to plug in. A good signal you've placed something wrong: it only works for one workflow, or a second caller would have to copy-paste the same pattern. - -**Stop-sign phrases.** If you find yourself writing "mirrors X", "bit-exact copy of X", "keep in sync with X", or "identical to X" in a comment, you are duplicating code. Pause and consider why you're doing it. If it's not easily factorable into a shared feature, stop executing and raise the issue to the user. - -## Modularity Principle - -**Default to modular.** When you design anything with more than one variant — or that will plausibly grow one — the first question is "what's the unit, and how does the rest of the code stay ignorant of which one it's looking at?" That mindset applies at every scale: from a small enum where one method per variant beats a `match` at the call site, up to full subsystems with traits, registries, and per-variant files. The cost of designing modularly up front is almost always small; the cost of retrofitting after centralized branching has spread across the codebase is large. Hand-written dispatch should feel like an exception that needs justifying, not the default shape. - -This is a stronger claim than the Engineering Principle's "build a proper system for it" — that one says *don't hack*; this one says *the proper system is almost always one where new variants slot in without consumers being edited*. - -Module-specific code lives in the module. Module-generic infrastructure — registries, dispatchers, shared state, caches — is generic by name and by shape, never named after any single module that happens to use it today. - -When adding a new item to a modular system (filter, tool, brush, etc.): - -- **DO:** Create a single file in the appropriate directory that contains everything about that module — struct, implementation, registration function, constants, helpers. -- **DO NOT:** Add match arms to a central dispatcher. Add entries to a handwritten list. Touch any file outside the module directory except the generated `mod.rs`. - -Mechanics: `build.rs` scans module directories and generates each `mod.rs` (never edit by hand) with a `registrations()` function. Each variant file exports `pub fn register() -> XRegistration`; the registry calls `registrations()` to populate itself. Generic infrastructure (`Trait`, `Registration`, `Registry`) is named after the kind, not after the first variant that happened to exist. See `gpu/veil.rs` + `gpu/veils/*.rs` for a worked example. - -The "default to modular" stance leads directly to the type-owned dispatch rule below: once a system is modular, the consumer must not re-introduce centralized branching by asking variants what they are. - -**Type-owned dispatch:** Anything a type knows about itself — behavior, properties, capabilities, identity — lives on the type, behind a uniform interface. Consumers call methods; they never introspect, classify, or branch on which variant they got. The diagnostic question: *would adding a new variant, or changing what an existing one knows about itself, force me to edit this code?* If yes, the knowledge is misplaced. The violation has one recurring shape — `matches!(type_id, ...)`, `if kind == X`, `fn is_foo(type_id) -> bool`, or any consumer-side helper that routes by type — code outside a type's own module asking questions the type should be answering itself. Replace it with a trait method, defaulted to the common case and overridden per variant, so new variants are purely additive. - -## Ownership Principle - -State belongs to the thing it describes — not to a parent that manages it on its behalf. Don't let Rust's borrow checker dictate the data model. If splitting state out of a struct makes borrowing easier but scatters a logical concept across multiple locations, find a different way to satisfy the borrow checker (helper methods, borrow-splitting, restructured access) and keep the data model clean. - -## Document Authority Principle - -The **document** is the authoritative model. The **compositor** is a derived realization. State falls into three categories: - -- **Document** (`crates/darkly/src/document.rs`, `src/layer.rs`): persistent, undoable, serializable. Tree structure, layer properties, mask presence, layer extents, selection regions, canvas size + `canvas_origin` (see [Coordinate Systems](#coordinate-systems) — the canvas window is a plane rect anchored at `canvas_origin`). Must be possible to reason about without a GPU. -- **Session** (fields on `DarklyEngine` and tool/UI structs): transient editor state. Active tool, mask-editing target, viewport transform. Does not survive reload. -- **Compositor** (`src/gpu/compositor.rs` and friends): GPU textures, bind groups, pipelines, render caches. Always derivable from document + dirty regions; rebuildable on demand. - -**Data flows downhill: document → compositor, session → compositor.** The compositor never feeds back upward. If a piece of state seems to want to flow up, the model is broken — fix the originating operation to lead with the document. - -**Bulk pixel data (layer pixels, mask pixels) is the principled exception** — GPU-authoritative because it's huge and the GPU is where it's used. The document tracks "this layer has pixels" structurally (e.g. `has_mask`); the bytes themselves live in VRAM. - -**Anti-patterns to recognize and refuse:** - -- A doc-side bool and a `HashMap` on the compositor that mirror the same fact (`has_mask` vs `mask_textures.contains_key(id)` was the canonical example). -- A doc-side field and a GPU resource's metadata that must be manually re-synced after a compositor-led operation. -- The same logical fact stored in two places "for ergonomics" — pick one home and expose a getter for the other side. - -**When in doubt:** if the value survives save/load, it's document. If it can be rebuilt from the document on the next frame, it's compositor. Otherwise it's session. - -## Prior Art Principle - -Before deciding on an approach, research how established editors handle it. Krita and GIMP are checked out under the project root (`krita/`, `gimp/`). Read the actual source — never rely on web searches, docs, blog posts, or LLM training data for architectural claims. If a reference repo isn't checked out, clone it. Never claim "Krita does X" without pointing to a specific file and function. When delegating research to a subagent, instruct it to clone and cite specific files and line numbers — reject any claim not backed by source. - -We do not blindly copy prior art; we use it to inform our own decisions. Our implementation will differ in specifics (GPU pipelines, tile formats, Rust idioms), but core algorithms and architectural decisions should be informed by prior art, not invented from scratch. - -## Credit Principle - -When an idea, algorithm, shader, or implementation comes from an external source — open source code, Shadertoy, papers, blog posts, video tutorials, etc. — credit the source and author at the top of the file (or inline next to the borrowed fragment, if it's smaller than file-scope). Include the author's name or handle and a link to the original. - -## Planning and Independent Review Workflow - -Unless the user explicitly waives it, every bug fix and feature follows this workflow. Production code may not change before step 5. - -### 1. Draft - -Delegate planning to a fresh, isolated agent with only the repository instructions and user request. It must investigate the code and required prior art, then write a self-contained plan to `docs/plans/.md` covering: - -- Problem and root cause or feature semantics -- Architectural impact and implementation steps -- Tests, risks, and unresolved questions -- A rough LOC estimate — lines added or lines removed, not lines touched — split - into production, tests, and generated/docs changes. This estimate is a primary - scope and complexity signal, not optional metadata. -- For bugs, a regression test that will fail before the fix - -The planning agent must not modify production code. If isolated agents are unavailable, ask the user to run this step in a fresh session. - -### 2. Review - -Have a different fresh, isolated agent independently investigate the repository and review the plan. Give it only the repository instructions, plan path, and review task. - -The reviewer must challenge the diagnosis, scope, architecture, ownership, authority, modularity, duplication, complexity, prior-art support, and test coverage. It should seek the simplest general solution, including removing machinery or relocating behavior to its proper owner, and ensure bug tests reproduce the reported failure. - -Add concrete, file-referenced findings under `## Independent Review` at the top of the plan and give a verdict: `accept`, `revise`, or `rethink`. Do not modify production code. If isolated agents are unavailable, ask the user to run this step in a fresh session. - -### 3. Revise - -The orchestrator addresses every substantive finding in the plan or records an evidence-backed reason for rejecting it. A `rethink` verdict requires re-investigation and a rewritten approach, not an incremental patch. Preserve the review. - -### 4. Approve - -Give the user: - -- **First:** the estimated LOC range from the plan. Lead the approval summary with - this because it is the clearest signal of implementation size and possible - over-design. -- The plan path and review verdict -- The proposed approach, tradeoffs, and unresolved questions -- Confirmation that implementation has not begun - -Then stop and request explicit approval. Plan changes require revision and, when material, another independent review and approval. - -### 5. Implement - -After approval, the orchestrator implements and verifies the plan. For bugs, first demonstrate the regression test failing, then make it pass. - -Keep the plan synchronized with material discoveries. If the implementation's -expected LOC materially exceeds the approved estimate, stop and explain why -before continuing. If implementation requires a material redesign, stop and -return to review, revision, and user approval. - -## Testing Principle - -**Every feature must have a test.** Verify the feature works. The test exists; it passes. That's it. - -**Every bug must have a _regression_ test — one that defends against that specific bug being reintroduced.** "Regression" means "the bug we just fixed must not come back"; a test for a new feature is not a regression test, even if it follows the same pattern. Write it FIRST, confirm it FAILS against the unfixed code, then fix the bug and confirm it passes; if it doesn't fail without the fix, it doesn't count. - -## No Blocking GPU Readbacks - -**Never use `device.poll(Wait)`, `blocking_read()`, `readback_texture()`, or any synchronous GPU→CPU readback in production code.** These deadlock on WebGPU/WASM — the browser event loop is the only mechanism for resolving GPU buffer mappings, and any form of blocking (`recv()`, spin-wait, `thread::park()`) prevents it from running. See `docs/lessons-learned/gpu-lessons-learned.md` §5 for the full stack trace of why. - -The correct pattern is async readback: `request_readback()` → `readbacks.submit()` → poll on the next frame via `ReadbackScheduler`. If CPU data is needed from a GPU texture that changes infrequently (e.g., the selection mask), maintain a CPU cache populated by the async readback and read from that. - -`test_utils::readback_texture()` and `blocking_read()` are **test-only** — they work on native (Vulkan/Metal) where `device.poll(Wait)` drives the completion queue synchronously. They must be gated behind `#[cfg(test)]` and never called from engine, compositor, or WASM bridge code. - -## Engineering Principle - -Every system must be implemented properly. No hacks, no hardcoding, no shortcuts in Rust or the WASM bridge. If we implement one of something, we build a proper system for it. It's okay to take a step back from the current task to do things right. - -**Every bug is a signal that something nearby is awkward or overcomplicated.** Before patching, ask: "is this an elegant solution?" If the answer is no, the bug is telling you the code wants to be restructured — propose a refactor instead of layering a fix on top. The cleanest fix is often the one that makes the bug impossible to express, not the one that handles it. - -**Comments describe the code, not the plan that produced it.** Write comments about what the code does and why it's there as it stands — never about the process that got it there. Do not reference ephemeral planning artifacts: step or phase numbers, plan-list items, "TODO from the plan", "as decided in step 3", or before/after framing ("new", "now", "previously", "used to") that only makes sense relative to a change in flight. A comment that would be meaningless to someone reading the file fresh — with no knowledge of the task that introduced it — is in the wrong register; rewrite it to stand on its own, or delete it. - -## No Migrations / No Backwards Compatibility (pre-release) - -Darkly is in pre-release / alpha. Until the first public release, breaking on-disk and on-the-wire formats is fine — do not write migrations, format-version upgrade paths, or legacy compatibility shims. Make the breaking change directly and update every producer and consumer in the same pass; existing user data can be invalidated. - -## PR Descriptions - -Fork every feature branch off `dev` and target PRs at `dev`, never `master` (which only receives release merges from `dev`, despite being GitHub's default branch). - -Every PR body has **two parts**: a human-written preamble explaining *why* the work was undertaken and who it's useful to, then the AI-generated technical description below a `---` separator. When you finish implementing a plan, emit the PR description in a fenced markdown code block as part of your reply, shaped like this — leave the top as a placeholder for the human to fill in: - -````markdown - - ---- - - -```` - -The AI portion must cover the *entire* feature branch (everything since it diverged from `dev`), not just the latest change — the user pastes the whole block as the PR body. On follow-up work, re-emit the complete, updated block as a single description that wholly replaces the previous one; never emit a delta or a partial revision. - -## Lint / CI Checks - -Run at commit time only — not during iterative debugging. Use `cargo check` for mid-iteration build sanity. All must pass: - -```bash -cargo fmt --all -- --check -RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets --exclude darkly-wasm --features darkly/testing -- -D warnings -RUSTFLAGS="-D warnings" cargo clippy -p darkly-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings -# `--features darkly/testing` exposes `gpu::test_utils`, `blocking_read`, and -# the engine's `test_readback_*` accessors that integration tests rely on -# (compile-time gate enforcing CLAUDE.md "No Blocking GPU Readbacks"). -# `--test-threads=1` is mandatory: GPU-touching integration tests (`engine.rs`, `blend_modes.rs`, etc.) share a process-wide wgpu device and SIGSEGV when run in parallel. -cargo test --workspace --exclude darkly-wasm --features darkly/testing -- --test-threads=1 -(cd frontend/wasm && wasm-pack build --release --target web --out-dir pkg) -# `tsc --noEmit` is the TS gate for `.ts` files — but it CANNOT see inside -# `.svelte` files (it doesn't parse the extension), and neither `vite build` -# nor Vitest type-checks components. `svelte-check` is the only gate that -# type-checks `.svelte` scripts + templates (via `svelte2tsx` + the TS API): -# it catches nonexistent engine methods, wrong props, and null-safety in -# components. Both are required — `tsc` alone gives false green on component bugs. -(cd frontend && npx tsc --noEmit) -(cd frontend && npm run check) -(cd frontend && npm run build) -# Vitest runs in the node environment — there is no DOM, so globals like -# `KeyboardEvent` / `PointerEvent` / `window` are undefined. Test against -# plain object fakes (`{ key, shiftKey } as KeyboardEvent`), and for code -# that touches `window`, stub it with `vi.stubGlobal('window', …)` and a -# fake node — see `src/lib/__tests__/clickOutside.test.ts`. -(cd frontend && npm test) -# Reclaim stale build artifacts — Cargo orphans a ~300 MB static test binary on -# every fingerprint change and never GCs it, so `target/` balloons over time. -# `cargo install cargo-sweep` once, then periodically: -cargo sweep --time 7 -``` - -Never run `git commit` — make the changes and leave staging and committing to the user. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..eada936c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CONTRIBUTING.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d..eada936c 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +CONTRIBUTING.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c61d2a3..26edfadd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,24 +1,343 @@ # Contributing to Darkly -Thanks for wanting to contribute. +Darkly is a web-based, gpu-native paint program written in Rust, Svelte and Typescript, leveraging WebAssembly and WebGPU. + +This document exists to keep Darkly **minimal, elegant, and proper**. The best code is the code never written; nearly all the principles below are in support of this core principle. It is the whole contributor guide — architecture, principles, workflow, and the check suite — and it is addressed to humans and coding agents alike, because the standards are the same for both. `AGENTS.md` and `CLAUDE.md` are symlinks to this file. -## How to contribute +Thanks for wanting to contribute. -The development setup, build commands, and project conventions live in [AGENTS.md](AGENTS.md). The short version: +## Getting Started ```bash # Rust core + tests cargo check --workspace -cargo test --workspace --exclude darkly-wasm -- --test-threads=1 +cargo test --workspace --exclude darkly-wasm --features darkly/testing -- --test-threads=1 # WASM bridge (cd frontend/wasm && wasm-pack build --release --target web --out-dir pkg) -# Frontend +# Frontend dev server (cd frontend && npm install && npm run dev) ``` -Before opening a PR, please run the full check suite from [AGENTS.md](AGENTS.md) (fmt, clippy, tests, wasm build, frontend build). Each new feature should have a test; each bug fix should have a regression test (written first, confirmed failing against the unfixed code). +Before opening a PR, run the full suite under [Lint / CI Checks](#lint--ci-checks) and read the [Testing Principle](#testing-principle) for what your change owes in tests. + +## Architecture + +Darkly's Rust core (`crates/darkly/`) is platform-agnostic — document, brush engine, GPU compositor, undo, and `DarklyEngine` itself, all with zero platform dependencies. A WASM bridge (`frontend/wasm/`) wraps the engine for the browser. The frontend's `Engine` transport (`frontend/src/engine/`) issues typed requests by kind (`send`/`post`) over a single id→promise FIFO; the wasm `DarklyHandle` enqueues them — borrowing nothing — and resolves them on a scheduled drain or at frame time. Payloads cross the boundary as `serde_wasm_bindgen` values (with raw `bytes` alongside for binary responses). A process-level `DarklySession` owns one wgpu device and hands out one `DarklyHandle` per canvas (`createHandle`); the multi-tab editor runs N handles on the one shared device. + +State splits three ways: the **document** is authoritative, the **session** is transient editor state on `DarklyEngine`, and the **compositor** is a derived GPU realization. Data flows downhill, never up. The [Document Authority Principle](#document-authority-principle) is the full statement — what belongs in each, where it lives, and how to place a new piece of state. + +**Runtime stack** — pointer event to pixel: + +```mermaid +flowchart LR + User[Pointer / keyboard] + Svelte[Svelte UI
frontend/src/] + Transport[Engine transport
frontend/src/engine/
id→promise request/response] + Handle[DarklyHandle
frontend/wasm/
enqueue → drain / render] + Core[DarklyEngine
crates/darkly/] + WGPU[wgpu] + Canvas[WebGPU canvas] + + User --> Svelte + Svelte <-->|send / post requests by kind| Transport + Transport <-->|enqueue + drain / render| Handle + Handle --> Core + Core --> WGPU + WGPU --> Canvas +``` + +**Repo layout** — `★` marks modular subsystems (drop a new file with `pub fn register()`; `build.rs` discovers it — no central registration to touch): + +``` +crates/darkly/src/ + document/ Authoritative model (layer tree, canvas, ...) + layer_kinds/ ★ group, raster, vector, void, filter + filters/ ★ mask, selection + engine/ DarklyEngine — session + per-domain dispatch + (painting, rendering, load/save, export, + floating, flatten, merge, undo_dispatch, …) + gpu/ Compositor, ping-pong blend, regions, readback + blend_modes/ ★ normal, multiply, hue, color_burn, … + veils/ ★ post-process effects (rainy_glass, VHS, painting, …) + voids/ ★ procedural fill sources (camera, noise, …) + brush/ Stroke engine + node-graph brush engine, GPU + compute pipelines, WGSL compilation, brush + bundles + import. Files: stroke_engine, eval, + pipeline, composite_pipeline, gpu_context, + wgsl_compile, bundle, library, save_points, + preview_renderer, checkpoint_ring, … + nodes/ ★ graph nodes — input, math, color, shape, + modulation, output terminals + stabilizers/ ★ stroke stabilizers (laplacian, …) + import/ brush-bundle importers (krita) + config/ + sections/ ★ schema sections (canvas, input, ui, …) + presets/ ★ bundled presets (gimp, krita, photoshop) + tools/ ★ brush, fill, gradient, colorpicker, + select (rect/ellipse/lasso/polygon/magic_wand), transform + undo/ Per-domain undoable ops (layer, modifier, property, + selection, gpu_region, compound) + format/ Save/load — zip container, manifest, registry I/O + nodegraph/ Generic node-graph (graph, compiler, layout) + docs_md/ Generated regions in this repo's markdown + fragments/ ★ what a region can be filled with (catalog_table, …) +frontend/wasm/ WASM bridge (wasm-bindgen) — single API surface +frontend/src/ Svelte UI +``` + +### Coordinate Systems + +**If you're touching anything with x/y coordinates, read +[`docs/coordinate-systems.md`](docs/coordinate-systems.md) first.** Darkly moves +a pixel through several frames (screen → plane → window-local → layer-local), and +a value carried into the wrong frame is the single most recurring class of bug +here — invisible until the canvas is cropped. The doc covers the frames, their +authority, how to convert between them, and the pitfalls that have bitten us. + +### Brush Preview & Overlays + +**If you're touching the brush hover preview or on-canvas overlays, read +[`docs/brush-preview-and-overlays.md`](docs/brush-preview-and-overlays.md) +first.** Brushes compile to two shader variants (stroke + cursor-preview), +`setOverlay` is single-slot, and preview swaps stroke-only bindings for +fallbacks — the model that makes hover-feedback bugs hard to see otherwise. + +### Hotkey & Config Presets + +Darkly's settings use a three-layer resolution order: `user → overlay (krita/ps/gimp) → defaults`. Placement rule is documented in [`crates/darkly/presets/defaults.yaml`](crates/darkly/presets/defaults.yaml)'s header; host-editor reference hotkeys live in [`docs/*-default-hotkeys.md`](docs/). + +## DRY Principle + +Don't Repeat Yourself — and interpret this broadly. If two pieces of code aren't identical but follow a similar enough pattern that they could be generalized, they should be. This applies across modules, layers (Rust, WASM bridge, JS), and systems. + +**Search before writing.** To ensure what you're about to write doesn't have siblings somewhere else in the codebase, grep for similar functionality. By doing this, you may discover overlap that can be extracted into a shared component (DRYify opportunity), or even better, that what you need is already written, and can simply be imported. + +**Place functionality where it generalizes.** Before writing logic, ask: "where does this belong so that it works for all cases, not just this one?" If a behavior applies to any tool, it belongs in the tool system's generic hooks — not inside one specific tool. If a behavior applies to any async operation, it belongs in the async completion pipeline — not special-cased at one call site. Putting the right logic in the right architectural layer eliminates the need to repeat it, and prevents future features from having to rediscover where to plug in. A good signal you've placed something wrong: it only works for one workflow, or a second caller would have to copy-paste the same pattern. + +**Stop-sign phrases.** If you find yourself writing "mirrors X", "bit-exact copy of X", "keep in sync with X", or "identical to X" in a comment, you are duplicating code. Pause and consider why you're doing it. If it's not easily factorable into a shared feature, stop executing and raise the issue to the user. + +## Modularity Principle + +**Default to modular.** When you design anything with more than one variant — or that will plausibly grow one — the first question is "what's the unit, and how does the rest of the code stay ignorant of which one it's looking at?" That mindset applies at every scale: from a small enum where one method per variant beats a `match` at the call site, up to full subsystems with traits, registries, and per-variant files. The cost of designing modularly up front is almost always small; the cost of retrofitting after centralized branching has spread across the codebase is large. Hand-written dispatch should feel like an exception that needs justifying, not the default shape. + +This is a stronger claim than the Engineering Principle's "build a proper system for it" — that one says *don't hack*; this one says *the proper system is almost always one where new variants slot in without consumers being edited*. + +Module-specific code lives in the module. Module-generic infrastructure — registries, dispatchers, shared state, caches — is generic by name and by shape, never named after any single module that happens to use it today. + +When adding a new item to a modular system (filter, tool, brush, etc.): + +- **DO:** Create a single file in the appropriate directory that contains everything about that module — struct, implementation, registration function, constants, helpers. +- **DO NOT:** Add match arms to a central dispatcher. Add entries to a handwritten list. Touch any file outside the module directory except the generated `mod.rs`. + +Mechanics: `build.rs` scans module directories and generates each `mod.rs` (never edit by hand) with a `registrations()` function. Each variant file exports `pub fn register() -> XRegistration`; the registry calls `registrations()` to populate itself. Generic infrastructure (`Trait`, `Registration`, `Registry`) is named after the kind, not after the first variant that happened to exist. See `gpu/veil.rs` + `gpu/veils/*.rs` for a worked example. + +The "default to modular" stance leads directly to the type-owned dispatch rule below: once a system is modular, the consumer must not re-introduce centralized branching by asking variants what they are. + +**Type-owned dispatch:** Anything a type knows about itself — behavior, properties, capabilities, identity — lives on the type, behind a uniform interface. Consumers call methods; they never introspect, classify, or branch on which variant they got. The diagnostic question: *would adding a new variant, or changing what an existing one knows about itself, force me to edit this code?* If yes, the knowledge is misplaced. The violation has one recurring shape — `matches!(type_id, ...)`, `if kind == X`, `fn is_foo(type_id) -> bool`, or any consumer-side helper that routes by type — code outside a type's own module asking questions the type should be answering itself. Replace it with a trait method, defaulted to the common case and overridden per variant, so new variants are purely additive. + +## Ownership Principle + +State belongs to the thing it describes — not to a parent that manages it on its behalf. Don't let Rust's borrow checker dictate the data model. If splitting state out of a struct makes borrowing easier but scatters a logical concept across multiple locations, find a different way to satisfy the borrow checker (helper methods, borrow-splitting, restructured access) and keep the data model clean. + +## Document Authority Principle + +The **document** is the authoritative model. The **compositor** is a derived realization. State falls into three categories: + +- **Document** (`crates/darkly/src/document.rs`, `src/layer.rs`): persistent, undoable, serializable. Tree structure, layer properties, mask presence, layer extents, selection regions, canvas size + `canvas_origin` (see [Coordinate Systems](#coordinate-systems) — the canvas window is a plane rect anchored at `canvas_origin`). Must be possible to reason about without a GPU. +- **Session** (fields on `DarklyEngine` and tool/UI structs): transient editor state. Active tool, mask-editing target, viewport transform, in-flight stroke, undo stack. Does not survive reload. +- **Compositor** (`src/gpu/compositor.rs` and friends): GPU textures, bind groups, pipelines, render caches. Always derivable from document + dirty regions; rebuildable on demand. + +**Data flows downhill: document → compositor, session → compositor.** The compositor never feeds back upward. If a piece of state seems to want to flow up, the model is broken — fix the originating operation to lead with the document. + +**Bulk pixel data (layer pixels, mask pixels) is the principled exception** — GPU-authoritative because it's huge and the GPU is where it's used. The document tracks "this layer has pixels" structurally (e.g. `has_mask`); the bytes themselves live in VRAM. + +**Anti-patterns to recognize and refuse:** + +- A doc-side bool and a `HashMap` on the compositor that mirror the same fact (`has_mask` vs `mask_textures.contains_key(id)` was the canonical example). +- A doc-side field and a GPU resource's metadata that must be manually re-synced after a compositor-led operation. +- The same logical fact stored in two places "for ergonomics" — pick one home and expose a getter for the other side. + +**When in doubt:** if the value survives save/load, it's document. If it can be rebuilt from the document on the next frame, it's compositor. Otherwise it's session. + +## Prior Art Principle + +Before deciding on an approach, research how established editors handle it. Krita and GIMP are checked out under the project root (`krita/`, `gimp/`). Read the actual source — never rely on web searches, docs, blog posts, or LLM training data for architectural claims. If a reference repo isn't checked out, clone it. Never claim "Krita does X" without pointing to a specific file and function. When delegating research to a subagent, instruct it to clone and cite specific files and line numbers — reject any claim not backed by source. + +We do not blindly copy prior art; we use it to inform our own decisions. Our implementation will differ in specifics (GPU pipelines, tile formats, Rust idioms), but core algorithms and architectural decisions should be informed by prior art, not invented from scratch. + +## Credit Principle + +When an idea, algorithm, shader, or implementation comes from an external source — open source code, Shadertoy, papers, blog posts, video tutorials, etc. — credit the source and author at the top of the file (or inline next to the borrowed fragment, if it's smaller than file-scope). Include the author's name or handle and a link to the original. + +## Planning and Independent Review Workflow + +Unless the user explicitly waives it, every bug fix and feature follows this workflow. Production code may not change before step 5. + +### 1. Draft + +Delegate planning to a fresh, isolated agent with only the repository instructions and user request. It must investigate the code and required prior art, then write a self-contained plan to `docs/plans/.md` covering: + +- Problem and root cause or feature semantics +- Architectural impact and implementation steps +- Tests, risks, and unresolved questions +- A rough LOC estimate — lines added or lines removed, not lines touched — split + into production, tests, and generated/docs changes. This estimate is a primary + scope and complexity signal, not optional metadata. +- For bugs, a regression test that will fail before the fix + +The planning agent must not modify production code. If isolated agents are unavailable, ask the user to run this step in a fresh session. + +### 2. Review + +Have a different fresh, isolated agent independently investigate the repository and review the plan. Give it only the repository instructions, plan path, and review task. + +The reviewer must challenge the diagnosis, scope, architecture, ownership, authority, modularity, duplication, complexity, prior-art support, and test coverage. It should seek the simplest general solution, including removing machinery or relocating behavior to its proper owner, and ensure bug tests reproduce the reported failure. + +Add concrete, file-referenced findings under `## Independent Review` at the top of the plan and give a verdict: `accept`, `revise`, or `rethink`. Do not modify production code. If isolated agents are unavailable, ask the user to run this step in a fresh session. + +### 3. Revise + +The orchestrator addresses every substantive finding in the plan or records an evidence-backed reason for rejecting it. A `rethink` verdict requires re-investigation and a rewritten approach, not an incremental patch. Preserve the review. + +### 4. Approve + +Give the user: + +- **First:** the estimated LOC range from the plan. Lead the approval summary with + this because it is the clearest signal of implementation size and possible + over-design. +- The plan path and review verdict +- The proposed approach, tradeoffs, and unresolved questions +- Confirmation that implementation has not begun + +Then stop and request explicit approval. Plan changes require revision and, when material, another independent review and approval. + +### 5. Implement + +After approval, the orchestrator implements and verifies the plan. For bugs, first demonstrate the regression test failing, then make it pass. + +Keep the plan synchronized with material discoveries. If the implementation's +expected LOC materially exceeds the approved estimate, stop and explain why +before continuing. If implementation requires a material redesign, stop and +return to review, revision, and user approval. + +## Testing Principle + +**Every feature must have a test.** Verify the feature works. The test exists; it passes. That's it. + +**Every bug must have a _regression_ test — one that defends against that specific bug being reintroduced.** "Regression" means "the bug we just fixed must not come back"; a test for a new feature is not a regression test, even if it follows the same pattern. Write it FIRST, confirm it FAILS against the unfixed code, then fix the bug and confirm it passes; if it doesn't fail without the fix, it doesn't count. + +## No Blocking GPU Readbacks + +**Never use `device.poll(Wait)`, `blocking_read()`, `readback_texture()`, or any synchronous GPU→CPU readback in production code.** These deadlock on WebGPU/WASM — the browser event loop is the only mechanism for resolving GPU buffer mappings, and any form of blocking (`recv()`, spin-wait, `thread::park()`) prevents it from running. See `docs/lessons-learned/gpu-lessons-learned.md` §5 for the full stack trace of why. + +The correct pattern is async readback: `request_readback()` → `readbacks.submit()` → poll on the next frame via `ReadbackScheduler`. If CPU data is needed from a GPU texture that changes infrequently (e.g., the selection mask), maintain a CPU cache populated by the async readback and read from that. + +`test_utils::readback_texture()` and `blocking_read()` are **test-only** — they work on native (Vulkan/Metal) where `device.poll(Wait)` drives the completion queue synchronously. They must be gated behind `#[cfg(test)]` and never called from engine, compositor, or WASM bridge code. + +## Engineering Principle + +Every system must be implemented properly. No hacks, no hardcoding, no shortcuts in Rust or the WASM bridge. If we implement one of something, we build a proper system for it. It's okay to take a step back from the current task to do things right. + +**Every bug is a signal that something nearby is awkward or overcomplicated.** Before patching, ask: "is this an elegant solution?" If the answer is no, the bug is telling you the code wants to be restructured — propose a refactor instead of layering a fix on top. The cleanest fix is often the one that makes the bug impossible to express, not the one that handles it. + +**Comments describe the code, not the plan that produced it.** Write comments about what the code does and why it's there as it stands — never about the process that got it there. Do not reference ephemeral planning artifacts: step or phase numbers, plan-list items, "TODO from the plan", "as decided in step 3", or before/after framing ("new", "now", "previously", "used to") that only makes sense relative to a change in flight. A comment that would be meaningless to someone reading the file fresh — with no knowledge of the task that introduced it — is in the wrong register; rewrite it to stand on its own, or delete it. + +## No Migrations / No Backwards Compatibility (pre-release) + +Darkly is in pre-release / alpha. Until the first public release, breaking on-disk and on-the-wire formats is fine — do not write migrations, format-version upgrade paths, or legacy compatibility shims. Make the breaking change directly and update every producer and consumer in the same pass; existing user data can be invalidated. + +## PR Descriptions + +Fork every feature branch off `dev` and target PRs at `dev`, never `master` (which only receives release merges from `dev`, despite being GitHub's default branch). + +Every PR body has **two parts**: a human-written preamble explaining *why* the work was undertaken and who it's useful to, then the AI-generated technical description below a `---` separator. When you finish implementing a plan, emit the PR description in a fenced markdown code block as part of your reply, shaped like this — leave the top as a placeholder for the human to fill in: + +````markdown + + +--- + + +```` + +The AI portion must cover the *entire* feature branch (everything since it diverged from `dev`), not just the latest change — the user pastes the whole block as the PR body. On follow-up work, re-emit the complete, updated block as a single description that wholly replaces the previous one; never emit a delta or a partial revision. + +## Generated Markdown + +Parts of this repository's markdown are generated from the registries. A file +opts a span of itself in by bracketing it with HTML comments, which render as +nothing: + +```markdown + +…generated… + +``` + +**Never edit inside a region** — the next sync overwrites it. Every name and +description in one is a `&'static str` on the registration that owns it, so a +typo in the README's veil table is fixed in `crates/darkly/src/gpu/veils/`. + +```bash +cargo sync-docs # refill every region +cargo sync-docs -- --check # report drift, write nothing +``` + +`tests/docs_md.rs` fails if a committed region is stale, so the ordinary test +suite is the gate — run `cargo sync-docs` when you have touched a registration +and it will tell you what it rewrote. A new kind of region is a new file in +[`crates/darkly/src/docs_md/fragments/`](crates/darkly/src/docs_md/fragments/) +exporting `pub fn register()` — nothing else is touched. + +Preview stills are the one part that is **not** automatic: they need a GPU and +land in the repository as binaries, so they are rendered deliberately when a +catalog gains or loses an entry. `tests/docs_md.rs` fails on a region linking to +an image that is not in the checkout, which is how you find out. + +```bash +cargo run --release -p darkly --features testing --bin render_docs -- \ + --stills --catalog veils +``` + +## Lint / CI Checks + +Run at commit time only — not during iterative debugging. Use `cargo check` for mid-iteration build sanity. All must pass: + +```bash +cargo fmt --all -- --check +RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets --exclude darkly-wasm --features darkly/testing -- -D warnings +RUSTFLAGS="-D warnings" cargo clippy -p darkly-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings +# `--features darkly/testing` exposes `gpu::test_utils`, `blocking_read`, and +# the engine's `test_readback_*` accessors that integration tests rely on +# (compile-time gate enforcing CONTRIBUTING.md "No Blocking GPU Readbacks"). +# `--test-threads=1` is mandatory: GPU-touching integration tests (`engine.rs`, `blend_modes.rs`, etc.) share a process-wide wgpu device and SIGSEGV when run in parallel. +cargo test --workspace --exclude darkly-wasm --features darkly/testing -- --test-threads=1 +(cd frontend/wasm && wasm-pack build --release --target web --out-dir pkg) +# `tsc --noEmit` is the TS gate for `.ts` files — but it CANNOT see inside +# `.svelte` files (it doesn't parse the extension), and neither `vite build` +# nor Vitest type-checks components. `svelte-check` is the only gate that +# type-checks `.svelte` scripts + templates (via `svelte2tsx` + the TS API): +# it catches nonexistent engine methods, wrong props, and null-safety in +# components. Both are required — `tsc` alone gives false green on component bugs. +(cd frontend && npx tsc --noEmit) +(cd frontend && npm run check) +(cd frontend && npm run build) +# Vitest runs in the node environment — there is no DOM, so globals like +# `KeyboardEvent` / `PointerEvent` / `window` are undefined. Test against +# plain object fakes (`{ key, shiftKey } as KeyboardEvent`), and for code +# that touches `window`, stub it with `vi.stubGlobal('window', …)` and a +# fake node — see `src/lib/__tests__/clickOutside.test.ts`. +(cd frontend && npm test) +# Reclaim stale build artifacts — Cargo orphans a ~300 MB static test binary on +# every fingerprint change and never GCs it, so `target/` balloons over time. +# `cargo install cargo-sweep` once, then periodically: +cargo sweep --time 7 +``` + +Never run `git commit` — make the changes and leave staging and committing to the user. ## Questions diff --git a/README.md b/README.md index 25faa373..7760e822 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ darkly [![Discord](https://img.shields.io/discord/1495886270780539021?label=Discord&logo=discord&logoColor=white&style=for-the-badge&color=9500ff)](https://discord.gg/kFz2FGhbpu) -[![Patreon](https://img.shields.io/badge/Patreon-Forbidden_Relics-orange?logo=patreon&style=for-the-badge&color=6914ff)](https://www.patreon.com/c/DarklyArt) +[![Patreon](https://img.shields.io/badge/Patreon-Hidden_Relics-orange?logo=patreon&style=for-the-badge&color=6914ff)](https://www.patreon.com/c/DarklyArt) [![Blog](https://img.shields.io/badge/Blog-Deranged_Texts-orange?logo=substack&logoColor=white&style=for-the-badge&color=4400ff)](https://darkly.art/blog) ![Rust](https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=9500ff) @@ -14,7 +14,7 @@ > [!IMPORTANT] > **Darkly is in beta**! Features are being [added daily](#feature-roadmap). Please [report bugs](https://github.com/darkly-art/darkly/issues/new) so we can squash them. -Do you suffer from the _oppressive sanity_ of rulers, guides, and nondestructive workflows? Break free with [Darkly](https://darkly.art), the home of happy accidents and beautiful catastrophies. Madness isn't a bug, it's a feature. +Do you suffer from the _oppressive sanity_ of rulers, guides, and nondestructive workflows? Break free with [Darkly](https://darkly.art), the home of happy accidents and beautiful catastrophies. Embrace the chaos, and release your hidden masterpiece. Madness isn't a bug, it's a feature. Darkly is a Photoshop alternative where painters are first-class citizens. It has a powerful brush engine, and **[dark arts](#dark-arts)** to help you commune with your imagination. @@ -43,18 +43,35 @@ Darkly's unique brushes live inside a node-based system. This enables infinite c ### Veils -https://github.com/user-attachments/assets/ee281ac2-37a8-4e52-91b3-78d564420e9d +Veils are where Darkly gets its name; *"For now we see through a glass, darkly"*. They're a special layer that sits above the viewport, visible only to the artist. By shrouding your canvas behind a mysterious pane, they invite you to see something that maybe wasn't there before. -Veils are where Darkly gets its name; *"For now we see through a glass, darkly"*. They're a special type of layer that sits overtop the viewport, visible only to the artist. By shrouding your art behind a mysterious pane, they invite you to see something that maybe wasn't there before. +![veil-demo](https://github.com/user-attachments/assets/df05c881-4572-46a1-9a31-366236fabbd3) -Veils have practical uses too: +Veils are nondestructive. You can paint as usual, behind the veil, and when you disable it, you'll see the full-res result. + + +| | Name | What it does | +| :-: | --- | --- | +| Black and White | **Black and White** | Desaturate to black and white — six grayscale formulas or custom channel weights, with an optional color tint. | +| Chromatic Aberration | **Chromatic Aberration** | Split the color channels apart along their hue axes, like a misaligned lens. | +| Frozen | **Frozen** | Frost the view behind a pane of refracting ice. | +| Grain | **Grain** | Film grain noise over the view, optionally animated. | +| Lens Blur | **Lens Blur** | Defocus the view with a soft camera-lens blur. | +| Painting | **Painting** | Smooth the view into painterly, brush-like daubs. | +| Pixelate | **Pixelate** | Downsample the view into a blocky pixel mosaic. | +| Rainy Glass | **Rainy Glass** | Raindrops run down a pane of glass over the view. | +| VHS | **VHS** | Analog VHS tape artifacts — scanlines, noise, and color bleed. | +| Watercolor | **Watercolor** | Bleed the view outward into soft watercolor washes. | + + +Veils are a fun toy, but they have practical uses too: - By hiding fine details, they can prevent **premature fixation on detail**, freeing you to focus on composition. - During the sketching / ideation phase, they can help with **blank page syndrome** and **destructive self-criticism** by giving you permission to be messy, and explore freely. -- They can also help remedy **art fatigue** (losing eyes for a piece by staring at it for too long) by helping you view it through a fresh lens. +- They can also help remedy **art fatigue** (losing eyes for a piece by staring at it for too long) by helping you see it through a fresh lens. > [!NOTE] -> Veils live in their own group, but within it you can stack and order them however you like. Remember that adding too many can drain your battery, due to the heavy load on your GPU. +> You can add unlimited veils, and stack them in any order; but adding too many can drain your battery because of the heavy load on your GPU. ### Voids @@ -231,7 +248,7 @@ See the [crate README](crates/darkly/README.md) for a runnable example, and the ## Contribution -We love hackers as much as we love artists. Contributions are welcome! Please see [AGENTS.md](./AGENTS.md) for details on how to contribute and rules of thumb for the repo. +We love hackers as much as we love artists. Contributions are welcome! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for details on how to contribute and rules of thumb for the repo. ### Use of AI @@ -239,7 +256,7 @@ It's acceptable to use AI for this codebase, but careless vibe coding is **stric I (TheTechromancer) learned to code before AI, and have spent much of my career maintaining [large codebases](https://github.com/blacklanternsecurity/bbot). The [danger](https://www.reddit.com/r/vibecoding/comments/1su03dk/vibe_coded_for_6_months_my_codebase_is_a_disaster/) of feature creep and architectural bloat is real, which is why whenever a feature is implemented in Darkly, a human must first understand the changes and their long-term implications for the codebase. -Great care is being taken to keep Darkly lean and clean. This means enforcing modularity, guarding vigilantly against duplicate/dead code, and writing a *shit ton* of unit tests, including at least one regression test for every bug. See [AGENTS.md](AGENTS.md) for how we avoid AI slop. +Great care is being taken to keep Darkly lean and clean. This means enforcing modularity, guarding vigilantly against duplicate/dead code, and writing a *shit ton* of unit tests, including at least one regression test for every bug. See [CONTRIBUTING.md](CONTRIBUTING.md) for how we avoid AI slop. Note that while we allow AI for coding, we are **unlikely to accept any PR implementing generative AI in Darkly itself**. AI features are not off the table; however they must run fully offline and without any reliance on third party APIs. Additionally, any feature that speeds up generation while sacrificing creative input or control from the artist, will likely be rejected. diff --git a/crates/darkly/Cargo.toml b/crates/darkly/Cargo.toml index ebbf1cce..ed7af282 100644 --- a/crates/darkly/Cargo.toml +++ b/crates/darkly/Cargo.toml @@ -13,7 +13,7 @@ categories = ["graphics", "rendering", "wasm"] [features] profile = [] # Enables blocking GPU readbacks, `gpu::test_utils`, and `*::test_readback_*` -# accessors. WebGPU/WASM deadlocks on these (see CLAUDE.md "No Blocking GPU +# accessors. WebGPU/WASM deadlocks on these (see CONTRIBUTING.md "No Blocking GPU # Readbacks"); the feature exists so `cargo test` and the bench bins can opt # in while production / WASM builds cannot reach the API at all. testing = [] @@ -61,12 +61,16 @@ pollster = "1.0" naga = { version = "29.0", features = ["wgsl-in"] } syn = { version = "3", features = ["visit", "full"] } -# The hyphenated name does not match the file stem, so cargo cannot infer the -# path from it. No `required-features`: the exporter needs no GPU. +# The hyphenated names do not match the file stems, so cargo cannot infer the +# paths from them. No `required-features`: neither needs a GPU. [[bin]] name = "export-docs" path = "src/bin/export_docs.rs" +[[bin]] +name = "sync-docs" +path = "src/bin/sync_docs.rs" + [[bin]] name = "render_docs" required-features = ["testing"] diff --git a/crates/darkly/brushes/airbrush.yaml b/crates/darkly/brushes/airbrush.yaml index c96df70d..2308cb65 100644 --- a/crates/darkly/brushes/airbrush.yaml +++ b/crates/darkly/brushes/airbrush.yaml @@ -1,5 +1,4 @@ name: Airbrush -category: Basic description: A fully soft disc that builds color up gradually; hold it in one place and the tone deepens. nodes: pen_input: diff --git a/crates/darkly/brushes/blur.yaml b/crates/darkly/brushes/blur.yaml index 793562f4..a7a8eaf2 100644 --- a/crates/darkly/brushes/blur.yaml +++ b/crates/darkly/brushes/blur.yaml @@ -1,5 +1,4 @@ name: Blur -category: Effects description: Softens whatever is already on the layer instead of laying down color. nodes: pen_input: diff --git a/crates/darkly/brushes/calligraphy.yaml b/crates/darkly/brushes/calligraphy.yaml index ed4299f3..a4888b07 100644 --- a/crates/darkly/brushes/calligraphy.yaml +++ b/crates/darkly/brushes/calligraphy.yaml @@ -1,5 +1,4 @@ name: Calligraphy -category: Basic description: A broad elliptical nib held at a fixed angle, so strokes thicken and thin with direction. nodes: pen_input: diff --git a/crates/darkly/brushes/charcoal.yaml b/crates/darkly/brushes/charcoal.yaml index 0b8b8a43..30a28224 100644 --- a/crates/darkly/brushes/charcoal.yaml +++ b/crates/darkly/brushes/charcoal.yaml @@ -1,5 +1,4 @@ name: Charcoal -category: Dry Media description: A grainy stick that catches on the paper's tooth, laying color down heavily where you press and skipping where you don't. nodes: brush_settings: diff --git a/crates/darkly/brushes/clone.yaml b/crates/darkly/brushes/clone.yaml index ee2d577b..4b3fe3f0 100644 --- a/crates/darkly/brushes/clone.yaml +++ b/crates/darkly/brushes/clone.yaml @@ -1,5 +1,4 @@ name: Clone -category: Misc description: Paints with pixels sampled from elsewhere on the canvas rather than with the current color. nodes: pen_input: diff --git a/crates/darkly/brushes/hair.yaml b/crates/darkly/brushes/hair.yaml index 777b2853..c484c0f2 100644 --- a/crates/darkly/brushes/hair.yaml +++ b/crates/darkly/brushes/hair.yaml @@ -1,5 +1,4 @@ name: Hair -category: Dry Media description: A lock of individual strands with optional twirling nodes: add: @@ -9,7 +8,7 @@ nodes: inputs: size: 0.2 spacing: 0.01 - stabilize: 1.0 + stabilize: 0.5 circle: type: circle curve: diff --git a/crates/darkly/brushes/ink_pen.yaml b/crates/darkly/brushes/ink_pen.yaml index eff1617e..890854a8 100644 --- a/crates/darkly/brushes/ink_pen.yaml +++ b/crates/darkly/brushes/ink_pen.yaml @@ -1,5 +1,4 @@ name: Ink Pen -category: Basic description: A crisp-edged nib with a slow pressure ramp, for confident line work that holds its weight. nodes: brush_settings: diff --git a/crates/darkly/brushes/liquify.yaml b/crates/darkly/brushes/liquify.yaml index e8e14140..b812bc86 100644 --- a/crates/darkly/brushes/liquify.yaml +++ b/crates/darkly/brushes/liquify.yaml @@ -1,5 +1,4 @@ name: Liquify -category: Effects description: Pushes the pixels under the cursor along the stroke, warping the image without repainting it. nodes: pen_input: @@ -14,7 +13,6 @@ nodes: type: liquify connections: - 'pen_input.distance -> liquify.distance' -- 'pen_input.drawing_angle -> liquify.direction' - 'pen_input.motion -> liquify.motion' - 'pen_input.position -> liquify.position' exposed_ports: diff --git a/crates/darkly/brushes/rough_ink.yaml b/crates/darkly/brushes/rough_ink.yaml index 274f7795..9f98b9f6 100644 --- a/crates/darkly/brushes/rough_ink.yaml +++ b/crates/darkly/brushes/rough_ink.yaml @@ -1,5 +1,4 @@ name: Rough Ink -category: Basic description: An ink nib whose edge is reshaped at random every dab — line work that looks bitten rather than printed. nodes: brush_settings: diff --git a/crates/darkly/brushes/rough_watercolor.yaml b/crates/darkly/brushes/rough_watercolor.yaml index 11248dd3..2096d7e0 100644 --- a/crates/darkly/brushes/rough_watercolor.yaml +++ b/crates/darkly/brushes/rough_watercolor.yaml @@ -1,5 +1,4 @@ name: Rough Watercolor -category: Wet Media description: The same bleeding pigment over a rougher paper — granulated, with a broken edge. nodes: pen_input: diff --git a/crates/darkly/brushes/smooth_watercolor.yaml b/crates/darkly/brushes/smooth_watercolor.yaml index 70632a74..cbb23df8 100644 --- a/crates/darkly/brushes/smooth_watercolor.yaml +++ b/crates/darkly/brushes/smooth_watercolor.yaml @@ -1,5 +1,4 @@ name: Smooth Watercolor -category: Wet Media description: Wet pigment that pools and blends into what is already on the canvas, with a soft, even edge. nodes: pen_input: diff --git a/crates/darkly/brushes/smudge.yaml b/crates/darkly/brushes/smudge.yaml index bead1893..e1857cbc 100644 --- a/crates/darkly/brushes/smudge.yaml +++ b/crates/darkly/brushes/smudge.yaml @@ -1,5 +1,4 @@ name: Smudge -category: Effects description: Drags existing pigment along the stroke, the way a finger pulls through wet paint. nodes: pen_input: diff --git a/crates/darkly/brushes/sponge.yaml b/crates/darkly/brushes/sponge.yaml index 89ae73b5..b229b975 100644 --- a/crates/darkly/brushes/sponge.yaml +++ b/crates/darkly/brushes/sponge.yaml @@ -1,5 +1,4 @@ name: Sponge -category: Dry Media description: A textured brush useful for laying down big, smooth shapes nodes: add: @@ -15,9 +14,9 @@ nodes: inputs: curve: - - 0.0 - - 0.70523924 - - - 0.59307975 - - 0.36470732 + - 0.7 + - - 0.6 + - 0.36 - - 1.0 - 0.0 curve_2: @@ -26,12 +25,23 @@ nodes: curve: - - 0.0 - 0.0 - - - 0.6392242 - - 0.36304563 + - - 0.64 + - 0.36 - - 1.0 - 1.0 + curve_3: + type: curve + inputs: + curve: + - - 0.0 + - 0.0 + - - 1.0 + - 0.5 + input: 0.10 levels: type: levels + multiply: + type: multiply noise: type: noise inputs: @@ -47,10 +57,10 @@ nodes: type: polygon inputs: points: 4 - rounding: 0.30 - softness: 0.30 - squeeze: 0.35 - squeeze_angle: -0.7853981852531433 + rounding: 0.5 + softness: 0.3 + squeeze: 0.5 + squeeze_angle: -0.78 random: type: random random_2: @@ -63,9 +73,12 @@ nodes: a: 0.11924592405557632 connections: - add.result -> levels.input +- brush_settings.size -> multiply.a - curve.output -> levels.in_low - curve_2.output -> paint.flow +- curve_3.output -> multiply.b - levels.output -> stamp.tip +- multiply.result -> noise.scale - noise.value -> subtract.b - paint_color.color -> stamp.color - pen_input.drawing_angle -> polygon.rotation_input @@ -84,3 +97,7 @@ exposed_ports: paint.opacity: {} polygon.softness: {} polygon.rounding: {} + curve_3.input: + label: Texture Size + description: Fineness of the brush texture + icon: mdi:blur diff --git a/crates/darkly/build.rs b/crates/darkly/build.rs index 7d01cbbc..89274261 100644 --- a/crates/darkly/build.rs +++ b/crates/darkly/build.rs @@ -124,6 +124,11 @@ fn main() { "crate::config::schema::SchemaSection", ); + generate_registry( + &src.join("docs_md/fragments"), + "crate::docs_md::FragmentRegistration", + ); + generate_catalog_registry( &src.join("document/filters"), "crate::document::filter::FilterEntityRegistration", @@ -145,13 +150,18 @@ fn main() { &mut catalog_sources, ); - // Brushes are a directory of YAML data rather than of `register()` modules, - // so they record their catalog source from inside their own scan. Must run - // before `generate_catalog_sources`, which consumes the vector. + // Brushes and packs are directories of YAML data rather than of + // `register()` modules, so they record their catalog source from inside + // their own scan. Must run before `generate_catalog_sources`, which + // consumes the vector. generate_builtin_brushes( &PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("brushes"), &mut catalog_sources, ); + generate_builtin_packs( + &PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("packs"), + &mut catalog_sources, + ); generate_catalog_sources(catalog_sources, &src); @@ -506,84 +516,135 @@ fn generate_grouped_registry(dir: &Path, registration_type: &str) { println!("cargo:rerun-if-changed={}", dir.display()); } -/// Scan `presets/*.yaml` and emit a generated Rust module to `OUT_DIR` with -/// one `include_str!` per YAML file plus a `defaults()` constant and an -/// `overlays()` function returning the editor-flavored overlays in -/// alphabetical order. `defaults.yaml` is required; the build panics if -/// it's missing. Every other `.yaml` becomes an equal-status overlay whose -/// display name is the file stem (Title Case). -fn generate_yaml_presets(dir: &Path) { - let mut defaults_path: Option = None; - let mut overlays: Vec<(String, PathBuf)> = Vec::new(); +/// The Rust constant name holding one YAML file's source: the file stem, +/// upper-cased, with `-` normalized to `_`. +fn yaml_const_name(stem: &str) -> String { + format!("{}_YAML", stem.to_uppercase().replace('-', "_")) +} +/// Scan `dir` for `*.yaml`/`*.yml` and append one `pub const _YAML: &str +/// = include_str!(…)` per file to `code`. Returns the `(stem, filename)` pairs +/// in emission order, sorted by stem so the generated file is deterministic +/// across builds. +/// +/// Paths are emitted relative to `CARGO_MANIFEST_DIR` so the generated file +/// carries no absolute paths and is portable across checkouts — the form +/// [`generate_texture_registry`] documents and which the YAML scans previously +/// each got wrong in their own way. +fn emit_yaml_consts(dir: &Path, code: &mut String) -> Vec<(String, String)> { + let dir_name = dir + .file_name() + .and_then(|s| s.to_str()) + .expect("yaml directory has a name"); + + let mut files: Vec<(String, String)> = Vec::new(); if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if !path.extension().is_some_and(|e| e == "yaml" || e == "yml") { continue; } - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - if stem == "defaults" { - defaults_path = Some(path); - } else if !stem.is_empty() { - overlays.push((stem, path)); + let (Some(stem), Some(file_name)) = ( + path.file_stem().and_then(|s| s.to_str()), + path.file_name().and_then(|s| s.to_str()), + ) else { + continue; + }; + if stem.is_empty() { + continue; } + files.push((stem.to_string(), file_name.to_string())); } } + files.sort_by(|a, b| a.0.cmp(&b.0)); - let defaults_path = - defaults_path.unwrap_or_else(|| panic!("presets/defaults.yaml is required")); + for (stem, file_name) in &files { + let rel = format!("/{dir_name}/{file_name}"); + code.push_str(&format!( + "pub const {}: &str = include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), {rel:?}));\n", + yaml_const_name(stem), + )); + } + code.push('\n'); - // Display-name comes from the YAML's `name:` field; fall back to a - // titlecased file stem if the YAML doesn't set one. Order alphabetically - // (by stem) so no editor is privileged. - overlays.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut display_names: Vec<(String, String)> = Vec::new(); - for (stem, path) in &overlays { - let yaml = fs::read_to_string(path).unwrap_or_default(); - let name = parse_yaml_display_name(&yaml).unwrap_or_else(|| titlecase(stem)); - display_names.push((stem.clone(), name)); + files +} + +/// Embed every `*.yaml` in `dir` as `: &[(filename, source)]`, +/// written to `OUT_DIR/`. +/// +/// The shape behind "drop a `.yaml` file in the directory and it is loaded" — +/// used for built-in brushes and built-in packs alike, so neither owns a copy +/// of the scan. +fn generate_embedded_yaml_dir(dir: &Path, const_name: &str, out_file: &str, header: &str) { + let mut code = String::new(); + code.push_str("// @generated by build.rs — do not edit manually.\n"); + code.push_str(header); + code.push('\n'); + + let files = emit_yaml_consts(dir, &mut code); + + code.push_str(&format!("pub const {const_name}: &[(&str, &str)] = &[\n")); + for (stem, file_name) in &files { + code.push_str(&format!( + " ({:?}, {}),\n", + file_name, + yaml_const_name(stem) + )); } + code.push_str("];\n"); + let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); + let out_path = PathBuf::from(out_dir).join(out_file); + fs::write(&out_path, code).unwrap(); + + println!("cargo:rerun-if-changed={}", dir.display()); +} + +/// Scan `presets/*.yaml` and emit a generated Rust module to `OUT_DIR` with +/// one `include_str!` per YAML file plus a `DEFAULTS_YAML` constant and an +/// `OVERLAYS` list of the editor-flavored overlays in alphabetical order. +/// `defaults.yaml` is required; the build panics if it's missing. Every other +/// `.yaml` becomes an equal-status overlay whose display name is its `name:` +/// field, falling back to a titlecased file stem. +fn generate_yaml_presets(dir: &Path) { let mut code = String::new(); code.push_str("// @generated by build.rs — do not edit manually.\n"); code.push_str( "// To add a new editor overlay, drop `.yaml` in `crates/darkly/presets/`.\n\n", ); - code.push_str(&format!( - "pub const DEFAULTS_YAML: &str = include_str!({:?});\n\n", - defaults_path.display().to_string() - )); + // `defaults.yaml`'s stem yields `DEFAULTS_YAML`, which is the name the + // config layer already reads — it needs no special emission, only to be + // held out of the overlay list below. + let files = emit_yaml_consts(dir, &mut code); + assert!( + files.iter().any(|(stem, _)| stem == "defaults"), + "presets/defaults.yaml is required" + ); - for (stem, path) in &overlays { - code.push_str(&format!( - "const {}_YAML: &str = include_str!({:?});\n", - stem.to_uppercase().replace('-', "_"), - path.display().to_string() - )); - } - code.push('\n'); + // Display-name comes from the YAML's `name:` field; fall back to a + // titlecased file stem if the YAML doesn't set one. + let overlays: Vec<(String, String)> = files + .iter() + .filter(|(stem, _)| stem != "defaults") + .map(|(stem, file_name)| { + let yaml = fs::read_to_string(dir.join(file_name)).unwrap_or_default(); + let name = parse_yaml_display_name(&yaml).unwrap_or_else(|| titlecase(stem)); + (stem.clone(), name) + }) + .collect(); // Equal-status overlay list: (display_name, yaml_source). code.push_str("pub const OVERLAYS: &[(&str, &str)] = &[\n"); - for (stem, name) in &display_names { - code.push_str(&format!( - " ({:?}, {}_YAML),\n", - name, - stem.to_uppercase().replace('-', "_") - )); + for (stem, name) in &overlays { + code.push_str(&format!(" ({:?}, {}),\n", name, yaml_const_name(stem))); } code.push_str("];\n\n"); // BASE_SETTINGS_OPTIONS feeds the `app.baseSettings` enum schema. code.push_str("pub const BASE_SETTINGS_OPTIONS: &[(&str, &str)] = &[\n"); - for (_, name) in &display_names { + for (_, name) in &overlays { code.push_str(&format!(" ({:?}, {:?}),\n", name, name)); } code.push_str("];\n"); @@ -641,56 +702,37 @@ fn generate_builtin_brushes(dir: &Path, catalog_sources: &mut Vec<(String, Strin catalog_sources, ); - let mut brushes: Vec<(String, PathBuf)> = Vec::new(); - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.extension().is_some_and(|e| e == "yaml" || e == "yml") { - continue; - } - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - if stem.is_empty() { - continue; - } - brushes.push((stem, path)); - } - } - brushes.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut code = String::new(); - code.push_str("// @generated by build.rs — do not edit manually.\n"); - code.push_str("// To add a new built-in brush, drop `.yaml` in\n"); - code.push_str("// `crates/darkly/brushes/`. It is loaded automatically.\n\n"); - - for (stem, path) in &brushes { - code.push_str(&format!( - "const {}_YAML: &str = include_str!({:?});\n", - stem.to_uppercase().replace('-', "_"), - path.display().to_string() - )); - } - code.push('\n'); - - code.push_str("pub const BUILTIN_BRUSHES_YAML: &[(&str, &str)] = &[\n"); - for (stem, _) in &brushes { - let filename = format!("{stem}.yaml"); - code.push_str(&format!( - " ({:?}, {}_YAML),\n", - filename, - stem.to_uppercase().replace('-', "_"), - )); - } - code.push_str("];\n"); + generate_embedded_yaml_dir( + dir, + "BUILTIN_BRUSHES_YAML", + "builtin_brushes_gen.rs", + "// To add a new built-in brush, drop `.yaml` in\n\ + // `crates/darkly/brushes/`. It is loaded automatically.\n", + ); +} - let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); - let out_path = PathBuf::from(out_dir).join("builtin_brushes_gen.rs"); - fs::write(&out_path, code).unwrap(); +/// Scan `packs/*.yaml` and emit a generated module to `OUT_DIR` listing each +/// `(filename, yaml_source)` pair, the same way [`generate_builtin_brushes`] +/// does for brushes. A pack's id is its file stem. +/// +/// Also records the directory as a catalog source, so a shipped pack is +/// published in `metadata.json` alongside the brushes it groups. +fn generate_builtin_packs(dir: &Path, catalog_sources: &mut Vec<(String, String)>) { + record_catalog_source( + dir.file_name() + .and_then(|s| s.to_str()) + .expect("pack directory has a name"), + "crate::brush::packs", + catalog_sources, + ); - println!("cargo:rerun-if-changed={}", dir.display()); + generate_embedded_yaml_dir( + dir, + "BUILTIN_PACKS_YAML", + "builtin_packs_gen.rs", + "// To add a new built-in brush pack, drop `.yaml` in\n\ + // `crates/darkly/packs/`. Its file stem is its pack id.\n", + ); } /// Scan `resources/textures/*.{jpg,jpeg,png,webp}` and emit a generated diff --git a/crates/darkly/packs/basic.yaml b/crates/darkly/packs/basic.yaml new file mode 100644 index 00000000..11447473 --- /dev/null +++ b/crates/darkly/packs/basic.yaml @@ -0,0 +1,6 @@ +name: Basic +description: Everyday marks — the brushes to reach for first. +icon: mdi:brush +primary: "#d8d4cc" +secondary: "#2a2723" +members: [airbrush, calligraphy, ink_pen, rough_ink] diff --git a/crates/darkly/packs/dry_media.yaml b/crates/darkly/packs/dry_media.yaml new file mode 100644 index 00000000..70870f8e --- /dev/null +++ b/crates/darkly/packs/dry_media.yaml @@ -0,0 +1,6 @@ +name: Dry Media +description: Sticks and powders that catch on the paper's tooth. +icon: mdi:pencil +primary: "#c8b48a" +secondary: "#3a2f22" +members: [charcoal, hair, sponge] diff --git a/crates/darkly/packs/effects.yaml b/crates/darkly/packs/effects.yaml new file mode 100644 index 00000000..097c4382 --- /dev/null +++ b/crates/darkly/packs/effects.yaml @@ -0,0 +1,6 @@ +name: Effects +description: Brushes that move and reshape the pixels already on the canvas. +icon: mdi:blur +primary: "#b48ad8" +secondary: "#2b2236" +members: [blur, liquify, smudge] diff --git a/crates/darkly/packs/misc.yaml b/crates/darkly/packs/misc.yaml new file mode 100644 index 00000000..dfd5f751 --- /dev/null +++ b/crates/darkly/packs/misc.yaml @@ -0,0 +1,6 @@ +name: Misc +description: Everything that fits nowhere else. +icon: mdi:dots-horizontal +primary: "#9aa0a6" +secondary: "#26292c" +members: [clone] diff --git a/crates/darkly/packs/wet_media.yaml b/crates/darkly/packs/wet_media.yaml new file mode 100644 index 00000000..b915de68 --- /dev/null +++ b/crates/darkly/packs/wet_media.yaml @@ -0,0 +1,6 @@ +name: Wet Media +description: Pigment carried by water — it pools, blooms and blends. +icon: mdi:water +primary: "#6f9fd8" +secondary: "#1d2b3d" +members: [rough_watercolor, smooth_watercolor] diff --git a/crates/darkly/src/actions/brush.rs b/crates/darkly/src/actions/brush.rs index 5a3147d3..7ccc10da 100644 --- a/crates/darkly/src/actions/brush.rs +++ b/crates/darkly/src/actions/brush.rs @@ -31,6 +31,20 @@ const ACTIONS: &[ActionDef] = &[ description: "Open the add-node menu at the cursor (brush builder).", icon: "fa6-solid:diagram-project", }, + // Both say "pack": a `.darkly-brush` file names a container, not a count, + // the same way `.darkly` does for layers. One may hold twenty brushes. + ActionDef { + id: "importBrushPack", + display_name: "Import Brush Pack…", + description: "Import a `.darkly-brush` pack — one file may contain any number of brushes.", + icon: "fa6-solid:file-import", + }, + ActionDef { + id: "exportBrushPack", + display_name: "Export Brush Pack…", + description: "Export one of your brush packs as a `.darkly-brush` file to share.", + icon: "fa6-solid:file-export", + }, ]; pub fn register() -> ActionCategory { diff --git a/crates/darkly/src/bin/render_docs.rs b/crates/darkly/src/bin/render_docs.rs index 0715498f..c76178c0 100644 --- a/crates/darkly/src/bin/render_docs.rs +++ b/crates/darkly/src/bin/render_docs.rs @@ -5,6 +5,11 @@ //! cargo run -p darkly --bin render_docs --features testing -- --out //! ``` //! +//! `--stills --catalog ` writes one JPEG poster per entry instead, into this +//! repository's own preview directory — the images the generated markdown tables +//! embed. That mode is run by hand when a catalog gains or loses an entry; the +//! sequence mode above is what the release workflow runs. +//! //! Kept separate from `export-docs` because that one is GPU-free by //! construction: folding both into one binary would drag the metadata export //! behind a GPU device and the `testing` feature it does not need. @@ -15,19 +20,37 @@ use std::process::ExitCode; -use darkly::docs_render::{self, Args}; +use darkly::docs_render::{self, Command}; fn main() -> ExitCode { - let args = match docs_render::parse_args(std::env::args().skip(1)) { - Ok(a) => a, + let command = match docs_render::parse_args(std::env::args().skip(1)) { + Ok(c) => c, Err(e) => { eprintln!("render_docs: {e}\n\n{}", docs_render::USAGE); return ExitCode::FAILURE; } }; - let Args { out: Some(out) } = args else { - print!("{}", docs_render::USAGE); - return ExitCode::SUCCESS; + let out = match command { + Command::Help => { + print!("{}", docs_render::USAGE); + return ExitCode::SUCCESS; + } + Command::Stills { out, catalog } => { + return match docs_render::render_stills(&out, &catalog) { + Ok(written) => { + for path in &written { + println!("{}", path.display()); + } + println!("{catalog}: {} stills", written.len()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("render_docs: {e}"); + ExitCode::FAILURE + } + } + } + Command::Frames { out } => out, }; match docs_render::render_all(&out) { diff --git a/crates/darkly/src/bin/sync_docs.rs b/crates/darkly/src/bin/sync_docs.rs new file mode 100644 index 00000000..6896f01c --- /dev/null +++ b/crates/darkly/src/bin/sync_docs.rs @@ -0,0 +1,105 @@ +//! Re-render every generated region in the repository's markdown. +//! +//! ```text +//! cargo sync-docs # rewrite +//! cargo sync-docs -- --check # report only +//! ``` +//! +//! `--check` is what `tests/docs_md.rs` asserts and what CI therefore enforces; +//! the writing mode is what you run by hand to make a stale checkout correct. +//! Needs no GPU — every fragment builds from `&'static` registration data, the +//! same property that lets the check live in the ordinary test suite. + +use std::path::PathBuf; +use std::process::ExitCode; + +use darkly::docs_md::{self, Mode}; + +const HELP: &str = "\ +sync-docs — fill the generated regions of the repository's markdown + +USAGE: + sync-docs [--check] [--root ] + +OPTIONS: + --check Report out-of-date files and write nothing. Exits non-zero + if any region is stale. + --root Repository root. Defaults to this crate's own checkout. + -h, --help Show this message. +"; + +struct Args { + mode: Mode, + root: PathBuf, +} + +fn parse_args() -> Result { + let mut mode = Mode::Write; + let mut root = None; + let mut argv = std::env::args().skip(1); + while let Some(a) = argv.next() { + match a.as_str() { + "--check" => mode = Mode::Check, + "--root" => root = Some(PathBuf::from(argv.next().ok_or("--root needs a path")?)), + "-h" | "--help" => { + print!("{HELP}"); + std::process::exit(0); + } + other => return Err(format!("unrecognized argument `{other}`")), + } + } + Ok(Args { + mode, + root: root.unwrap_or_else(docs_md::repo_root), + }) +} + +fn main() -> ExitCode { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("sync-docs: {e}\n\n{HELP}"); + return ExitCode::FAILURE; + } + }; + + let report = match docs_md::sync(&args.root, args.mode) { + Ok(r) => r, + Err(e) => { + eprintln!("sync-docs: {e}"); + return ExitCode::FAILURE; + } + }; + + if report.changed.is_empty() { + println!( + "{} generated {} up to date", + report.generated.len(), + if report.generated.len() == 1 { + "file" + } else { + "files" + } + ); + return ExitCode::SUCCESS; + } + + for file in &report.changed { + println!( + "{} {}", + if args.mode == Mode::Check { + "stale:" + } else { + "wrote:" + }, + file.display() + ); + } + match args.mode { + Mode::Check => { + eprintln!("sync-docs: run `cargo run -p darkly --bin sync-docs` to update"); + ExitCode::FAILURE + } + Mode::Write => ExitCode::SUCCESS, + } +} diff --git a/crates/darkly/src/brush/builtin_brushes.rs b/crates/darkly/src/brush/builtin_brushes.rs index 0bd53118..618e8b16 100644 --- a/crates/darkly/src/brush/builtin_brushes.rs +++ b/crates/darkly/src/brush/builtin_brushes.rs @@ -4,12 +4,12 @@ //! describes its node graph in the [`PortableBrush`] format. The //! build script (`crates/darkly/build.rs`) embeds every `.yaml` in //! that directory at compile time — adding a new brush is "drop a -//! file, no code changes." See the modularity rules in CLAUDE.md. +//! file, no code changes." See the modularity rules in CONTRIBUTING.md. use std::sync::OnceLock; -use crate::brush::bundle::Brush; use crate::brush::library::BrushInfo; +use crate::brush::metadata::Brush; use crate::brush::portable::PortableBrush; use crate::catalog::{Catalog, CatalogEntry}; use crate::gpu::preview::PreviewAnim; @@ -34,8 +34,9 @@ fn parsed() -> Vec<(&'static str, Brush)> { let portable: PortableBrush = serde_yaml_ng::from_str(yaml) .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")); let brush = portable - .into_brush(registry) - .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")); + .into_brush(registry, stem) + .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")) + .into_shipped(); (stem, brush) }) .collect() @@ -65,7 +66,7 @@ pub fn docs() -> &'static [(&'static str, BrushInfo)] { DOCS.get_or_init(|| { parsed() .into_iter() - .map(|(stem, brush)| (stem, BrushInfo::from(&brush.metadata))) + .map(|(stem, brush)| (stem, BrushInfo::from(&brush))) .collect() }) .as_slice() @@ -124,7 +125,15 @@ pub fn catalog() -> Catalog { .map(|(stem, info)| { let entry = CatalogEntry::new(stem, info.name.as_str()) .with_description(info.description.as_str()) - .with_category(info.category.as_str()) + // Grouping is derived, not stored: membership lives on + // the pack and a brush may be in several, so this is one + // stored fact projected into the export rather than two to + // keep in agreement. + .with_category( + crate::brush::packs::pack_of(stem) + .map(|p| p.name.as_str()) + .unwrap_or(""), + ) // Brushes carry no `preview` field of their own — the recipe // lives on the catalog — so previewability is the same // question put to the same authority. @@ -186,12 +195,25 @@ mod tests { } #[test] - fn builtin_brushes_round_trip() { + fn builtin_brushes_round_trip_through_a_pack() { + // Every shipped brush must survive the archive it would be shared in + // — which is a pack, even when it holds one brush. + use crate::brush::pack::BrushPack; + use crate::brush::pack_file::PackFile; + for brush in all() { - let name = brush.metadata.name.clone(); - let bytes = brush.to_bytes().unwrap(); - let loaded = Brush::from_bytes(&bytes).unwrap(); - assert_eq!(loaded.metadata.name, name); + let (id, name) = (brush.id().to_string(), brush.name().to_string()); + let mut pack = BrushPack::new("p", "Pack", "mdi:brush", "#000000", "#ffffff"); + pack.members = vec![id.clone()]; + + let bytes = PackFile::new(&pack, vec![brush.metadata.clone()]) + .to_bytes() + .unwrap(); + let loaded = PackFile::from_bytes(&bytes).unwrap(); + + assert_eq!(loaded.brushes.len(), 1, "'{id}' round-trips as one brush"); + assert_eq!(loaded.brushes[0].id, id); + assert_eq!(loaded.brushes[0].name, name); } } @@ -242,7 +264,7 @@ mod tests { .find(|b| b.metadata.name == name) .unwrap_or_else(|| panic!("built-in brush '{name}' must exist")); assert_eq!( - BrushInfo::from(&brush.metadata).icon, + BrushInfo::from(brush).icon, icon, "brush '{name}' preview fallback icon" ); diff --git a/crates/darkly/src/brush/bundle.rs b/crates/darkly/src/brush/bundle.rs deleted file mode 100644 index 118aaed5..00000000 --- a/crates/darkly/src/brush/bundle.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! `.darkly-brush` bundle format — ZIP archive containing a JSON envelope -//! and an optional pre-baked thumbnail. -//! -//! Format: -//! brush.json — metadata + serialized node graph -//! preview.png — optional pre-baked thumbnail - -use std::io::{Cursor, Read, Write}; - -use serde::{Deserialize, Serialize}; - -use crate::brush::stabilizer::StabilizerConfig; -use crate::brush::wire::BrushWireType; -use crate::nodegraph::Graph; - -/// Metadata for a brush — the JSON-serialized envelope inside a -/// `.darkly-brush` archive. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BrushMetadata { - pub name: String, - #[serde(default = "default_engine_version")] - pub engine_version: String, - #[serde(default)] - pub category: String, - #[serde(default)] - pub author: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub tags: Vec, - pub graph: Graph, - /// Stabilizer configuration. Default = no stabilization (pass-through). - #[serde(default)] - pub stabilizer: StabilizerConfig, -} - -/// A fully-loaded brush — the unit of save/load/share. -#[derive(Clone, Debug)] -pub struct Brush { - pub metadata: BrushMetadata, - /// Optional pre-rendered preview PNG, stored in the ZIP as - /// `preview.png`. Produced by the async thumbnail bake on brush save - /// and consumed by the brush picker grid. `None` for freshly-saved - /// brushes whose bake hasn't completed yet. - pub thumbnail_png: Option>, -} - -fn default_engine_version() -> String { - crate::VERSION.to_string() -} - -impl BrushMetadata { - /// Create metadata from just a graph. - pub fn from_graph(name: impl Into, graph: Graph) -> Self { - BrushMetadata { - name: name.into(), - engine_version: default_engine_version(), - category: String::new(), - author: String::new(), - description: String::new(), - tags: Vec::new(), - graph, - stabilizer: StabilizerConfig::default(), - } - } -} - -impl Brush { - /// Create a brush from metadata. - pub fn from_metadata(metadata: BrushMetadata) -> Self { - Brush { - metadata, - thumbnail_png: None, - } - } - - /// ZIP entry path for the JSON envelope. - const METADATA_JSON_PATH: &'static str = "brush.json"; - - /// ZIP entry path for the optional preview PNG. - const PREVIEW_PNG_PATH: &'static str = "preview.png"; - - /// Serialize to `.darkly-brush` ZIP bytes. - pub fn to_bytes(&self) -> Result, String> { - let buf = Vec::new(); - let cursor = Cursor::new(buf); - let mut zip = zip::ZipWriter::new(cursor); - - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - - // Write the JSON envelope. - let json = serde_json::to_string_pretty(&self.metadata) - .map_err(|e| format!("failed to serialize brush metadata: {e}"))?; - zip.start_file(Self::METADATA_JSON_PATH, options) - .map_err(|e| format!("zip write error: {e}"))?; - zip.write_all(json.as_bytes()) - .map_err(|e| format!("zip write error: {e}"))?; - - // Optional pre-baked preview PNG for the brush picker grid. - if let Some(png) = &self.thumbnail_png { - zip.start_file(Self::PREVIEW_PNG_PATH, options) - .map_err(|e| format!("zip write error: {e}"))?; - zip.write_all(png) - .map_err(|e| format!("zip write error: {e}"))?; - } - - let cursor = zip - .finish() - .map_err(|e| format!("zip finalize error: {e}"))?; - Ok(cursor.into_inner()) - } - - /// Deserialize from `.darkly-brush` ZIP bytes. - pub fn from_bytes(bytes: &[u8]) -> Result { - let cursor = Cursor::new(bytes); - let mut archive = - zip::ZipArchive::new(cursor).map_err(|e| format!("invalid ZIP archive: {e}"))?; - - // Read the JSON envelope. - let metadata: BrushMetadata = { - let mut file = archive - .by_name(Self::METADATA_JSON_PATH) - .map_err(|e| format!("missing {}: {e}", Self::METADATA_JSON_PATH))?; - let mut json = String::new(); - file.read_to_string(&mut json) - .map_err(|e| format!("failed to read {}: {e}", Self::METADATA_JSON_PATH))?; - serde_json::from_str(&json) - .map_err(|e| format!("invalid {}: {e}", Self::METADATA_JSON_PATH))? - }; - - // Read the optional preview PNG — older archives don't have one - // and we treat that as `None`, not an error. - let thumbnail_png = match archive.by_name(Self::PREVIEW_PNG_PATH) { - Ok(mut file) => { - let mut data = Vec::with_capacity(file.size() as usize); - file.read_to_end(&mut data) - .map_err(|e| format!("failed to read preview.png: {e}"))?; - Some(data) - } - Err(_) => None, - }; - - Ok(Brush { - metadata, - thumbnail_png, - }) - } - - /// Save to a file path. - #[cfg(not(target_arch = "wasm32"))] - pub fn save(&self, path: &std::path::Path) -> Result<(), String> { - let bytes = self.to_bytes()?; - std::fs::write(path, bytes).map_err(|e| format!("failed to write brush: {e}")) - } - - /// Load from a file path. - #[cfg(not(target_arch = "wasm32"))] - pub fn load(path: &std::path::Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("failed to read brush file: {e}"))?; - Self::from_bytes(&bytes) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::brush; - - #[test] - fn engine_version_default_is_crate_version() { - // Lives here because `default_engine_version` is private to this module. - // The brush-bundle breadcrumb is the git-derived crate version. - assert_eq!(default_engine_version(), crate::VERSION); - } - - #[test] - fn round_trip_no_resources() { - let graph = brush::default_graph(); - let metadata = BrushMetadata::from_graph("Test Brush", graph.clone()); - let brush = Brush::from_metadata(metadata); - - let bytes = brush.to_bytes().unwrap(); - let loaded = Brush::from_bytes(&bytes).unwrap(); - - assert_eq!(loaded.metadata.name, "Test Brush"); - - // Verify graph round-trips: same nodes and connections. - // Compare as serde_json::Value to avoid HashMap key ordering differences. - let orig_val = serde_json::to_value(&brush.metadata.graph).unwrap(); - let loaded_val = serde_json::to_value(&loaded.metadata.graph).unwrap(); - assert_eq!(orig_val, loaded_val); - } - - #[test] - fn corrupt_zip_returns_error() { - let err = Brush::from_bytes(b"not a zip").unwrap_err(); - assert!(err.contains("invalid ZIP"), "got: {err}"); - } - - #[test] - fn missing_metadata_json_returns_error() { - // Create a valid ZIP with no envelope JSON. - let buf = Vec::new(); - let cursor = Cursor::new(buf); - let mut zip = zip::ZipWriter::new(cursor); - let opts = zip::write::SimpleFileOptions::default(); - zip.start_file("dummy.txt", opts).unwrap(); - zip.write_all(b"hello").unwrap(); - let cursor = zip.finish().unwrap(); - let bytes = cursor.into_inner(); - - let err = Brush::from_bytes(&bytes).unwrap_err(); - assert!(err.contains("missing"), "got: {err}"); - } - - #[test] - fn thumbnail_png_round_trip() { - // A brush with a baked thumbnail should serialize the PNG as a - // `preview.png` ZIP entry and reload it back into `thumbnail_png`. - let graph = brush::default_graph(); - let metadata = BrushMetadata::from_graph("Thumbnailed", graph); - let png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3]; - let mut brush = Brush::from_metadata(metadata); - brush.thumbnail_png = Some(png.clone()); - - let bytes = brush.to_bytes().unwrap(); - let loaded = Brush::from_bytes(&bytes).unwrap(); - assert_eq!(loaded.thumbnail_png, Some(png)); - } - - #[test] - fn thumbnail_absent_loads_as_none() { - // Archives without `preview.png` — the case for freshly-saved - // brushes whose bake hasn't landed yet — must load as - // `thumbnail_png: None`, not error. - let graph = brush::default_graph(); - let metadata = BrushMetadata::from_graph("Bare", graph); - let brush = Brush::from_metadata(metadata); - let bytes = brush.to_bytes().unwrap(); - - let loaded = Brush::from_bytes(&bytes).unwrap(); - assert!(loaded.thumbnail_png.is_none()); - } - - #[test] - fn unknown_fields_ignored() { - // Simulate a brush envelope with extra fields (forward-compat). - let graph = brush::default_graph(); - let metadata = BrushMetadata::from_graph("Compat", graph); - let mut json_val: serde_json::Value = serde_json::to_value(&metadata).unwrap(); - json_val["unknown_field"] = serde_json::json!("should be ignored"); - json_val["nested_unknown"] = serde_json::json!({"a": 1, "b": [2,3]}); - - let json_str = serde_json::to_string_pretty(&json_val).unwrap(); - - // Build a ZIP with the modified JSON. - let buf = Vec::new(); - let cursor = Cursor::new(buf); - let mut zip = zip::ZipWriter::new(cursor); - let opts = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - zip.start_file(Brush::METADATA_JSON_PATH, opts).unwrap(); - zip.write_all(json_str.as_bytes()).unwrap(); - let cursor = zip.finish().unwrap(); - let bytes = cursor.into_inner(); - - // Should load successfully, ignoring unknown fields. - let loaded = Brush::from_bytes(&bytes).unwrap(); - assert_eq!(loaded.metadata.name, "Compat"); - } -} diff --git a/crates/darkly/src/brush/checkpoint_ring.rs b/crates/darkly/src/brush/checkpoint_ring.rs index 7f454b32..da259462 100644 --- a/crates/darkly/src/brush/checkpoint_ring.rs +++ b/crates/darkly/src/brush/checkpoint_ring.rs @@ -75,6 +75,7 @@ impl CheckpointSlot { last_dab_size: [0.0, 0.0], last_dab_pos: None, dab_count: 0, + stamp_angle: None, }, valid: false, extra: Vec::new(), @@ -297,65 +298,43 @@ impl CheckpointRing { tip_vi: usize, max_div_window: usize, ) { - let layer_rect = match stroke.canvas_to_layer_rect(canvas_bbox) { - Some(r) if !r.is_empty() => r, - _ => return, - }; - // Use the clipped canvas rect (post-intersection) so the stored - // bbox matches the texels actually copied. - let clipped_canvas = match stroke.canvas_extent.intersect(canvas_bbox) { - Some(r) => r, - None => return, - }; + // The region to snapshot, as a texture-local rect paired with the + // clipped canvas rect (so the stored bbox matches the texels + // actually copied). `None` when the checkpoint covers no texels. + // + // An empty region is a *valid* checkpoint: it records "nothing had + // been painted at this index", and restoring it is fully served by + // the caller's reset to the terminal's baseline. Claiming the slot + // anyway is what keeps the `vi = 0` anchor present when a stroke's + // first dab is an identity write (a stationary smudge, say) — + // without it every early divergence falls back to a full re-render. + let region = stroke + .canvas_to_layer_rect(canvas_bbox) + .filter(|r| !r.is_empty()) + .zip(stroke.canvas_extent.intersect(canvas_bbox)); let extra_formats: Vec = extra.iter().map(|t| t.format()).collect(); let slot_idx = self.pick_slot(tip_vi, max_div_window, vector_index); let slot = &mut self.slots[slot_idx]; - slot.ensure_texture( - device, - layer_rect.width, - layer_rect.height, - stroke.texture.format(), - &extra_formats, - ); - slot.canvas_bbox = clipped_canvas; + slot.canvas_bbox = + region.map_or_else(|| CanvasRect::from_xywh(0, 0, 0, 0), |(_, clipped)| clipped); slot.save_point_index = save_point_index; slot.vector_index = vector_index; slot.render_state = render_state; slot.valid = true; // Copy bbox region from stroke texture to slot texture. - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: stroke.texture, - mip_level: 0, - origin: wgpu::Origin3d { - x: layer_rect.x0(), - y: layer_rect.y0(), - z: 0, - }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: slot.texture.as_ref().unwrap(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: layer_rect.width, - height: layer_rect.height, - depth_or_array_layers: 1, - }, - ); - - // Same region, same coordinates — the accumulators are layer-sized - // and grown in lockstep with the stroke buffer, so one rect - // addresses all of them. - for (src, dst) in extra.iter().zip(slot.extra.iter()) { + if let Some((layer_rect, _)) = region { + slot.ensure_texture( + device, + layer_rect.width, + layer_rect.height, + stroke.texture.format(), + &extra_formats, + ); encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: src, + texture: stroke.texture, mip_level: 0, origin: wgpu::Origin3d { x: layer_rect.x0(), @@ -365,7 +344,10 @@ impl CheckpointRing { aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyTextureInfo { - texture: dst, + texture: slot + .texture + .as_ref() + .expect("ensure_texture just allocated the slot"), mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, @@ -376,6 +358,35 @@ impl CheckpointRing { depth_or_array_layers: 1, }, ); + + // Same region, same coordinates — the accumulators are layer-sized + // and grown in lockstep with the stroke buffer, so one rect + // addresses all of them. + for (src, dst) in extra.iter().zip(slot.extra.iter()) { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: src, + mip_level: 0, + origin: wgpu::Origin3d { + x: layer_rect.x0(), + y: layer_rect.y0(), + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: layer_rect.width, + height: layer_rect.height, + depth_or_array_layers: 1, + }, + ); + } } // Coverage invariant: after every save, at least one valid slot @@ -467,51 +478,26 @@ impl CheckpointRing { ) -> Option { let slot_idx = self.best_slot_before(div_vector_index)?; let slot = &self.slots[slot_idx]; - let layer_rect = stroke.canvas_to_layer_rect(slot.canvas_bbox)?; - if layer_rect.is_empty() { - return None; - } // Copy checkpoint bbox region back to stroke buffer. The caller has // already reset outside-bbox pixels to the terminal's starting - // state, so only the mutated region needs restoring here. - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: slot.texture.as_ref().unwrap(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: stroke.texture, - mip_level: 0, - origin: wgpu::Origin3d { - x: layer_rect.x0(), - y: layer_rect.y0(), - z: 0, - }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: layer_rect.width, - height: layer_rect.height, - depth_or_array_layers: 1, - }, - ); - - // Restore the accumulators the stroke buffer's pixels were - // derived from, or the next resolve recomputes this region from - // state describing dabs that were just discarded. - for (dst, src) in extra.iter().zip(slot.extra.iter()) { + // state, so only the mutated region needs restoring here — and a + // checkpoint that snapshotted no texels (nothing had been painted + // yet) is fully restored by that reset alone. + if let Some((layer_rect, texture)) = stroke + .canvas_to_layer_rect(slot.canvas_bbox) + .filter(|r| !r.is_empty()) + .zip(slot.texture.as_ref()) + { encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: src, + texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyTextureInfo { - texture: dst, + texture: stroke.texture, mip_level: 0, origin: wgpu::Origin3d { x: layer_rect.x0(), @@ -526,6 +512,35 @@ impl CheckpointRing { depth_or_array_layers: 1, }, ); + + // Restore the accumulators the stroke buffer's pixels were + // derived from, or the next resolve recomputes this region from + // state describing dabs that were just discarded. + for (dst, src) in extra.iter().zip(slot.extra.iter()) { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: src, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d { + x: layer_rect.x0(), + y: layer_rect.y0(), + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: layer_rect.width, + height: layer_rect.height, + depth_or_array_layers: 1, + }, + ); + } } Some(CheckpointRestore { diff --git a/crates/darkly/src/brush/gpu_context.rs b/crates/darkly/src/brush/gpu_context.rs index deb02aca..e769936a 100644 --- a/crates/darkly/src/brush/gpu_context.rs +++ b/crates/darkly/src/brush/gpu_context.rs @@ -226,11 +226,12 @@ pub struct DabBatch { /// sizeof(Record)`; the count is tracked explicitly so flush code /// doesn't need to know the record size. pub count: u32, - /// Layer-local bounding box covered by the queued dabs, as - /// `[x0, y0, x1, y1]`. The terminal's `flush_dabs` reads it as a - /// workload metric (recorded into `BrushPerfCounters` for the bench - /// harness). `None` when the queue is empty. - pub bbox: Option<[u32; 4]>, + /// Canvas-space bounding box covered by the queued dabs. The terminal's + /// `flush_dabs` reads it as a workload metric (recorded into + /// `BrushPerfCounters` for the bench harness). `None` when the queue is + /// empty. Unioned by [`Self::record_dab_footprint`] from the same rect it + /// publishes as the per-dab footprint, so the two cannot drift. + pub batch_canvas_bbox: Option, /// Terminal-private per-dab CPU meta, packed by `evaluate_gpu` in /// lockstep with [`Self::bytes`] and drained by the terminal's /// `flush_dabs` hook. Only used by per-dab-feedback terminals @@ -321,7 +322,7 @@ impl DabBatch { pub fn take(&mut self) -> (Vec, u32) { let bytes = std::mem::take(&mut self.bytes); let count = std::mem::take(&mut self.count); - self.bbox = None; + self.batch_canvas_bbox = None; (bytes, count) } @@ -339,7 +340,7 @@ impl DabBatch { pub fn clear(&mut self) { self.bytes.clear(); self.count = 0; - self.bbox = None; + self.batch_canvas_bbox = None; self.meta_bytes.clear(); self.live_textures.clear(); } @@ -368,6 +369,47 @@ impl DabBatch { .map(|(_, v)| v) } + /// Clamp a dab's extent-derived footprint to `paint_target` and publish + /// it — as this dab's write footprint (which `stroke_engine` reads for + /// the save-point bbox) and into the batch-wide union the terminal's + /// `flush_dabs` reports as its workload. Returns the clamped rect, or + /// `None` when the dab lands entirely off-extent and so has no pixels to + /// draw; callers early-out on `None`. + /// + /// Every dab-batching terminal records its footprint here rather than + /// folding the two unions by hand, so the rect a dab publishes and the + /// rect its pass writes are the same value by construction — the + /// divergence [`crate::brush::wgsl::extent::ExtentContribution`]'s doc + /// comment records the cost of. + pub fn record_dab_footprint( + &mut self, + paint_target: &GpuPaintTarget<'_>, + position: [f32; 2], + bbox_radius: f32, + ) -> Option { + let canvas_bbox = paint_target.canvas_extent().clamp_f32( + position[0] - bbox_radius, + position[1] - bbox_radius, + position[0] + bbox_radius, + position[1] + bbox_radius, + )?; + self.push_write_bbox(canvas_bbox); + self.batch_canvas_bbox = Some(match self.batch_canvas_bbox { + Some(prev) => prev.union(canvas_bbox), + None => canvas_bbox, + }); + Some(canvas_bbox) + } + + /// Width and height of the queued dabs' union, in canvas pixels — the + /// workload metric `flush_dabs` records. Every footprint is clamped to + /// the paint target before it is unioned, so the layer-local projection + /// of this rect is a pure translation and has the same extent. + pub fn batch_extent(&self) -> (u32, u32) { + self.batch_canvas_bbox + .map_or((0, 0), |r| (r.width, r.height)) + } + /// Union a write-pass footprint into [`Self::write_canvas_bbox`]. /// Called by any GPU node whose pass writes to the stroke scratch, /// so `stroke_engine` can record a save-point bbox that matches what diff --git a/crates/darkly/src/brush/interpolation.rs b/crates/darkly/src/brush/interpolation.rs index 302364c5..e3d49570 100644 --- a/crates/darkly/src/brush/interpolation.rs +++ b/crates/darkly/src/brush/interpolation.rs @@ -45,17 +45,26 @@ fn lerp2(a: [f32; 2], b: [f32; 2], t: f32) -> [f32; 2] { [lerp(a[0], b[0], t), lerp(a[1], b[1], t)] } -/// Lerp angles via shortest arc (handles wrapping around 2π). +/// Shortest signed difference `b - a`, wrapped to (−π, π]. +/// +/// The one wrap implementation: angle lerping, Catmull-Rom angle unwrapping, +/// and the stroke engine's stamp-orientation tracker all route through it. #[inline] -fn lerp_angle(a: f32, b: f32, t: f32) -> f32 { - use std::f32::consts::TAU; +pub fn shortest_angle_diff(a: f32, b: f32) -> f32 { + use std::f32::consts::{PI, TAU}; let mut diff = (b - a) % TAU; - if diff > std::f32::consts::PI { + if diff > PI { diff -= TAU; - } else if diff < -std::f32::consts::PI { + } else if diff < -PI { diff += TAU; } - a + diff * t + diff +} + +/// Lerp angles via shortest arc (handles wrapping around 2π). +#[inline] +fn lerp_angle(a: f32, b: f32, t: f32) -> f32 { + a + shortest_angle_diff(a, b) * t } // ── Catmull-Rom spline interpolation ────────────────────────────────── @@ -89,17 +98,8 @@ fn catmull_rom2(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) /// discontinuities at the ±π boundary. #[inline] fn catmull_rom_angle(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { - use std::f32::consts::{PI, TAU}; // Unwrap all angles relative to p1. - let unwrap = |a: f32, ref_: f32| -> f32 { - let mut d = (a - ref_) % TAU; - if d > PI { - d -= TAU; - } else if d < -PI { - d += TAU; - } - ref_ + d - }; + let unwrap = |a: f32, ref_: f32| -> f32 { ref_ + shortest_angle_diff(ref_, a) }; let u0 = unwrap(p0, p1); let u2 = unwrap(p2, p1); let u3 = unwrap(p3, p1); @@ -336,6 +336,31 @@ mod tests { assert!(result.abs() < 0.5 || (result - std::f32::consts::TAU).abs() < 0.5); } + /// The one wrap implementation behind `lerp_angle`, `catmull_rom_angle`'s + /// unwrap, and the stroke engine's orientation tracker: always the + /// shortest signed arc, always within (−π, π]. + #[test] + fn shortest_angle_diff_wraps_at_pi() { + use std::f32::consts::{PI, TAU}; + + assert!((shortest_angle_diff(0.0, 0.5) - 0.5).abs() < 1e-6); + assert!((shortest_angle_diff(0.5, 0.0) + 0.5).abs() < 1e-6); + + // Across the wrap: 0.1 rad short of a full turn is −0.1, not +6.18. + assert!((shortest_angle_diff(0.0, TAU - 0.1) + 0.1).abs() < 1e-5); + assert!((shortest_angle_diff(TAU - 0.1, 0.0) - 0.1).abs() < 1e-5); + + // Multiple turns of winding collapse to the same short arc. + assert!((shortest_angle_diff(0.0, TAU * 3.0 + 0.25) - 0.25).abs() < 1e-4); + + // Never leaves (−π, π], including at the antipode. + for i in 0..64 { + let b = -TAU * 2.0 + i as f32 * (TAU * 4.0 / 64.0); + let d = shortest_angle_diff(0.7, b); + assert!(d > -PI - 1e-5 && d <= PI + 1e-5, "diff {d} out of range"); + } + } + // ── Catmull-Rom tests ──────────────────────────────────────────── fn pt(x: f32, y: f32) -> PaintInformation { diff --git a/crates/darkly/src/brush/library.rs b/crates/darkly/src/brush/library.rs index d7c60955..9dad33c6 100644 --- a/crates/darkly/src/brush/library.rs +++ b/crates/darkly/src/brush/library.rs @@ -1,20 +1,38 @@ -//! In-memory brush library with optional filesystem backing. +//! The brush library — every brush that exists, and every pack that groups +//! them. //! -//! Stores loaded `Brush`es keyed by name. On native targets, can scan -//! a directory for `.darkly-brush` files and save brushes to disk. +//! "Which brushes exist" and "which packs exist" are one logical concept, so +//! one type owns both. Membership lives on the pack (see +//! [`crate::brush::pack`]); nothing on a brush records which packs hold it, +//! and a brush may be in any number of them. +//! +//! The library is **process-global**, like [`crate::config`]: one library +//! serves every canvas handle on the shared device, so a brush saved in one +//! tab is immediately visible in the next. Reach it through [`with`] and +//! [`with_mut`]. +//! +//! It is not document, session or compositor state in the Document Authority +//! sense — a pack belongs to no canvas, never rides a `.darkly` file, and is +//! not derivable from one. It is library state, and the library is exactly one +//! thing. +use std::cell::RefCell; use std::collections::HashMap; -use super::bundle::{Brush, BrushMetadata}; -use crate::brush::wire::BrushWireType; -use crate::nodegraph::Graph; +use indexmap::IndexMap; + +use super::metadata::Brush; +use crate::brush::pack::{validate_pack, BrushId, BrushPack, PackId, PackMutability}; +use crate::brush::pack_file::PackFile; /// Summary info for listing brushes without loading the full graph. #[derive(Clone, Debug, serde::Serialize)] #[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] pub struct BrushInfo { + /// Opaque identity — what pack member lists and recents hold. + pub id: String, + /// Display name, and the engine's public lookup key. pub name: String, - pub category: String, pub author: String, pub description: String, pub tags: Vec, @@ -23,108 +41,248 @@ pub struct BrushInfo { /// preview bake renders blank (clone, blur, smudge, liquify). See /// [`crate::brush::graph_capabilities`]. pub icon: Option<&'static str>, + /// Whether the painter may rename or delete this brush, so the UI can grey + /// out affordances it would otherwise offer. A hint, not the authority — + /// same contract as [`BrushPackInfo::can_edit_members`]. + pub can_edit: bool, } -impl From<&BrushMetadata> for BrushInfo { - fn from(p: &BrushMetadata) -> Self { +impl From<&Brush> for BrushInfo { + fn from(b: &Brush) -> Self { + let p = &b.metadata; BrushInfo { + id: p.id.clone(), name: p.name.clone(), - category: p.category.clone(), author: p.author.clone(), description: p.description.clone(), tags: p.tags.clone(), icon: crate::brush::graph_capabilities(&p.graph).preview_fallback_icon, + can_edit: b.can_edit(), + } + } +} + +/// A pack as the UI sees it. +#[derive(Clone, Debug, serde::Serialize)] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub struct BrushPackInfo { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + pub primary: String, + pub secondary: String, + /// Member brush ids, in the pack's order. The authority on membership — + /// nothing on [`BrushInfo`] repeats it. + pub members: Vec, + /// What the painter may change, so the UI can grey out affordances it + /// would otherwise offer. A hint, not the authority — the engine rejects a + /// forbidden edit regardless of what the UI believed. + pub can_edit_members: bool, + pub can_edit_identity: bool, +} + +impl From<&BrushPack> for BrushPackInfo { + fn from(p: &BrushPack) -> Self { + BrushPackInfo { + id: p.id.clone(), + name: p.name.clone(), + description: p.description.clone(), + icon: p.icon.clone(), + primary: p.primary.clone(), + secondary: p.secondary.clone(), + members: p.members.clone(), + can_edit_members: p.can_edit_members(), + can_edit_identity: p.can_edit_identity(), } } } -/// In-memory library of brushes. +/// Brushes and packs, in one round trip. +/// +/// One call rather than two so the two halves cannot disagree: independent +/// `await`s either side of a mutation can, and a member id pointing at a brush +/// the caller has not heard of is exactly the inconsistency this avoids. +#[derive(Clone, Debug, serde::Serialize)] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +pub struct LibrarySnapshot { + pub brushes: Vec, + pub packs: Vec, +} + +/// Every brush and pack in the process. pub struct BrushLibrary { - brushes: HashMap, - /// In-memory dab thumbnails for the picker tiles. Keyed by brush - /// name. Not part of the `.darkly-brush` archive — purely a render - /// cache that's rebuilt on theme change alongside the stroke - /// thumbnails on each `Brush`. - dab_thumbnails: HashMap>, + /// Keyed by id. One keyspace, not two — a parallel name map would be the + /// same fact in two places, and a linear name scan over a few dozen + /// brushes is free. + brushes: IndexMap, + /// Insertion-ordered so shipped packs keep their declared order and the + /// painter's own land after them. + packs: IndexMap, + /// In-memory dab thumbnails for the picker tiles. Not part of any archive + /// — purely a render cache, rebuilt on theme change alongside the stroke + /// thumbnails on each [`Brush`]. + dab_thumbnails: HashMap>, } impl BrushLibrary { pub fn new() -> Self { BrushLibrary { - brushes: HashMap::new(), + brushes: IndexMap::new(), + packs: IndexMap::new(), dab_thumbnails: HashMap::new(), } } - /// List all loaded brushes (summary info only). + /// The shipped library: every built-in brush, then every built-in pack. + /// + /// Panics if a shipped pack names a brush that does not exist — that is a + /// typo in data we control, caught at startup rather than surfacing later + /// as a pack that renders one brush short. + pub fn builtin() -> Self { + let mut lib = BrushLibrary::new(); + for brush in crate::brush::builtin_brushes::all() { + lib.insert(brush); + } + for pack in crate::brush::packs::all() { + for member in &pack.members { + assert!( + lib.brushes.contains_key(member), + "shipped pack '{}' names brush '{member}', which does not exist", + pack.id + ); + } + lib.packs.insert(pack.id.clone(), pack); + } + lib + } + + // ---- brushes ---- + + /// Every brush, sorted by name. pub fn list(&self) -> Vec { - let mut infos: Vec = self - .brushes - .values() - .map(|b| BrushInfo::from(&b.metadata)) - .collect(); + let mut infos: Vec = self.brushes.values().map(BrushInfo::from).collect(); infos.sort_by(|a, b| a.name.cmp(&b.name)); infos } - /// Get a brush by name. - pub fn get(&self, name: &str) -> Option<&Brush> { - self.brushes.get(name) + /// Brushes and packs together — see [`LibrarySnapshot`]. + pub fn snapshot(&self) -> LibrarySnapshot { + LibrarySnapshot { + brushes: self.list(), + packs: self.pack_infos(), + } + } + + pub fn get(&self, id: &str) -> Option<&Brush> { + self.brushes.get(id) + } + + /// Look a brush up by its display name — the engine's public lookup key. + pub fn by_name(&self, name: &str) -> Option<&Brush> { + self.brushes.values().find(|b| b.name() == name) } - /// Get the graph for a brush by name. - pub fn graph(&self, name: &str) -> Option<&Graph> { - self.brushes.get(name).map(|b| &b.metadata.graph) + /// The id of the brush displayed as `name`. + pub fn id_for_name(&self, name: &str) -> Option<&str> { + self.by_name(name).map(|b| b.id()) } - /// Add or replace a brush in the library. + /// Add or replace a brush, keyed by its id. pub fn insert(&mut self, brush: Brush) { - let name = brush.metadata.name.clone(); - self.brushes.insert(name, brush); + self.brushes.insert(brush.metadata.id.clone(), brush); } - /// Remove a brush by name. Returns true if it existed. - pub fn remove(&mut self, name: &str) -> bool { - self.brushes.remove(name).is_some() + /// Reject a name already spoken for by a *different* brush. + /// + /// Names are the engine's public lookup key (`by_name`), so two brushes + /// sharing one makes `brush_load` ambiguous. `rename` has always enforced + /// this; saving enforces the same rule so the two cannot disagree. + pub fn ensure_name_free(&self, id: &str, name: &str) -> Result<(), String> { + if self + .brushes + .values() + .any(|b| b.id() != id && b.name() == name) + { + return Err(format!("a brush named '{name}' already exists")); + } + Ok(()) } - /// Read a brush's baked thumbnail PNG bytes. Returns `None` if the - /// brush doesn't exist or its thumbnail hasn't been baked yet. - pub fn thumbnail_png(&self, name: &str) -> Option<&[u8]> { - self.brushes - .get(name) - .and_then(|b| b.thumbnail_png.as_deref()) + /// Remove a brush and drop it from the member list of every pack that + /// holds it, so no pack is left pointing at a ghost. + /// + /// Bypasses each pack's member gate deliberately: this is not an edit to + /// those packs, it is the library declining to name something that no + /// longer exists. + pub fn delete_brush(&mut self, id: &str) -> Result<(), String> { + self.ensure_brush_editable(id)?; + self.brushes.shift_remove(id); + self.dab_thumbnails.remove(id); + for pack in self.packs.values_mut() { + pack.members.retain(|m| m != id); + } + Ok(()) } - /// Drop every baked stroke + dab thumbnail in the library. Called - /// on theme change so the next picker refresh re-bakes against the - /// new palette — without this, brushes stay frozen at whatever - /// theme they were first viewed under. - pub fn clear_thumbnails(&mut self) { - for brush in self.brushes.values_mut() { - brush.thumbnail_png = None; + /// Reject a rename or deletion of a brush that is not the painter's. + /// + /// A shipped brush comes back from embedded YAML on the next boot, so an + /// edit to one would appear to work and then silently undo itself. The + /// same reasoning locks a shipped pack. + fn ensure_brush_editable(&self, id: &str) -> Result<(), String> { + match self.brushes.get(id) { + None => Err(format!("brush '{id}' not found")), + Some(b) if !b.can_edit() => Err(format!( + "brush '{}' is built in and cannot be renamed or deleted", + b.name() + )), + Some(_) => Ok(()), } - self.dab_thumbnails.clear(); } - /// Read a brush's cached dab thumbnail PNG bytes. Returns `None` if - /// the brush hasn't been baked yet. - pub fn dab_thumbnail_png(&self, name: &str) -> Option<&[u8]> { - self.dab_thumbnails.get(name).map(|v| v.as_slice()) + /// Rename a brush. No pack and no recents entry is touched, because both + /// hold ids — that is what having an id is for. + pub fn rename(&mut self, id: &str, new_name: &str) -> Result<(), String> { + let new_name = new_name.trim(); + if new_name.is_empty() { + return Err("a brush needs a name".into()); + } + self.ensure_brush_editable(id)?; + self.ensure_name_free(id, new_name)?; + if let Some(brush) = self.brushes.get_mut(id) { + brush.metadata.name = new_name.to_string(); + } + Ok(()) } - /// Install a freshly-baked dab PNG for `name`. Used by the async - /// thumbnail bake completion path. - pub fn set_dab_thumbnail(&mut self, name: &str, png: Vec) { - self.dab_thumbnails.insert(name.to_string(), png); + /// A name not already taken, suffixing `"(2)"`, `"(3)"`, … as needed. + pub fn unique_brush_name(&self, base: &str) -> String { + unique(base, |c| self.brushes.values().any(|b| b.name() == c)) } - /// Attach a baked `preview.png` to an existing brush. Used by the - /// async thumbnail bake path — save returns immediately without a - /// thumbnail, and this method installs the PNG once the readback - /// completes on a later frame. - pub fn set_thumbnail(&mut self, name: &str, png: Vec) -> bool { - match self.brushes.get_mut(name) { + pub fn len(&self) -> usize { + self.brushes.len() + } + + pub fn is_empty(&self) -> bool { + self.brushes.is_empty() + } + + // ---- thumbnails ---- + + /// A brush's baked stroke thumbnail, if one has been baked. + pub fn thumbnail_png(&self, id: &str) -> Option<&[u8]> { + self.brushes + .get(id) + .and_then(|b| b.thumbnail_png.as_deref()) + } + + /// Attach a freshly-baked stroke PNG. Used by the async bake completion + /// path — save returns immediately without a thumbnail, and this installs + /// the PNG once the readback lands on a later frame. + pub fn set_thumbnail(&mut self, id: &str, png: Vec) -> bool { + match self.brushes.get_mut(id) { Some(brush) => { brush.thumbnail_png = Some(png); true @@ -133,72 +291,194 @@ impl BrushLibrary { } } - /// Import a brush from `.darkly-brush` ZIP bytes. - pub fn import_bytes(&mut self, bytes: &[u8]) -> Result { - let brush = Brush::from_bytes(bytes)?; - let name = brush.metadata.name.clone(); - self.insert(brush); - Ok(name) + pub fn dab_thumbnail_png(&self, id: &str) -> Option<&[u8]> { + self.dab_thumbnails.get(id).map(|v| v.as_slice()) } - /// Export a brush to `.darkly-brush` ZIP bytes. - pub fn export_bytes(&self, name: &str) -> Result, String> { - let brush = self - .brushes - .get(name) - .ok_or_else(|| format!("brush '{}' not found", name))?; - brush.to_bytes() + pub fn set_dab_thumbnail(&mut self, id: &str, png: Vec) { + self.dab_thumbnails.insert(id.to_string(), png); } - /// Number of brushes in the library. - pub fn len(&self) -> usize { - self.brushes.len() + /// Drop every baked stroke and dab thumbnail. Called on theme change so + /// the next picker refresh re-bakes against the new palette — without + /// this, brushes stay frozen at whatever theme they were first viewed + /// under. + pub fn clear_thumbnails(&mut self) { + for brush in self.brushes.values_mut() { + brush.thumbnail_png = None; + } + self.dab_thumbnails.clear(); } - pub fn is_empty(&self) -> bool { - self.brushes.is_empty() + // ---- packs ---- + + pub fn packs(&self) -> impl Iterator { + self.packs.values() } - /// Scan a directory for `.darkly-brush` files and load them all. - /// Errors on individual files are logged and skipped. - #[cfg(not(target_arch = "wasm32"))] - pub fn scan_directory(&mut self, dir: &std::path::Path) -> Result { - let entries = std::fs::read_dir(dir) - .map_err(|e| format!("failed to read directory '{}': {e}", dir.display()))?; - - let mut count = 0; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("darkly-brush") { - match Brush::load(&path) { - Ok(brush) => { - self.insert(brush); - count += 1; - } - Err(e) => { - log::warn!("skipping brush '{}': {e}", path.display()); - } - } - } + pub fn pack(&self, id: &str) -> Option<&BrushPack> { + self.packs.get(id) + } + + pub fn pack_infos(&self) -> Vec { + self.packs.values().map(BrushPackInfo::from).collect() + } + + fn pack_mut(&mut self, id: &str) -> Result<&mut BrushPack, String> { + self.packs + .get_mut(id) + .ok_or_else(|| format!("brush pack '{id}' not found")) + } + + /// A pack name not already taken. + pub fn unique_pack_name(&self, base: &str) -> String { + unique(base, |c| self.packs.values().any(|p| p.name == c)) + } + + /// Create a painter-owned pack under a caller-supplied id. + /// + /// The id comes from the caller because this crate has no random-number + /// source and adding one for wasm means the `getrandom/js` dance; the + /// frontend already has a generator. Rust's job is to reject an empty or + /// duplicate id, which is deterministic and testable. + pub fn create_pack( + &mut self, + id: &str, + name: &str, + description: &str, + icon: &str, + primary: &str, + secondary: &str, + ) -> Result<(), String> { + if id.trim().is_empty() { + return Err("a brush pack needs an id".into()); + } + if self.packs.contains_key(id) { + return Err(format!("brush pack '{id}' already exists")); } - Ok(count) + validate_pack(name, icon, primary, secondary)?; + + let mut pack = BrushPack::new(id, name.trim(), icon, primary, secondary); + pack.description = description.to_string(); + self.packs.insert(id.to_string(), pack); + Ok(()) } - /// Save a brush to a directory as `.darkly-brush`. - #[cfg(not(target_arch = "wasm32"))] - pub fn save_to_directory( - &self, + /// Change a pack's name, description, icon or colors. + pub fn edit_pack( + &mut self, + id: &str, name: &str, - dir: &std::path::Path, - ) -> Result { - let brush = self - .brushes - .get(name) - .ok_or_else(|| format!("brush '{}' not found", name))?; - let filename = sanitize_filename(name); - let path = dir.join(format!("{filename}.darkly-brush")); - brush.save(&path)?; - Ok(path) + description: &str, + icon: &str, + primary: &str, + secondary: &str, + ) -> Result<(), String> { + validate_pack(name, icon, primary, secondary)?; + let taken = self + .packs + .values() + .any(|p| p.id != id && p.name == name.trim()); + if taken { + return Err(format!( + "a brush pack named '{}' already exists", + name.trim() + )); + } + + let pack = self.pack_mut(id)?; + pack.ensure_identity_editable()?; + pack.name = name.trim().to_string(); + pack.description = description.to_string(); + pack.icon = icon.to_string(); + pack.primary = primary.to_string(); + pack.secondary = secondary.to_string(); + Ok(()) + } + + /// Delete a pack. **Its brushes survive** — a pack is a grouping, not a + /// container, and a member that other packs also list is entirely + /// unaffected. A brush left in no pack is a reachable, safe state. + pub fn delete_pack(&mut self, id: &str) -> Result<(), String> { + self.pack_mut(id)?.ensure_identity_editable()?; + self.packs.shift_remove(id); + Ok(()) + } + + /// Copy a brush into a pack. It does not leave any pack it is already in. + pub fn add_to_pack(&mut self, pack: &str, brush: &str) -> Result<(), String> { + if !self.brushes.contains_key(brush) { + return Err(format!("brush '{brush}' not found")); + } + self.pack_mut(pack)?.add(brush.to_string()) + } + + pub fn remove_from_pack(&mut self, pack: &str, brush: &str) -> Result<(), String> { + self.pack_mut(pack)?.remove(brush) + } + + pub fn reorder_in_pack(&mut self, pack: &str, brush: &str, index: usize) -> Result<(), String> { + self.pack_mut(pack)?.reorder(brush, index) + } + + /// Export a pack as `.darkly-brush` bytes, carrying its members' records + /// in member order. + pub fn export_pack(&self, id: &str) -> Result, String> { + let pack = self + .packs + .get(id) + .ok_or_else(|| format!("brush pack '{id}' not found"))?; + let brushes = pack + .members + .iter() + .filter_map(|m| self.brushes.get(m)) + .map(|b| b.metadata.clone()) + .collect(); + PackFile::new(pack, brushes).to_bytes() + } + + /// Import a `.darkly-brush` archive as a new pack under `id`. + /// + /// The pack is **always** new, never merged into or replacing an existing + /// one — merging risks silently overwriting the painter's edits. Its name + /// is suffixed if it collides. + /// + /// Per brush record: a brush whose id the library already has is + /// **reused**, and the incoming copy discarded. Re-importing your own + /// export therefore does not multiply your library, and a friend's pack + /// containing a brush you already have does not overwrite the edits you + /// made to it. The tradeoff is deliberate — the sender's version of a + /// shared brush loses to the recipient's. + pub fn import_pack(&mut self, id: &str, bytes: &[u8]) -> Result { + if id.trim().is_empty() { + return Err("an imported brush pack needs an id".into()); + } + if self.packs.contains_key(id) { + return Err(format!("brush pack '{id}' already exists")); + } + let file = PackFile::from_bytes(bytes)?; + + let mut members: Vec = Vec::with_capacity(file.brushes.len()); + for mut metadata in file.brushes { + if self.brushes.contains_key(&metadata.id) { + // Already ours: keep our copy, and just join the new pack. + members.push(metadata.id.clone()); + continue; + } + // A new brush whose *name* collides is display-suffixed. Names are + // display; ids are identity. + metadata.name = self.unique_brush_name(&metadata.name); + members.push(metadata.id.clone()); + self.insert(Brush::from_metadata(metadata)); + } + + let name = self.unique_pack_name(&file.name); + let mut pack = BrushPack::new(id, name, file.icon, file.primary, file.secondary); + pack.description = file.description; + pack.mutability = PackMutability::Full; + pack.members = members; + self.packs.insert(id.to_string(), pack); + Ok(id.to_string()) } } @@ -208,34 +488,68 @@ impl Default for BrushLibrary { } } -/// Sanitize a brush name for use as a filename. -#[cfg(not(target_arch = "wasm32"))] -fn sanitize_filename(name: &str) -> String { - name.chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - _ => c, - }) - .collect() +/// `base`, or the first `"base (n)"` that `taken` does not claim. +fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { + let base = base.trim(); + if !taken(base) { + return base.to_string(); + } + (2..) + .map(|n| format!("{base} ({n})")) + .find(|candidate| !taken(candidate)) + .expect("an unbounded range always yields a free name") +} + +thread_local! { + /// The process-wide library. `thread_local!` + `RefCell` mirrors + /// [`crate::config`], which solved the same problem the same way: wasm is + /// single-threaded, and every canvas handle shares one device. + static LIBRARY: RefCell = RefCell::new(BrushLibrary::builtin()); +} + +/// Run `f` against the process-wide brush library. +/// +/// **Never call a `&mut self` engine method inside the closure.** The borrow +/// is held for the closure's whole body, and a re-entrant `with`/`with_mut` +/// panics at runtime rather than failing to compile. Clone what you need out +/// and end the borrow first — `brush_load` does exactly that. +pub fn with(f: impl FnOnce(&BrushLibrary) -> R) -> R { + LIBRARY.with(|lib| f(&lib.borrow())) +} + +/// Run `f` against the process-wide brush library, mutably. See [`with`] for +/// the borrow rule. +pub fn with_mut(f: impl FnOnce(&mut BrushLibrary) -> R) -> R { + LIBRARY.with(|lib| f(&mut lib.borrow_mut())) +} + +/// Restore the library to its shipped state. Tests only — the process-global +/// would otherwise carry one test's brushes into the next. +#[cfg(any(test, feature = "testing"))] +pub fn reset_for_test() { + LIBRARY.with(|lib| *lib.borrow_mut() = BrushLibrary::builtin()); } #[cfg(test)] mod tests { use super::*; use crate::brush; - use crate::brush::bundle::BrushMetadata; + use crate::brush::metadata::BrushMetadata; - #[test] - fn library_insert_list_get() { - let mut lib = BrushLibrary::new(); - assert!(lib.is_empty()); - - let metadata = BrushMetadata::from_graph("Alpha", brush::default_graph()); - lib.insert(Brush::from_metadata(metadata)); + fn brush_named(id: &str, name: &str) -> Brush { + Brush::from_metadata(BrushMetadata::from_graph(id, name, brush::default_graph())) + } - let metadata2 = BrushMetadata::from_graph("Beta", brush::default_graph()); - lib.insert(Brush::from_metadata(metadata2)); + fn lib_with_two() -> BrushLibrary { + let mut lib = BrushLibrary::new(); + lib.insert(brush_named("a", "Alpha")); + lib.insert(brush_named("b", "Beta")); + lib + } + #[test] + fn library_insert_list_get() { + let lib = lib_with_two(); assert_eq!(lib.len(), 2); let list = lib.list(); @@ -244,63 +558,350 @@ mod tests { assert_eq!(list[0].name, "Alpha"); assert_eq!(list[1].name, "Beta"); - assert!(lib.get("Alpha").is_some()); - assert!(lib.get("Missing").is_none()); + assert!(lib.get("a").is_some()); + assert!(lib.get("missing").is_none()); + assert_eq!(lib.by_name("Beta").unwrap().id(), "b"); + assert_eq!(lib.id_for_name("Alpha"), Some("a")); } #[test] - fn library_import_export_round_trip() { - let mut lib = BrushLibrary::new(); + fn every_shipped_brush_is_in_a_shipped_pack() { + // Shipped brush YAMLs and shipped pack member lists must agree — this + // is what catches a typo in a member list, and what makes the brushes + // catalog's derived grouping total. + let lib = BrushLibrary::builtin(); + for brush in lib.brushes.values() { + assert!( + lib.packs().any(|p| p.contains(brush.id())), + "shipped brush '{}' is in no shipped pack", + brush.id() + ); + } + } - let metadata = BrushMetadata::from_graph("Roundtrip", brush::default_graph()); - let brush = Brush::from_metadata(metadata); - let bytes = brush.to_bytes().unwrap(); + #[test] + fn a_brush_can_be_in_two_packs_at_once() { + // The invariant the whole design rests on: adding to a pack copies a + // reference, it does not move the brush. + let mut lib = lib_with_two(); + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.create_pack("p2", "Two", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + + lib.add_to_pack("p1", "a").unwrap(); + lib.add_to_pack("p2", "a").unwrap(); + + assert!(lib.pack("p1").unwrap().contains("a")); + assert!(lib.pack("p2").unwrap().contains("a")); + } - let name = lib.import_bytes(&bytes).unwrap(); - assert_eq!(name, "Roundtrip"); + #[test] + fn copying_a_locked_packs_brush_into_a_user_pack_is_allowed() { + // A shipped brush lives in a locked pack, and must still be copyable + // into any pack the painter makes. + let mut lib = BrushLibrary::builtin(); + let locked = lib + .packs() + .find(|p| !p.can_edit_members()) + .expect("a locked shipped pack"); + let (locked_id, member) = (locked.id.clone(), locked.members[0].clone()); + + lib.create_pack("mine", "Mine", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.add_to_pack("mine", &member).unwrap(); + + assert!(lib.pack("mine").unwrap().contains(&member)); + // And it did not leave the pack it came from. + assert!(lib.pack(&locked_id).unwrap().contains(&member)); + } + + #[test] + fn adding_to_a_locked_pack_is_rejected() { + let mut lib = BrushLibrary::builtin(); + lib.insert(brush_named("mine", "Mine")); + let before = lib.pack("basic").unwrap().members.clone(); - let exported = lib.export_bytes("Roundtrip").unwrap(); - let reloaded = Brush::from_bytes(&exported).unwrap(); - assert_eq!(reloaded.metadata.name, "Roundtrip"); + assert!(lib.add_to_pack("basic", "mine").is_err()); + assert_eq!(lib.pack("basic").unwrap().members, before); } #[test] - fn library_remove() { - let mut lib = BrushLibrary::new(); - let metadata = BrushMetadata::from_graph("ToRemove", brush::default_graph()); - lib.insert(Brush::from_metadata(metadata)); - assert_eq!(lib.len(), 1); + fn removing_from_a_locked_pack_is_rejected() { + let mut lib = BrushLibrary::builtin(); + let before = lib.pack("basic").unwrap().members.clone(); - assert!(lib.remove("ToRemove")); - assert!(lib.is_empty()); - assert!(!lib.remove("ToRemove")); + assert!(lib.remove_from_pack("basic", &before[0]).is_err()); + assert_eq!(lib.pack("basic").unwrap().members, before); } #[test] - fn library_scan_directory() { - let dir = std::env::temp_dir().join("darkly_brush_library_test"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - // Write two brushes. - for name in &["Scan A", "Scan B"] { - let metadata = BrushMetadata::from_graph(*name, brush::default_graph()); - let brush = Brush::from_metadata(metadata); - brush - .save(&dir.join(format!("{name}.darkly-brush"))) - .unwrap(); - } + fn deleting_a_pack_leaves_its_brushes_alone() { + let mut lib = lib_with_two(); + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.create_pack("p2", "Two", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.add_to_pack("p1", "a").unwrap(); + lib.add_to_pack("p2", "a").unwrap(); + + lib.delete_pack("p1").unwrap(); + + assert!(lib.pack("p1").is_none()); + // The brush survives, and its membership elsewhere is untouched. + assert!(lib.get("a").is_some()); + assert!(lib.pack("p2").unwrap().contains("a")); + } - // Also write a non-brush file (should be ignored). - std::fs::write(dir.join("readme.txt"), "not a brush").unwrap(); + #[test] + fn deleting_a_locked_pack_is_rejected() { + let mut lib = BrushLibrary::builtin(); + assert!(lib.delete_pack("basic").is_err()); + assert!(lib.pack("basic").is_some()); + } + + #[test] + fn deleting_a_brush_removes_it_from_every_pack() { + let mut lib = lib_with_two(); + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.create_pack("p2", "Two", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.add_to_pack("p1", "a").unwrap(); + lib.add_to_pack("p2", "a").unwrap(); + + lib.delete_brush("a").unwrap(); + + assert!(lib.get("a").is_none()); + assert!(!lib.pack("p1").unwrap().contains("a")); + assert!(!lib.pack("p2").unwrap().contains("a")); + assert!( + lib.delete_brush("a").is_err(), + "a brush that is gone cannot be deleted again" + ); + } + #[test] + fn a_shipped_brush_cannot_be_renamed_or_deleted() { + // It is rebuilt from embedded YAML on the next boot, so either edit + // would appear to work and then undo itself. + let mut lib = BrushLibrary::builtin(); + let member = lib.pack("basic").unwrap().members[0].clone(); + + assert!(!lib.get(&member).unwrap().can_edit()); + assert!(lib.delete_brush(&member).is_err()); + assert!(lib.rename(&member, "Mine Now").is_err()); + // The rejected edits changed nothing. + assert!(lib.get(&member).is_some()); + assert!(lib.pack("basic").unwrap().contains(&member)); + } + + #[test] + fn renaming_a_brush_touches_no_pack() { + // The payoff of id-keyed membership. + let mut lib = lib_with_two(); + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.add_to_pack("p1", "a").unwrap(); + let before = lib.pack("p1").unwrap().members.clone(); + + lib.rename("a", "Renamed").unwrap(); + + assert_eq!(lib.pack("p1").unwrap().members, before); + assert_eq!(lib.get("a").unwrap().name(), "Renamed"); + assert!(lib.by_name("Alpha").is_none()); + } + + #[test] + fn renaming_onto_a_taken_name_is_rejected() { + let mut lib = lib_with_two(); + assert!(lib.rename("a", "Beta").is_err()); + assert!(lib.rename("a", " ").is_err()); + assert!(lib.rename("missing", "Whatever").is_err()); + // Renaming to its own name is a no-op, not a collision. + lib.rename("a", "Alpha").unwrap(); + } + + #[test] + fn a_brush_in_no_pack_is_still_listed() { + // The reachable-orphan state: a brush does not depend on a pack to + // exist. + let mut lib = lib_with_two(); + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.add_to_pack("p1", "a").unwrap(); + lib.remove_from_pack("p1", "a").unwrap(); + + assert!(lib.get("a").is_some()); + assert!(lib.list().iter().any(|b| b.id == "a")); + assert!(!lib.packs().any(|p| p.contains("a"))); + } + + #[test] + fn creating_a_pack_rejects_a_duplicate_or_empty_id() { let mut lib = BrushLibrary::new(); - let count = lib.scan_directory(&dir).unwrap(); - assert_eq!(count, 2); - assert_eq!(lib.len(), 2); - assert!(lib.get("Scan A").is_some()); - assert!(lib.get("Scan B").is_some()); + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + assert!(lib + .create_pack("p1", "Other", "", "mdi:brush", "#000000", "#ffffff") + .is_err()); + assert!(lib + .create_pack(" ", "Other", "", "mdi:brush", "#000000", "#ffffff") + .is_err()); + // And a malformed color never reaches the library. + assert!(lib + .create_pack("p2", "Two", "", "mdi:brush", "not-a-color", "#ffffff") + .is_err()); + } + + #[test] + fn editing_a_pack_rejects_a_locked_one_and_a_taken_name() { + let mut lib = BrushLibrary::builtin(); + assert!(lib + .edit_pack("basic", "Renamed", "", "mdi:brush", "#000000", "#ffffff") + .is_err()); + + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.create_pack("p2", "Two", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + assert!(lib + .edit_pack("p2", "One", "", "mdi:brush", "#000000", "#ffffff") + .is_err()); + + lib.edit_pack("p2", "Renamed", "d", "mdi:water", "#111111", "#222222") + .unwrap(); + let p = lib.pack("p2").unwrap(); + assert_eq!(p.name, "Renamed"); + assert_eq!(p.icon, "mdi:water"); + } + + #[test] + fn adding_a_missing_brush_to_a_pack_is_rejected() { + let mut lib = BrushLibrary::new(); + lib.create_pack("p1", "One", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + assert!(lib.add_to_pack("p1", "nope").is_err()); + assert!(lib.add_to_pack("nope", "nope").is_err()); + } + + #[test] + fn pack_export_import_round_trip() { + let mut lib = lib_with_two(); + lib.create_pack("p1", "Mine", "d", "mdi:water", "#3355ff", "#ffffff") + .unwrap(); + lib.add_to_pack("p1", "a").unwrap(); + lib.add_to_pack("p1", "b").unwrap(); + + let bytes = lib.export_pack("p1").unwrap(); + + let mut fresh = BrushLibrary::new(); + fresh.import_pack("new", &bytes).unwrap(); + + let pack = fresh.pack("new").unwrap(); + assert_eq!(pack.name, "Mine"); + assert_eq!(pack.icon, "mdi:water"); + assert_eq!(pack.members, vec!["a", "b"], "member order survives"); + // An imported pack is always the painter's own. + assert!(pack.can_edit_identity()); + assert_eq!(fresh.len(), 2); + } + + #[test] + fn importing_a_pack_whose_name_collides_gets_a_suffixed_name() { + let mut lib = lib_with_two(); + lib.create_pack("p1", "Mine", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.add_to_pack("p1", "a").unwrap(); + let bytes = lib.export_pack("p1").unwrap(); + + lib.import_pack("p2", &bytes).unwrap(); + + // Both survive; neither was merged into the other. + assert_eq!(lib.pack("p1").unwrap().name, "Mine"); + assert_eq!(lib.pack("p2").unwrap().name, "Mine (2)"); + } + + #[test] + fn importing_a_pack_containing_a_known_brush_reuses_it() { + // Re-importing your own export must not multiply your library, and + // must not overwrite edits you made to a brush you already have. + let mut lib = lib_with_two(); + lib.create_pack("p1", "Mine", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + lib.add_to_pack("p1", "a").unwrap(); + let bytes = lib.export_pack("p1").unwrap(); + + lib.rename("a", "My Edited Name").unwrap(); + lib.import_pack("p2", &bytes).unwrap(); + + assert_eq!(lib.len(), 2, "the library did not grow"); + assert_eq!( + lib.get("a").unwrap().name(), + "My Edited Name", + "the recipient's copy wins" + ); + assert!(lib.pack("p2").unwrap().contains("a")); + } + + #[test] + fn importing_rejects_a_duplicate_pack_id() { + let mut lib = lib_with_two(); + lib.create_pack("p1", "Mine", "", "mdi:brush", "#000000", "#ffffff") + .unwrap(); + let bytes = lib.export_pack("p1").unwrap(); + assert!(lib.import_pack("p1", &bytes).is_err()); + assert!(lib.import_pack("", &bytes).is_err()); + } - let _ = std::fs::remove_dir_all(&dir); + #[test] + fn unique_names_suffix_until_free() { + let mut lib = lib_with_two(); + assert_eq!(lib.unique_brush_name("Gamma"), "Gamma"); + assert_eq!(lib.unique_brush_name("Alpha"), "Alpha (2)"); + lib.insert(brush_named("a2", "Alpha (2)")); + assert_eq!(lib.unique_brush_name("Alpha"), "Alpha (3)"); + } + + #[test] + fn thumbnails_are_keyed_by_id_and_cleared_together() { + let mut lib = lib_with_two(); + assert!(lib.set_thumbnail("a", vec![1, 2, 3])); + lib.set_dab_thumbnail("a", vec![4, 5, 6]); + assert_eq!(lib.thumbnail_png("a"), Some(&[1u8, 2, 3][..])); + assert_eq!(lib.dab_thumbnail_png("a"), Some(&[4u8, 5, 6][..])); + + assert!(!lib.set_thumbnail("missing", vec![])); + + lib.clear_thumbnails(); + assert!(lib.thumbnail_png("a").is_none()); + assert!(lib.dab_thumbnail_png("a").is_none()); + } + + #[test] + fn deleting_a_brush_drops_its_dab_thumbnail() { + let mut lib = lib_with_two(); + lib.set_dab_thumbnail("a", vec![1]); + lib.delete_brush("a").unwrap(); + assert!(lib.dab_thumbnail_png("a").is_none()); + } + + #[test] + fn the_snapshot_carries_both_halves() { + let lib = BrushLibrary::builtin(); + let snap = lib.snapshot(); + assert!(!snap.brushes.is_empty()); + assert!(!snap.packs.is_empty()); + // Every member id in the snapshot resolves to a brush in the same + // snapshot — the consistency one round trip buys. + for pack in &snap.packs { + for member in &pack.members { + assert!( + snap.brushes.iter().any(|b| &b.id == member), + "pack '{}' names '{member}', absent from the same snapshot", + pack.id + ); + } + } } } diff --git a/crates/darkly/src/brush/metadata.rs b/crates/darkly/src/brush/metadata.rs new file mode 100644 index 00000000..f1f8504d --- /dev/null +++ b/crates/darkly/src/brush/metadata.rs @@ -0,0 +1,158 @@ +//! A brush as the library holds it: identity, describing metadata, and the +//! node graph that paints. +//! +//! The archive a brush travels in is a *pack* — +//! [`crate::brush::pack_file`] — even when it holds exactly one brush. This +//! module owns the record; that one owns the container. + +use serde::{Deserialize, Serialize}; + +use crate::brush::pack::BrushId; +use crate::brush::stabilizer::StabilizerConfig; +use crate::brush::wire::BrushWireType; +use crate::nodegraph::Graph; + +/// A brush's serialized form — one entry in a pack archive, and one record in +/// the painter's stored library. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BrushMetadata { + /// Opaque identity. Shipped brushes use their YAML file stem; a painter's + /// brushes are given a minted id when saved. + /// + /// Separate from `name` so a rename touches no pack member list and no + /// recent-brushes entry — both hold ids. + pub id: BrushId, + pub name: String, + #[serde(default = "default_engine_version")] + pub engine_version: String, + #[serde(default)] + pub author: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub tags: Vec, + pub graph: Graph, + /// Stabilizer configuration. Default = no stabilization (pass-through). + #[serde(default)] + pub stabilizer: StabilizerConfig, +} + +/// A fully-loaded brush — the unit the library stores and a pack groups. +#[derive(Clone, Debug)] +pub struct Brush { + pub metadata: BrushMetadata, + /// Optional pre-rendered preview PNG. Produced by the async thumbnail bake + /// and consumed by the brush picker grid. `None` for freshly-saved brushes + /// whose bake hasn't completed yet. + /// + /// Deliberately not part of a pack archive: a baked preview is a + /// theme-derived render cache — `BrushLibrary::clear_thumbnails` drops + /// every one on theme change — so one baked by the sender would be wrong + /// for the recipient, whose own bake is a frame away. + pub thumbnail_png: Option>, + /// Whether this brush ships with the app. + /// + /// A shipped brush is rebuilt from embedded YAML on every boot, so it + /// cannot hold a rename or a deletion: storing one would shadow the YAML + /// it comes back from. The painter's own brushes are theirs to change. + /// Same reasoning as [`crate::brush::PackMutability`], one level down. + pub shipped: bool, +} + +fn default_engine_version() -> String { + crate::VERSION.to_string() +} + +impl BrushMetadata { + /// Create metadata from an id, a name and a graph. + pub fn from_graph( + id: impl Into, + name: impl Into, + graph: Graph, + ) -> Self { + BrushMetadata { + id: id.into(), + name: name.into(), + engine_version: default_engine_version(), + author: String::new(), + description: String::new(), + tags: Vec::new(), + graph, + stabilizer: StabilizerConfig::default(), + } + } +} + +impl Brush { + /// Create a brush the painter owns. + pub fn from_metadata(metadata: BrushMetadata) -> Self { + Brush { + metadata, + thumbnail_png: None, + shipped: false, + } + } + + /// Mark this brush as one that ships with the app. Only + /// [`crate::brush::builtin_brushes`] has any business calling this: every + /// other route into the library is the painter creating or importing. + pub fn into_shipped(mut self) -> Self { + self.shipped = true; + self + } + + /// Whether the painter may rename or delete this brush. + pub fn can_edit(&self) -> bool { + !self.shipped + } + + pub fn id(&self) -> &str { + &self.metadata.id + } + + pub fn name(&self) -> &str { + &self.metadata.name + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::brush; + + #[test] + fn engine_version_default_is_crate_version() { + // Lives here because `default_engine_version` is private to this + // module. The brush breadcrumb is the git-derived crate version. + assert_eq!(default_engine_version(), crate::VERSION); + } + + #[test] + fn metadata_round_trips_through_json() { + // The record shape a pack archive and a stored library record both + // carry. + let metadata = BrushMetadata::from_graph("ink_pen", "Ink Pen", brush::default_graph()); + let json = serde_json::to_string(&metadata).unwrap(); + let back: BrushMetadata = serde_json::from_str(&json).unwrap(); + + assert_eq!(back.id, "ink_pen"); + assert_eq!(back.name, "Ink Pen"); + assert_eq!( + serde_json::to_value(&metadata.graph).unwrap(), + serde_json::to_value(&back.graph).unwrap(), + ); + } + + #[test] + fn unknown_fields_are_ignored() { + // A record written by a newer build must not fail to load on an older + // one for carrying a field it does not know. + let metadata = BrushMetadata::from_graph("compat", "Compat", brush::default_graph()); + let mut value = serde_json::to_value(&metadata).unwrap(); + value["unknown_field"] = serde_json::json!("ignored"); + value["nested_unknown"] = serde_json::json!({ "a": 1, "b": [2, 3] }); + + let back: BrushMetadata = serde_json::from_value(value).unwrap(); + assert_eq!(back.name, "Compat"); + } +} diff --git a/crates/darkly/src/brush/mod.rs b/crates/darkly/src/brush/mod.rs index 5ef865a9..76dfc59a 100644 --- a/crates/darkly/src/brush/mod.rs +++ b/crates/darkly/src/brush/mod.rs @@ -1,7 +1,6 @@ //! Node-graph composable brush engine. pub mod builtin_brushes; -pub mod bundle; pub mod checkpoint_ring; pub mod composite_pipeline; pub mod curve_math; @@ -11,9 +10,14 @@ pub mod import; pub mod input_value; pub mod interpolation; pub mod library; +pub mod metadata; pub mod node; pub mod node_preview_subgraph; pub mod nodes; +pub mod pack; +pub mod pack_file; +pub mod pack_icons; +pub mod packs; pub mod paint_info; pub mod paint_target_ext; pub mod pipeline; diff --git a/crates/darkly/src/brush/node.rs b/crates/darkly/src/brush/node.rs index 5a98ab2b..78de8202 100644 --- a/crates/darkly/src/brush/node.rs +++ b/crates/darkly/src/brush/node.rs @@ -10,7 +10,7 @@ //! Bundling the evaluator constructor here is the load-bearing design choice //! that lets [`crate::brush::BrushNodeRegistry`] be the single source of //! truth for "what nodes exist?" — there is no parallel hand-written -//! evaluator map to keep in sync. See AGENTS.md "Modularity Principle". +//! evaluator map to keep in sync. See CONTRIBUTING.md "Modularity Principle". //! //! The nodegraph compiler only knows about [`NodeRegistration`]; the //! brush layer unwraps `.node` when feeding it into the compiler. The diff --git a/crates/darkly/src/brush/nodes/brush_settings.rs b/crates/darkly/src/brush/nodes/brush_settings.rs index f2016bc5..970af8f3 100644 --- a/crates/darkly/src/brush/nodes/brush_settings.rs +++ b/crates/darkly/src/brush/nodes/brush_settings.rs @@ -38,6 +38,16 @@ pub const TYPE_ID: &str = "brush_settings"; /// back to this. pub const DEFAULT_BASE_SIZE: f32 = 0.1; +/// Top of the `stamp_angle_rate` range, meaning *unlimited*: the stamp may turn +/// as far as it likes per dab and only the undirected-axis fold applies. Also +/// the registration default, so a brush that never touches the knob paints +/// exactly as it did before the rate limit existed. +/// +/// It is a sentinel rather than a large magnitude because no finite rate is +/// "unlimited" at every spacing — the per-dab bound is `rate × spacing_ratio`, +/// and brushes ship ratios from 1% to 10%. +pub const STAMP_ANGLE_RATE_UNLIMITED: f32 = 8.0 * std::f32::consts::PI; + /// Node id of the (first) `brush_settings` node in `graph`, if any. The /// out-of-band knobs all live on this node, so engine, CLI, and tests resolve /// it here rather than re-scanning by type. @@ -90,6 +100,17 @@ pub fn spacing_config(graph: &Graph) -> SpacingConfig { SpacingConfig { ratio, min_px } } +/// How far the stamp may turn to follow the stroke, in radians per brush +/// diameter of travel, read out-of-band from the `brush_settings` node's +/// `stamp_angle_rate` input-port default. Falls back to +/// [`STAMP_ANGLE_RATE_UNLIMITED`] for graphs that predate the port, so they keep +/// their current behaviour. +/// +/// Stroke-constant: read once at stroke start and handed to the stroke engine. +pub fn stamp_angle_rate(graph: &Graph) -> f32 { + read_scalar_input(graph, "stamp_angle_rate").unwrap_or(STAMP_ANGLE_RATE_UNLIMITED) +} + pub fn register() -> BrushNodeRegistration { BrushNodeRegistration::compute( NodeRegistration { @@ -159,6 +180,28 @@ pub fn register() -> BrushNodeRegistration { ratio above; non-zero pins spacing to at least \ this many canvas pixels regardless of brush size.", ), + // How fast the stamp may pivot to follow the stroke, per brush + // diameter of travel — so the limit is the same whether the + // brush is 10px or 400px, and tightening spacing doesn't loosen + // it. Read at stroke start. Expressed per *travel* rather than + // per dab because that is what the tearing depends on: a stamp + // of diameter D turning Δθ sweeps its extremity through + // (D/2)·Δθ, which must not outrun the overlap over the travel s. + // No `preview_irrelevant_scrub`: the editor preview runs a real + // StrokeEngine, so scrubbing this does change it. + PortDef::input("stamp_angle_rate", BrushWireType::Scalar) + .with_range(0.0, STAMP_ANGLE_RATE_UNLIMITED, STAMP_ANGLE_RATE_UNLIMITED) + .with_natural_range(0.0, 4.0 * std::f32::consts::PI) + .with_unit(UnitType::Degrees) + .with_icon("fa6-solid:arrows-spin") + .with_label("Turn rate") + .with_description( + "How far the stamp may turn to follow the stroke, per brush-width of \ + travel. At maximum the stamp turns freely. Lower values keep \ + sharp-cornered stamps from spinning between dabs and tearing at \ + corners \u{2014} 360\u{b0} completes a full turn in one brush width; \ + 0 locks the stamp to the angle it started at.", + ), ], is_gpu: false, is_terminal: false, @@ -237,6 +280,24 @@ mod tests { assert!((base_size(&graph) - DEFAULT_BASE_SIZE).abs() < 1e-6); } + #[test] + fn stamp_angle_rate_reads_scrubbed_value() { + let graph = graph_with_settings(&[("stamp_angle_rate", 1.25)]); + assert!((stamp_angle_rate(&graph) - 1.25).abs() < 1e-6); + } + + /// A graph that predates the port — and the shipped default — must leave + /// the stamp turning freely, so adding the rate limit changes no existing + /// brush's output. + #[test] + fn stamp_angle_rate_falls_back_to_unlimited() { + let empty = Graph::::new(); + assert!((stamp_angle_rate(&empty) - STAMP_ANGLE_RATE_UNLIMITED).abs() < 1e-6); + + let untouched = graph_with_settings(&[]); + assert!((stamp_angle_rate(&untouched) - STAMP_ANGLE_RATE_UNLIMITED).abs() < 1e-6); + } + #[test] fn spacing_config_reads_scrubbed_value() { let graph = graph_with_settings(&[("spacing", 0.5)]); diff --git a/crates/darkly/src/brush/nodes/liquify.rs b/crates/darkly/src/brush/nodes/liquify.rs index 21df5234..50db4871 100644 --- a/crates/darkly/src/brush/nodes/liquify.rs +++ b/crates/darkly/src/brush/nodes/liquify.rs @@ -119,9 +119,9 @@ const STRENGTH_EPSILON: f32 = 1.0e-4; /// nothing visible. const MIN_RADIUS_PX: f32 = 1.0; -/// Cumulative stroke distance below which liquify silently skips the -/// first dab. Without this, a stationary click would warp rightward -/// (default `drawing_angle = 0`). +/// Cumulative stroke distance below which liquify silently skips the dab. +/// The stroke's opening dabs have zero or sub-pixel per-dab motion, so their +/// displacement is nil and rendering them is wasted work. const MIN_DISTANCE_PX: f32 = 0.5; pub const TYPE_ID: &str = "liquify"; @@ -144,12 +144,6 @@ pub fn register() -> BrushNodeRegistration { ports: vec![ PortDef::input("position", BrushWireType::Vec2) .with_description("Where to apply the warp"), - // No `natural_range`: radians are a unit, not a normalized - // signal. `pen.drawing_angle → direction` (canonical wire) - // is a unit-preserving identity. - PortDef::input("direction", BrushWireType::Scalar) - .with_range(-std::f32::consts::TAU, std::f32::consts::TAU, 0.0) - .with_description("Direction to push pixels"), PortDef::input("distance", BrushWireType::Scalar) .with_description("How far the pen has traveled along the stroke"), // Per-dab cursor motion in canvas pixels. Wire from @@ -231,7 +225,7 @@ impl ReadMirrorTerminal for LiquifyEvaluator { // Symmetric read region — disc inflated by `displacement` per axis // so the warped sample at - // `target_pos - direction × displacement × falloff(d)` always lies + // `target_pos - motion × strength × falloff(d)` always lies // inside the mirror snapshot (the bilinear sampler reaches into // the inflation margin too). `displacement = strength × |motion|`, // recomputed identically by the shader from motion + strength. @@ -256,7 +250,6 @@ impl ReadMirrorTerminal for LiquifyEvaluator { }; let strength_expr = cctx.input("strength").as_f32(); let softness_expr = cctx.input("softness").as_f32(); - let direction_expr = cctx.input("direction").as_f32(); let motion_expr = cctx.input("motion").as_vec2(); // Per-node falloff fn — suffixed by node id so two liquify @@ -301,17 +294,17 @@ impl ReadMirrorTerminal for LiquifyEvaluator { // one sample of the source, and a masked-out fragment simply // contributes a zero offset (leaving the accumulated field // untouched). - let offset_expr = "-dir * (length(motion_vec) * strength) * f * sel * warp_mask"; + // Pixels are pushed straight along the per-dab motion vector — the + // signed direction *and* magnitude of where the cursor actually went. + let offset_expr = "-motion_vec * strength * f * sel * warp_mask"; wgsl.body = format!( " if (local_dist >= 1.0) {{ discard; }}\n\ \x20 let warp_mask = clamp({mask_expr}, 0.0, 1.0);\n\ \x20 let strength = clamp({strength_expr}, 0.0, 1.0);\n\ \x20 let softness = clamp({softness_expr}, 0.0, 1.0);\n\ \x20 let falloff_param = 1.0 - softness;\n\ - \x20 let direction_angle = {direction_expr};\n\ \x20 let motion_vec = {motion_expr};\n\ \x20 let f = {falloff_fn}(local_dist, falloff_param);\n\ - \x20 let dir = vec2(cos(direction_angle), sin(direction_angle));\n\ {}", crate::brush::warp_field::advect_wgsl(offset_expr, copy_origin_field), ); diff --git a/crates/darkly/src/brush/nodes/paint.rs b/crates/darkly/src/brush/nodes/paint.rs index 1f1548cb..19db172d 100644 --- a/crates/darkly/src/brush/nodes/paint.rs +++ b/crates/darkly/src/brush/nodes/paint.rs @@ -457,32 +457,15 @@ impl BrushNodeEvaluator for PaintEvaluator { // layer-clip bbox tracks exactly what the shader writes, and // mid-stroke rewinds can't truncate previous dabs. let bbox_radius = radius * compiled.brush_extent_factor + compiled.brush_extent_extra_px; - let canvas_ext = paint_target.canvas_extent(); - // Clamp the dab footprint to the layer extent; a dab entirely - // off-extent has no pixels to draw and is skipped. - let canvas_bbox = match canvas_ext.clamp_f32( - position[0] - bbox_radius, - position[1] - bbox_radius, - position[0] + bbox_radius, - position[1] + bbox_radius, - ) { - Some(r) => r, - None => return vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))], - }; - let local = paint_target - .canvas_frame() - .canvas_to_layer_rect(canvas_bbox) - .expect("canvas_bbox came from canvas_ext.clamp_f32, so it overlaps the extent"); - gpu.dab_batch.push_write_bbox(canvas_bbox); - gpu.dab_batch.bbox = Some(match gpu.dab_batch.bbox { - Some([x0, y0, x1, y1]) => [ - x0.min(local.x0()), - y0.min(local.y0()), - x1.max(local.x1()), - y1.max(local.y1()), - ], - None => [local.x0(), local.y0(), local.x1(), local.y1()], - }); + // Publish the footprint; `None` means the dab is entirely off-extent + // and has no pixels to draw. + if gpu + .dab_batch + .record_dab_footprint(paint_target, position, bbox_radius) + .is_none() + { + return vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))]; + } gpu.dab_batch .queue_dab(&compiled, position, bbox_radius, radius); @@ -499,9 +482,7 @@ impl BrushNodeEvaluator for PaintEvaluator { return; }; - let bbox = gpu.dab_batch.bbox.unwrap_or([0, 0, 0, 0]); - let union_w = bbox[2].saturating_sub(bbox[0]); - let union_h = bbox[3].saturating_sub(bbox[1]); + let (union_w, union_h) = gpu.dab_batch.batch_extent(); let (dab_bytes, total_dabs) = gpu.dab_batch.take(); if total_dabs == 0 { return; diff --git a/crates/darkly/src/brush/nodes/pen_input.rs b/crates/darkly/src/brush/nodes/pen_input.rs index 5a7cc653..137475de 100644 --- a/crates/darkly/src/brush/nodes/pen_input.rs +++ b/crates/darkly/src/brush/nodes/pen_input.rs @@ -69,7 +69,7 @@ pub fn register() -> BrushNodeRegistration { // stamp.rotation` is the canonical use case (brush faces the // stroke) and it must pass radians through unchanged. PortDef::output("drawing_angle", BrushWireType::Scalar) - .with_description("Direction of motion along the stroke in radians (0 = right, π/2 = down). Wire to `stamp.rotation` for brushes that face the stroke."), + .with_description("Orientation of the stroke in radians (0 = right, π/2 = down). Wire to `stamp.rotation` for brushes that face the stroke. This is the stroke's undirected axis — reversing along a stroke does not spin the stamp a half turn — and it turns no faster than Brush Settings → Turn rate. Use `motion` when you need the true signed direction of travel."), PortDef::output("time", BrushWireType::Scalar) .with_description("Elapsed time since the stroke began (seconds)"), PortDef::output("position", BrushWireType::Vec2) diff --git a/crates/darkly/src/brush/nodes/polygon.rs b/crates/darkly/src/brush/nodes/polygon.rs index 54ca60a3..f07b372e 100644 --- a/crates/darkly/src/brush/nodes/polygon.rs +++ b/crates/darkly/src/brush/nodes/polygon.rs @@ -133,6 +133,45 @@ pub fn register() -> BrushNodeRegistration { } } +/// Support of the rounded, squeezed silhouette, in units of the dab radius — +/// the largest distance from the dab centre at which [`compile_wgsl`] can +/// produce non-zero coverage. +/// +/// `a` is the squeeze semi-axis (`1 − 0.9·squeeze`), `rounding` the corner +/// radius `ρ`, `n` the side count, and `beta` the squeeze angle — or `None` +/// when the axis is not known at compile time, which yields the +/// orientation-agnostic worst case of a vertex on the stretched axis. +/// +/// Shared by [`PolygonEvaluator::extent`] and the feature test that asserts +/// nothing lies outside the bound, so the budgeted extent and the silhouette +/// it bounds cannot drift apart. +/// +/// [`compile_wgsl`]: PolygonEvaluator::compile_wgsl +pub fn silhouette_support(a: f32, rounding: f32, n: f32, beta: Option) -> f32 { + let a = a.max(0.01); + let rounding = rounding.clamp(0.0, 1.0); + // The body builds the polygon at circumradius `1 − ρ` and then dilates the + // distance field by `ρ`, so those two are the whole reach. + let cr = 1.0 - rounding; + let radial = match beta { + None => 1.0 / a, + Some(beta) => { + let n = n.round().max(3.0); + let (sb, cb) = beta.sin_cos(); + (0..n as u32).fold(0.0_f32, |acc, i| { + // Vertex i's base direction, matching the emitted body's + // `vec2(sin(ak), cos(ak))`, carried into the squeeze frame by + // `R(−β)` and scaled by `diag(a, 1/a)`. + let (s, c) = (std::f32::consts::TAU * i as f32 / n).sin_cos(); + let wx = s * cb + c * sb; + let wy = c * cb - s * sb; + acc.max(((a * wx).powi(2) + (wy / a).powi(2)).sqrt()) + }) + } + }; + cr * radial + rounding +} + pub struct PolygonEvaluator; impl BrushNodeEvaluator for PolygonEvaluator { @@ -256,15 +295,43 @@ impl BrushNodeEvaluator for PolygonEvaluator { Ok(wgsl) } - /// The polygon's circumradius is a constant `1.0` (vertices on the unit - /// circle; rounding stays within that circumradius), stretched by the - /// worst-case anisotropy the `squeeze` knob can deliver — the tip grows by - /// `1/a` along the stretched axis, where `a = 1 − 0.9·squeeze` is the - /// semi-axis (matching the emitted body). + /// Support of the silhouette the emitted body actually paints, in units of + /// the dab radius. + /// + /// `compile_wgsl` builds the polygon at circumradius `cr = 1 − ρ`, maps its + /// vertices through `T⁻¹` (semi-axes `a` and `1/a`), and then dilates the + /// result by the rounding radius `ρ` — `sd − ρ` is an *isotropic* offset + /// applied after the anisotropic map. So the reach is + /// + /// ```text + /// cr · maxᵢ ‖ diag(a, 1/a) · R(−β) · v̂ᵢ ‖ + ρ + /// ``` + /// + /// Bounding that with the ellipse's semi-major `1/a` is correct but loose: + /// it assumes `ρ = 0` *and* that some vertex lands on the stretched axis. + /// Looseness is not free here — the fragment stage's only early-out is a + /// circular discard at this radius, so every pixel inside the bound is + /// fully shaded (SDF loop included) before its coverage is evaluated. + /// + /// Only each mapped vertex's *magnitude* matters, and `T⁻¹`'s outer + /// `R(β − φ)` is a rotation, which preserves magnitude. Per-dab spin — + /// `rotation_input` (Sponge wires pen direction into it) and + /// `view_rotation` — therefore cannot affect this bound, which is what + /// makes evaluating it once at compile time sound. fn extent(&self, ctx: &ExtentCtx) -> ExtentContribution { let squeeze_max = ctx.port_max_value("squeeze").clamp(0.0, 1.0); - let a_min = (1.0 - 0.9 * squeeze_max).max(0.01); - let aniso_max = (1.0 / a_min).max(1.0); - ExtentContribution::Multiply(aniso_max) + let a = 1.0 - 0.9 * squeeze_max; + // `β` and `n` decide which vertices sit where relative to the stretched + // axis. A wired input's value is unknown here, so drop to the + // orientation-agnostic worst case rather than guessing an axis. + let axis_known = + !ctx.wired_inputs.contains("squeeze_angle") && !ctx.wired_inputs.contains("points"); + let beta = axis_known.then(|| ctx.port_max_value("squeeze_angle")); + ExtentContribution::Multiply(silhouette_support( + a, + ctx.port_max_value("rounding"), + ctx.port_max_value("points"), + beta, + )) } } diff --git a/crates/darkly/src/brush/nodes/watercolor.rs b/crates/darkly/src/brush/nodes/watercolor.rs index dc0f8703..105fac00 100644 --- a/crates/darkly/src/brush/nodes/watercolor.rs +++ b/crates/darkly/src/brush/nodes/watercolor.rs @@ -882,32 +882,15 @@ impl BrushNodeEvaluator for WatercolorEvaluator { } let bbox_radius = radius * compiled.brush_extent_factor + compiled.brush_extent_extra_px; - let canvas_ext = paint_target.canvas_extent(); - // Clamp the dab footprint to the layer extent; a dab entirely - // off-extent has no pixels to draw and is skipped. - let canvas_bbox = match canvas_ext.clamp_f32( - position[0] - bbox_radius, - position[1] - bbox_radius, - position[0] + bbox_radius, - position[1] + bbox_radius, - ) { - Some(r) => r, - None => return vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))], - }; - let local = paint_target - .canvas_frame() - .canvas_to_layer_rect(canvas_bbox) - .expect("canvas_bbox came from canvas_ext.clamp_f32, so it overlaps the extent"); - gpu.dab_batch.push_write_bbox(canvas_bbox); - gpu.dab_batch.bbox = Some(match gpu.dab_batch.bbox { - Some([x0, y0, x1, y1]) => [ - x0.min(local.x0()), - y0.min(local.y0()), - x1.max(local.x1()), - y1.max(local.y1()), - ], - None => [local.x0(), local.y0(), local.x1(), local.y1()], - }); + // Publish the footprint; `None` means the dab is entirely off-extent + // and has no pixels to draw. + if gpu + .dab_batch + .record_dab_footprint(paint_target, position, bbox_radius) + .is_none() + { + return vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))]; + } gpu.dab_batch .queue_dab(&compiled, position, bbox_radius, radius); @@ -924,9 +907,7 @@ impl BrushNodeEvaluator for WatercolorEvaluator { return; }; - let bbox = gpu.dab_batch.bbox.unwrap_or([0, 0, 0, 0]); - let union_w = bbox[2].saturating_sub(bbox[0]); - let union_h = bbox[3].saturating_sub(bbox[1]); + let (union_w, union_h) = gpu.dab_batch.batch_extent(); let (dab_bytes, total_dabs) = gpu.dab_batch.take(); if total_dabs == 0 { return; diff --git a/crates/darkly/src/brush/pack.rs b/crates/darkly/src/brush/pack.rs new file mode 100644 index 00000000..de6d374f --- /dev/null +++ b/crates/darkly/src/brush/pack.rs @@ -0,0 +1,348 @@ +//! A brush pack — a named, iconed, two-colored group of brushes. +//! +//! A brush may belong to any number of packs: adding one to a pack copies a +//! reference, it does not move the brush. The pack is the sole authority on +//! membership; nothing on a brush records which packs hold it. + +use serde::{Deserialize, Serialize}; + +use crate::brush::pack_icons::is_pack_icon; + +/// Opaque identity for a brush. Shipped brushes use their YAML file stem; +/// a painter's brushes are given a minted id when they are saved. +/// +/// Distinct from the brush *name*, which is the display value and may be +/// changed freely — that is the whole point of having an id, since a pack's +/// member list and the recent-brushes list both hold ids and so survive a +/// rename untouched. +pub type BrushId = String; + +/// Opaque identity for a pack. Shipped packs use their YAML file stem. +pub type PackId = String; + +/// How far a pack may be edited. +/// +/// Nothing outside this module matches on this. Consumers call the `ensure_*` +/// methods, which is what keeps "which packs are the painter's" a fact of the +/// data rather than a condition at a call site. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackMutability { + /// Shipped curation: it is rebuilt from embedded YAML on every boot, so it + /// cannot hold an edit and none is accepted. + #[default] + Locked, + /// The painter's own. Everything about it is theirs. + Full, +} + +/// A group of brushes, as the library holds it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrushPack { + pub id: PackId, + pub name: String, + #[serde(default)] + pub description: String, + pub icon: String, + pub primary: String, + pub secondary: String, + #[serde(default)] + pub mutability: PackMutability, + /// The brushes in this pack, in the painter's chosen order. The sole + /// authority on membership. + #[serde(default)] + pub members: Vec, +} + +impl BrushPack { + /// A pack the painter owns, with everything editable. + pub fn new( + id: impl Into, + name: impl Into, + icon: impl Into, + primary: impl Into, + secondary: impl Into, + ) -> Self { + BrushPack { + id: id.into(), + name: name.into(), + description: String::new(), + icon: icon.into(), + primary: primary.into(), + secondary: secondary.into(), + mutability: PackMutability::Full, + members: Vec::new(), + } + } + + /// Reject an edit to this pack's member list, if it is not the painter's + /// to make. + pub fn ensure_members_editable(&self) -> Result<(), String> { + match self.mutability { + PackMutability::Locked => Err(format!( + "brush pack '{}' is built in — its brushes cannot be changed", + self.name + )), + PackMutability::Full => Ok(()), + } + } + + /// Reject a change to this pack's name, description, icon, colors, or its + /// existence. + pub fn ensure_identity_editable(&self) -> Result<(), String> { + match self.mutability { + PackMutability::Locked => Err(format!( + "brush pack '{}' is built in and cannot be renamed, restyled or deleted", + self.name + )), + PackMutability::Full => Ok(()), + } + } + + /// Whether the painter may add and remove brushes here. + pub fn can_edit_members(&self) -> bool { + self.ensure_members_editable().is_ok() + } + + /// Whether the painter may rename, restyle or delete this pack. + pub fn can_edit_identity(&self) -> bool { + self.ensure_identity_editable().is_ok() + } + + pub fn contains(&self, brush: &str) -> bool { + self.members.iter().any(|m| m == brush) + } + + /// Add `brush` to the end of the member list. Idempotent — a brush already + /// present keeps its position, so re-adding it is not a reorder. + pub fn add(&mut self, brush: BrushId) -> Result<(), String> { + self.ensure_members_editable()?; + if !self.contains(&brush) { + self.members.push(brush); + } + Ok(()) + } + + /// Remove `brush`. Removing one that is not here is not an error: the + /// operation is convergent, so a retry after a partial write is safe. + pub fn remove(&mut self, brush: &str) -> Result<(), String> { + self.ensure_members_editable()?; + self.members.retain(|m| m != brush); + Ok(()) + } + + /// Move `brush` to `index` within the member list. + pub fn reorder(&mut self, brush: &str, index: usize) -> Result<(), String> { + self.ensure_members_editable()?; + let Some(from) = self.members.iter().position(|m| m == brush) else { + return Err(format!( + "brush pack '{}' does not contain that brush", + self.name + )); + }; + let member = self.members.remove(from); + let to = index.min(self.members.len()); + self.members.insert(to, member); + Ok(()) + } + + /// Drop members that no longer name a brush that exists. Returns whether + /// anything was dropped, so a caller can persist only when it must. + /// + /// Bypasses [`ensure_members_editable`] deliberately: this is not an edit + /// the painter asked for, it is the library refusing to point at a ghost. + pub fn retain_members(&mut self, exists: impl Fn(&str) -> bool) -> bool { + let before = self.members.len(); + self.members.retain(|m| exists(m)); + self.members.len() != before + } +} + +/// Accept `#rrggbb` or `#rrggbbaa`, hex digits only. +/// +/// Colors are validated on the way in rather than defaulted, because a pack +/// file may come from anywhere and a silently-black pack is worse than a +/// rejected one. +pub fn validate_color(value: &str, field: &str) -> Result<(), String> { + let Some(digits) = value.strip_prefix('#') else { + return Err(format!("{field} '{value}' must start with '#'")); + }; + if !matches!(digits.len(), 6 | 8) { + return Err(format!( + "{field} '{value}' must have 6 or 8 hex digits, got {}", + digits.len() + )); + } + if !digits.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("{field} '{value}' contains a non-hex digit")); + } + Ok(()) +} + +/// Accept a `collection:name` Iconify reference. +/// +/// Shape only. Whether the icon *renders* is the frontend's question, with a +/// frontend answer: an unbundled name falls back to +/// [`PACK_ICON_FALLBACK`](crate::brush::pack_icons::PACK_ICON_FALLBACK), which +/// is what lets a third-party pack degrade gracefully instead of showing a +/// hole. +pub fn validate_icon(value: &str) -> Result<(), String> { + match value.split_once(':') { + Some((collection, name)) if !collection.is_empty() && !name.is_empty() => Ok(()), + _ => Err(format!( + "pack icon '{value}' must be a `collection:name` Iconify reference" + )), + } +} + +/// Validate a pack the way an imported one must be: shape-checked colors and +/// icon, and a name that is actually a name. +pub fn validate_pack(name: &str, icon: &str, primary: &str, secondary: &str) -> Result<(), String> { + if name.trim().is_empty() { + return Err("a brush pack needs a name".into()); + } + validate_icon(icon)?; + validate_color(primary, "pack primary color")?; + validate_color(secondary, "pack secondary color")?; + Ok(()) +} + +/// Validate a *shipped* pack, which is held to the stricter rule that its icon +/// must be one the renderer actually has. +pub fn validate_shipped_pack(pack: &BrushPack) -> Result<(), String> { + validate_pack(&pack.name, &pack.icon, &pack.primary, &pack.secondary)?; + if !is_pack_icon(&pack.icon) { + return Err(format!( + "shipped pack '{}' names icon '{}', which is not in PACK_ICONS and would not render", + pack.id, pack.icon + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pack(mutability: PackMutability) -> BrushPack { + BrushPack { + id: "p".into(), + name: "P".into(), + description: String::new(), + icon: "mdi:brush".into(), + primary: "#ffffff".into(), + secondary: "#000000".into(), + mutability, + members: vec!["a".into(), "b".into()], + } + } + + #[test] + fn pack_mutability_permits_what_it_says() { + // One table over every variant × both gates, so a new variant has one + // place to declare itself rather than three tests to be forgotten in. + let cases = [ + (PackMutability::Locked, false, false), + (PackMutability::Full, true, true), + ]; + for (mutability, members, identity) in cases { + let p = pack(mutability); + assert_eq!( + p.can_edit_members(), + members, + "{mutability:?} member editability" + ); + assert_eq!( + p.can_edit_identity(), + identity, + "{mutability:?} identity editability" + ); + } + } + + #[test] + fn adding_a_member_twice_is_idempotent() { + let mut p = pack(PackMutability::Full); + p.add("c".into()).unwrap(); + p.add("c".into()).unwrap(); + assert_eq!(p.members, vec!["a", "b", "c"]); + + // Re-adding an existing member is not a reorder. + p.add("a".into()).unwrap(); + assert_eq!(p.members, vec!["a", "b", "c"]); + } + + #[test] + fn removing_an_absent_member_is_not_an_error() { + let mut p = pack(PackMutability::Full); + p.remove("nope").unwrap(); + assert_eq!(p.members, vec!["a", "b"]); + } + + #[test] + fn a_locked_pack_rejects_both_kinds_of_edit() { + let mut p = pack(PackMutability::Locked); + assert!(p.add("c".into()).is_err()); + assert!(p.remove("a").is_err()); + assert!(p.ensure_identity_editable().is_err()); + // The rejected edits changed nothing. + assert_eq!(p.members, vec!["a", "b"]); + } + + #[test] + fn reorder_moves_a_member_and_clamps_the_index() { + let mut p = pack(PackMutability::Full); + p.add("c".into()).unwrap(); + p.reorder("c", 0).unwrap(); + assert_eq!(p.members, vec!["c", "a", "b"]); + + p.reorder("c", 99).unwrap(); + assert_eq!(p.members, vec!["a", "b", "c"]); + + assert!(p.reorder("missing", 0).is_err()); + } + + #[test] + fn retain_members_drops_ghosts_and_reports_whether_it_did() { + let mut p = pack(PackMutability::Locked); + assert!(p.retain_members(|m| m != "b")); + assert_eq!(p.members, vec!["a"]); + // Nothing left to drop. + assert!(!p.retain_members(|_| true)); + } + + #[test] + fn malformed_pack_color_is_rejected() { + for bad in ["#xyz", "ff0000", "#ff00", "#gggggg", "", "#1234567"] { + assert!( + validate_color(bad, "c").is_err(), + "`{bad}` should be rejected" + ); + } + for good in ["#ff0000", "#ff0000aa", "#FFAA33"] { + assert!( + validate_color(good, "c").is_ok(), + "`{good}` should be accepted" + ); + } + } + + #[test] + fn pack_icon_must_be_collection_qualified() { + for bad in ["star", "", ":star", "fa6-solid:"] { + assert!(validate_icon(bad).is_err(), "`{bad}` should be rejected"); + } + assert!(validate_icon("fa6-solid:star").is_ok()); + // An icon the renderer lacks is still shape-valid — it falls back at + // render time rather than being rejected at import. + assert!(validate_icon("some-collection:nonexistent").is_ok()); + } + + #[test] + fn a_shipped_pack_must_name_a_renderable_icon() { + let mut p = pack(PackMutability::Locked); + assert!(validate_shipped_pack(&p).is_ok()); + p.icon = "some-collection:nonexistent".into(); + assert!(validate_shipped_pack(&p).is_err()); + } +} diff --git a/crates/darkly/src/brush/pack_file.rs b/crates/darkly/src/brush/pack_file.rs new file mode 100644 index 00000000..f0f472a7 --- /dev/null +++ b/crates/darkly/src/brush/pack_file.rs @@ -0,0 +1,365 @@ +//! The `.darkly-brush` archive — a brush pack, on disk and over the wire. +//! +//! There is one brush format and it is the pack. Exporting a single brush +//! produces a pack containing one brush, so there is one magic-byte case, one +//! importer, one writer, and one thing to explain to a painter. The extension +//! names a container rather than a count, the same way `.darkly` does for +//! layers. +//! +//! Layout: +//! ```text +//! pack.json — manifest: pack identity + the entry list +//! brushes/.json — one brush record per member, in member order +//! ``` +//! +//! Entry paths are keyed by brush id, which is opaque and filename-safe by +//! construction, so no name sanitizing or collision suffixing is needed here. + +use serde::{Deserialize, Serialize}; + +use crate::brush::metadata::BrushMetadata; +use crate::brush::pack::{validate_pack, BrushPack}; +use crate::format::unzip::unzip_entries; +use crate::format::zip_io::write_entries; + +/// Discriminates a pack archive from any other zip that reaches the importer. +pub const FORMAT_TAG: &str = "darkly-brush"; + +/// Archive schema version. +/// +/// A discriminator, not a migration hook — the same policy `CONFIG_VERSION` +/// states. Pre-release, a mismatch is rejected outright rather than upgraded. +pub const PACK_VERSION: u32 = 1; + +/// Zip entry path for the manifest. +const MANIFEST_PATH: &str = "pack.json"; + +/// Directory prefix for brush records inside the archive. +const BRUSH_DIR: &str = "brushes"; + +/// The manifest at the root of a pack archive. +#[derive(Clone, Debug, Serialize, Deserialize)] +struct PackManifest { + format: String, + version: u32, + name: String, + #[serde(default)] + description: String, + icon: String, + primary: String, + secondary: String, + #[serde(default)] + author: String, + /// Entry paths of the member brushes, **in the pack's member order**. + /// + /// Paths only: each brush's id and name live in its own record, and + /// repeating them here would be the same fact stored twice. The order is + /// the pack's own data and lives nowhere else in the archive. + brushes: Vec, +} + +/// A pack and its brushes, as an archive carries them. +/// +/// Deliberately not a [`BrushPack`]: an archive carries no id (the importer +/// always mints a fresh one) and no mutability (an imported pack is always the +/// painter's own, hence always `Full`). Writing either would invite a +/// hand-edited value the engine would have to distrust. +#[derive(Clone, Debug)] +pub struct PackFile { + pub name: String, + pub description: String, + pub icon: String, + pub primary: String, + pub secondary: String, + pub author: String, + /// Member brushes, in the pack's member order. + pub brushes: Vec, +} + +impl PackFile { + /// Build an archive payload from a pack and the brushes it names. + /// + /// `brushes` must already be in member order — the library resolves member + /// ids to records, and a member it cannot resolve is simply absent. + pub fn new(pack: &BrushPack, brushes: Vec) -> Self { + PackFile { + name: pack.name.clone(), + description: pack.description.clone(), + icon: pack.icon.clone(), + primary: pack.primary.clone(), + secondary: pack.secondary.clone(), + author: String::new(), + brushes, + } + } + + fn entry_path(id: &str) -> String { + format!("{BRUSH_DIR}/{id}.json") + } + + /// Serialize to `.darkly-brush` zip bytes. + pub fn to_bytes(&self) -> Result, String> { + validate_pack(&self.name, &self.icon, &self.primary, &self.secondary)?; + + let manifest = PackManifest { + format: FORMAT_TAG.to_string(), + version: PACK_VERSION, + name: self.name.clone(), + description: self.description.clone(), + icon: self.icon.clone(), + primary: self.primary.clone(), + secondary: self.secondary.clone(), + author: self.author.clone(), + brushes: self + .brushes + .iter() + .map(|b| Self::entry_path(&b.id)) + .collect(), + }; + + let manifest_json = serde_json::to_vec_pretty(&manifest) + .map_err(|e| format!("failed to serialize pack manifest: {e}"))?; + + // Own every buffer first: `write_entries` borrows, so the encoded + // records must outlive the entry list. + let records: Vec<(String, Vec)> = self + .brushes + .iter() + .map(|b| { + serde_json::to_vec_pretty(b) + .map(|json| (Self::entry_path(&b.id), json)) + .map_err(|e| format!("failed to serialize brush '{}': {e}", b.name)) + }) + .collect::>()?; + + let mut entries: Vec<(&str, &[u8])> = vec![(MANIFEST_PATH, &manifest_json)]; + entries.extend(records.iter().map(|(p, b)| (p.as_str(), b.as_slice()))); + + write_entries(&entries, zip::CompressionMethod::Deflated) + .map_err(|e| format!("failed to write pack archive: {e}")) + } + + /// Deserialize from `.darkly-brush` zip bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let entries = + unzip_entries(bytes).map_err(|e| format!("not a readable brush pack: {e}"))?; + + let manifest_bytes = entries + .get(MANIFEST_PATH) + .ok_or_else(|| format!("missing {MANIFEST_PATH} — not a brush pack"))?; + let manifest: PackManifest = serde_json::from_slice(manifest_bytes) + .map_err(|e| format!("invalid {MANIFEST_PATH}: {e}"))?; + + if manifest.format != FORMAT_TAG { + return Err(format!( + "'{}' is not a brush pack (format tag '{}')", + manifest.name, manifest.format + )); + } + if manifest.version != PACK_VERSION { + return Err(format!( + "brush pack '{}' is version {}, but this build reads version {PACK_VERSION}", + manifest.name, manifest.version + )); + } + + validate_pack( + &manifest.name, + &manifest.icon, + &manifest.primary, + &manifest.secondary, + )?; + + let mut brushes = Vec::with_capacity(manifest.brushes.len()); + for path in &manifest.brushes { + // A manifest naming an entry the archive does not hold is a + // truncated file — reject it rather than import half a pack. + let record = entries + .get(path) + .ok_or_else(|| format!("brush pack names '{path}', which the archive lacks"))?; + let metadata: BrushMetadata = serde_json::from_slice(record) + .map_err(|e| format!("invalid brush record '{path}': {e}"))?; + if metadata.id.trim().is_empty() { + return Err(format!("brush record '{path}' has no id")); + } + brushes.push(metadata); + } + + Ok(PackFile { + name: manifest.name, + description: manifest.description, + icon: manifest.icon, + primary: manifest.primary, + secondary: manifest.secondary, + author: manifest.author, + brushes, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::brush; + use crate::brush::pack::BrushPack; + use crate::format::zip_io::write_entries; + + fn pack() -> BrushPack { + let mut p = BrushPack::new("p1", "Watercolors", "mdi:water", "#3355ff", "#ffffff"); + p.description = "Wet pigment that pools and blends.".into(); + p.members = vec!["a".into(), "b".into()]; + p + } + + fn brushes() -> Vec { + vec![ + BrushMetadata::from_graph("a", "Rough Watercolor", brush::default_graph()), + BrushMetadata::from_graph("b", "Smooth Watercolor", brush::default_graph()), + ] + } + + #[test] + fn pack_round_trips_through_bytes() { + let file = PackFile::new(&pack(), brushes()); + let bytes = file.to_bytes().unwrap(); + let back = PackFile::from_bytes(&bytes).unwrap(); + + assert_eq!(back.name, "Watercolors"); + assert_eq!(back.description, "Wet pigment that pools and blends."); + assert_eq!(back.icon, "mdi:water"); + assert_eq!(back.primary, "#3355ff"); + assert_eq!(back.secondary, "#ffffff"); + + // Member order is the pack's own data and must survive. + let ids: Vec<&str> = back.brushes.iter().map(|b| b.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b"]); + assert_eq!(back.brushes[0].name, "Rough Watercolor"); + + // Every graph survives intact. + for (before, after) in file.brushes.iter().zip(&back.brushes) { + assert_eq!( + serde_json::to_value(&before.graph).unwrap(), + serde_json::to_value(&after.graph).unwrap(), + "graph for '{}'", + before.id + ); + } + } + + #[test] + fn pack_of_one_round_trips() { + // Exporting a single brush is a pack of one — the whole reason there + // is only one format. + let mut p = pack(); + p.members = vec!["a".into()]; + let one = vec![BrushMetadata::from_graph( + "a", + "Ink Pen", + brush::default_graph(), + )]; + let bytes = PackFile::new(&p, one).to_bytes().unwrap(); + + let back = PackFile::from_bytes(&bytes).unwrap(); + assert_eq!(back.brushes.len(), 1); + assert_eq!(back.brushes[0].name, "Ink Pen"); + } + + #[test] + fn an_empty_pack_round_trips() { + let mut p = pack(); + p.members.clear(); + let bytes = PackFile::new(&p, vec![]).to_bytes().unwrap(); + assert!(PackFile::from_bytes(&bytes).unwrap().brushes.is_empty()); + } + + #[test] + fn corrupt_zip_returns_error() { + let err = PackFile::from_bytes(b"not a zip at all").unwrap_err(); + assert!(err.contains("not a readable brush pack"), "got: {err}"); + } + + #[test] + fn missing_pack_json_returns_error() { + let bytes = write_entries( + &[("something-else.txt", b"hello")], + zip::CompressionMethod::Deflated, + ) + .unwrap(); + let err = PackFile::from_bytes(&bytes).unwrap_err(); + assert!(err.contains("missing pack.json"), "got: {err}"); + } + + /// Build an archive from a hand-written manifest, for the rejection cases. + fn archive_with_manifest(manifest: serde_json::Value) -> Vec { + let json = serde_json::to_vec(&manifest).unwrap(); + write_entries(&[(MANIFEST_PATH, &json)], zip::CompressionMethod::Deflated).unwrap() + } + + #[test] + fn version_mismatch_is_rejected() { + let bytes = archive_with_manifest(serde_json::json!({ + "format": FORMAT_TAG, "version": 2, "name": "Future", + "icon": "mdi:water", "primary": "#000000", "secondary": "#ffffff", + "brushes": [], + })); + let err = PackFile::from_bytes(&bytes).unwrap_err(); + assert!(err.contains("version 2"), "got: {err}"); + } + + #[test] + fn a_foreign_format_tag_is_rejected() { + // A `.darkly` document is also a zip; the tag is what tells them apart. + let bytes = archive_with_manifest(serde_json::json!({ + "format": "darkly-document", "version": 1, "name": "Doc", + "icon": "mdi:water", "primary": "#000000", "secondary": "#ffffff", + "brushes": [], + })); + let err = PackFile::from_bytes(&bytes).unwrap_err(); + assert!(err.contains("is not a brush pack"), "got: {err}"); + } + + #[test] + fn a_manifest_naming_a_missing_entry_is_rejected() { + let bytes = archive_with_manifest(serde_json::json!({ + "format": FORMAT_TAG, "version": PACK_VERSION, "name": "Truncated", + "icon": "mdi:water", "primary": "#000000", "secondary": "#ffffff", + "brushes": ["brushes/gone.json"], + })); + let err = PackFile::from_bytes(&bytes).unwrap_err(); + assert!(err.contains("which the archive lacks"), "got: {err}"); + } + + #[test] + fn a_malformed_manifest_color_is_rejected() { + let bytes = archive_with_manifest(serde_json::json!({ + "format": FORMAT_TAG, "version": PACK_VERSION, "name": "Bad", + "icon": "mdi:water", "primary": "not-a-color", "secondary": "#ffffff", + "brushes": [], + })); + assert!(PackFile::from_bytes(&bytes).is_err()); + } + + #[test] + fn an_unqualified_manifest_icon_is_rejected() { + let bytes = archive_with_manifest(serde_json::json!({ + "format": FORMAT_TAG, "version": PACK_VERSION, "name": "Bad", + "icon": "star", "primary": "#000000", "secondary": "#ffffff", + "brushes": [], + })); + assert!(PackFile::from_bytes(&bytes).is_err()); + } + + #[test] + fn an_icon_the_renderer_lacks_still_imports() { + // Shape is the format's business; renderability is the renderer's, and + // it falls back rather than showing a hole. A third-party pack must + // degrade, not fail. + let bytes = archive_with_manifest(serde_json::json!({ + "format": FORMAT_TAG, "version": PACK_VERSION, "name": "Exotic", + "icon": "some-collection:nonexistent", + "primary": "#000000", "secondary": "#ffffff", + "brushes": [], + })); + assert_eq!(PackFile::from_bytes(&bytes).unwrap().name, "Exotic"); + } +} diff --git a/crates/darkly/src/brush/pack_icons.rs b/crates/darkly/src/brush/pack_icons.rs new file mode 100644 index 00000000..f3feaa8d --- /dev/null +++ b/crates/darkly/src/brush/pack_icons.rs @@ -0,0 +1,76 @@ +//! The icons a brush pack may wear. +//! +//! Declared here as quoted string literals so the frontend's offline icon +//! bundle generator (`frontend/scripts/gen-icon-bundle.mjs`, which scans this +//! crate alongside the TypeScript and Svelte sources) picks them up. An +//! Iconify name that never appears as a literal in this repository is not in +//! the bundle, and the renderer has no network client to fall back on — it +//! would draw nothing at all. +//! +//! Shipped packs must name one of these, which a test in +//! [`crate::brush::packs`] enforces. The pack editor offers exactly this list, +//! so a painter cannot pick an icon that will not render either. + +/// `(iconify name, display label)` — the shape the icon-picker widget already +/// consumes, so exposing this list needs no second representation. +pub const PACK_ICONS: &[(&str, &str)] = &[ + ("mdi:brush", "Brush"), + ("mdi:pencil", "Pencil"), + ("mdi:water", "Water"), + ("mdi:blur", "Blur"), + ("mdi:spray", "Spray"), + ("mdi:fountain-pen-tip", "Pen"), + ("mdi:eraser", "Eraser"), + ("mdi:palette", "Palette"), + ("mdi:leaf", "Leaf"), + ("mdi:fire", "Fire"), + ("mdi:snowflake", "Snowflake"), + ("mdi:weather-cloudy", "Cloud"), + ("mdi:shimmer", "Shimmer"), + ("mdi:diamond-stone", "Gem"), + ("mdi:dots-horizontal", "Dots"), + ("mdi:grain", "Grain"), + ("mdi:texture-box", "Texture"), + ("mdi:vector-curve", "Curve"), + ("mdi:shape", "Shape"), + ("mdi:image-filter-vintage", "Vintage"), + ("fa6-solid:star", "Star"), + ("fa6-solid:heart", "Heart"), + ("fa6-solid:flask", "Flask"), + ("fa6-solid:folder", "Folder"), +]; + +/// Drawn in place of an icon the renderer does not have — a pack that arrived +/// in an archive may name anything at all. Must itself be in [`PACK_ICONS`]. +pub const PACK_ICON_FALLBACK: &str = "fa6-solid:folder"; + +/// Whether `name` is an icon a pack may wear. +pub fn is_pack_icon(name: &str) -> bool { + PACK_ICONS.iter().any(|(n, _)| *n == name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_fallback_is_itself_a_pack_icon() { + // Otherwise the glyph drawn when an icon is missing would itself be + // missing. + assert!(is_pack_icon(PACK_ICON_FALLBACK)); + } + + #[test] + fn pack_icons_are_collection_qualified_and_unique() { + let mut seen: Vec<&str> = Vec::new(); + for (name, label) in PACK_ICONS { + assert!( + name.contains(':'), + "`{name}` is not `collection:name` qualified" + ); + assert!(!label.is_empty(), "`{name}` has no label"); + assert!(!seen.contains(name), "`{name}` is listed twice"); + seen.push(name); + } + } +} diff --git a/crates/darkly/src/brush/packs.rs b/crates/darkly/src/brush/packs.rs new file mode 100644 index 00000000..f632c3be --- /dev/null +++ b/crates/darkly/src/brush/packs.rs @@ -0,0 +1,208 @@ +//! Built-in brush packs shipped with the application. +//! +//! Each pack is a YAML file under `crates/darkly/packs/` naming its display +//! identity and the brushes it holds. The build script +//! (`crates/darkly/build.rs`) embeds every `.yaml` in that directory at +//! compile time — adding a new pack is "drop a file, no code changes", the +//! same shape [`crate::brush::builtin_brushes`] already uses. +//! +//! A pack's id is its file stem and is not written inside the file: one fact, +//! one home. + +use std::sync::OnceLock; + +use serde::Deserialize; + +use crate::brush::pack::{validate_shipped_pack, BrushId, BrushPack, PackMutability}; +use crate::catalog::{Catalog, CatalogEntry}; + +// `BUILTIN_PACKS_YAML: &[(filename, yaml_source)]` — generated by +// `crates/darkly/build.rs` from `crates/darkly/packs/*.yaml`. +include!(concat!(env!("OUT_DIR"), "/builtin_packs_gen.rs")); + +/// A shipped pack as written on disk. The id is absent — it is the file stem. +#[derive(Deserialize)] +struct PackYaml { + name: String, + #[serde(default)] + description: String, + icon: String, + primary: String, + secondary: String, + #[serde(default)] + members: Vec, +} + +/// Every built-in pack, in the order `build.rs` emitted them. +/// +/// Parse and validation failures panic — a shipped pack failing to load is a +/// build-time bug in data we control, not a runtime error a caller can +/// recover from. This is the reasoning `builtin_brushes::parsed` states. +fn parsed() -> Vec { + BUILTIN_PACKS_YAML + .iter() + .map(|(filename, yaml)| { + let stem = filename + .strip_suffix(".yaml") + .expect("build.rs names every embedded pack `.yaml`"); + let y: PackYaml = serde_yaml_ng::from_str(yaml) + .unwrap_or_else(|e| panic!("invalid built-in pack '{filename}': {e}")); + let pack = BrushPack { + id: stem.to_string(), + name: y.name, + description: y.description, + icon: y.icon, + primary: y.primary, + secondary: y.secondary, + // A shipped pack is rebuilt from this YAML on every boot, so + // it can never hold an edit. `shipped_packs_are_locked` is the + // gate on that; there is deliberately no knob here. + mutability: PackMutability::Locked, + members: y.members, + }; + validate_shipped_pack(&pack) + .unwrap_or_else(|e| panic!("invalid built-in pack '{filename}': {e}")); + pack + }) + .collect() +} + +/// All built-in packs, parsed from their YAML sources. +pub fn all() -> Vec { + parsed() +} + +/// The shipped packs, parsed once for the process. +/// +/// Every pack is embedded at compile time, so the set is fixed and a +/// process-lifetime cache is the data's real lifetime rather than an +/// approximation of it — and it is what lets a [`CatalogEntry`]'s +/// `&'static str` fields borrow strings that arrived as owned YAML values. +pub fn docs() -> &'static [BrushPack] { + static DOCS: OnceLock> = OnceLock::new(); + DOCS.get_or_init(parsed).as_slice() +} + +/// Id of the catalog the shipped packs project into. +pub const CATALOG_ID: &str = "brushPacks"; + +/// The first shipped pack, in declared order, that lists `brush`. +/// +/// The brushes catalog's grouping axis is derived through this rather than +/// stored on the brush: membership lives on the pack, and a brush may be in +/// several, so there is one stored fact projected into the export rather than +/// two facts to keep in agreement. +pub fn pack_of(brush: &str) -> Option<&'static BrushPack> { + docs().iter().find(|p| p.contains(brush)) +} + +/// The brush-pack catalog — every shipped pack, in declared order. +/// +/// `type_id` is the YAML file stem, which is also the pack id, matching every +/// other catalog's snake_case type ids. +pub fn catalog() -> Catalog { + Catalog::new( + CATALOG_ID, + "Brush Packs", + docs() + .iter() + .map(|pack| { + CatalogEntry::new(pack.id.as_str(), pack.name.as_str()) + .with_description(pack.description.as_str()) + .with_icon(pack.icon.as_str()) + // A pack has nothing to render: it is a grouping, and its + // brushes carry their own previews. + .with_supports_preview(false) + }) + .collect(), + ) + .with_description( + "The brush packs Darkly ships with. A pack groups brushes by the medium \ + they imitate; a brush may appear in several, and copying one into a pack \ + never removes it from another.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_shipped_pack_parses() { + let packs = all(); + assert_eq!( + packs.len(), + BUILTIN_PACKS_YAML.len(), + "one pack per shipped YAML file" + ); + assert!(!packs.is_empty(), "no shipped packs found"); + for pack in &packs { + assert!(!pack.id.is_empty(), "a shipped pack has an empty id"); + assert!(!pack.name.is_empty(), "`{}` has no name", pack.id); + assert!( + !pack.description.is_empty(), + "`{}` has no description — it is published in metadata.json", + pack.id + ); + } + } + + #[test] + fn shipped_pack_ids_are_unique() { + let mut seen: Vec<&str> = Vec::new(); + for pack in docs() { + assert!( + !seen.contains(&pack.id.as_str()), + "`{}` is declared twice", + pack.id + ); + seen.push(&pack.id); + } + } + + #[test] + fn shipped_packs_are_locked() { + // The immutability rule stated once, in data. A shipped pack is + // curation rebuilt from YAML on every boot, so it cannot hold an edit; + // anything the painter is meant to change is a pack they own. + for pack in docs() { + assert_eq!( + pack.mutability, + PackMutability::Locked, + "`{}` mutability", + pack.id + ); + } + } + + #[test] + fn every_shipped_pack_icon_is_a_pack_icon() { + // What makes a shipped pack's icon render at all: the offline icon + // bundle is scraped from string literals in this crate, so an icon + // named only in YAML would draw nothing. + for pack in docs() { + assert!( + crate::brush::pack_icons::is_pack_icon(&pack.icon), + "`{}` names icon `{}`, which is not in PACK_ICONS", + pack.id, + pack.icon + ); + } + } + + #[test] + fn the_pack_catalog_covers_every_shipped_pack() { + let catalog = catalog(); + let ids: Vec<&str> = catalog.entries.iter().map(|e| e.type_id).collect(); + let stems: Vec<&str> = docs().iter().map(|p| p.id.as_str()).collect(); + assert_eq!(ids, stems, "catalog entries must match the shipped packs"); + } + + #[test] + fn pack_of_finds_the_first_declaring_pack() { + // The derivation the brushes catalog's grouping axis rests on. + let pack = pack_of("charcoal").expect("charcoal ships in a pack"); + assert_eq!(pack.id, "dry_media"); + assert!(pack_of("no_such_brush").is_none()); + } +} diff --git a/crates/darkly/src/brush/paint_info.rs b/crates/darkly/src/brush/paint_info.rs index 637407d7..b0de0b8a 100644 --- a/crates/darkly/src/brush/paint_info.rs +++ b/crates/darkly/src/brush/paint_info.rs @@ -36,7 +36,12 @@ pub struct PaintInformation { /// Cumulative distance travelled in pixels (not normalised — used for /// spacing calculations, normalised on demand by sensor nodes). pub distance: f32, - /// Drawing angle in radians (direction of pen travel, 0 = right). + /// Drawing angle in radians (0 = right). `derive_sensors` computes the + /// directed angle of pen travel; for dabs emitted through + /// `StrokeEngine::place_dab` that value is then replaced by the stamp + /// orientation — the stroke's undirected axis, approached no faster than + /// the brush's turn rate. Paths with no stroke engine behind them (the + /// hover preview) keep the raw directed value. pub drawing_angle: f32, /// Per-dab motion vector in canvas pixels — the position delta from the /// previous *emitted dab* into this one. Populated by the stroke engine diff --git a/crates/darkly/src/brush/portable.rs b/crates/darkly/src/brush/portable.rs index 1b191067..addf800a 100644 --- a/crates/darkly/src/brush/portable.rs +++ b/crates/darkly/src/brush/portable.rs @@ -36,8 +36,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::brush::bundle::{Brush, BrushMetadata}; use crate::brush::input_value::InputValue; +use crate::brush::metadata::{Brush, BrushMetadata}; use crate::brush::stabilizer::StabilizerConfig; use crate::brush::wire::BrushWireType; use crate::brush::BrushNodeRegistry; @@ -54,8 +54,6 @@ pub struct PortableBrush { #[serde(default, skip_serializing_if = "String::is_empty")] pub name: String, #[serde(default, skip_serializing_if = "String::is_empty")] - pub category: String, - #[serde(default, skip_serializing_if = "String::is_empty")] pub description: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub author: String, @@ -173,7 +171,6 @@ impl PortableBrush { .then(|| brush.metadata.stabilizer.clone()); Ok(Self { name: brush.metadata.name.clone(), - category: brush.metadata.category.clone(), description: brush.metadata.description.clone(), author: brush.metadata.author.clone(), tags: brush.metadata.tags.clone(), @@ -279,13 +276,18 @@ impl PortableBrush { }) } - /// Materialize a full `Brush` from the portable form. Re-derives port - /// shapes from the registration and validates the graph compiles. - pub fn into_brush(self, registry: &BrushNodeRegistry) -> Result { + /// Materialize a full `Brush` from the portable form under the identity + /// `id`. Re-derives port shapes from the registration and validates the + /// graph compiles. + /// + /// The id is the caller's to supply: the portable form is a graph plus + /// describing metadata, and which brush it *is* depends on where it came + /// from — a shipped brush's file stem, or a minted id for one the painter + /// saved. + pub fn into_brush(self, registry: &BrushNodeRegistry, id: &str) -> Result { let graph = self.graph_from_nodes(registry)?; crate::brush::compile_graph(&graph)?; - let mut metadata = BrushMetadata::from_graph(self.name, graph); - metadata.category = self.category; + let mut metadata = BrushMetadata::from_graph(id, self.name, graph); metadata.description = self.description; metadata.author = self.author; metadata.tags = self.tags; @@ -682,6 +684,7 @@ nodes: {} fn stabilizer_round_trip_and_elision() { let registry = registry(); let mut brush = Brush::from_metadata(BrushMetadata::from_graph( + "test", "Test", crate::brush::default_graph(), )); @@ -702,7 +705,7 @@ nodes: {} let portable = PortableBrush::from_brush(&brush, registry).unwrap(); let yaml = serde_yaml_ng::to_string(&portable).unwrap(); let parsed: PortableBrush = serde_yaml_ng::from_str(&yaml).unwrap(); - let restored = parsed.into_brush(registry).unwrap(); + let restored = parsed.into_brush(registry, "test").unwrap(); assert_eq!(restored.metadata.stabilizer.algorithm, "laplacian"); assert_eq!(restored.metadata.stabilizer.params.len(), 1); } diff --git a/crates/darkly/src/brush/preview_renderer.rs b/crates/darkly/src/brush/preview_renderer.rs index 54b0ede9..2aca902a 100644 --- a/crates/darkly/src/brush/preview_renderer.rs +++ b/crates/darkly/src/brush/preview_renderer.rs @@ -212,6 +212,7 @@ impl BrushStrokePreviewRenderer { Box::new(PassThrough::new()), clone_source_anchor, PREVIEW_STROKE_SEED, + brush_settings::stamp_angle_rate(graph), ); if clone_source_anchor.is_some() { // The snapshot being sampled is the pre-stroke, which covers the diff --git a/crates/darkly/src/brush/read_mirror_terminal.rs b/crates/darkly/src/brush/read_mirror_terminal.rs index 92c29ad3..3a49a173 100644 --- a/crates/darkly/src/brush/read_mirror_terminal.rs +++ b/crates/darkly/src/brush/read_mirror_terminal.rs @@ -405,31 +405,15 @@ pub fn evaluate_gpu( // clamp). let layer_x0 = canvas_ext.x0() as f32; let layer_y0 = canvas_ext.y0() as f32; - // Clamp the dab footprint to the layer extent; a dab entirely - // off-extent has no pixels to draw and is skipped. - let canvas_bbox = match canvas_ext.clamp_f32( - position[0] - bbox_radius, - position[1] - bbox_radius, - position[0] + bbox_radius, - position[1] + bbox_radius, - ) { - Some(r) => r, - None => return dab_size(), - }; - let local = paint_target - .canvas_frame() - .canvas_to_layer_rect(canvas_bbox) - .expect("canvas_bbox came from canvas_ext.clamp_f32, so it overlaps the extent"); - gpu.dab_batch.push_write_bbox(canvas_bbox); - gpu.dab_batch.bbox = Some(match gpu.dab_batch.bbox { - Some([x0, y0, x1, y1]) => [ - x0.min(local.x0()), - y0.min(local.y0()), - x1.max(local.x1()), - y1.max(local.y1()), - ], - None => [local.x0(), local.y0(), local.x1(), local.y1()], - }); + // Publish the footprint; `None` means the dab is entirely off-extent + // and has no pixels to draw. + if gpu + .dab_batch + .record_dab_footprint(paint_target, position, bbox_radius) + .is_none() + { + return dab_size(); + } // The write region is the dab footprint; the read region is the // mirror snapshot. Clamp the read half up to at least the write half @@ -486,9 +470,7 @@ pub fn flush_dabs(gpu: &mut BrushGpuContext) { return; }; - let bbox = gpu.dab_batch.bbox.unwrap_or([0, 0, 0, 0]); - let union_w = bbox[2].saturating_sub(bbox[0]); - let union_h = bbox[3].saturating_sub(bbox[1]); + let (union_w, union_h) = gpu.dab_batch.batch_extent(); let (dab_bytes, total_dabs) = gpu.dab_batch.take(); let meta_bytes = gpu.dab_batch.take_meta(); if total_dabs == 0 { diff --git a/crates/darkly/src/brush/save_points.rs b/crates/darkly/src/brush/save_points.rs index 026448ba..eb76ea3b 100644 --- a/crates/darkly/src/brush/save_points.rs +++ b/crates/darkly/src/brush/save_points.rs @@ -117,6 +117,7 @@ mod tests { last_dab_size: [10.0, 10.0], last_dab_pos: None, dab_count: 0, + stamp_angle: None, } } diff --git a/crates/darkly/src/brush/stroke_engine.rs b/crates/darkly/src/brush/stroke_engine.rs index 7b9e2b65..5537ae84 100644 --- a/crates/darkly/src/brush/stroke_engine.rs +++ b/crates/darkly/src/brush/stroke_engine.rs @@ -29,6 +29,7 @@ pub struct RenderCheckpoint { pub last_dab_size: [f32; 2], pub last_dab_pos: Option<[f32; 2]>, pub dab_count: u32, + pub stamp_angle: Option, } /// Reference fade distance in pixels. The fade sensor goes from 0 to 1 @@ -67,6 +68,16 @@ pub struct StrokeEngine { /// Running dab index within the stroke. dab_count: u32, + /// Held stamp orientation (canvas-frame radians) — the stroke axis the + /// dab is currently facing, as opposed to the instantaneous travel + /// direction. `None` until the first dab that has actually travelled. + /// Reset at stroke start and on full re-render; carried across a partial + /// re-render on [`RenderCheckpoint`] so the seam is continuous. + stamp_angle: Option, + /// How far `stamp_angle` may turn per brush diameter of travel (radians). + /// Stroke-constant, read from `brush_settings` at stroke start. + stamp_angle_rate: f32, + /// Stroke seed for deterministic per-dab randomness. Passed to /// the runner so random nodes can generate independent sequences. stroke_seed: u32, @@ -94,10 +105,11 @@ impl StrokeEngine { /// /// `runner` is a pre-compiled brush graph. `color` is the foreground /// color (raw sRGB RGBA, as picked). `spacing` controls dab placement. - /// `stabilizer` is the stroke stabilization algorithm. `stroke_seed` - /// drives every `random`/`noise` node in the graph — a real stroke passes - /// [`Self::random_seed`], a render that has to be reproducible passes a - /// constant. + /// `stabilizer` is the stroke stabilization algorithm. `stamp_angle_rate` + /// caps how fast the stamp pivots to follow the stroke, in radians per + /// brush diameter of travel. `stroke_seed` drives every `random`/`noise` + /// node in the graph — a real stroke passes [`Self::random_seed`], a render + /// that has to be reproducible passes a constant. pub fn new( mut runner: BrushGraphRunner, color: [f32; 4], @@ -106,6 +118,7 @@ impl StrokeEngine { stabilizer: Box, clone_source_anchor: Option<[f32; 2]>, stroke_seed: u32, + stamp_angle_rate: f32, ) -> Self { // Base brush size is stroke-constant, read out-of-band from // `pen_input.size` at stroke start. Injected as ambient state so every @@ -136,6 +149,8 @@ impl StrokeEngine { last_dab_size: [d, d], last_dab_pos: None, dab_count: 0, + stamp_angle: None, + stamp_angle_rate, stroke_seed, clone_source_anchor, clone_dest_anchor: None, @@ -208,6 +223,7 @@ impl StrokeEngine { last_dab_size: self.last_dab_size, last_dab_pos: self.last_dab_pos, dab_count: self.dab_count, + stamp_angle: self.stamp_angle, } } @@ -219,6 +235,7 @@ impl StrokeEngine { self.last_dab_size = checkpoint.last_dab_size; self.last_dab_pos = checkpoint.last_dab_pos; self.dab_count = checkpoint.dab_count; + self.stamp_angle = checkpoint.stamp_angle; } /// Reset rendering state for a full re-render from scratch. @@ -233,6 +250,9 @@ impl StrokeEngine { self.last_dab_size = [d, d]; self.last_dab_pos = None; self.dab_count = 0; + // Re-seeded from the first travelling dab of the re-render. The rate + // itself is stroke-constant configuration and survives. + self.stamp_angle = None; // Recapture the destination anchor from the re-stabilized first // dab on the next `place_dab`. self.clone_dest_anchor = None; @@ -248,6 +268,20 @@ impl StrokeEngine { advance_dab_motion(&mut self.last_dab_pos, pos) } + /// Advance the held stamp orientation for a dab travelling `travel` canvas + /// pixels in direction `direction`, at brush diameter `diameter`. Thin + /// wrapper over the free function, mirroring [`Self::next_dab_motion`], so + /// the orientation contract is unit-testable without a GPU. + fn next_stamp_angle(&mut self, direction: f32, travel: f32, diameter: f32) -> f32 { + advance_stamp_angle( + &mut self.stamp_angle, + direction, + travel, + diameter, + self.stamp_angle_rate, + ) + } + /// Render dabs along the stabilized polyline starting from `start_vector_index`. /// /// Used for partial re-render after checkpoint restoration. Walks the @@ -413,6 +447,14 @@ impl StrokeEngine { // Interpolators leave it zero (they have no view of dab order); we // fill it here so smudge sees the correct smear-sample offset. dab_info.motion = self.next_dab_motion(dab_info.pos); + // Stamp orientation is likewise per-dab and order-dependent: the stamp + // pivots as the brush travels, toward the stroke's undirected axis and + // no faster than the brush's turn rate. Runs after interpolation (the + // caller interpolates before every `place_dab`), so it is the last + // transform on the angle before the graph sees it. + let travel = dab_info.motion[0].hypot(dab_info.motion[1]); + let diameter = self.effective_diameter(); + dab_info.drawing_angle = self.next_stamp_angle(dab_info.drawing_angle, travel, diameter); // Clone uniforms: capture the destination at the first rendered // dab (post-stabilization), then seed the runner's CloneState so @@ -460,6 +502,10 @@ impl StrokeEngine { // Reset the write-bbox accumulator so each terminal's passes can // publish their footprint fresh. Read back after execute_gpu below. gpu.dab_batch.write_canvas_bbox = None; + // Queue depth before the terminal runs — a dab that lands in the + // queue but publishes no footprint is a programming error, caught + // by the debug-assert below. + let queued_before = gpu.dab_batch.count; self.runner.execute_gpu(gpu); gpu.flush_if_needed(); @@ -473,25 +519,27 @@ impl StrokeEngine { self.last_dab_size = size; } - // Dab bounding box for save points, in canvas coords. Prefer the - // footprint the terminal actually wrote (post-scatter, post-anything - // else the graph did). Fall back to the `info.pos ± radius` - // envelope for graphs without a scratch-writing terminal, so they - // still get sensible checkpoint bounds. - let canvas_bbox = gpu.dab_batch.write_canvas_bbox.unwrap_or_else(|| { - let diameter = self.effective_diameter(); - let half = diameter * 0.5; - let x = (info.pos[0] - half).floor() as i32; - let y = (info.pos[1] - half).floor() as i32; - let x2 = (info.pos[0] + half).ceil() as i32; - let y2 = (info.pos[1] + half).ceil() as i32; - crate::coord::CanvasRect::from_xywh( - x, - y, - (x2 - x).max(0) as u32, - (y2 - y).max(0) as u32, - ) - }); + // Dab bounding box for save points, in canvas coords: the footprint + // the terminal published for the pass it issued (post-scatter, + // post-anything else the graph did). A dab that wrote nothing — zero + // diameter, entirely off-extent, an identity-transform early-out — + // publishes nothing and records an empty rect, which unions away. + // + // There is deliberately no geometric fallback here. An envelope + // derived from `pos ± radius` omits the compiled brush's extent + // inflation, so it can bound the checkpoint more tightly than the + // shader writes — and a rewind then clears pixels it cannot restore. + // See `ExtentContribution`'s doc comment for the shipped instance of + // that bug. + let canvas_bbox = gpu + .dab_batch + .write_canvas_bbox + .unwrap_or(crate::coord::CanvasRect::from_xywh(0, 0, 0, 0)); + debug_assert!( + gpu.dab_batch.count == queued_before || !canvas_bbox.is_empty(), + "terminal queued a dab without publishing its write footprint; \ + the save-point bbox would miss pixels the shader writes", + ); // Render state is captured at end-of-segment, not per-dab. // Push a placeholder; the loop in render_from_stabilized_range // overwrites the last save point's render_state after each segment. @@ -505,6 +553,7 @@ impl StrokeEngine { last_dab_size: [0.0, 0.0], last_dab_pos: None, dab_count: 0, + stamp_angle: None, }, ); @@ -628,9 +677,72 @@ fn advance_dab_motion(tracker: &mut Option<[f32; 2]>, pos: [f32; 2]) -> [f32; 2] motion } +/// Advance the held stamp orientation one dab and return what the dab should +/// face. +/// +/// `held` carries the orientation from the previous emitted dab. It is `None` +/// at stroke start and after a full re-render; while it is `None` and `travel` +/// is zero the direction passes through untouched and nothing is adopted, +/// because a dab that has not travelled has no measured direction to adopt +/// (`PaintInformation::derive_sensors` leaves a stroke's first `drawing_angle` +/// at its default of zero). The first dab that has travelled seeds `held`. +/// +/// `direction` is the dab's signed travel angle, `travel` the canvas-pixel +/// distance from the previous dab, `diameter` the brush's effective canvas +/// diameter, and `rate` the permitted turn in radians per diameter of travel — +/// or [`STAMP_ANGLE_RATE_UNLIMITED`], at which the cap is skipped entirely and +/// only the fold applies. +/// +/// The axis fold — taking whichever of `direction` / `direction + π` is nearer +/// to the held orientation — is unconditional. A symmetric stamp is identical +/// at both, so reversing along a stroke must not spin it a half turn. +/// +/// [`STAMP_ANGLE_RATE_UNLIMITED`]: crate::brush::nodes::brush_settings::STAMP_ANGLE_RATE_UNLIMITED +fn advance_stamp_angle( + held: &mut Option, + direction: f32, + travel: f32, + diameter: f32, + rate: f32, +) -> f32 { + use crate::brush::interpolation::shortest_angle_diff; + use crate::brush::nodes::brush_settings::STAMP_ANGLE_RATE_UNLIMITED; + use std::f32::consts::{FRAC_PI_2, PI}; + + let Some(phi) = *held else { + if travel <= 0.0 { + return direction; + } + *held = Some(direction); + return direction; + }; + + // Fold to the nearer of the two representatives of the same axis, so a + // direction reversal costs no rotation at all. + let mut d = shortest_angle_diff(phi, direction); + if d.abs() > FRAC_PI_2 { + d -= d.signum() * PI; + } + + // A turn rate per unit of travel: zero travel permits zero rotation, so a + // stationary pen cannot spin the stamp. `max(diameter, 1.0)` keeps the + // division defined if a terminal ever publishes a degenerate dab size. + if rate < STAMP_ANGLE_RATE_UNLIMITED { + let allowed = rate * travel / diameter.max(1.0); + d = d.clamp(-allowed, allowed); + } + + // Wrapped each dab so a long stroke can't drift the magnitude upward; the + // value only ever reaches `cos`/`sin` downstream, so this is invisible. + let next = shortest_angle_diff(0.0, phi + d); + *held = Some(next); + next +} + #[cfg(test)] mod tests { use super::*; + use crate::brush::interpolation::shortest_angle_diff; /// Regression: per-dab motion must be the previous-dab → this-dab delta, /// not the segment delta. The old bug carried `PaintInformation.motion` @@ -673,4 +785,216 @@ mod tests { let m = advance_dab_motion(&mut tracker, [13.0, 24.0]); assert!((m[0] - 3.0).abs() < 1e-6 && (m[1] - 4.0).abs() < 1e-6); } + + // ── Stamp orientation tracker ─────────────────────────────────────── + + /// Diameter and per-dab travel used by the orientation tests: a 40 px + /// brush stepping 4 px per dab, i.e. the default 10% spacing. + const D: f32 = 40.0; + const STEP: f32 = 4.0; + + /// Rate that permits a quarter turn per dab at the constants above, so a + /// test that wants the cap out of the way can say so without reaching for + /// the sentinel. + const LOOSE_RATE: f32 = std::f32::consts::FRAC_PI_2 * D / STEP; + + fn feed(held: &mut Option, direction: f32, rate: f32) -> f32 { + advance_stamp_angle(held, direction, STEP, D, rate) + } + + /// The stroke axis is undirected: reversing direction must not spin a + /// symmetric stamp a half turn. This is the fold, and it holds at any rate. + #[test] + fn reversal_folds_to_axis_without_half_turn() { + use std::f32::consts::{FRAC_PI_2, PI}; + let mut held = None; + for _ in 0..5 { + feed(&mut held, 0.0, LOOSE_RATE); + } + let before = held.unwrap(); + + let after = feed(&mut held, PI, LOOSE_RATE); + assert!( + shortest_angle_diff(before, after).abs() <= FRAC_PI_2 + 1e-5, + "a reversal must not rotate the stamp more than a quarter turn; \ + went from {before} to {after}" + ); + assert!( + after.abs() < 1e-4, + "after reversing, the stamp should still lie on the original axis \ + (near 0), not near π; got {after}" + ); + } + + /// The load-bearing invariant: the cap is per unit of *travel*, so the + /// same geometric turn over the same total distance ends at the same + /// orientation no matter how finely it is subdivided. A per-dab cap fails + /// this by the ratio of the two spacings. + #[test] + fn rate_is_per_diameter_of_travel_not_per_dab() { + // A rate tight enough that the cap is the binding constraint in both + // runs: a quarter turn demanded immediately, far more than allowed. + let rate = 0.5; + let target = std::f32::consts::FRAC_PI_2; + + let mut coarse = Some(0.0); + for _ in 0..10 { + advance_stamp_angle(&mut coarse, target, 0.1 * D, D, rate); + } + + let mut fine = Some(0.0); + for _ in 0..20 { + advance_stamp_angle(&mut fine, target, 0.05 * D, D, rate); + } + + // Both travelled 1.0 × D in total. + let (a, b) = (coarse.unwrap(), fine.unwrap()); + assert!( + (a - b).abs() < 1e-4, + "equal total travel must give equal orientation regardless of dab \ + subdivision; coarse={a}, fine={b} (a per-dab cap would differ by ~2x)" + ); + assert!( + (a - rate).abs() < 1e-4, + "after 1.0 diameters of travel at {rate} rad/diameter the stamp \ + should have turned {rate} rad; got {a}" + ); + } + + /// A cap is a cap, not a smoothing filter: turns comfortably inside the + /// budget are tracked exactly, with no lag. This is the deliberate + /// divergence from GIMP's unconditional EMA. + #[test] + fn gentle_curve_tracks_without_lag() { + let mut held = Some(0.0); + // 1° per dab, against a budget of 5.7° per dab at this rate. + let per_dab = 1.0_f32.to_radians(); + for i in 1..=30 { + let target = per_dab * i as f32; + let got = advance_stamp_angle(&mut held, target, STEP, D, 1.0); + assert!( + (got - target).abs() < 1e-5, + "dab {i}: a turn inside the rate budget must track exactly; \ + wanted {target}, got {got}" + ); + } + } + + /// Zero travel permits zero rotation whenever the cap is engaged — a + /// stationary pen cannot make the stamp twitch. This is what lets the rate + /// cap subsume a separate idle-noise filter. + /// + /// It is a property of the cap, not of the tracker: at the unlimited + /// sentinel there is no cap to enforce it, and a stationary dab takes its + /// angle directly, exactly as it did before the rate limit existed. + #[test] + fn zero_travel_cannot_rotate() { + use crate::brush::nodes::brush_settings::STAMP_ANGLE_RATE_UNLIMITED; + + for rate in [0.0, 0.5, STAMP_ANGLE_RATE_UNLIMITED - 1.0] { + let mut held = Some(0.0); + for target in [0.3, -0.7, 1.2, 0.05] { + let got = advance_stamp_angle(&mut held, target, 0.0, D, rate); + assert_eq!( + got, 0.0, + "rate {rate}: a dab that has not travelled must not rotate \ + the stamp" + ); + } + } + } + + /// The bottom of the range locks the stamp to the angle it started at. + #[test] + fn zero_rate_locks_orientation() { + let mut held = None; + let start = feed(&mut held, 0.4, 0.0); + assert!((start - 0.4).abs() < 1e-6); + for target in [1.0, -1.0, 2.5] { + let got = feed(&mut held, target, 0.0); + assert!( + (got - 0.4).abs() < 1e-6, + "rate 0 must freeze the orientation; got {got}" + ); + } + } + + /// The top of the range is a sentinel meaning *unlimited*, and it is the + /// shipped default — so this guards the promise that a brush which never + /// touches the knob is unaffected by the rate limit. + #[test] + fn unlimited_rate_skips_the_cap() { + use crate::brush::nodes::brush_settings::STAMP_ANGLE_RATE_UNLIMITED; + let mut held = None; + feed(&mut held, 0.0, STAMP_ANGLE_RATE_UNLIMITED); + + // A near-quarter-turn demanded over a sliver of travel: any finite rate + // at this travel would clamp it hard. + let got = advance_stamp_angle(&mut held, 1.5, 0.001, D, STAMP_ANGLE_RATE_UNLIMITED); + assert!( + (got - 1.5).abs() < 1e-5, + "at the unlimited sentinel the stamp must reach the folded target \ + in one dab; got {got}" + ); + } + + /// A stroke's first point has no segment behind it, so `derive_sensors` + /// leaves its `drawing_angle` at the default 0 — see + /// `tests/paint_info_derive_sensors.rs`. Adopting that would point every + /// stroke rightward at birth and then rate-limit the recovery. + #[test] + fn stroke_start_does_not_adopt_zero() { + use std::f32::consts::FRAC_PI_2; + let mut held = None; + + // The stroke's first dab: no travel, and a meaningless angle. + let first = advance_stamp_angle(&mut held, 0.0, 0.0, D, 0.5); + assert_eq!(first, 0.0, "the first dab passes its angle through"); + assert!( + held.is_none(), + "nothing should be adopted from a dab that has not travelled" + ); + + // The first travelling dab establishes the axis outright, with no + // rate-limited crawl up from 0. + let second = advance_stamp_angle(&mut held, FRAC_PI_2, STEP, D, 0.5); + assert!( + (second - FRAC_PI_2).abs() < 1e-6, + "the first travelling dab should adopt its direction, not ease \ + toward it from a bogus 0; got {second}" + ); + } + + /// The cap and the fold compose: a *smooth* turn stays inside the budget, + /// so the fold never fires and the stamp follows all the way through 180°. + /// A turn too fast for the budget is allowed to settle on the other axis + /// representative instead — identical for a symmetric stamp, and the + /// documented limitation for an asymmetric one. + #[test] + fn gradual_u_turn_tracks_without_flipping() { + use std::f32::consts::PI; + + // 180° over 90 dabs = 2° per dab, well inside a 5.7°/dab budget. + let mut held = Some(0.0); + let mut target = 0.0; + for _ in 0..90 { + target += 2.0_f32.to_radians(); + advance_stamp_angle(&mut held, target, STEP, D, 1.0); + } + let tracked = held.unwrap(); + assert!( + shortest_angle_diff(PI, tracked).abs() < 1e-3, + "a gradual U-turn should be followed the whole way to π; got {tracked}" + ); + + // The same 180°, demanded at once under a tight cap: the fold picks + // the near representative, so the stamp does not move. + let mut held = Some(0.0); + let got = advance_stamp_angle(&mut held, PI, STEP, D, 0.01); + assert!( + got.abs() < 1e-5, + "an instant reversal folds to a no-op rather than crawling half a \ + turn; got {got}" + ); + } } diff --git a/crates/darkly/src/catalog.rs b/crates/darkly/src/catalog.rs index 973d6352..92d882bc 100644 --- a/crates/darkly/src/catalog.rs +++ b/crates/darkly/src/catalog.rs @@ -12,7 +12,6 @@ //! registries is generated by `build.rs` from the module directories it scans, //! so a registry cannot be silently left out of the projection. -use crate::config::schema::WidgetHint; use crate::engine::types::ParamInfo; use crate::gpu::void::CaptureKind; @@ -187,20 +186,19 @@ include!(concat!(env!("OUT_DIR"), "/catalog_sources_gen.rs")); /// settings surface with no second code path. Ids are prefixed `settings.`, /// which cannot collide with a registry catalog id because none contains a dot. /// -/// Prefs declaring [`WidgetHint::Hidden`] are excluded: panel visibility, the -/// recent-files list and friends ride the same persistence pipe as real -/// settings but are not settings, and the live UI already skips them. Applying -/// the rule here keeps it in one place rather than leaving it to each consumer. +/// Every declared pref is projected, including those declaring +/// [`WidgetHint::Hidden`] — panel visibility and friends, which ride the same +/// persistence pipe as real settings without being settings. They carry +/// `widget: "hidden"` and it is the renderer that skips them. +/// +/// This projection is also the schema stored prefs are validated against, so a +/// pref omitted here is not merely unrendered: it is dropped as an unknown key +/// and erased from the user's settings file on the next load. pub fn settings_catalogs() -> Vec { let mut out: Vec<(i32, &'static str, Catalog)> = crate::config::sections::registrations() .iter() .map(|section| { - let params: Vec = section - .prefs - .iter() - .filter(|p| !matches!(p.widget, WidgetHint::Hidden)) - .map(ParamInfo::from_pref) - .collect(); + let params: Vec = section.prefs.iter().map(ParamInfo::from_pref).collect(); let entry = CatalogEntry { type_id: section.id, display_name: section.display_name, @@ -252,6 +250,7 @@ fn settings_catalog_id(section_id: &'static str) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::config::schema::WidgetHint; /// Nothing ships undocumented: every catalog names and describes itself, /// and so does every entry in it. @@ -340,9 +339,14 @@ mod tests { /// Settings project on the same footing as a registry: one catalog per /// section, `settings.`-prefixed so it cannot collide with a registry id, - /// and carrying only prefs that are actually settings. + /// and carrying *every* declared pref — hidden ones marked as such rather + /// than dropped. + /// + /// The projection is the schema the frontend validates stored prefs + /// against, and a pref missing from it is erased from disk on the next + /// reload. Hiding is a rendering decision and belongs to the renderer. #[test] - fn settings_project_as_catalogs_without_hidden_prefs() { + fn settings_project_every_pref_and_mark_hidden_ones() { let cats = settings_catalogs(); assert!(!cats.is_empty(), "no settings sections found"); @@ -362,12 +366,25 @@ mod tests { .sum(); assert!( hidden > 0, - "expected some hidden prefs to exercise the filter" + "expected some hidden prefs to exercise the projection" ); assert_eq!( - exported, - declared - hidden, - "settings export must carry every declared pref except the hidden ones" + exported, declared, + "settings export must carry every declared pref, including hidden ones \ + — a pref absent from the schema is dropped by `validateOverrides` and \ + erased from the user's settings file on reload" + ); + + let exported_hidden = cats + .iter() + .flat_map(|c| &c.entries) + .flat_map(|e| &e.params) + .filter(|p| p.widget == "hidden") + .count(); + assert_eq!( + exported_hidden, hidden, + "every hidden pref must reach the schema carrying `widget: \"hidden\"`, \ + which is how consumers know not to render it" ); for c in &cats { @@ -387,9 +404,6 @@ mod tests { "`{}` should hold exactly one entry", c.id ); - for p in &c.entries[0].params { - assert_ne!(p.widget, "hidden", "`{}` exported a hidden pref", c.id); - } } // Settings ids and registry ids share one namespace and must not collide. diff --git a/crates/darkly/src/config/mod.rs b/crates/darkly/src/config/mod.rs index dad1ad6f..45ddb177 100644 --- a/crates/darkly/src/config/mod.rs +++ b/crates/darkly/src/config/mod.rs @@ -16,8 +16,8 @@ use std::collections::{BTreeMap, HashMap}; /// to the schema or YAML layers cannot be auto-cleaned by /// [`super::schema`]-driven validation — e.g. a pref key is renamed, a /// pref's kind changes shape (str→int, scalar→list), or the file's -/// envelope itself changes. Pre-release we just discard mismatched files -/// (per CLAUDE.md "No Migrations"); post-release this is the discriminator +/// envelope itself changes. Pre-release we just discard mismatched files (per +/// CONTRIBUTING.md "No Migrations"); post-release this is the discriminator /// migrations key off. /// /// Forward-compatible changes don't need a bump: new prefs get default diff --git a/crates/darkly/src/docs_md/fragments/catalog_table.rs b/crates/darkly/src/docs_md/fragments/catalog_table.rs new file mode 100644 index 00000000..ef5214d1 --- /dev/null +++ b/crates/darkly/src/docs_md/fragments/catalog_table.rs @@ -0,0 +1,67 @@ +//! `` — one markdown row per entry of +//! a catalog, with the entry's rendered still where it has one. +//! +//! Not veil-specific: `catalog` names anything [`crate::catalog::catalogs`] +//! produces, so the same fragment documents voids, filters, blend modes or +//! brushes the day a page wants one. +//! +//! Every name and description here is a `&'static str` in the registration that +//! owns it. Fixing a typo visible in a generated table means editing +//! `crates/darkly/src/**` and re-running the sync — editing the markdown only +//! survives until the next run. + +use crate::catalog::catalogs; +use crate::docs_md::{FragmentCtx, FragmentError, FragmentRegistration, STILLS_DIR}; + +/// Rendered width of a still in the table, in CSS pixels. +/// +/// The assets are [`PREVIEW_MAX_DIM`](crate::gpu::preview::PREVIEW_MAX_DIM) +/// squares — 256 — which is the ceiling worth asking for: past it a browser is +/// upscaling what the renderer wrote. +const STILL_WIDTH: u32 = 200; + +pub fn register() -> FragmentRegistration { + FragmentRegistration { + id: "catalog-table", + args: &["catalog"], + render, + } +} + +fn render(ctx: &FragmentCtx) -> Result { + let id = ctx.arg("catalog")?; + let catalog = catalogs() + .into_iter() + .find(|c| c.id == id) + .ok_or_else(|| FragmentError::new(format!("no catalog named `{id}`")))?; + + let mut out = String::from("| | Name | What it does |\n| :-: | --- | --- |\n"); + for entry in &catalog.entries { + // An entry with no preview leaves the cell empty rather than the row + // out — it is still part of the catalog. + let still = if entry.supports_preview { + let path = ctx.link(&format!( + "{STILLS_DIR}/{}/{}.jpg", + catalog.id, entry.type_id + )); + format!( + "\"{}\"", + entry.display_name + ) + } else { + String::new() + }; + out.push_str(&format!( + "| {still} | **{}** | {} |\n", + entry.display_name, + cell(entry.description.unwrap_or_default()), + )); + } + Ok(out) +} + +/// A registration's prose as a table cell. Descriptions are written for a +/// tooltip, so nothing stops one containing the character that ends a column. +fn cell(text: &str) -> String { + text.replace('|', r"\|") +} diff --git a/crates/darkly/src/docs_md/fragments/mod.rs b/crates/darkly/src/docs_md/fragments/mod.rs new file mode 100644 index 00000000..7e704226 --- /dev/null +++ b/crates/darkly/src/docs_md/fragments/mod.rs @@ -0,0 +1,14 @@ +// @generated by build.rs — do not edit manually. +// To add a new module, create a .rs file in this directory +// that exports `pub fn register() -> crate::docs_md::FragmentRegistration`. + +pub mod catalog_table; + +use crate::docs_md::FragmentRegistration; + +#[rustfmt::skip] +pub fn registrations() -> Vec { + vec![ + catalog_table::register(), + ] +} diff --git a/crates/darkly/src/docs_md/mod.rs b/crates/darkly/src/docs_md/mod.rs new file mode 100644 index 00000000..97ed1545 --- /dev/null +++ b/crates/darkly/src/docs_md/mod.rs @@ -0,0 +1,689 @@ +//! Marked regions in the repository's markdown, filled from the registries. +//! +//! Everything Darkly registers already describes itself — a veil carries its own +//! display name and description, and the picker, `metadata.json` and the +//! documentation site all read that rather than restating it. Markdown checked +//! into this repository was the one consumer left restating it by hand, so a +//! table of veils in `README.md` drifted the moment a veil was added. +//! +//! A file opts a span of itself into being generated by bracketing it with HTML +//! comments, which render as nothing: +//! +//! ```markdown +//! +//! …whatever the fragment writes… +//! +//! ``` +//! +//! [`sync`] walks the tree, re-renders every region it finds and either writes +//! the result back or reports the drift. `cargo sync-docs` is the writer and +//! `tests/docs_md.rs` is the checker, so a stale region fails the ordinary test +//! suite and one command fixes it. +//! +//! Fragments are a modular registry: a new one is a new file in `fragments/` +//! exporting `pub fn register()`, and nothing here is edited to admit it. +//! +//! Needs no GPU. Like [`crate::catalog`], every fragment builds from `&'static` +//! registration data alone — which is what lets the check run in the ordinary +//! test suite rather than behind a device. + +pub mod fragments; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Where preview stills for generated tables live, relative to the repository +/// root. Named once because two halves depend on it: the fragment that links to +/// a still, and `render_docs --stills`, which writes it. +pub const STILLS_DIR: &str = "docs/images/previews"; + +/// Directories the walk never descends into. `target` and `node_modules` are +/// build output; the rest are the prior-art checkouts `CONTRIBUTING.md` asks +/// for, tens of thousands of markdown files that are not ours to rewrite. +const SKIP_DIRS: &[&str] = &[ + "target", + "node_modules", + "krita", + "krita-source", + "gimp", + "gegl", +]; + +// --------------------------------------------------------------------------- +// Fragments +// --------------------------------------------------------------------------- + +/// One kind of generated region. `id` is what a marker names; `args` is the +/// complete set of keys it accepts, so an unrecognised key is caught by the +/// parser rather than ignored by the fragment. +pub struct FragmentRegistration { + pub id: &'static str, + pub args: &'static [&'static str], + pub render: fn(&FragmentCtx) -> Result, +} + +/// Anything a fragment is allowed to know: the arguments its marker carried, and +/// where the file it is writing into sits. +/// +/// Deliberately not the repository root. A fragment's output is a pure function +/// of the registries and these two fields, so `sync` in check mode answers the +/// same way whether or not the assets it links to have been rendered yet — a +/// row pointing at a missing image is a visible, testable failure, where a row +/// that silently omits itself is not. +pub struct FragmentCtx<'a> { + args: BTreeMap<&'a str, &'a str>, + /// The markdown file's directory, relative to the repository root. Empty for + /// a file at the root. + md_dir: &'a Path, +} + +impl FragmentCtx<'_> { + /// A declared argument's value. + pub fn arg(&self, key: &str) -> Result<&str, FragmentError> { + self.args + .get(key) + .copied() + .ok_or_else(|| FragmentError(format!("`{key}` is required"))) + } + + /// A repository-relative path, rewritten to reach the same file from the + /// markdown that links to it. Markdown resolves relative links against the + /// file, not the root, so a fragment that emitted root-relative paths would + /// work in `README.md` and nowhere else. + pub fn link(&self, target: &str) -> String { + let target = Path::new(target); + let mut from = self.md_dir.components().peekable(); + let mut to = target.components().peekable(); + while from.peek().is_some() && from.peek() == to.peek() { + from.next(); + to.next(); + } + let mut parts: Vec = from.map(|_| "..".to_string()).collect(); + parts.extend(to.map(|c| c.as_os_str().to_string_lossy().into_owned())); + parts.join("/") + } +} + +/// Why a fragment could not render. Carries prose because there is nothing +/// generic to do with the failure but show it to whoever wrote the marker. +#[derive(Debug)] +pub struct FragmentError(pub String); + +impl FragmentError { + pub fn new(message: impl Into) -> Self { + FragmentError(message.into()) + } +} + +fn fragment(id: &str) -> Option { + fragments::registrations().into_iter().find(|f| f.id == id) +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Where something went wrong, in the terms of the person who has to fix it: a +/// file, a line, and what the marker on that line did. +#[derive(Debug)] +pub struct SyncError { + pub file: PathBuf, + pub line: usize, + pub kind: SyncErrorKind, +} + +#[derive(Debug)] +pub enum SyncErrorKind { + UnknownFragment(String), + UnknownArg { + id: String, + key: String, + }, + MalformedArg { + id: String, + token: String, + }, + /// A region was opened and the file ended before it closed. + Unterminated(String), + /// A close marker naming a fragment other than the open one. + MismatchedClose { + open: String, + close: String, + }, + /// A close marker with no region open. + StrayClose(String), + Render { + id: String, + error: FragmentError, + }, + Io(std::io::Error), +} + +impl std::fmt::Display for SyncError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}: ", self.file.display(), self.line)?; + match &self.kind { + SyncErrorKind::UnknownFragment(id) => { + write!(f, "no fragment named `{id}`") + } + SyncErrorKind::UnknownArg { id, key } => { + write!(f, "`{id}` accepts no argument `{key}`") + } + SyncErrorKind::MalformedArg { id, token } => { + write!(f, "`{id}` got `{token}`, which is not `key=value`") + } + SyncErrorKind::Unterminated(id) => { + write!(f, "`darkly:{id}` is never closed") + } + SyncErrorKind::MismatchedClose { open, close } => { + write!(f, "`darkly:{open}` is closed by `/darkly:{close}`") + } + SyncErrorKind::StrayClose(id) => { + write!(f, "`/darkly:{id}` closes a region that was never opened") + } + SyncErrorKind::Render { id, error } => { + write!(f, "`{id}` could not render: {}", error.0) + } + SyncErrorKind::Io(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for SyncError {} + +// --------------------------------------------------------------------------- +// Markers +// --------------------------------------------------------------------------- + +/// The inside of an HTML comment occupying a whole line, or `None` for any other +/// line. Indentation is tolerated so a region can sit inside a list item. +fn comment_body(line: &str) -> Option<&str> { + let t = line.trim(); + t.strip_prefix("").map(str::trim) +} + +/// The fragment a close marker names. +fn close_marker(line: &str) -> Option<&str> { + comment_body(line)?.strip_prefix("/darkly:").map(str::trim) +} + +/// The fragment an open marker names, with its arguments still unparsed. +fn open_marker(line: &str) -> Option<(&str, &str)> { + let body = comment_body(line)?.strip_prefix("darkly:")?; + Some(match body.split_once(char::is_whitespace) { + Some((id, rest)) => (id, rest.trim()), + None => (body, ""), + }) +} + +/// Whether the walk is inside a fenced code block, and which fence opened it. +/// +/// A marker in a code block is an example, not a region. Documentation about +/// this system has to be able to show the syntax it is documenting — +/// `CONTRIBUTING.md` does, and without this it would rewrite its own +/// explanation into a table of veils. +#[derive(Default)] +struct Fence(Option<(char, usize)>); + +impl Fence { + /// Feed the next line and answer whether it is fenced. The fence lines + /// themselves count as fenced, so neither can be mistaken for a marker. + fn consume(&mut self, line: &str) -> bool { + let text = line.trim_start(); + let opener = text.chars().next().filter(|c| *c == '`' || *c == '~'); + let run = opener.map_or(0, |c| text.chars().take_while(|x| *x == c).count()); + match self.0 { + // A closing fence repeats the opener at least as many times and + // carries no info string — ```rust closes nothing. + Some((char, len)) => { + if opener == Some(char) && run >= len && text[run..].trim().is_empty() { + self.0 = None; + } + true + } + None => { + if run >= 3 { + self.0 = Some((opener.expect("a run implies its character"), run)); + true + } else { + false + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Rendering one file +// --------------------------------------------------------------------------- + +/// A file after its regions have been re-rendered. +pub struct Rendered { + pub text: String, + /// How many regions were found. Zero means the file only *mentions* the + /// syntax — in a code fence, say — and nothing in it is generated. + pub regions: usize, +} + +/// Re-render every region in `text`. `rel` is the markdown file's path relative +/// to the repository root — used to place the file's directory in the context, +/// and to name the file in errors. +/// +/// Everything outside a region is copied byte for byte. A file may hold any +/// number of regions, including several of the same fragment with different +/// arguments — one page listing two catalogs is a table each, not a conflict. +pub fn render_text(rel: &Path, text: &str) -> Result { + let md_dir = rel.parent().unwrap_or(Path::new("")); + let err = |line: usize, kind: SyncErrorKind| SyncError { + file: rel.to_path_buf(), + line, + kind, + }; + + let mut out = String::with_capacity(text.len()); + let mut open: Option<(String, usize)> = None; + let mut regions = 0; + // Only tracked outside a region: a region's body is about to be replaced, so + // an unbalanced fence left in the old one says nothing about the file. + let mut fence = Fence::default(); + + for (i, line) in text.lines().enumerate() { + let no = i + 1; + + if open.is_none() && fence.consume(line) { + out.push_str(line); + out.push('\n'); + continue; + } + + if let Some((id, arg_text)) = open_marker(line) { + if let Some((outer, at)) = &open { + // A marker inside a region is content the region owns, and the + // region is about to be overwritten — so it cannot be one. + return Err(err(*at, SyncErrorKind::Unterminated(outer.clone()))); + } + let Some(reg) = fragment(id) else { + return Err(err(no, SyncErrorKind::UnknownFragment(id.to_string()))); + }; + let mut args = BTreeMap::new(); + for token in arg_text.split_whitespace() { + let Some((key, value)) = token.split_once('=') else { + return Err(err( + no, + SyncErrorKind::MalformedArg { + id: id.to_string(), + token: token.to_string(), + }, + )); + }; + if !reg.args.contains(&key) { + return Err(err( + no, + SyncErrorKind::UnknownArg { + id: id.to_string(), + key: key.to_string(), + }, + )); + } + args.insert(key, value); + } + let body = (reg.render)(&FragmentCtx { args, md_dir }).map_err(|error| { + err( + no, + SyncErrorKind::Render { + id: id.to_string(), + error, + }, + ) + })?; + out.push_str(line); + out.push('\n'); + out.push_str(body.trim_end_matches('\n')); + out.push('\n'); + open = Some((id.to_string(), no)); + regions += 1; + continue; + } + + if let Some(id) = close_marker(line) { + match open.take() { + None => return Err(err(no, SyncErrorKind::StrayClose(id.to_string()))), + Some((opened, _)) if opened != id => { + return Err(err( + no, + SyncErrorKind::MismatchedClose { + open: opened, + close: id.to_string(), + }, + )) + } + Some(_) => {} + } + out.push_str(line); + out.push('\n'); + continue; + } + + // Inside a region the old body is dropped; the new one is already out. + if open.is_none() { + out.push_str(line); + out.push('\n'); + } + } + + if let Some((id, at)) = open { + return Err(err(at, SyncErrorKind::Unterminated(id))); + } + + // `lines()` discards the distinction between a file ending in a newline and + // one that does not, and rewriting that is not this tool's business. + if !text.ends_with('\n') { + out.pop(); + } + Ok(Rendered { text: out, regions }) +} + +// --------------------------------------------------------------------------- +// Walking the tree +// --------------------------------------------------------------------------- + +/// Every markdown file under `root`, relative to it, in a stable order. +pub fn markdown_files(root: &Path) -> Result, std::io::Error> { + let mut found = Vec::new(); + walk(root, Path::new(""), &mut found)?; + found.sort(); + Ok(found) +} + +fn walk(root: &Path, rel: &Path, found: &mut Vec) -> Result<(), std::io::Error> { + for entry in std::fs::read_dir(root.join(rel))? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + let path = rel.join(name.as_ref()); + // Symlinks are never followed. `AGENTS.md` and `CLAUDE.md` are links to + // `CONTRIBUTING.md`, and rewriting one file three times under three + // names is at best noise in the report; a link pointing out of the tree + // would be worse. A link's target is walked on its own if it is in the + // tree, which is where it belongs. + if entry.file_type()?.is_symlink() { + continue; + } + if entry.file_type()?.is_dir() { + // Hidden directories are tooling (`.git`, `.github`, `.cargo`); + // nothing in them is documentation a reader browses. + if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) { + continue; + } + walk(root, &path, found)?; + } else if path.extension().is_some_and(|e| e == "md") { + found.push(path); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// The whole tree +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Mode { + /// Rewrite files whose regions are out of date. + Write, + /// Report them and change nothing. + Check, +} + +/// What `sync` found, in repository-relative paths. +#[derive(Debug, Default)] +pub struct Report { + /// Files that were rewritten, or in [`Mode::Check`] would have been. + pub changed: Vec, + /// Files holding at least one region — what the run actually covered. + pub generated: Vec, +} + +/// Re-render every region in every markdown file under `root`. +pub fn sync(root: &Path, mode: Mode) -> Result { + let io = |e: std::io::Error, file: &Path| SyncError { + file: file.to_path_buf(), + line: 0, + kind: SyncErrorKind::Io(e), + }; + + let mut report = Report::default(); + for rel in markdown_files(root).map_err(|e| io(e, root))? { + let path = root.join(&rel); + let text = std::fs::read_to_string(&path).map_err(|e| io(e, &rel))?; + if !text.contains("darkly:") { + continue; + } + let rendered = render_text(&rel, &text)?; + if rendered.regions == 0 { + continue; + } + report.generated.push(rel.clone()); + if rendered.text == text { + continue; + } + report.changed.push(rel.clone()); + if mode == Mode::Write { + std::fs::write(&path, rendered.text).map_err(|e| io(e, &rel))?; + } + } + Ok(report) +} + +/// The repository root, derived from this crate's location. +/// +/// The binary and the tests both need it and neither should guess: a +/// `current_dir` answer depends on where the caller stood, and `cargo test` and +/// a git hook do not stand in the same place. +pub fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render(text: &str) -> Result { + render_text(Path::new("README.md"), text).map(|r| r.text) + } + + fn regions(text: &str) -> usize { + render_text(Path::new("README.md"), text) + .expect("the markdown parses") + .regions + } + + /// The one fragment shipped today, rendered into the smallest file that can + /// hold it. Used by the tests that care about the machinery rather than the + /// table. + const OPEN: &str = ""; + const CLOSE: &str = ""; + + #[test] + fn text_outside_a_region_survives_byte_for_byte() { + let text = format!("# Title\n\nprose\n\n{OPEN}\nstale\n{CLOSE}\n\nmore prose\n"); + let out = render(&text).unwrap(); + assert!(out.starts_with("# Title\n\nprose\n\n")); + assert!(out.ends_with("\n\nmore prose\n")); + assert!(!out.contains("stale")); + } + + #[test] + fn a_file_with_no_markers_is_unchanged() { + let text = "# Title\n\njust prose.\n"; + assert_eq!(render(text).unwrap(), text); + } + + #[test] + fn rendering_is_idempotent() { + let text = format!("intro\n\n{OPEN}\n{CLOSE}\n"); + let once = render(&text).unwrap(); + assert_eq!(render(&once).unwrap(), once); + } + + #[test] + fn a_missing_trailing_newline_is_not_invented() { + let text = format!("{OPEN}\n{CLOSE}"); + assert!(!render(&text).unwrap().ends_with('\n')); + } + + #[test] + fn two_regions_of_one_fragment_are_allowed() { + let text = format!("{OPEN}\n{CLOSE}\n\n{OPEN}\n{CLOSE}\n"); + let out = render(&text).unwrap(); + assert_eq!(out.matches("| Name |").count(), 2); + assert_eq!(regions(&text), 2); + } + + /// A marker in a code block is documentation *about* the syntax, and + /// `CONTRIBUTING.md` is full of it. Rewriting an explanation into a veil + /// table was this tool's first act on the repository, before fences were + /// understood. + #[test] + fn markers_inside_a_code_fence_are_examples() { + let text = format!("Like so:\n\n```markdown\n{OPEN}\n…\n{CLOSE}\n```\n\nSee?\n"); + assert_eq!(render(&text).unwrap(), text); + assert_eq!(regions(&text), 0); + } + + /// The info string is part of the opening fence, not a second fence, and a + /// tilde fence is a fence — a file that mixes them must still come out + /// unchanged. + #[test] + fn fences_close_only_on_their_own_terms() { + let text = format!("```rust\nlet x = 1;\n```\n\n~~~\n{OPEN}\n~~~\n\n{OPEN}\n{CLOSE}\n"); + assert_eq!(regions(&text), 1, "only the unfenced marker is a region"); + + // A longer run closes a shorter fence; a shorter one does not close a + // longer one, so the marker between them stays fenced. + let text = format!("````\n```\n{OPEN}\n````\n"); + assert_eq!(regions(&text), 0); + } + + /// A fence *inside* a region is content that is about to be replaced, so it + /// cannot leave the parser thinking the rest of the file is code. + #[test] + fn a_fence_in_a_stale_region_body_does_not_escape_it() { + let text = format!("{OPEN}\n```\nstale\n{CLOSE}\n\n{OPEN}\n{CLOSE}\n"); + assert_eq!(regions(&text), 2); + } + + #[test] + fn an_unknown_fragment_is_an_error() { + let err = render("\n\n").unwrap_err(); + assert!( + matches!(err.kind, SyncErrorKind::UnknownFragment(_)), + "{err}" + ); + } + + #[test] + fn an_unknown_argument_is_an_error() { + let text = "\n\n"; + let err = render(text).unwrap_err(); + assert!( + matches!(err.kind, SyncErrorKind::UnknownArg { .. }), + "{err}" + ); + } + + #[test] + fn a_bare_argument_token_is_an_error() { + let text = "\n\n"; + let err = render(text).unwrap_err(); + assert!( + matches!(err.kind, SyncErrorKind::MalformedArg { .. }), + "{err}" + ); + } + + #[test] + fn an_unterminated_region_is_an_error() { + let err = render(&format!("{OPEN}\nbody\n")).unwrap_err(); + assert!(matches!(err.kind, SyncErrorKind::Unterminated(_)), "{err}"); + } + + #[test] + fn a_close_naming_another_fragment_is_an_error() { + let err = render(&format!("{OPEN}\n\n")).unwrap_err(); + assert!( + matches!(err.kind, SyncErrorKind::MismatchedClose { .. }), + "{err}" + ); + } + + #[test] + fn a_close_with_nothing_open_is_an_error() { + let err = render(&format!("prose\n{CLOSE}\n")).unwrap_err(); + assert!(matches!(err.kind, SyncErrorKind::StrayClose(_)), "{err}"); + } + + #[test] + fn a_fragment_that_cannot_render_names_itself() { + let text = "\n\n"; + let err = render(text).unwrap_err(); + assert!(matches!(err.kind, SyncErrorKind::Render { .. }), "{err}"); + } + + /// Links resolve against the file that carries them, not the repository + /// root — the whole reason a fragment is handed a directory. + #[test] + fn links_are_relative_to_the_markdown_file() { + let at_root = FragmentCtx { + args: BTreeMap::new(), + md_dir: Path::new(""), + }; + assert_eq!(at_root.link("docs/images/a.jpg"), "docs/images/a.jpg"); + + let nested = FragmentCtx { + args: BTreeMap::new(), + md_dir: Path::new("docs/manual"), + }; + assert_eq!(nested.link("docs/images/a.jpg"), "../images/a.jpg"); + + let sibling = FragmentCtx { + args: BTreeMap::new(), + md_dir: Path::new("crates/darkly"), + }; + assert_eq!(sibling.link("docs/images/a.jpg"), "../../docs/images/a.jpg"); + } + + /// The walk must not wander into the prior-art checkouts: `krita/` alone is + /// thousands of markdown files, and rewriting any of them would be wrong. + #[test] + fn the_walk_skips_vendored_and_hidden_directories() { + let files = markdown_files(&repo_root()).unwrap(); + assert!(files.iter().any(|p| p == Path::new("README.md"))); + for f in &files { + let first = f.components().next().unwrap().as_os_str().to_string_lossy(); + assert!( + !SKIP_DIRS.contains(&first.as_ref()) && !first.starts_with('.'), + "walk descended into `{}`", + f.display() + ); + } + } + + /// `AGENTS.md` and `CLAUDE.md` are symlinks to `CONTRIBUTING.md`. One file, + /// one entry — otherwise a report names the same content three times and a + /// link out of the tree would be followed out of it. + #[test] + fn the_walk_reaches_a_linked_file_once_under_its_real_name() { + let files = markdown_files(&repo_root()).unwrap(); + assert!(files.iter().any(|p| p == Path::new("CONTRIBUTING.md"))); + for link in ["AGENTS.md", "CLAUDE.md"] { + assert!( + !files.iter().any(|p| p == Path::new(link)), + "`{link}` was walked as well as its target" + ); + } + } +} diff --git a/crates/darkly/src/docs_render/mod.rs b/crates/darkly/src/docs_render/mod.rs index 075ece50..6175ea0a 100644 --- a/crates/darkly/src/docs_render/mod.rs +++ b/crates/darkly/src/docs_render/mod.rs @@ -85,6 +85,8 @@ pub enum DocsRenderError { catalog: String, type_id: String, }, + /// A caller named a catalog no registry produces. + UnknownCatalog(String), Usage(String), Io(std::io::Error), Encode(image::ImageError), @@ -100,6 +102,7 @@ impl std::fmt::Display for DocsRenderError { Self::NoRecipe { catalog, type_id } => { write!(f, "`{catalog}/{type_id}` declares no preview recipe") } + Self::UnknownCatalog(id) => write!(f, "no catalog named `{id}`"), Self::Usage(m) => write!(f, "{m}"), Self::Io(e) => write!(f, "{e}"), Self::Encode(e) => write!(f, "{e}"), @@ -173,6 +176,36 @@ pub struct Rendered { pub still: u32, } +impl Rendered { + /// Turn what a renderer produced into what a consumer receives. + /// + /// The three renderers differ in how they get their pixels and in nothing + /// else, so the two facts that depend on *how the frames were asked for* — + /// whether a one-way sequence needs its hand-back dissolved in, and where + /// the poster sits — are settled once, here. + fn assemble( + variant: PreviewVariant, + anim: PreviewAnim, + frames: Frames, + width: u32, + height: u32, + ) -> Self { + // A still is its own poster, and one frame has no hand-back to close. + let (frames, still) = match variant { + PreviewVariant::Still => (frames, 0), + PreviewVariant::Animated => (close_loop(anim, frames), anim.still_frame()), + }; + Rendered { + frames, + width, + height, + fps: anim.fps, + loops: anim.emits_a_loop(), + still, + } + } +} + // --------------------------------------------------------------------------- // Shared GPU state // --------------------------------------------------------------------------- @@ -320,6 +353,7 @@ impl Gpu { mech: &'static dyn PreviewMechanism, catalog: &str, type_id: &str, + variant: PreviewVariant, ) -> Result { let no_recipe = || DocsRenderError::NoRecipe { catalog: catalog.to_string(), @@ -347,10 +381,11 @@ impl Gpu { voids, filters, }; - // The whole sequence: a documentation asset is every frame, and the - // poster is recorded as an index into it rather than written twice. - let mut seq = PreviewSequence::open(mech, regs, type_id, PreviewVariant::Animated) - .ok_or_else(no_recipe)?; + // An animated asset is every frame, with the poster recorded as an + // index into it rather than written twice; a still asks the same + // sequence for the one frame the entry nominates. + let mut seq = + PreviewSequence::open(mech, regs, type_id, variant).ok_or_else(no_recipe)?; drive( &mut seq, &device.device, @@ -369,20 +404,17 @@ impl Gpu { }, ); } - Ok(Rendered { - frames: close_loop(anim, frames), - width: w, - height: h, - fps: anim.fps, - loops: anim.emits_a_loop(), - still: anim.still_frame(), - }) + Ok(Rendered::assemble(variant, anim, frames, w, h)) } /// Render one blend mode through a real document, driving the top layer's /// opacity — the one thing a consumer without a document cannot do, which is /// why this catalog has no offscreen mechanism. - fn render_blend_mode(&mut self, type_id: &str) -> Result { + fn render_blend_mode( + &mut self, + type_id: &str, + variant: PreviewVariant, + ) -> Result { let anim: PreviewAnim = crate::gpu::blend_mode::registry() .preview(type_id) .ok_or_else(|| DocsRenderError::NoRecipe { @@ -392,24 +424,28 @@ impl Gpu { let doc = self.blend_doc(); doc.engine.set_blend_mode(doc.top, type_id); - let mut frames = Vec::with_capacity(anim.frames as usize); - for i in 0..anim.frames { - let opacity = blend_opacity_at(frame_t(i, anim.frames)); - doc.engine.set_opacity(doc.top, opacity); + // The timeline this catalog is driven over — the whole of it, or the one + // moment the entry nominates as standing for it. + let timeline: Vec = match variant { + PreviewVariant::Still => vec![anim.still_at], + PreviewVariant::Animated => (0..anim.frames).map(|i| frame_t(i, anim.frames)).collect(), + }; + let mut frames = Vec::with_capacity(timeline.len()); + for t in timeline { + doc.engine.set_opacity(doc.top, blend_opacity_at(t)); frames.push(doc.engine.test_readback_canvas()); } // The document is reused across every mode, and a mode only ever writes // the frames it renders — so leaving the last frame's opacity behind // would leak into the next entry's first frame. doc.engine.set_opacity(doc.top, 1.0); - Ok(Rendered { - frames: close_loop(anim, frames), - width: DOCS_SUBJECT_DIM, - height: DOCS_SUBJECT_DIM, - fps: anim.fps, - loops: anim.emits_a_loop(), - still: anim.still_frame(), - }) + Ok(Rendered::assemble( + variant, + anim, + frames, + DOCS_SUBJECT_DIM, + DOCS_SUBJECT_DIM, + )) } /// Render one brush's preview stroke — the same synthetic S-curve, through @@ -421,7 +457,11 @@ impl Gpu { /// over one image, so like a blend mode it has no `src → out` mechanism to /// open — it is a second caller of the same `PreviewAnim`, not a second /// preview system. - fn render_brush_stroke(&mut self, type_id: &str) -> Result { + fn render_brush_stroke( + &mut self, + type_id: &str, + variant: PreviewVariant, + ) -> Result { let no_recipe = || DocsRenderError::NoRecipe { catalog: crate::brush::builtin_brushes::CATALOG_ID.to_string(), type_id: type_id.to_string(), @@ -489,17 +529,11 @@ impl Gpu { DOCS_STROKE_FG, DOCS_STROKE_BG, ); - Ok(Rendered { - // A brush stroke is one frame, so closing is a no-op — routed - // through it anyway so no arm of this module is the one that - // decides for itself what a declaration means. - frames: close_loop(anim, vec![framed]), - width: tw, - height: th, - fps: anim.fps, - loops: anim.emits_a_loop(), - still: anim.still_frame(), - }) + // A brush stroke is one frame either way, so both variants and the + // closing pass are no-ops here — routed through the shared assembly + // anyway so no arm of this module is the one that decides for itself + // what a declaration means. + Ok(Rendered::assemble(variant, anim, vec![framed], tw, th)) } } @@ -516,18 +550,19 @@ pub fn render_entry( gpu: &mut Gpu, catalog: &str, type_id: &str, + variant: PreviewVariant, ) -> Result { if let Some((_, mech)) = preview_mechanisms() .into_iter() .find(|(id, _)| *id == catalog) { - return gpu.render_offscreen(mech, catalog, type_id); + return gpu.render_offscreen(mech, catalog, type_id, variant); } if catalog == crate::gpu::blend_mode::CATALOG_ID { - return gpu.render_blend_mode(type_id); + return gpu.render_blend_mode(type_id, variant); } if catalog == crate::brush::builtin_brushes::CATALOG_ID { - return gpu.render_brush_stroke(type_id); + return gpu.render_brush_stroke(type_id, variant); } Err(DocsRenderError::NoRenderer { catalog: catalog.to_string(), @@ -538,8 +573,8 @@ pub fn render_entry( /// The source the offscreen path handed the effect, read back. /// /// Test-only, and gated for the same reason `PreviewTarget::source_texture` is: -/// nothing in a run reads the source back, and `AGENTS.md` §No Blocking GPU -/// Readbacks keeps readback surface behind the gate. A value-pinned assertion +/// nothing in a run reads the source back, and `CONTRIBUTING.md` §No Blocking +/// GPU Readbacks keeps readback surface behind the gate. A value-pinned assertion /// about what a filter *did* has to compare against what it was *given* — the /// 2:1 area average of the subject, not the subject itself. #[cfg(any(test, feature = "testing"))] @@ -573,6 +608,70 @@ fn write_frames(dir: &Path, frames: &[Vec], w: u32, h: u32) -> Result<(), Do Ok(()) } +/// Write one frame as JPEG, dropping the alpha channel. +/// +/// The stills are photographs of the documentation subject with an effect on +/// them, embedded in markdown at a fraction of their rendered size and committed +/// to this repository — which is the case JPEG is for. As PNG the same images +/// are several hundred kilobytes each, and a repository pays that on every +/// re-render forever. The frames themselves stay lossless; this is the last step +/// before a reader sees them. +/// +/// Quality 90 rather than the encoder's default: these are 256 px squares read +/// at 120, where the ringing a lower setting leaves around a pixelate veil's +/// hard block edges is visible. +fn write_jpeg(path: &Path, pixels: &[u8], w: u32, h: u32) -> Result<(), DocsRenderError> { + let rgb: Vec = pixels + .chunks_exact(4) + .flat_map(|p| [p[0], p[1], p[2]]) + .collect(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(std::io::Cursor::new(&mut out), 90).encode( + &rgb, + w, + h, + image::ExtendedColorType::Rgb8, + )?; + std::fs::write(path, out)?; + Ok(()) +} + +/// Write one catalog's poster frames as JPEG — what markdown in this repository +/// embeds, since a table cannot play a video. +/// +/// One frame per entry, not a sequence: [`PreviewVariant::Still`] renders at the +/// moment the entry itself nominates, so this is the same picture the editor's +/// picker shows at rest and the same one the release artifact's poster carries. +/// +/// The pixels come from whichever adapter runs this, so re-rendering an +/// unchanged catalog on a different GPU than the last committed run can produce +/// a diff with no visible change. That is why this is a deliberate command +/// rather than a build step: it is run when a catalog gains or loses an entry. +pub fn render_stills(out: &Path, catalog_id: &str) -> Result, DocsRenderError> { + let catalog = crate::catalog::catalogs() + .into_iter() + .find(|c| c.id == catalog_id) + .ok_or_else(|| DocsRenderError::UnknownCatalog(catalog_id.to_string()))?; + + let mut gpu = Gpu::new(); + let mut written = Vec::new(); + for entry in catalog.entries.iter().filter(|e| e.supports_preview) { + let rendered = render_entry(&mut gpu, catalog.id, entry.type_id, PreviewVariant::Still)?; + let path = out.join(catalog.id).join(format!("{}.jpg", entry.type_id)); + write_jpeg( + &path, + &rendered.frames[rendered.still as usize], + rendered.width, + rendered.height, + )?; + written.push(path); + } + Ok(written) +} + /// Render every previewable catalog entry into `out` and write the index /// beside them. /// @@ -585,7 +684,12 @@ pub fn render_all(out: &Path) -> Result { for catalog in crate::catalog::catalogs() { for entry in catalog.entries.iter().filter(|e| e.supports_preview) { - let rendered = render_entry(&mut gpu, catalog.id, entry.type_id)?; + let rendered = render_entry( + &mut gpu, + catalog.id, + entry.type_id, + PreviewVariant::Animated, + )?; let rel = PathBuf::from(catalog.id).join(entry.type_id); write_frames( &out.join(&rel), @@ -625,35 +729,52 @@ pub fn render_all(out: &Path) -> Result { // --------------------------------------------------------------------------- pub const USAGE: &str = "\ -render_docs — render an animated preview for every previewable registry entry +render_docs — render previews for the registry entries that declare one USAGE: render_docs --out + render_docs --stills --catalog [--out ] OPTIONS: - --out Directory to write frame sequences and assets.json into - --help Print this message + --out Where to write. Frame sequences and assets.json by + default; with --stills, defaults to this repository's own + preview directory. + --stills Write one JPEG poster per entry instead of a sequence — + what markdown in this repository embeds. + --catalog Which catalog to render stills for. Required by --stills. + --help Print this message "; -pub struct Args { - /// `None` when `--help` was asked for and there is no work to do. - pub out: Option, +/// What a command line asked for. +pub enum Command { + /// `--help` was asked for; there is no work to do. + Help, + /// Every previewable entry, as PNG frame sequences plus an index — the + /// release artifact. + Frames { out: PathBuf }, + /// One catalog's poster frames, as JPEG — what this repository's markdown + /// embeds. + Stills { out: PathBuf, catalog: String }, } /// Parse the command line. This lives here rather than in the binary because /// coverage tooling runs test targets and never executes a `[[bin]]` — anything /// left inside `fn main` is untestable by construction. -pub fn parse_args(argv: impl Iterator) -> Result { - let mut out = None; +pub fn parse_args(argv: impl Iterator) -> Result { + let mut out: Option = None; + let mut catalog: Option = None; + let mut stills = false; let mut argv = argv.peekable(); while let Some(arg) = argv.next() { + let mut value = |what: &str| { + argv.next() + .ok_or_else(|| DocsRenderError::Usage(format!("{what} needs a value"))) + }; match arg.as_str() { - "--help" | "-h" => return Ok(Args { out: None }), - "--out" => { - out = Some(PathBuf::from(argv.next().ok_or_else(|| { - DocsRenderError::Usage("--out needs a directory".into()) - })?)) - } + "--help" | "-h" => return Ok(Command::Help), + "--stills" => stills = true, + "--out" => out = Some(PathBuf::from(value("--out")?)), + "--catalog" => catalog = Some(value("--catalog")?), other => { return Err(DocsRenderError::Usage(format!( "unrecognized argument `{other}`" @@ -661,7 +782,22 @@ pub fn parse_args(argv: impl Iterator) -> Result".into()))?, }) } diff --git a/crates/darkly/src/document/filter.rs b/crates/darkly/src/document/filter.rs index 873b291e..ff9526c3 100644 --- a/crates/darkly/src/document/filter.rs +++ b/crates/darkly/src/document/filter.rs @@ -7,8 +7,8 @@ //! `FilterKind::apply` for each visible filter. The outer compositor never //! branches on whether a host has a mask. //! -//! Per the Modularity Principle in [AGENTS.md], each kind lives in a single -//! file under `document/filters/.rs` and exports a `register()` that +//! Per the Modularity Principle in [CONTRIBUTING.md], each kind lives in a +//! single file under `document/filters/.rs` and exports a `register()` that //! returns a [`FilterEntityRegistration`]. `build.rs` auto-discovers the directory //! and emits `document/filters/mod.rs`. diff --git a/crates/darkly/src/document/filters/mask.rs b/crates/darkly/src/document/filters/mask.rs index c4f93f7c..e7b61174 100644 --- a/crates/darkly/src/document/filters/mask.rs +++ b/crates/darkly/src/document/filters/mask.rs @@ -1,7 +1,7 @@ //! Mask filter — multiplies a host's alpha by an R8 alpha texture. //! -//! Per the Modularity Principle in [AGENTS.md], the entire mask kind lives in -//! this file: data struct, construction, wire format, and the `register()` +//! Per the Modularity Principle in [CONTRIBUTING.md], the entire mask kind +//! lives in this file: data struct, construction, wire format, and the `register()` //! discovery hook. use serde::{Deserialize, Serialize}; diff --git a/crates/darkly/src/document/filters/selection.rs b/crates/darkly/src/document/filters/selection.rs index e737f449..ffcaf039 100644 --- a/crates/darkly/src/document/filters/selection.rs +++ b/crates/darkly/src/document/filters/selection.rs @@ -1,7 +1,7 @@ //! Selection filter — global single-channel mask for which pixels are //! affected by edits (paint, fill, transform, clipboard). //! -//! Per the Modularity Principle in [AGENTS.md], the entire selection kind +//! Per the Modularity Principle in [CONTRIBUTING.md], the entire selection kind //! lives in this file: data struct, CPU cache, construction, wire format, //! and the `register()` discovery hook. //! @@ -26,7 +26,7 @@ use crate::layer::{LayerId, NodeCommon, PixelBuffer}; /// readback after each mutating op (combine/invert/upload). Read paths that /// need pixel-level access (transform source bounds, copy region masking, /// flood-fill intersection) consult this rather than triggering a synchronous -/// GPU readback (forbidden by AGENTS.md "No Blocking GPU Readbacks"). +/// GPU readback (forbidden by CONTRIBUTING.md "No Blocking GPU Readbacks"). pub struct SelectionCpuCache { pub data: Option>, } diff --git a/crates/darkly/src/document/layer_kinds/group.rs b/crates/darkly/src/document/layer_kinds/group.rs index 84b361d5..9d4ea524 100644 --- a/crates/darkly/src/document/layer_kinds/group.rs +++ b/crates/darkly/src/document/layer_kinds/group.rs @@ -1,6 +1,6 @@ //! Group layer kind — a tree container for nested layers / groups. //! -//! Per the Modularity Principle in [AGENTS.md], the entire group kind +//! Per the Modularity Principle in [CONTRIBUTING.md], the entire group kind //! lives in this file: data lives on [`crate::layer::LayerGroup`], wire //! format (`GroupBody`) and serializer / deserializer / id-remap //! functions live here. diff --git a/crates/darkly/src/document/layer_kinds/raster.rs b/crates/darkly/src/document/layer_kinds/raster.rs index b11aa982..03b2067f 100644 --- a/crates/darkly/src/document/layer_kinds/raster.rs +++ b/crates/darkly/src/document/layer_kinds/raster.rs @@ -1,6 +1,6 @@ //! Raster layer kind — pixel-storing leaf in the layer tree. //! -//! Per the Modularity Principle in [AGENTS.md], the entire raster kind +//! Per the Modularity Principle in [CONTRIBUTING.md], the entire raster kind //! lives in this file: data lives on [`crate::layer::RasterLayer`], and //! the wire format (`RasterBody`) plus serializer / deserializer / //! id-remap functions all live here. Adding a new layer kind copies diff --git a/crates/darkly/src/engine/brush_graph.rs b/crates/darkly/src/engine/brush_graph.rs index fa501a01..eabbc3a7 100644 --- a/crates/darkly/src/engine/brush_graph.rs +++ b/crates/darkly/src/engine/brush_graph.rs @@ -470,7 +470,7 @@ impl DarklyEngine { self.invalidate_brush_stroke_preview(); // Drop baked PNG thumbnails so picker tiles re-bake on demand. // The frontend's rAF poll handles the empty→bake→present flow. - self.brush_library.clear_thumbnails(); + crate::brush::library::with_mut(|lib| lib.clear_thumbnails()); } /// Render a full-stroke brush editor preview and return the most recent diff --git a/crates/darkly/src/engine/brush_library.rs b/crates/darkly/src/engine/brush_library.rs index caf5d8a3..cc20f9ee 100644 --- a/crates/darkly/src/engine/brush_library.rs +++ b/crates/darkly/src/engine/brush_library.rs @@ -3,8 +3,8 @@ use darkly_macros::handlers; use super::{DarklyEngine, ReadbackContext}; -use crate::brush::bundle::{Brush, BrushMetadata}; -use crate::brush::library::BrushInfo; +use crate::brush::library::{self as library, BrushInfo, LibrarySnapshot}; +use crate::brush::metadata::{Brush, BrushMetadata}; /// Dimensions used for baked brush thumbnails. Matches the live editor /// preview so brushes look identical in the picker grid. @@ -52,42 +52,64 @@ pub(crate) const BRUSH_DAB_RENDER_SIZE: (u32, u32) = (1792, 1792); #[handlers] impl DarklyEngine { + /// Every brush and every pack, in one round trip. + /// + /// One call rather than two so the halves cannot disagree across a + /// concurrent mutation — a member id naming a brush the caller has not + /// been told about is the inconsistency this rules out. + #[handler] + pub fn library_list(&self) -> LibrarySnapshot { + library::with(|lib| lib.snapshot()) + } + /// List all brushes in the library (summary info only). #[handler] pub fn brush_list(&self) -> Vec { - self.brush_library.list() + library::with(|lib| lib.list()) } /// Load a brush by name and set it as the active brush graph. #[handler] pub fn brush_load(&mut self, name: &str) -> Result<(), String> { - let brush = self - .brush_library - .get(name) - .ok_or_else(|| format!("brush '{}' not found", name))? - .clone(); - - let json = serde_json::to_string(&brush.metadata.graph) - .map_err(|e| format!("failed to serialize graph: {e}"))?; + // The library borrow ends before `set_brush_graph`, which takes + // `&mut self` and would otherwise re-enter it. + let json = library::with(|lib| { + let brush = lib + .by_name(name) + .ok_or_else(|| format!("brush '{name}' not found"))?; + serde_json::to_string(&brush.metadata.graph) + .map_err(|e| format!("failed to serialize graph: {e}")) + })?; self.set_brush_graph(&json)?; - Ok(()) } - /// Save the active brush graph as a brush in the library. + /// Save the active brush graph as a brush in the library, under the + /// caller-supplied `id`. + /// + /// The id comes from the frontend because this crate has no random-number + /// source; saving over an existing id replaces that brush, which is what + /// "save" means when the painter is editing one they already have. /// - /// Returns immediately with the brush registered (no thumbnail yet). - /// A theme-colored preview render is scheduled; when its readback - /// lands, the resulting PNG is installed on the library entry via - /// `BrushLibrary::set_thumbnail`. Callers that export the brush - /// before the bake completes simply get an archive without - /// `preview.png` — loads still work, pickers fall back to whatever - /// placeholder they prefer. + /// Returns immediately with the brush registered (no thumbnail yet). A + /// theme-colored preview render is scheduled; when its readback lands, the + /// resulting PNG is installed on the library entry via + /// `BrushLibrary::set_thumbnail`. #[handler] - pub fn brush_save(&mut self, name: &str, category: &str) -> Result<(), String> { - let mut metadata = BrushMetadata::from_graph(name, self.active_brush_graph()); - metadata.category = category.to_string(); - self.brush_library.insert(Brush::from_metadata(metadata)); + pub fn brush_save(&mut self, id: &str, name: &str) -> Result<(), String> { + if id.trim().is_empty() { + return Err("a brush needs an id".into()); + } + let name = name.trim(); + if name.is_empty() { + return Err("a brush needs a name".into()); + } + let metadata = BrushMetadata::from_graph(id, name, self.active_brush_graph()); + library::with_mut(|lib| { + lib.ensure_name_free(id, name)?; + lib.insert(Brush::from_metadata(metadata)); + Ok::<(), String>(()) + })?; // Saving establishes a new "brush baseline" — what the user just // saved IS what reset-to-default should now return to. self.snapshot_brush_defaults(); @@ -101,7 +123,7 @@ impl DarklyEngine { self.request_stroke_preview_readback( self.active_brush_graph(), |width, height, backdrop| ReadbackContext::BrushThumbnailForSave { - name: name.to_string(), + id: id.to_string(), width, height, backdrop, @@ -110,9 +132,101 @@ impl DarklyEngine { Ok(()) } - /// Export a brush to `.darkly-brush` ZIP bytes. - pub fn brush_export(&self, name: &str) -> Result, String> { - self.brush_library.export_bytes(name) + /// A library brush's graph as portable YAML, without making it active. + /// + /// Reading a brush should not disturb what the painter is painting with, + /// which is why this exists alongside `brush_graph_export_yaml` (the + /// *active* graph) rather than callers loading each brush in turn. + #[handler] + pub fn brush_export_yaml(&self, id: &str) -> Result { + let graph = library::with(|lib| { + lib.get(id) + .map(|b| b.metadata.graph.clone()) + .ok_or_else(|| format!("brush '{id}' not found")) + })?; + let portable = crate::brush::portable::PortableBrush::from_graph_only( + &graph, + crate::brush::registry(), + )?; + serde_yaml_ng::to_string(&portable).map_err(|e| format!("YAML serialize error: {e}")) + } + + /// Rename a brush. Touches no pack and no recents entry — both hold ids. + #[handler] + pub fn brush_rename(&mut self, id: &str, name: &str) -> Result<(), String> { + library::with_mut(|lib| lib.rename(id, name)) + } + + /// Delete a brush, removing it from every pack that held it. + #[handler] + pub fn brush_delete(&mut self, id: &str) -> Result<(), String> { + library::with_mut(|lib| lib.delete_brush(id)) + } + + /// Create a brush pack under a caller-supplied id. + #[handler] + pub fn pack_create( + &mut self, + id: &str, + name: &str, + description: &str, + icon: &str, + primary: &str, + secondary: &str, + ) -> Result<(), String> { + library::with_mut(|lib| lib.create_pack(id, name, description, icon, primary, secondary)) + } + + /// Change a pack's name, description, icon or colors. + #[handler] + pub fn pack_edit( + &mut self, + id: &str, + name: &str, + description: &str, + icon: &str, + primary: &str, + secondary: &str, + ) -> Result<(), String> { + library::with_mut(|lib| lib.edit_pack(id, name, description, icon, primary, secondary)) + } + + /// Delete a pack. Its brushes survive. + #[handler] + pub fn pack_delete(&mut self, id: &str) -> Result<(), String> { + library::with_mut(|lib| lib.delete_pack(id)) + } + + /// Copy a brush into a pack. It does not leave any pack it is already in. + #[handler] + pub fn pack_add_brush(&mut self, pack: &str, brush: &str) -> Result<(), String> { + library::with_mut(|lib| lib.add_to_pack(pack, brush)) + } + + #[handler] + pub fn pack_remove_brush(&mut self, pack: &str, brush: &str) -> Result<(), String> { + library::with_mut(|lib| lib.remove_from_pack(pack, brush)) + } + + #[handler] + pub fn pack_reorder_brush( + &mut self, + pack: &str, + brush: &str, + index: u32, + ) -> Result<(), String> { + library::with_mut(|lib| lib.reorder_in_pack(pack, brush, index as usize)) + } + + /// Import a `.darkly-brush` archive as a new pack under `id`. + #[handler] + pub fn pack_import(&mut self, id: &str, bytes: &[u8]) -> Result { + library::with_mut(|lib| lib.import_pack(id, bytes)) + } + + /// Export a pack as `.darkly-brush` bytes. + pub fn pack_export(&self, id: &str) -> Result, String> { + library::with(|lib| lib.export_pack(id)) } /// Return the cached PNG thumbnail bytes for a library brush, kicking @@ -121,29 +235,39 @@ impl DarklyEngine { /// on rAF until non-empty bytes arrive. Subsequent calls hit the cache. #[handler(returns = bytes)] pub fn brush_thumbnail(&mut self, name: &str) -> Vec { - if let Some(png) = self.brush_library.thumbnail_png(name) { - return png.to_vec(); + // Resolve and copy out under one short borrow: the bake below takes + // `&mut self`, so nothing may still be borrowing the library. + let resolved = library::with(|lib| { + lib.by_name(name).map(|b| { + ( + b.id().to_string(), + b.thumbnail_png.clone(), + b.metadata.graph.clone(), + ) + }) + }); + let Some((id, cached, graph)) = resolved else { + return Vec::new(); + }; + if let Some(png) = cached { + return png; } // A bake for this brush is already pending — don't queue another; // racing readbacks would step on each other's library entry. - let already_pending = self.readbacks.any( - |c| matches!(c, ReadbackContext::BrushThumbnailForSave { name: n, .. } if n == name), - ); + let already_pending = self + .readbacks + .any(|c| matches!(c, ReadbackContext::BrushThumbnailForSave { id: i, .. } if *i == id)); if already_pending { return Vec::new(); } - let Some(brush) = self.brush_library.get(name).cloned() else { - return Vec::new(); - }; - self.request_stroke_preview_readback( - brush.metadata.graph.clone(), - |width, height, backdrop| ReadbackContext::BrushThumbnailForSave { - name: name.to_string(), + self.request_stroke_preview_readback(graph, |width, height, backdrop| { + ReadbackContext::BrushThumbnailForSave { + id: id.clone(), width, height, backdrop, - }, - ); + } + }); Vec::new() } @@ -154,37 +278,38 @@ impl DarklyEngine { /// to the stroke preview. #[handler(returns = bytes)] pub fn brush_dab_thumbnail(&mut self, name: &str) -> Vec { - if let Some(png) = self.brush_library.dab_thumbnail_png(name) { - return png.to_vec(); + let resolved = library::with(|lib| { + lib.by_name(name).map(|b| { + let id = b.id().to_string(); + let cached = lib.dab_thumbnail_png(&id).map(<[u8]>::to_vec); + (id, cached, b.metadata.graph.clone()) + }) + }); + let Some((id, cached, graph)) = resolved else { + return Vec::new(); + }; + if let Some(png) = cached { + return png; } let already_pending = self .readbacks - .any(|c| matches!(c, ReadbackContext::BrushDabThumbnail { name: n, .. } if n == name)); + .any(|c| matches!(c, ReadbackContext::BrushDabThumbnail { id: i, .. } if *i == id)); if already_pending { return Vec::new(); } - let Some(brush) = self.brush_library.get(name).cloned() else { - return Vec::new(); - }; // The shared helper resets every exposed scrub (size, opacity, // hardness, …) to its registration default before rendering — same // treatment the active-dab preview applies. Keeping the two paths on // one helper means `brush_dab_thumbnail(active_name)` and // `brush_active_dab_preview()` produce byte-identical PNGs, so the // picker tile and the BrushBar trigger always agree. - self.request_dab_preview_readback(brush.metadata.graph.clone(), |width, height| { + self.request_dab_preview_readback(graph, |width, height| { ReadbackContext::BrushDabThumbnail { - name: name.to_string(), + id: id.clone(), width, height, } }); Vec::new() } - - /// Import a brush from `.darkly-brush` ZIP bytes into the library. - #[handler] - pub fn brush_import(&mut self, bytes: &[u8]) -> Result { - self.brush_library.import_bytes(bytes) - } } diff --git a/crates/darkly/src/engine/canvas_resize.rs b/crates/darkly/src/engine/canvas_resize.rs index 3f59d520..d9005dee 100644 --- a/crates/darkly/src/engine/canvas_resize.rs +++ b/crates/darkly/src/engine/canvas_resize.rs @@ -65,9 +65,9 @@ impl DarklyEngine { if !self.has_selection() { return; } - // Selection pixel bounds are *window-local* (see CLAUDE.md selection - // notes); fall back to recomputing them from the CPU cache when the - // async readback hasn't landed yet. + // Selection pixel bounds are *window-local* (see CONTRIBUTING.md + // selection notes); fall back to recomputing them from the CPU cache + // when the async readback hasn't landed yet. let local = match self.selection_pixel_bounds().filter(|b| !b.is_empty()) { Some(b) => b, None => match self.selection_cpu_cache().and_then(|data| { diff --git a/crates/darkly/src/engine/mod.rs b/crates/darkly/src/engine/mod.rs index 70844f40..bcc1a360 100644 --- a/crates/darkly/src/engine/mod.rs +++ b/crates/darkly/src/engine/mod.rs @@ -45,7 +45,6 @@ mod perf; use crate::brush::gpu_context::BrushPerfCounters; use crate::brush::checkpoint_ring::CheckpointRing; -use crate::brush::library::BrushLibrary; use crate::brush::pipeline::BrushPipelines; use crate::brush::preview_renderer::BrushStrokePreviewRenderer; use crate::brush::stabilizer::StabilizerRegistry; @@ -238,12 +237,11 @@ pub(crate) enum ReadbackContext { /// caching stale results if another render has superseded this one. graph_version: u64, }, - /// Async readback of the preview render used to bake a `.darkly-brush` - /// archive's embedded `preview.png`. Completion PNG-encodes the pixels - /// and installs the result on the library entry via - /// `BrushLibrary::set_thumbnail`. + /// Async readback of the preview render baked for a brush's picker tile. + /// Completion PNG-encodes the pixels and installs the result on the + /// library entry via `BrushLibrary::set_thumbnail`. BrushThumbnailForSave { - name: String, + id: String, width: u32, height: u32, /// See [`ReadbackContext::BrushStrokePreview::backdrop`]. @@ -255,7 +253,7 @@ pub(crate) enum ReadbackContext { /// `BrushLibrary::set_dab_thumbnail`. Used by the picker tiles to /// show a tip silhouette next to the stroke thumbnail. BrushDabThumbnail { - name: String, + id: String, width: u32, height: u32, }, @@ -562,9 +560,6 @@ pub struct DarklyEngine { /// canvas as it then stands. pub(crate) previews: HashMap, - // --- Brush Library --- - pub(crate) brush_library: BrushLibrary, - /// Stroke buffer for stabilizer-driven rewind + re-render. pub(crate) stroke_buffer: Option, @@ -768,13 +763,6 @@ impl DarklyEngine { preview_source_is_composite: false, preview_active: None, previews: HashMap::new(), - brush_library: { - let mut lib = BrushLibrary::new(); - for brush in crate::brush::builtin_brushes::all() { - lib.insert(brush); - } - lib - }, stroke_buffer: None, checkpoint_ring: CheckpointRing::new(), stabilizer_registry: StabilizerRegistry::new(), @@ -868,6 +856,15 @@ impl DarklyEngine { self.compositor.tool_overlay().cursor_preview_mask_size() } + /// Cumulative canvas-space bbox of every dab the in-flight stroke has + /// recorded — the region the checkpoint ring saves and restores on a + /// mid-stroke rewind. `None` when no stroke is in flight or no dab has + /// been placed. Test-only. + #[cfg(any(test, feature = "testing"))] + pub fn test_stroke_save_point_bbox(&self) -> Option { + self.brush_stroke_engine.as_ref()?.save_points.full_bbox() + } + /// Whether the frame loop would schedule another frame right now — the /// `needs_more` value `render` returns to JS. Test-only. #[cfg(any(test, feature = "testing"))] diff --git a/crates/darkly/src/engine/painting.rs b/crates/darkly/src/engine/painting.rs index c18e98b1..460de57c 100644 --- a/crates/darkly/src/engine/painting.rs +++ b/crates/darkly/src/engine/painting.rs @@ -63,6 +63,18 @@ impl DarklyEngine { crate::brush::nodes::brush_settings::base_size(&brush.graph) } + /// Read the active brush's stamp turn rate from its + /// `brush_settings.stamp_angle_rate` knob — radians per brush diameter of + /// travel, read out-of-band at stroke start like spacing and base size. + fn active_stamp_angle_rate(&self) -> f32 { + use crate::brush::state::BrushState; + let tool = self.tool_session.read(); + let brush = tool + .get::() + .expect("BrushState registered at session init"); + crate::brush::nodes::brush_settings::stamp_angle_rate(&brush.graph) + } + /// Flush any pending diff-based undo commit. Called before overwriting the /// scratch texture (e.g. at the start of a new stroke). Uses Poll (not Wait) /// — if the diff hasn't completed yet, falls back to a full-canvas rect. @@ -947,6 +959,7 @@ impl DarklyEngine { stabilizer, clone_source_anchor, StrokeEngine::random_seed(), + self.active_stamp_angle_rate(), )); // Merged clone freezes the root composite, so make sure it's diff --git a/crates/darkly/src/engine/preview.rs b/crates/darkly/src/engine/preview.rs index 6371b2b8..b2cbc1fa 100644 --- a/crates/darkly/src/engine/preview.rs +++ b/crates/darkly/src/engine/preview.rs @@ -12,8 +12,9 @@ //! bounds the memory — opening a picker with seventeen animated cards would //! otherwise put the whole sequence's staging buffers in flight at once. //! -//! Capture is asynchronous throughout (`AGENTS.md` §No Blocking GPU Readbacks): -//! each frame's readback is appended to the *same* submission that encoded it, +//! Capture is asynchronous throughout (`CONTRIBUTING.md` §No Blocking GPU +//! Readbacks): each frame's readback is appended to the *same* submission that +//! encoded it, //! so it captures that frame before the next overwrites the output texture. use super::DarklyEngine; diff --git a/crates/darkly/src/engine/protocol/handlers/brush_library.rs b/crates/darkly/src/engine/protocol/handlers/brush_library.rs index 3a769b3b..5d03b63a 100644 --- a/crates/darkly/src/engine/protocol/handlers/brush_library.rs +++ b/crates/darkly/src/engine/protocol/handlers/brush_library.rs @@ -1,31 +1,30 @@ -//! Brush-bundle export. The rest of the brush library (list / save / load / -//! import / thumbnails) is `#[handler]`-generated on `engine/brush_library.rs`; -//! `brush_export` stays hand-written because it's a *fallible* binary response -//! (`Result, String>`) — the `returns = bytes` mode is infallible, and -//! the engine error must reject rather than ride the side-channel. +//! Brush-pack export. The rest of the brush library (list / save / load / +//! import / packs / thumbnails) is `#[handler]`-generated on +//! `engine/brush_library.rs`; `pack_export` stays hand-written because it's a +//! *fallible* binary response (`Result, String>`) — the +//! `returns = bytes` mode is infallible, and the engine error must reject +//! rather than ride the side-channel. use serde::Deserialize; use crate::engine::protocol::{decode, ProtocolError, RequestRegistration, Response}; -/// `{ name }` — the brush to export as a bundle. +/// `{ id }` — the pack to export as a `.darkly-brush` archive. #[derive(Deserialize)] #[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] -pub struct BrushExportReq { - pub name: String, +pub struct PackExportReq { + pub id: String, } pub fn registrations() -> Vec { vec![ - RequestRegistration::new("brush_export", |engine, payload, _b| { - let r: BrushExportReq = decode(payload)?; - let bytes = engine - .brush_export(&r.name) - .map_err(ProtocolError::engine)?; + RequestRegistration::new("pack_export", |engine, payload, _b| { + let r: PackExportReq = decode(payload)?; + let bytes = engine.pack_export(&r.id).map_err(ProtocolError::engine)?; Ok(Response::binary(serde_json::Value::Null, bytes)) }) .send() - .req::() + .req::() .resp_literal("{ bytes: Uint8Array }"), ] } diff --git a/crates/darkly/src/engine/rendering.rs b/crates/darkly/src/engine/rendering.rs index baf9a5c6..185abfe4 100644 --- a/crates/darkly/src/engine/rendering.rs +++ b/crates/darkly/src/engine/rendering.rs @@ -474,7 +474,7 @@ impl DarklyEngine { } } ReadbackContext::BrushThumbnailForSave { - name, + id, width, height, backdrop, @@ -492,17 +492,13 @@ impl DarklyEngine { ); let png_bytes = encode_rgba_as_png(&framed, tw, th); if !png_bytes.is_empty() { - self.brush_library.set_thumbnail(&name, png_bytes); + crate::brush::library::with_mut(|lib| lib.set_thumbnail(&id, png_bytes)); } } - ReadbackContext::BrushDabThumbnail { - name, - width, - height, - } => { + ReadbackContext::BrushDabThumbnail { id, width, height } => { let png_bytes = frame_dab_thumbnail(&pixels, width, height, self.preview_theme_bg); if !png_bytes.is_empty() { - self.brush_library.set_dab_thumbnail(&name, png_bytes); + crate::brush::library::with_mut(|lib| lib.set_dab_thumbnail(&id, png_bytes)); } } ReadbackContext::BrushCursorPreviewScale { diff --git a/crates/darkly/src/format/mod.rs b/crates/darkly/src/format/mod.rs index 8d9ccc57..fdaabee1 100644 --- a/crates/darkly/src/format/mod.rs +++ b/crates/darkly/src/format/mod.rs @@ -10,7 +10,6 @@ pub mod manifest; pub mod registry_io; pub mod stroke_recording; pub mod unzip; -#[cfg(test)] pub mod zip_io; #[cfg(test)] diff --git a/crates/darkly/src/format/tests.rs b/crates/darkly/src/format/tests.rs index 88894643..93bbde65 100644 --- a/crates/darkly/src/format/tests.rs +++ b/crates/darkly/src/format/tests.rs @@ -394,7 +394,8 @@ fn instance_payload_shape_is_type_id_plus_params() { use crate::document::Document; use crate::engine::DarklyEngine; use crate::format::manifest::SaveBundle; -use crate::format::zip_io::{assemble_zip, extract_zip}; +use crate::format::unzip::unzip_entries; +use crate::format::zip_io::assemble_zip; use crate::layer::LayerId; /// Populate the engine with at least one of every closed-set variant @@ -564,13 +565,13 @@ fn round_trip_kitchen_sink_document() { let bundle = drive_save_to_completion(&mut original); let zip_bytes = assemble_zip(&bundle); - let entries = extract_zip(&zip_bytes); + let entries = unzip_entries(&zip_bytes).expect("kitchen-sink zip must be readable"); assert!( - entries.get("manifest.json").is_some(), + entries.contains_key("manifest.json"), "kitchen-sink zip must contain manifest.json" ); assert!( - entries.get("composite.png").is_some(), + entries.contains_key("composite.png"), "kitchen-sink zip must contain composite.png" ); diff --git a/crates/darkly/src/format/zip_io.rs b/crates/darkly/src/format/zip_io.rs index 9522c90b..4921bcaf 100644 --- a/crates/darkly/src/format/zip_io.rs +++ b/crates/darkly/src/format/zip_io.rs @@ -1,97 +1,138 @@ -//! Test-only zip assembly and extraction for `.darkly` containers. +//! Zip assembly for Darkly's containers. //! -//! Production save assembles the zip in JS (via `fflate`) to keep slow -//! encoders off the WASM main thread. This module exists purely so the -//! Rust-side kitchen-sink test can drive the full save→file→reload loop -//! without crossing the WASM/JS boundary. +//! [`write_entries`] is the counterpart to +//! [`unzip_entries`](super::unzip::unzip_entries) and is production code: the +//! brush-pack archive is written through it. //! -//! Gated `#[cfg(test)]` at the module declaration in -//! [`super::mod`] — never reachable from engine or WASM code. +//! `.darkly` *document* saves remain a JS-side write (via `fflate`) to keep +//! slow encoders off the WASM main thread — [`assemble_zip`] exists only so +//! the Rust-side kitchen-sink test can drive the full save→file→reload loop +//! without crossing the WASM/JS boundary, and is gated `#[cfg(test)]` +//! accordingly. A pack is a handful of small JSONs, so writing one in Rust +//! does not run into that constraint. -use std::collections::HashMap; -use std::io::{Cursor, Read, Write}; +use std::io::{Cursor, Write}; -use super::manifest::SaveBundle; +use super::error::LoadError; -/// Path inside the zip for the manifest JSON. -const MANIFEST_PATH: &str = "manifest.json"; -/// Path inside the zip for the baked composite PNG. The save flow stores -/// raw RGBA in `SaveBundle::composite_rgba`; this helper PNG-encodes it -/// on the way into the zip so the extracted archive is consumable by any -/// standard tool (file managers, image viewers). -const COMPOSITE_PATH: &str = "composite.png"; - -/// Assemble a `SaveBundle` into the `.darkly` zip bytes used by the -/// kitchen-sink test. Mirrors what JS does in production via `fflate`: -/// -/// 1. Write `manifest.json` verbatim from `bundle.manifest_json`. -/// 2. PNG-encode the composite RGBA and write to `composite.png`. -/// 3. Write each `blobs[i].path` → `blobs[i].bytes` verbatim. +/// Write named entries into a zip. /// -/// Compression is Deflated — matches what the JS path will produce. -pub fn assemble_zip(bundle: &SaveBundle) -> Vec { - let buf = Vec::new(); - let cursor = Cursor::new(buf); - let mut zip = zip::ZipWriter::new(cursor); - let options = - zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - - zip.start_file(MANIFEST_PATH, options).unwrap(); - zip.write_all(&bundle.manifest_json).unwrap(); - - let composite_png = encode_rgba_as_png( - &bundle.composite_rgba, - bundle.composite_width, - bundle.composite_height, - ); - zip.start_file(COMPOSITE_PATH, options).unwrap(); - zip.write_all(&composite_png).unwrap(); - - for blob in &bundle.blobs { - zip.start_file(&blob.path, options).unwrap(); - zip.write_all(&blob.bytes).unwrap(); +/// `method` is a parameter rather than a constant because the two callers +/// genuinely differ: the `.darkly` test container is `Stored`, and a pack +/// archive of JSON compresses well enough to be worth `Deflated`. Hardcoding +/// either would silently change the other's output. +pub fn write_entries( + entries: &[(&str, &[u8])], + method: zip::CompressionMethod, +) -> Result, LoadError> { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default().compression_method(method); + + for (path, bytes) in entries { + zip.start_file(*path, options) + .map_err(|e| LoadError::Zip(e.to_string()))?; + zip.write_all(bytes) + .map_err(|e| LoadError::Zip(e.to_string()))?; } - let cursor = zip.finish().unwrap(); - cursor.into_inner() + let cursor = zip.finish().map_err(|e| LoadError::Zip(e.to_string()))?; + Ok(cursor.into_inner()) } -/// All entries extracted from a `.darkly` zip, keyed by zip path. Used -/// by the kitchen-sink test to feed bytes into the load path without -/// going through the production unzip code (which Phase 4 will own). -pub struct ZipEntries { - pub entries: HashMap>, -} +#[cfg(test)] +pub use test_only::assemble_zip; + +#[cfg(test)] +mod test_only { + use std::io::Cursor; + + use super::write_entries; + use crate::format::manifest::SaveBundle; + + /// Path inside the zip for the manifest JSON. + const MANIFEST_PATH: &str = "manifest.json"; + /// Path inside the zip for the baked composite PNG. The save flow stores + /// raw RGBA in `SaveBundle::composite_rgba`; this helper PNG-encodes it + /// on the way into the zip so the extracted archive is consumable by any + /// standard tool (file managers, image viewers). + const COMPOSITE_PATH: &str = "composite.png"; + + /// Assemble a `SaveBundle` into the `.darkly` zip bytes used by the + /// kitchen-sink test. Mirrors what JS does in production via `fflate`: + /// + /// 1. Write `manifest.json` verbatim from `bundle.manifest_json`. + /// 2. PNG-encode the composite RGBA and write to `composite.png`. + /// 3. Write each `blobs[i].path` → `blobs[i].bytes` verbatim. + /// + /// Entries are `Stored`, which is what this container has always been. + pub fn assemble_zip(bundle: &SaveBundle) -> Vec { + let composite_png = encode_rgba_as_png( + &bundle.composite_rgba, + bundle.composite_width, + bundle.composite_height, + ); -impl ZipEntries { - pub fn get(&self, path: &str) -> Option<&[u8]> { - self.entries.get(path).map(Vec::as_slice) + let mut entries: Vec<(&str, &[u8])> = vec![ + (MANIFEST_PATH, &bundle.manifest_json), + (COMPOSITE_PATH, &composite_png), + ]; + for blob in &bundle.blobs { + entries.push((blob.path.as_str(), &blob.bytes)); + } + + write_entries(&entries, zip::CompressionMethod::Stored).expect("assembling a test zip") } -} -/// Extract every entry in a `.darkly` zip into a map keyed by path. -pub fn extract_zip(bytes: &[u8]) -> ZipEntries { - let cursor = Cursor::new(bytes); - let mut archive = zip::ZipArchive::new(cursor).unwrap(); - let mut entries = HashMap::with_capacity(archive.len()); - for i in 0..archive.len() { - let mut entry = archive.by_index(i).unwrap(); - let path = entry.name().to_string(); - let mut bytes = Vec::with_capacity(entry.size() as usize); - entry.read_to_end(&mut bytes).unwrap(); - entries.insert(path, bytes); + /// PNG-encode an RGBA8 buffer for the in-zip composite. Mirrors what JS + /// does in production via `OffscreenCanvas.convertToBlob`. + fn encode_rgba_as_png(rgba: &[u8], width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + let cursor = Cursor::new(&mut out); + use image::ImageEncoder; + image::codecs::png::PngEncoder::new(cursor) + .write_image(rgba, width, height, image::ExtendedColorType::Rgba8) + .unwrap(); + out } - ZipEntries { entries } } -/// PNG-encode an RGBA8 buffer for the in-zip composite. Mirrors what JS -/// does in production via `OffscreenCanvas.convertToBlob`. -fn encode_rgba_as_png(rgba: &[u8], width: u32, height: u32) -> Vec { - let mut out = Vec::new(); - let cursor = Cursor::new(&mut out); - use image::ImageEncoder; - image::codecs::png::PngEncoder::new(cursor) - .write_image(rgba, width, height, image::ExtendedColorType::Rgba8) - .unwrap(); - out +#[cfg(test)] +mod tests { + use super::*; + use crate::format::unzip::unzip_entries; + + #[test] + fn write_entries_round_trips_through_unzip_entries() { + // The shared writer and the production reader must agree, under either + // compression method — the parameter exists precisely because both are + // in use. + for method in [ + zip::CompressionMethod::Stored, + zip::CompressionMethod::Deflated, + ] { + let json = br#"{"format":"darkly-brush"}"#; + let blob: Vec = (0u8..=255).cycle().take(4096).collect(); + let bytes = write_entries(&[("pack.json", json), ("brushes/9f1c.json", &blob)], method) + .unwrap(); + + let entries = unzip_entries(&bytes).unwrap(); + assert_eq!(entries.len(), 2, "{method:?}"); + assert_eq!( + entries.get("pack.json").unwrap().as_slice(), + json, + "{method:?}" + ); + assert_eq!( + entries.get("brushes/9f1c.json").unwrap(), + &blob, + "{method:?}" + ); + } + } + + #[test] + fn writing_no_entries_yields_a_readable_empty_zip() { + let bytes = write_entries(&[], zip::CompressionMethod::Deflated).unwrap(); + assert!(unzip_entries(&bytes).unwrap().is_empty()); + } } diff --git a/crates/darkly/src/gpu/atlas.rs b/crates/darkly/src/gpu/atlas.rs index a0899508..4ec97112 100644 --- a/crates/darkly/src/gpu/atlas.rs +++ b/crates/darkly/src/gpu/atlas.rs @@ -11,7 +11,7 @@ use crate::coord::{CanvasPoint, CanvasRect, LayerPoint, LayerRect}; /// accessors ([`canvas_extent`], [`layer_extent`], [`canvas_to_layer*`], /// [`layer_to_canvas*`]) so the canvas/layer-local distinction lives in the /// type system rather than in convention. See module docs of -/// [`crate::coord`] and the project's CLAUDE.md for the rule: every +/// [`crate::coord`] and the project's CONTRIBUTING.md for the rule: every /// coordinate at every interface names its space; only the texture itself /// translates between them. /// diff --git a/crates/darkly/src/gpu/flood_fill.rs b/crates/darkly/src/gpu/flood_fill.rs index 5c200d59..513aa84a 100644 --- a/crates/darkly/src/gpu/flood_fill.rs +++ b/crates/darkly/src/gpu/flood_fill.rs @@ -19,8 +19,8 @@ use std::collections::VecDeque; /// The algorithm is the same scanline approach used by the tile-based fill, but /// operates on contiguous pixel data from a GPU readback. /// -/// Algorithm notes (per CLAUDE.md "Performance Principle"): the implementation -/// is Smith/Heckbert scanline fill — `VecDeque<(y, start, end)>` holds whole +/// Algorithm notes (per CONTRIBUTING.md "Performance Principle"): the +/// implementation is Smith/Heckbert scanline fill — `VecDeque<(y, start, end)>` holds whole /// horizontal segments, not per-pixel work. Queue depth is bounded by the /// number of distinct segments in the fill region (O(perimeter)), not the /// pixel count. The `mask` is a flat `Vec` indexed directly; no HashMap. diff --git a/crates/darkly/src/gpu/selection.rs b/crates/darkly/src/gpu/selection.rs index f9ad8fa1..7320720a 100644 --- a/crates/darkly/src/gpu/selection.rs +++ b/crates/darkly/src/gpu/selection.rs @@ -951,7 +951,7 @@ impl SelectionState { /// Borrow the current selection texture as a `CanvasFrame`. The selection /// texture is window-sized; its `canvas_extent` is window-local `(0, 0, /// w, h)` (the plane anchoring is realized by [`Self::resize`], not by a - /// non-zero extent origin — see CLAUDE.md selection notes). + /// non-zero extent origin — see CONTRIBUTING.md selection notes). pub fn canvas_frame(&self) -> crate::gpu::atlas::CanvasFrame<'_> { crate::gpu::atlas::CanvasFrame { texture: self.texture(), diff --git a/crates/darkly/src/lib.rs b/crates/darkly/src/lib.rs index 63f411e7..573c4a92 100644 --- a/crates/darkly/src/lib.rs +++ b/crates/darkly/src/lib.rs @@ -5,6 +5,11 @@ pub mod catalog; pub mod clipboard; pub mod config; pub mod coord; +/// Fills the marked regions of the repository's own markdown from the +/// registries. Repository tooling that walks a source tree, so it is native-only +/// — a browser has no checkout to sync. +#[cfg(not(target_arch = "wasm32"))] +pub mod docs_md; /// Renders the documentation preview assets. Performs blocking GPU readbacks, /// so it lives behind the same gate as `gpu::test_utils` — engine, compositor /// and WASM-bridge code cannot name it in a production build. diff --git a/crates/darkly/tests/brush_editor_preview.rs b/crates/darkly/tests/brush_editor_preview.rs index 9e8b3b57..b6f4def3 100644 --- a/crates/darkly/tests/brush_editor_preview.rs +++ b/crates/darkly/tests/brush_editor_preview.rs @@ -276,7 +276,7 @@ fn set_preview_theme_invalidates_cache() { #[test] fn brush_save_bakes_thumbnail_asynchronously() { - use darkly::brush::bundle::Brush; + use darkly::brush::library; use darkly::engine::DarklyEngine; use darkly::gpu::context::GpuContext; @@ -285,14 +285,12 @@ fn brush_save_bakes_thumbnail_asynchronously() { let mut engine = DarklyEngine::new(gpu, 1024, 768); // Save a brush — kicks off an async thumbnail readback against the - // engine's library copy. - engine.brush_save("TestBrush", "basic").unwrap(); + // process-wide library. + engine.brush_save("test_brush", "TestBrush").unwrap(); // Before the readback lands, the library entry has no thumbnail. - let exported_before = engine.brush_export("TestBrush").expect("brush exported"); - let bundle_before = Brush::from_bytes(&exported_before).unwrap(); assert!( - bundle_before.thumbnail_png.is_none(), + library::with(|lib| lib.thumbnail_png("test_brush").is_none()), "thumbnail should be absent before readback completes" ); @@ -300,11 +298,11 @@ fn brush_save_bakes_thumbnail_asynchronously() { // back onto the library entry. engine.test_flush_readbacks(); - let exported_after = engine.brush_export("TestBrush").unwrap(); - let bundle_after = Brush::from_bytes(&exported_after).unwrap(); - let png = bundle_after - .thumbnail_png - .expect("thumbnail present after readback"); + let png = library::with(|lib| { + lib.thumbnail_png("test_brush") + .expect("thumbnail present after readback") + .to_vec() + }); // Valid PNG — starts with the PNG magic signature. assert_eq!( &png[..8], diff --git a/crates/darkly/tests/brush_erase.rs b/crates/darkly/tests/brush_erase.rs index e5d9e44d..7d1761a8 100644 --- a/crates/darkly/tests/brush_erase.rs +++ b/crates/darkly/tests/brush_erase.rs @@ -12,7 +12,7 @@ //! Run with: `cargo test -p darkly --test brush_erase -- --test-threads=1` //! (GPU integration tests share a process-wide wgpu device.) //! -//! Per CLAUDE.md's Testing Principle: confirm this test FAILS against the +//! Per CONTRIBUTING.md's Testing Principle: confirm this test FAILS against the //! unfixed `paint.rs` (per-dab `erase_pipeline` branch leaves the scratch //! at zero, so `destination_out` is a no-op), then passes after removing //! that branch. diff --git a/crates/darkly/tests/brush_packs.rs b/crates/darkly/tests/brush_packs.rs new file mode 100644 index 00000000..4adb8ad6 --- /dev/null +++ b/crates/darkly/tests/brush_packs.rs @@ -0,0 +1,356 @@ +//! End-to-end coverage for brush packs through a real `DarklyEngine`. +//! +//! The library is process-global, so each test resets it first — otherwise one +//! test's packs leak into the next within this binary. + +use darkly::brush::library; +use darkly::engine::DarklyEngine; +use darkly::gpu::context::GpuContext; +use darkly::gpu::test_utils::test_device; + +fn fresh_engine() -> DarklyEngine { + library::reset_for_test(); + let (device, queue) = test_device(); + let gpu = GpuContext::new_headless(device, queue); + DarklyEngine::new(gpu, 1024, 768) +} + +#[test] +fn library_list_reports_every_shipped_pack_with_its_members() { + let engine = fresh_engine(); + let snap = engine.library_list(); + + assert!(!snap.brushes.is_empty(), "shipped brushes are listed"); + let ids: Vec<&str> = snap.packs.iter().map(|p| p.id.as_str()).collect(); + for expected in ["basic", "dry_media", "wet_media", "effects", "misc"] { + assert!(ids.contains(&expected), "pack '{expected}' is listed"); + } + + // Every member id resolves to a brush in the same snapshot. + for pack in &snap.packs { + for member in &pack.members { + assert!( + snap.brushes.iter().any(|b| &b.id == member), + "pack '{}' names '{member}', absent from the same snapshot", + pack.id + ); + } + } + + let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); + assert!(basic.members.contains(&"ink_pen".to_string())); +} + +#[test] +fn pack_info_reports_permissions_matching_the_pack() { + let engine = fresh_engine(); + let snap = engine.library_list(); + + let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); + assert!(!basic.can_edit_members, "a shipped pack is fixed"); + assert!(!basic.can_edit_identity); +} + +#[test] +fn brush_save_then_library_list_shows_it() { + let mut engine = fresh_engine(); + engine.brush_save("my_brush", "My Brush").unwrap(); + + let snap = engine.library_list(); + let saved = snap + .brushes + .iter() + .find(|b| b.id == "my_brush") + .expect("the saved brush is listed"); + assert_eq!(saved.name, "My Brush"); + + // Saved brushes belong to no pack until the painter puts them in one, and + // that is a reachable, safe state. + assert!(!snap.packs.iter().any(|p| p.members.contains(&saved.id))); +} + +#[test] +fn brush_info_reports_who_may_edit_the_brush() { + // The wire hint the UI greys affordances by, and the engine gate behind + // it. A shipped brush is rebuilt from YAML on every boot, so an edit to + // one would appear to work and then undo itself. + let mut engine = fresh_engine(); + engine.brush_save("my_brush", "My Brush").unwrap(); + + let snap = engine.library_list(); + let shipped = snap.brushes.iter().find(|b| b.id == "ink_pen").unwrap(); + let mine = snap.brushes.iter().find(|b| b.id == "my_brush").unwrap(); + + assert!(!shipped.can_edit, "a shipped brush is not the painter's"); + assert!(mine.can_edit, "one they saved is"); + + // The hint is not the authority: the engine refuses regardless. + assert!(engine.brush_rename("ink_pen", "Mine Now").is_err()); + assert!(engine.brush_delete("ink_pen").is_err()); + engine.brush_rename("my_brush", "Renamed").unwrap(); + engine.brush_delete("my_brush").unwrap(); +} + +#[test] +fn brush_save_rejects_an_empty_id() { + let mut engine = fresh_engine(); + assert!(engine.brush_save(" ", "Nameless").is_err()); +} + +#[test] +fn brush_save_rejects_a_name_another_brush_already_has() { + // Names are the engine's public lookup key, so two brushes sharing one + // makes `brush_load` ambiguous. `brush_rename` has always refused this; + // saving refuses it identically. + let mut engine = fresh_engine(); + assert!(engine.brush_save("mine", "Ink Pen").is_err()); + assert!(engine.brush_save("mine", " ").is_err()); + + engine.brush_save("mine", "My Brush").unwrap(); + // Re-saving under the same id keeps the name: that is an update, not a + // collision with itself. + engine.brush_save("mine", "My Brush").unwrap(); + assert!(engine.brush_save("other", "My Brush").is_err()); +} + +#[test] +fn a_shipped_brush_can_be_copied_into_a_painters_pack() { + let mut engine = fresh_engine(); + engine + .pack_create("mine", "Mine", "", "mdi:star", "#f5c542", "#2b2213") + .unwrap(); + engine.pack_add_brush("mine", "ink_pen").unwrap(); + + let snap = engine.library_list(); + let mine = snap.packs.iter().find(|p| p.id == "mine").unwrap(); + let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); + + assert!(mine.members.contains(&"ink_pen".to_string())); + assert!( + basic.members.contains(&"ink_pen".to_string()), + "copying into a pack does not remove it from another" + ); +} + +#[test] +fn mutating_a_locked_pack_is_rejected_through_the_engine() { + let mut engine = fresh_engine(); + engine.brush_save("mine", "Mine").unwrap(); + + assert!(engine.pack_add_brush("basic", "mine").is_err()); + assert!(engine.pack_remove_brush("basic", "ink_pen").is_err()); + assert!(engine.pack_delete("basic").is_err()); + assert!(engine + .pack_edit("basic", "Renamed", "", "mdi:brush", "#000000", "#ffffff") + .is_err()); + + // Nothing changed. + let snap = engine.library_list(); + let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); + assert_eq!(basic.name, "Basic"); + assert!(basic.members.contains(&"ink_pen".to_string())); +} + +#[test] +fn a_painter_pack_is_created_edited_and_deleted() { + let mut engine = fresh_engine(); + engine + .pack_create("mine", "Mine", "d", "mdi:water", "#3355ff", "#ffffff") + .unwrap(); + engine.pack_add_brush("mine", "ink_pen").unwrap(); + engine.pack_add_brush("mine", "charcoal").unwrap(); + + engine.pack_reorder_brush("mine", "charcoal", 0).unwrap(); + let snap = engine.library_list(); + let mine = snap.packs.iter().find(|p| p.id == "mine").unwrap(); + assert_eq!(mine.members, vec!["charcoal", "ink_pen"]); + assert!(mine.can_edit_members && mine.can_edit_identity); + + engine + .pack_edit("mine", "Renamed", "d2", "mdi:brush", "#111111", "#222222") + .unwrap(); + assert_eq!( + engine + .library_list() + .packs + .iter() + .find(|p| p.id == "mine") + .unwrap() + .name, + "Renamed" + ); + + engine.pack_delete("mine").unwrap(); + let snap = engine.library_list(); + assert!(!snap.packs.iter().any(|p| p.id == "mine")); + // Its brushes survived, still in the packs that shipped them. + assert!(snap.brushes.iter().any(|b| b.id == "ink_pen")); + let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); + assert!(basic.members.contains(&"ink_pen".to_string())); +} + +#[test] +fn pack_export_import_round_trip_through_the_engine() { + let mut engine = fresh_engine(); + engine + .pack_create("mine", "Mine", "d", "mdi:water", "#3355ff", "#ffffff") + .unwrap(); + engine.brush_save("custom", "Custom").unwrap(); + engine.pack_add_brush("mine", "custom").unwrap(); + + let bytes = engine.pack_export("mine").unwrap(); + + // Delete both the pack and its brush, then bring them back. + engine.pack_delete("mine").unwrap(); + engine.brush_delete("custom").unwrap(); + assert!(!engine + .library_list() + .brushes + .iter() + .any(|b| b.id == "custom")); + + let id = engine.pack_import("restored", &bytes).unwrap(); + assert_eq!(id, "restored"); + + let snap = engine.library_list(); + let restored = snap.packs.iter().find(|p| p.id == "restored").unwrap(); + assert_eq!(restored.name, "Mine"); + assert_eq!(restored.icon, "mdi:water"); + assert_eq!(restored.members, vec!["custom"]); + assert!( + snap.brushes.iter().any(|b| b.id == "custom"), + "the brush came back with the pack" + ); +} + +#[test] +fn importing_a_pack_holding_a_brush_we_have_reuses_ours() { + let mut engine = fresh_engine(); + engine.brush_save("my_brush", "My Brush").unwrap(); + engine + .pack_create("mine", "Mine", "", "mdi:water", "#3355ff", "#ffffff") + .unwrap(); + engine.pack_add_brush("mine", "my_brush").unwrap(); + let bytes = engine.pack_export("mine").unwrap(); + + let before = engine.library_list().brushes.len(); + engine.brush_rename("my_brush", "My Renamed Brush").unwrap(); + engine.pack_import("theirs", &bytes).unwrap(); + + let snap = engine.library_list(); + assert_eq!(snap.brushes.len(), before, "the library did not grow"); + assert_eq!( + snap.brushes + .iter() + .find(|b| b.id == "my_brush") + .unwrap() + .name, + "My Renamed Brush", + "our copy wins over the sender's" + ); + let theirs = snap.packs.iter().find(|p| p.id == "theirs").unwrap(); + assert_eq!(theirs.name, "Mine (2)", "the colliding name is suffixed"); +} + +#[test] +fn importing_corrupt_bytes_is_rejected_and_changes_nothing() { + let mut engine = fresh_engine(); + let before = engine.library_list(); + + assert!(engine.pack_import("new", b"not a pack at all").is_err()); + + let after = engine.library_list(); + assert_eq!(before.packs.len(), after.packs.len()); + assert_eq!(before.brushes.len(), after.brushes.len()); +} + +#[test] +fn renaming_a_brush_leaves_pack_membership_intact() { + let mut engine = fresh_engine(); + engine.brush_save("my_brush", "My Brush").unwrap(); + engine + .pack_create("mine", "Mine", "", "mdi:water", "#3355ff", "#ffffff") + .unwrap(); + engine.pack_add_brush("mine", "my_brush").unwrap(); + let before = engine + .library_list() + .packs + .iter() + .find(|p| p.id == "mine") + .unwrap() + .members + .clone(); + + engine.brush_rename("my_brush", "Fancy Nib").unwrap(); + + let snap = engine.library_list(); + let mine = snap.packs.iter().find(|p| p.id == "mine").unwrap(); + assert_eq!(mine.members, before, "membership is id-keyed"); + assert_eq!( + snap.brushes + .iter() + .find(|b| b.id == "my_brush") + .unwrap() + .name, + "Fancy Nib" + ); +} + +#[test] +fn deleting_a_brush_removes_it_from_every_pack_through_the_engine() { + let mut engine = fresh_engine(); + engine.brush_save("my_brush", "My Brush").unwrap(); + engine + .pack_create("mine", "Mine", "", "mdi:star", "#f5c542", "#2b2213") + .unwrap(); + engine.pack_add_brush("mine", "my_brush").unwrap(); + + engine.brush_delete("my_brush").unwrap(); + + let snap = engine.library_list(); + assert!(!snap.brushes.iter().any(|b| b.id == "my_brush")); + for pack in &snap.packs { + assert!( + !pack.members.contains(&"my_brush".to_string()), + "pack '{}' still names the deleted brush", + pack.id + ); + } + assert!(engine.brush_delete("ink_pen").is_err(), "already gone"); +} + +#[test] +fn two_engines_share_one_library() { + // The whole point of a process-global library: a brush saved through one + // canvas handle is immediately visible through the next. + let mut first = fresh_engine(); + first.brush_save("shared", "Shared").unwrap(); + + let (device, queue) = test_device(); + let second = DarklyEngine::new(GpuContext::new_headless(device, queue), 64, 64); + + assert!( + second + .library_list() + .brushes + .iter() + .any(|b| b.id == "shared"), + "the second engine sees the first engine's brush" + ); +} + +#[test] +fn brush_load_still_takes_a_name() { + // Names stay the engine's public lookup key even though identity is an id. + let mut engine = fresh_engine(); + engine.brush_load("Ink Pen").unwrap(); + assert!(engine.brush_load("No Such Brush").is_err()); + + engine.brush_save("my_brush", "My Brush").unwrap(); + engine.brush_rename("my_brush", "Fancy Nib").unwrap(); + engine.brush_load("Fancy Nib").unwrap(); + assert!( + engine.brush_load("My Brush").is_err(), + "the old name no longer resolves" + ); +} diff --git a/crates/darkly/tests/brush_preview_staging.rs b/crates/darkly/tests/brush_preview_staging.rs index 8bfad60d..2370253a 100644 --- a/crates/darkly/tests/brush_preview_staging.rs +++ b/crates/darkly/tests/brush_preview_staging.rs @@ -265,7 +265,7 @@ fn the_dab_slot_belongs_to_the_icon() { Some(icon), ); assert_eq!( - darkly::brush::library::BrushInfo::from(&brush.metadata).icon, + darkly::brush::library::BrushInfo::from(brush).icon, Some(icon), "'{name}' projects its glyph to the picker" ); diff --git a/crates/darkly/tests/brush_stamp_angle.rs b/crates/darkly/tests/brush_stamp_angle.rs new file mode 100644 index 00000000..885eb709 --- /dev/null +++ b/crates/darkly/tests/brush_stamp_angle.rs @@ -0,0 +1,203 @@ +//! Native-only integration test for the stamp turn-rate limit. +//! +//! The unit tests in `stroke_engine.rs` pin the tracker's math against the +//! free function directly. This one proves the *wiring*: that the +//! `brush_settings.stamp_angle_rate` port is read at stroke start, reaches the +//! tracker, lands back on the dab's drawing angle, and rotates the stamp the +//! shader draws. Delete any link in that chain and every unit test still +//! passes while the feature is silently gone. +//! +//! Uses the blocking `test_utils::readback_texture` helper — native only. + +use darkly::brush::paint_info::PaintInformation; +use darkly::brush::{ + default_graph, nodes::brush_settings, pipeline::BrushPipelines, + preview_renderer::BrushStrokePreviewRenderer, +}; +use darkly::gpu::preview::PreviewBackdrop; +use darkly::gpu::test_utils::{readback_texture, test_device}; +use darkly::nodegraph::PortRef; + +const WIDTH: u32 = 160; +const HEIGHT: u32 = 160; + +/// An L-shaped path: out along +x, then a hard 90° turn up along +y. +/// +/// A 90° corner rather than a hairpin, deliberately. The axis fold makes a +/// 180° reversal a no-op at *any* rate — that is the whole point of it — so a +/// hairpin would render identically under both settings and prove nothing. +fn corner_path() -> Vec { + let mut out = Vec::new(); + let corner = [110.0_f32, 80.0_f32]; + for i in 0..24 { + out.push(sample([30.0 + i as f32 * (80.0 / 23.0), corner[1]], i)); + } + for i in 1..24 { + out.push(sample( + [corner[0], corner[1] - i as f32 * (60.0 / 23.0)], + 23 + i, + )); + } + out +} + +fn sample(pos: [f32; 2], i: usize) -> PaintInformation { + PaintInformation { + pos, + pressure: 1.0, + time: i as f32 * 0.008, + ..Default::default() + } +} + +/// The default brush, with a strongly anisotropic tip whose orientation +/// follows the stroke — so the stamp's angle is visible in the pixels — and +/// the given turn rate. +fn graph_with_rate(rate: f32) -> darkly::nodegraph::Graph { + let mut graph = default_graph(); + + let circle = graph + .nodes() + .iter() + .find(|(_, n)| n.type_id == "circle") + .map(|(id, _)| id.clone()) + .expect("default graph has a circle node"); + let pen = graph + .nodes() + .iter() + .find(|(_, n)| n.type_id == "pen_input") + .map(|(id, _)| id.clone()) + .expect("default graph has a pen_input node"); + + // A slit rather than a disc: a disc is rotationally symmetric and would + // render identically at every orientation. + graph.set_port_default(&circle, "aspect", 0.12).unwrap(); + graph + .connect( + PortRef { + node: pen, + port: "drawing_angle".into(), + }, + PortRef { + node: circle.clone(), + port: "rotation_input".into(), + }, + ) + .expect("drawing_angle -> rotation_input"); + + let settings = brush_settings::node_id(&graph).expect("default graph has brush_settings"); + graph.set_port_default(&settings, "size", 0.35).unwrap(); + graph + .set_port_default(&settings, "stamp_angle_rate", rate) + .unwrap(); + + graph +} + +fn render(rate: f32) -> Vec { + let (device, queue) = test_device(); + let pipelines = BrushPipelines::new( + &device, + &queue, + &darkly::gpu::selection::selection_mask_bgl(&device), + ); + let mut renderer = BrushStrokePreviewRenderer::new(); + + let texture = renderer + .render_stroke( + &device, + &queue, + &pipelines, + &graph_with_rate(rate), + &corner_path(), + [1.0, 1.0, 1.0, 1.0], + [0.0, 0.0, 0.0, 1.0], + PreviewBackdrop::Flat, + WIDTH, + HEIGHT, + None, + ) + .expect("render_stroke should return a texture"); + + readback_texture( + &device, + &queue, + texture, + wgpu::TextureFormat::Rgba8Unorm, + WIDTH, + HEIGHT, + ) +} + +/// Clamping the turn rate must visibly change what gets painted around a +/// corner — the stamp lags into the turn instead of snapping through it. +#[test] +fn turn_rate_changes_the_painted_corner() { + let free = render(brush_settings::STAMP_ANGLE_RATE_UNLIMITED); + // 20°/width: at the default 10% spacing, 2° per dab — a 90° corner then + // takes tens of dabs to come around instead of one. + let damped = render(20.0_f32.to_radians()); + + assert_eq!(free.len(), damped.len()); + + let differing = free + .chunks_exact(4) + .zip(damped.chunks_exact(4)) + .filter(|(a, b)| { + // Compare luminance-ish: the stroke is white on black, so any + // channel drifting is the stamp having covered different pixels. + (a[0] as i16 - b[0] as i16).abs() > 24 + }) + .count(); + + let painted = free.chunks_exact(4).filter(|p| p[0] > 24).count(); + assert!(painted > 0, "the unlimited render painted nothing at all"); + + assert!( + differing > painted / 20, + "clamping the turn rate must change the painted corner: only \ + {differing} px differ against {painted} px painted. If this is 0, the \ + rate never reached the tracker — check that \ + `brush_settings.stamp_angle_rate` is read at stroke start and applied \ + in `place_dab`." + ); +} + +/// A locked stamp (rate 0) holds the angle it started at for the whole +/// stroke, so the post-corner leg is painted with a stamp still oriented +/// along the *first* leg. Distinct from the test above: that one proves the +/// rate matters, this one proves which direction it biases. +#[test] +fn zero_rate_paints_the_whole_stroke_at_one_angle() { + let locked = render(0.0); + let free = render(brush_settings::STAMP_ANGLE_RATE_UNLIMITED); + + let count = |px: &[u8]| px.chunks_exact(4).filter(|p| p[0] > 24).count(); + assert!(count(&locked) > 0, "the locked render painted nothing"); + + let differing = locked + .chunks_exact(4) + .zip(free.chunks_exact(4)) + .filter(|(a, b)| (a[0] as i16 - b[0] as i16).abs() > 24) + .count(); + + assert!( + differing > count(&free) / 10, + "a stamp locked at its starting angle must paint the vertical leg \ + differently from one that turned to follow it; only {differing} px \ + differ" + ); +} + +/// The preview seed is fixed, so two renders of the same graph are identical +/// — without which the comparisons above could be measuring dab jitter. +#[test] +fn renders_are_deterministic() { + let a = render(brush_settings::STAMP_ANGLE_RATE_UNLIMITED); + let b = render(brush_settings::STAMP_ANGLE_RATE_UNLIMITED); + assert!( + a == b, + "the same graph must render identically twice, or the turn-rate \ + comparisons are measuring noise" + ); +} diff --git a/crates/darkly/tests/dab_footprint_ledger.rs b/crates/darkly/tests/dab_footprint_ledger.rs new file mode 100644 index 00000000..35c16143 --- /dev/null +++ b/crates/darkly/tests/dab_footprint_ledger.rs @@ -0,0 +1,127 @@ +//! The dab-footprint ledger: the save-point bbox a dab records must be the +//! footprint its terminal published for the pass it actually issued — never a +//! bbox recomputed from position and radius alongside it. +//! +//! Guards the failure class documented on `ExtentContribution` +//! (`crates/darkly/src/brush/wgsl/extent.rs`): a CPU-side geometric envelope +//! and the shader's real write footprint were maintained independently, +//! disagreed by the compiled brush's extent inflation, and a mid-stroke rewind +//! then cleared pixels outside the CPU bbox while restoring only into it — +//! visibly truncating earlier dabs into a square as the user kept painting. +//! +//! `smudge` gives a reachable instance. Its `read_half` early-outs on a +//! stationary dab, and `advance_dab_motion` reports zero motion for the first +//! dab of a stroke (there is no previous dab position yet), so that dab issues +//! no pass and writes no pixels. A dab that wrote nothing must claim no damage. +//! +//! Run with: `cargo test -p darkly --test dab_footprint_ledger --features +//! darkly/testing -- --test-threads=1` + +use darkly::engine::types::StrokeOp; +use darkly::engine::DarklyEngine; +use darkly::gpu::context::GpuContext; +use darkly::gpu::test_utils::test_device; +use darkly::layer::LayerId; + +const CANVAS: u32 = 256; + +fn test_engine() -> DarklyEngine { + let (device, queue) = test_device(); + let gpu = GpuContext::new_headless(device, queue); + DarklyEngine::new(gpu, CANVAS, CANVAS) +} + +fn set_builtin_brush(engine: &mut DarklyEngine, name: &str) { + let brush = darkly::brush::builtin_brushes::all() + .into_iter() + .find(|b| b.metadata.name == name) + .unwrap_or_else(|| panic!("builtin brush `{name}` not registered")); + let json = serde_json::to_string(&brush.metadata.graph).expect("serialize brush graph"); + engine.set_brush_graph(&json).expect("brush graph compiles"); +} + +fn stroke_to(engine: &mut DarklyEngine, x: f32, y: f32, time_ms: f64) { + engine.stroke_to(StrokeOp::BrushStroke { + x, + y, + pressure: 1.0, + x_tilt: 0.0, + y_tilt: 0.0, + rotation: 0.0, + tangential_pressure: 0.0, + time_ms, + cr: 1.0, + cg: 0.0, + cb: 0.0, + ca: 1.0, + }); +} + +fn painted_layer(engine: &mut DarklyEngine) -> LayerId { + let layer_id = engine.add_raster_layer(None); + // Smudge drags existing pigment; give it something to drag so the + // stationary early-out is the only reason a dab could write nothing. + set_builtin_brush(engine, "Ink Pen"); + engine.begin_stroke(layer_id); + stroke_to(engine, 100.0, 128.0, 0.0); + stroke_to(engine, 156.0, 128.0, 16.0); + engine.end_stroke(); + engine.render(0.0); + layer_id +} + +/// A dab whose terminal issued no pass must contribute nothing to the +/// cumulative save-point bbox. +/// +/// Before the fallback envelope was removed from `StrokeEngine::place_dab`, +/// an unpublished footprint fell back to a `pos ± effective_diameter / 2` +/// rect — a bbox recomputed from geometry, omitting the compiled brush's +/// `brush_extent_factor`, for a dab that wrote no pixels at all. +#[test] +fn dab_that_writes_nothing_records_no_damage() { + let mut engine = test_engine(); + let layer_id = painted_layer(&mut engine); + + set_builtin_brush(&mut engine, "Smudge"); + engine.begin_stroke(layer_id); + stroke_to(&mut engine, 128.0, 128.0, 0.0); + + let bbox = engine.test_stroke_save_point_bbox(); + assert!( + bbox.is_none_or(|r| r.is_empty()), + "smudge's first dab is stationary and issues no pass, so it must \ + record an empty footprint; got {bbox:?}. A non-empty rect here means \ + the save-point bbox was recomputed from position and radius rather \ + than taken from what the terminal published — the divergence that \ + lets a rewind clear pixels it cannot restore." + ); + + engine.end_stroke(); +} + +/// The complement: once the stroke moves, smudge does issue a pass, and the +/// recorded footprint must be non-empty. Without this the test above would +/// pass just as well against a ledger that never records anything. +#[test] +fn dab_that_writes_records_its_footprint() { + let mut engine = test_engine(); + let layer_id = painted_layer(&mut engine); + + set_builtin_brush(&mut engine, "Smudge"); + engine.begin_stroke(layer_id); + stroke_to(&mut engine, 100.0, 128.0, 0.0); + for i in 1..=8 { + stroke_to(&mut engine, 100.0 + 8.0 * i as f32, 128.0, 16.0 * i as f64); + } + + let bbox = engine + .test_stroke_save_point_bbox() + .expect("a moving smudge stroke places dabs"); + assert!( + !bbox.is_empty(), + "a smudge dab that issued a pass must record its write footprint; \ + got an empty rect" + ); + + engine.end_stroke(); +} diff --git a/crates/darkly/tests/docs_export.rs b/crates/darkly/tests/docs_export.rs index e6137233..9a068225 100644 --- a/crates/darkly/tests/docs_export.rs +++ b/crates/darkly/tests/docs_export.rs @@ -276,7 +276,9 @@ fn export_is_a_faithful_projection() { info.name.as_str(), info.icon, some(info.description.as_str()), - some(info.category.as_str()), + // Grouping is derived from shipped pack membership, the + // same way the catalog derives it. + darkly::brush::packs::pack_of(stem).map(|p| p.name.as_str()), None, ) }) @@ -380,22 +382,20 @@ fn export_is_a_faithful_projection() { ); } - // Settings ride on the same footing, against the section schema minus the - // prefs the UI does not treat as settings. + // Settings ride on the same footing, against the section schema. Every + // declared pref is exported, including those marked `Hidden` — the export + // is also the schema stored prefs are validated against, so a pref missing + // from it would be erased from the user's settings file on reload. for section in darkly::config::sections::registrations() { let cat = catalog(&json, &format!("settings.{}", section.id)); assert_eq!(cat["title"].as_str(), Some(section.display_name)); assert_eq!(cat["order"].as_i64(), Some(section.order as i64)); let params = cat["entries"][0]["params"].as_array().unwrap(); - let want: Vec<_> = section - .prefs - .iter() - .filter(|p| !matches!(p.widget, darkly::config::schema::WidgetHint::Hidden)) - .collect(); + let want: Vec<_> = section.prefs.iter().collect(); assert_eq!( params.len(), want.len(), - "settings.{} exports {} prefs, the section declares {} visible", + "settings.{} exports {} prefs, the section declares {}", section.id, params.len(), want.len() @@ -409,7 +409,17 @@ fn export_is_a_faithful_projection() { ); assert_eq!(got["label"].as_str(), Some(pref.display_name)); assert_eq!(got["description"].as_str(), pref.description); - assert_ne!(got["widget"].as_str(), Some("hidden")); + // A `Hidden` pref is exported carrying that fact, so consumers + // know not to render it. It is the renderer that hides. + if matches!(pref.widget, darkly::config::schema::WidgetHint::Hidden) { + assert_eq!( + got["widget"].as_str(), + Some("hidden"), + "settings.{} pref `{}` must carry its hidden widget", + section.id, + pref.key + ); + } } } } diff --git a/crates/darkly/tests/docs_md.rs b/crates/darkly/tests/docs_md.rs new file mode 100644 index 00000000..bd8edcd0 --- /dev/null +++ b/crates/darkly/tests/docs_md.rs @@ -0,0 +1,112 @@ +//! The generated regions of this repository's own markdown, checked against the +//! registries they are generated from. +//! +//! `docs_md`'s unit tests cover the machinery — the marker grammar, relative +//! links, idempotence. What is left is the part that can only be asserted +//! against the checkout: that what is committed matches what the registries say +//! today, and that everything those regions point a reader at exists. +//! +//! Needs no GPU. That is the whole reason the text half and the image half are +//! separate commands: this runs in the ordinary suite, on every change to any +//! registration, which is exactly when a README drifts. +//! +//! Run with: `cargo test -p darkly --test docs_md` + +use std::path::{Path, PathBuf}; + +use darkly::docs_md::{self, Mode}; + +/// Committed markdown says what the registries say. +/// +/// This is the test that fires when someone adds a veil, renames one, or edits +/// a `description:` — none of which look like documentation changes from inside +/// `crates/darkly/src/`. +#[test] +fn generated_regions_are_up_to_date() { + let root = docs_md::repo_root(); + let report = docs_md::sync(&root, Mode::Check).expect("the markdown parses"); + + assert!( + !report.generated.is_empty(), + "no generated regions found under {} — the walk is not reaching the \ + checkout, so this test is asserting nothing", + root.display() + ); + assert!( + report.changed.is_empty(), + "out of date: {}\nrun `cargo sync-docs`", + report + .changed + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ); +} + +/// Every local image in a file carrying a generated region is on disk. +/// +/// The one coupling between the two halves. Preview stills are rendered by hand +/// (`render_docs --stills`) because they need a GPU and land in the repository +/// as binaries; nothing but this stops a new veil from shipping a row with a +/// broken image in it. +/// +/// Whole files rather than just the region bodies: a generated row and the +/// hand-written image above it break the same way, and there is nothing to gain +/// from checking only the half a program wrote. +#[test] +fn generated_regions_link_to_images_that_exist() { + let root = docs_md::repo_root(); + let report = docs_md::sync(&root, Mode::Check).expect("the markdown parses"); + + let mut checked = 0; + for rel in &report.generated { + let text = std::fs::read_to_string(root.join(rel)).expect("a file the walk just read"); + let dir = rel.parent().unwrap_or(Path::new("")); + for src in image_sources(&text) { + // Remote images are somebody else's to serve; only what this + // repository is supposed to contain is checkable here. + if src.starts_with("http") { + continue; + } + let path = normalize(&root.join(dir).join(&src)); + assert!( + path.exists(), + "{} links to `{src}`, which is not in the checkout — run \ + `cargo run --release -p darkly --features testing --bin render_docs \ + -- --stills --catalog `", + rel.display() + ); + checked += 1; + } + } + assert!( + checked > 0, + "no local images in any generated region — nothing was asserted" + ); +} + +/// Every `src="…"` in `text`. Deliberately naive: generated tables emit plain +/// `` tags, and a markdown file is not worth a parser. +fn image_sources(text: &str) -> Vec { + text.split("src=\"") + .skip(1) + .filter_map(|rest| rest.split_once('"').map(|(src, _)| src.to_string())) + .collect() +} + +/// Resolve `..` segments textually. `Path::canonicalize` would do it, but only +/// for a path that already exists — and a missing image is what this is for. +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in path.components() { + match c { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other), + } + } + out +} diff --git a/crates/darkly/tests/docs_render.rs b/crates/darkly/tests/docs_render.rs index f1b25453..4eaa8d55 100644 --- a/crates/darkly/tests/docs_render.rs +++ b/crates/darkly/tests/docs_render.rs @@ -21,9 +21,9 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; use darkly::catalog::{catalogs, preview_mechanisms}; -use darkly::docs_render::{self, Gpu, Manifest, Rendered}; +use darkly::docs_render::{self, Command, Gpu, Manifest, Rendered}; use darkly::gpu::params::ParamValue; -use darkly::gpu::preview::{frame_t, PreviewAnim}; +use darkly::gpu::preview::{frame_t, PreviewAnim, PreviewVariant}; // --------------------------------------------------------------------------- // Shared enumeration — one source for every test below @@ -283,7 +283,8 @@ fn assets() -> &'static (PathBuf, Manifest) { /// what lets the tests that care about cross-asset leakage drive several entries /// through the same documents. fn render_one(gpu: &mut Gpu, catalog: &str, type_id: &str) -> Rendered { - docs_render::render_entry(gpu, catalog, type_id).expect("render_entry") + docs_render::render_entry(gpu, catalog, type_id, PreviewVariant::Animated) + .expect("render_entry") } /// Every `(catalog, entry)` directory actually present under `root`. @@ -708,11 +709,14 @@ fn blend_mode_frames_at_full_opacity_are_pairwise_distinct() { fn parse_args_reads_out() { let args = docs_render::parse_args(["--out".to_string(), "/tmp/x".to_string()].into_iter()) .expect("--out parses"); - assert_eq!(args.out, Some(PathBuf::from("/tmp/x"))); + assert!( + matches!(args, Command::Frames { out } if out == Path::new("/tmp/x")), + "--out alone names the frame sequences" + ); // `--help` is not an error, and it names no work to do. let args = docs_render::parse_args(["--help".to_string()].into_iter()).unwrap(); - assert_eq!(args.out, None); + assert!(matches!(args, Command::Help)); } #[test] @@ -722,6 +726,48 @@ fn parse_args_rejects_a_missing_out() { assert!(docs_render::parse_args(["--wat".to_string()].into_iter()).is_err()); } +/// The stills mode names a catalog and defaults its destination to the one the +/// generated tables link to — so nobody has to remember the path, and a typo in +/// it cannot put the images somewhere the markdown does not look. +#[test] +fn parse_args_reads_the_stills_mode() { + let args = docs_render::parse_args( + ["--stills", "--catalog", "veils"] + .map(String::from) + .into_iter(), + ) + .expect("--stills --catalog parses"); + let Command::Stills { out, catalog } = args else { + panic!("--stills names the stills mode"); + }; + assert_eq!(catalog, "veils"); + assert_eq!( + out, + darkly::docs_md::repo_root().join(darkly::docs_md::STILLS_DIR) + ); + + let args = docs_render::parse_args( + ["--stills", "--catalog", "veils", "--out", "/tmp/x"] + .map(String::from) + .into_iter(), + ) + .expect("--out overrides the destination"); + assert!(matches!(args, Command::Stills { out, .. } if out == Path::new("/tmp/x"))); +} + +#[test] +fn parse_args_rejects_a_catalog_without_stills() { + // Naming a catalog for the sequence mode is a misunderstanding, not a + // no-op: that mode renders every catalog there is. + assert!(docs_render::parse_args( + ["--out", "/tmp/x", "--catalog", "veils"] + .map(String::from) + .into_iter() + ) + .is_err()); + assert!(docs_render::parse_args(["--stills".to_string()].into_iter()).is_err()); +} + /// **Every** brush renders the same bytes twice — all thirteen, not a sample. /// /// Unlike the other catalogs the failure mode here *is* per-entry: `rough_ink`, diff --git a/crates/darkly/tests/liquify.rs b/crates/darkly/tests/liquify.rs index 8b3cf92a..7737f376 100644 --- a/crates/darkly/tests/liquify.rs +++ b/crates/darkly/tests/liquify.rs @@ -78,8 +78,8 @@ fn pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] { [rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]] } -/// One `(pos, direction_rad, distance)` per dab. `distance > 0.5` so -/// the per-dab first-dab gate doesn't fire. +/// One `(pos, direction_rad, distance)` per dab — the direction sets the +/// per-dab motion vector. `distance > 0.5` so the first-dab gate doesn't fire. fn render_liquify_dabs(size_override: f32, dabs: &[([f32; 2], f32, f32)]) -> Vec { render_liquify_dabs_on(&two_tone_canvas(36), size_override, dabs) } @@ -178,11 +178,11 @@ fn render_liquify_dabs_on( // Simulate a real stroke's per-dab motion: in a live // stroke the engine places dabs a spacing apart along // the cursor's path, so `pen.motion` per dab has that - // magnitude along the drawing angle. + // magnitude along the drawing angle. `motion` is the only + // direction signal liquify consumes. let motion = [TEST_DAB_STEP_PX * dir.cos(), TEST_DAB_STEP_PX * dir.sin()]; let info = PaintInformation { pos: *pos, - drawing_angle: *dir, distance: *dist, motion, pressure: 1.0, diff --git a/crates/darkly/tests/wgsl.rs b/crates/darkly/tests/wgsl.rs index 1e4c414f..30f83fff 100644 --- a/crates/darkly/tests/wgsl.rs +++ b/crates/darkly/tests/wgsl.rs @@ -1197,16 +1197,23 @@ fn polygon_rounded_rectangle_stays_convex() { } /// Feature invariant: for every squeeze / angle / rounding, nothing beyond the -/// tip's screen-space footprint bound (`1/a`, matching `extent()`) is inside — -/// the rounding never grows the tip past its budgeted extent. +/// tip's screen-space footprint bound is inside — the rounding never grows the +/// tip past its budgeted extent. +/// +/// The bound comes from `silhouette_support`, the same function `extent()` +/// budgets with, so this asserts the real invariant rather than a copy of the +/// formula that can go stale. (It previously hardcoded `1/a` while claiming to +/// match `extent()`; that stopped being the budgeted value once the bound +/// accounted for the rounding inset and vertex placement.) #[test] fn polygon_within_extent_bound() { + use darkly::brush::nodes::polygon::silhouette_support; for &n in &[3.0_f32, 4.0, 5.0, 6.0] { for &a in &[0.2_f32, 0.5, 1.0] { for &round in &[0.0_f32, 0.5, 1.0] { for &phi in &[0.0_f32, 0.7] { for &beta in &[0.0_f32, 0.9] { - let bound = 1.0 / a; + let bound = silhouette_support(a, round, n, Some(beta)); for i in 0..96 { let ang = (i as f32) * std::f32::consts::TAU / 96.0; // Just outside the footprint bound (+2%). @@ -1637,3 +1644,94 @@ fn image_dab_tip_needs_no_shape_node() { naga_validate(&compiled.stroke_wgsl, "image dab-tip stroke"); naga_validate(&compiled.cursor_preview_wgsl, "image dab-tip preview"); } + +/// `polygon`'s dab bound must be the support of the silhouette it actually +/// paints, not the enclosing circle of the squeeze ellipse. +/// +/// The emitted body builds the n-gon at circumradius `cr = 1 − ρ`, maps its +/// vertices through `T⁻¹` (semi-axes `a` and `1/a`), then dilates by `ρ` — +/// an *isotropic* offset applied after the anisotropic map. So the reach is +/// `cr · maxᵢ‖diag(a, 1/a)·R(−β)·v̂ᵢ‖ + ρ`. +/// +/// Regression: the bound was a flat `1/a`, which ignores both the rounding +/// inset and where the vertices sit relative to the squeeze axis. At the +/// settings below (Sponge's shipped tip) that reads 1.818 instead of 1.175 — +/// over-covering by 1.55× in radius, 2.4× in area. The fragment stage's only +/// early-out is a circular discard at this radius, so every pixel of the +/// excess is fully shaded before being thrown away. +#[test] +fn polygon_extent_is_the_rounded_silhouette_support() { + let reg = registry(); + let mut graph = Graph::::new(); + let pen = graph.add_node("pen_input", reg.get("pen_input").unwrap().ports.clone()); + let paint_color = graph.add_node("paint_color", reg.get("paint_color").unwrap().ports.clone()); + let poly = graph.add_node("polygon", reg.get("polygon").unwrap().ports.clone()); + let stamp = graph.add_node("stamp", reg.get("stamp").unwrap().ports.clone()); + let term = graph.add_node("paint", reg.get("paint").unwrap().ports.clone()); + // Sponge's shipped tip settings, as literals so the compile-time branch + // (rather than the wired worst-case fallback) is the one under test. + graph + .set_port_value(&poly, "points", InputValue::Int(4)) + .unwrap(); + graph.set_port_default(&poly, "rounding", 0.5).unwrap(); + graph.set_port_default(&poly, "squeeze", 0.5).unwrap(); + graph + .set_port_default(&poly, "squeeze_angle", -0.78) + .unwrap(); + wire( + &mut graph, + &[ + (poly.clone(), "mask", stamp.clone(), "tip"), + (paint_color.clone(), "color", stamp.clone(), "color"), + (stamp.clone(), "dab", term.clone(), "rgba"), + (pen.clone(), "position", term.clone(), "position"), + ], + ); + let plan = compile(&graph, reg.as_map()).unwrap(); + let compiled = compile_brush_to_wgsl(&graph, &plan, &evals()).unwrap(); + // a = 1 − 0.9·0.5 = 0.55, cr = ρ = 0.5. The vertex at base angle 0 maps to + // magnitude 1.3492, so the support is 0.5·1.3492 + 0.5. + assert!( + (compiled.brush_extent_factor - 1.1746).abs() < 1e-3, + "expected the rounded-silhouette support 1.1746, got {}", + compiled.brush_extent_factor, + ); +} + +/// When the squeeze *axis* is wired its value is unknown at compile time, so +/// the bound must fall back to the orientation-agnostic worst case — a vertex +/// landing on the stretched axis — rather than guessing an axis. +#[test] +fn polygon_extent_falls_back_when_squeeze_axis_is_wired() { + let reg = registry(); + let mut graph = Graph::::new(); + let pen = graph.add_node("pen_input", reg.get("pen_input").unwrap().ports.clone()); + let paint_color = graph.add_node("paint_color", reg.get("paint_color").unwrap().ports.clone()); + let rand_angle = graph.add_node("random", reg.get("random").unwrap().ports.clone()); + let poly = graph.add_node("polygon", reg.get("polygon").unwrap().ports.clone()); + let stamp = graph.add_node("stamp", reg.get("stamp").unwrap().ports.clone()); + let term = graph.add_node("paint", reg.get("paint").unwrap().ports.clone()); + graph + .set_port_value(&poly, "points", InputValue::Int(4)) + .unwrap(); + graph.set_port_default(&poly, "rounding", 0.5).unwrap(); + graph.set_port_default(&poly, "squeeze", 0.5).unwrap(); + wire( + &mut graph, + &[ + (rand_angle.clone(), "value", poly.clone(), "squeeze_angle"), + (poly.clone(), "mask", stamp.clone(), "tip"), + (paint_color.clone(), "color", stamp.clone(), "color"), + (stamp.clone(), "dab", term.clone(), "rgba"), + (pen.clone(), "position", term.clone(), "position"), + ], + ); + let plan = compile(&graph, reg.as_map()).unwrap(); + let compiled = compile_brush_to_wgsl(&graph, &plan, &evals()).unwrap(); + // cr/a + ρ = 0.5/0.55 + 0.5. + assert!( + (compiled.brush_extent_factor - 1.4091).abs() < 1e-3, + "wired squeeze_angle must fall back to cr/a + ρ = 1.4091, got {}", + compiled.brush_extent_factor, + ); +} diff --git a/docs/architecture-history.md b/docs/architecture-history.md index 7e0a6ef1..ebba3930 100644 --- a/docs/architecture-history.md +++ b/docs/architecture-history.md @@ -11,7 +11,7 @@ This is a living document. It explains the **why**, not the **what** — for the current API surface see [getting-started-typescript.md](getting-started-typescript.md) and [getting-started-rust.md](getting-started-rust.md); for the architecture in -the abstract see [`CLAUDE.md`](../CLAUDE.md). +the abstract see [`CONTRIBUTING.md`](../CONTRIBUTING.md). --- @@ -26,7 +26,7 @@ TypeScript: 1. **One authoritative core, many frontends.** The document model (layer tree, modifiers, undo, serialization) must be reasoned about, tested, and evolved *without a GPU and without a browser*. That's the [Document Authority - Principle](../CLAUDE.md): the document is authoritative and serializable; the + Principle](../CONTRIBUTING.md): the document is authoritative and serializable; the compositor is a derived realization. A Rust core compiles three ways — the WASM bridge for the browser, a future Tauri/native backend, and a **headless `cargo test`** harness that drives the real engine on Vulkan/Metal. A @@ -132,7 +132,7 @@ re-entrancy** — accepted as "a bug to fix structurally, not paper over"), and taxonomy**. Every new engine operation forced an author to pick a bucket, add a `Command` variant, and wire a drain arm — a central `enum Command` and a central `match` that grew without bound (the exact thing the [Modularity -Principle](../CLAUDE.md) forbids). The ~15 "direct mutation" methods were a +Principle](../CONTRIBUTING.md) forbids). The ~15 "direct mutation" methods were a standing latent panic. And there was still no uniform way to *return a value from an operation that needs an async GPU readback* (copy, export, save) — those were special-cased. The command queue solved the stroke race; it did not give the @@ -279,7 +279,7 @@ Future refactorers should know these are **deliberate**, not oversights: internally; the boundary just isn't a future bridge. - **Synchronous return values from GPU-reading ops.** Impossible by physics on WASM (no blocking readback) — these are deferred and resolve a promise. See - [No Blocking GPU Readbacks](../CLAUDE.md). + [No Blocking GPU Readbacks](../CONTRIBUTING.md). - **Typed, per-method TS ergonomics, *for now*.** `await engine.send('copy', {…})` is stringly-typed. The typed client (`await engine.copy(id)`) is a planned thin wrapper *over* the transport (plan "Phase C") — it recovers the ergonomics @@ -289,7 +289,7 @@ Future refactorers should know these are **deliberate**, not oversights: keep the core platform-agnostic and the waker model trivial. - **A single `RefCell` as one actor cell.** State is *not* scattered into per-subsystem cells to please the borrow checker — see the [Ownership - Principle](../CLAUDE.md). Splitting the engine into `RenderHandle`/`PaintHandle` + Principle](../CONTRIBUTING.md). Splitting the engine into `RenderHandle`/`PaintHandle` (Era-2 option 3) was considered and rejected for the same reason. --- diff --git a/docs/brush-preview-and-overlays.md b/docs/brush-preview-and-overlays.md index e2232bbf..e6dfdc0b 100644 --- a/docs/brush-preview-and-overlays.md +++ b/docs/brush-preview-and-overlays.md @@ -1,6 +1,6 @@ # Brush Preview & On-Canvas Overlays -The pointer-to-pixel diagram in [`AGENTS.md`](../AGENTS.md) is the *paint* path. +The pointer-to-pixel diagram in [`CONTRIBUTING.md`](../CONTRIBUTING.md) is the *paint* path. Most on-canvas feedback comes from two derived paths, and a lot of tool/UX bugs live here — invisible unless you know the model below. diff --git a/docs/getting-started-rust.md b/docs/getting-started-rust.md index 248d13e4..6ff00010 100644 --- a/docs/getting-started-rust.md +++ b/docs/getting-started-rust.md @@ -184,7 +184,7 @@ engine's `test_readback_*` accessors that integration tests rely on. ## Where to go next -- Architecture and state boundaries: [`CLAUDE.md`](../CLAUDE.md). +- Architecture and state boundaries: [`CONTRIBUTING.md`](../CONTRIBUTING.md). - Anything involving x/y coordinates: [`docs/coordinate-systems.md`](coordinate-systems.md). - GPU readback rules: [`docs/lessons-learned/gpu-lessons-learned.md`](lessons-learned/gpu-lessons-learned.md). - Driving the engine from the browser: [`docs/getting-started-typescript.md`](getting-started-typescript.md). diff --git a/docs/images/previews/veils/black_and_white.jpg b/docs/images/previews/veils/black_and_white.jpg new file mode 100644 index 00000000..59413cb1 Binary files /dev/null and b/docs/images/previews/veils/black_and_white.jpg differ diff --git a/docs/images/previews/veils/chromatic_aberration.jpg b/docs/images/previews/veils/chromatic_aberration.jpg new file mode 100644 index 00000000..b3bcaa83 Binary files /dev/null and b/docs/images/previews/veils/chromatic_aberration.jpg differ diff --git a/docs/images/previews/veils/frozen.jpg b/docs/images/previews/veils/frozen.jpg new file mode 100644 index 00000000..af204052 Binary files /dev/null and b/docs/images/previews/veils/frozen.jpg differ diff --git a/docs/images/previews/veils/grain.jpg b/docs/images/previews/veils/grain.jpg new file mode 100644 index 00000000..20a0d5fc Binary files /dev/null and b/docs/images/previews/veils/grain.jpg differ diff --git a/docs/images/previews/veils/lens_blur.jpg b/docs/images/previews/veils/lens_blur.jpg new file mode 100644 index 00000000..6544ac87 Binary files /dev/null and b/docs/images/previews/veils/lens_blur.jpg differ diff --git a/docs/images/previews/veils/painting.jpg b/docs/images/previews/veils/painting.jpg new file mode 100644 index 00000000..44005c07 Binary files /dev/null and b/docs/images/previews/veils/painting.jpg differ diff --git a/docs/images/previews/veils/pixelate.jpg b/docs/images/previews/veils/pixelate.jpg new file mode 100644 index 00000000..8c8d886a Binary files /dev/null and b/docs/images/previews/veils/pixelate.jpg differ diff --git a/docs/images/previews/veils/rainy_glass.jpg b/docs/images/previews/veils/rainy_glass.jpg new file mode 100644 index 00000000..a18f5250 Binary files /dev/null and b/docs/images/previews/veils/rainy_glass.jpg differ diff --git a/docs/images/previews/veils/vhs.jpg b/docs/images/previews/veils/vhs.jpg new file mode 100644 index 00000000..4700a0b8 Binary files /dev/null and b/docs/images/previews/veils/vhs.jpg differ diff --git a/docs/images/previews/veils/watercolor.jpg b/docs/images/previews/veils/watercolor.jpg new file mode 100644 index 00000000..a035c1e5 Binary files /dev/null and b/docs/images/previews/veils/watercolor.jpg differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 00000000..4616d845 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index dc97d5e5..1e84f514 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -14,6 +14,7 @@ import LayerPickers from './ui/layers/LayerPickers.svelte'; import ConfirmDiscardModal from './ui/ConfirmDiscardModal.svelte'; import RecoveryModal from './ui/RecoveryModal.svelte'; + import PackExportModal from './ui/PackExportModal.svelte'; import AboutModal from './ui/AboutModal.svelte'; import MenuBar from './ui/menu/MenuBar.svelte'; import CommandPalette from './ui/menu/CommandPalette.svelte'; @@ -21,6 +22,8 @@ import CanvasOverlay from './multi_tab/CanvasOverlay.svelte'; import { shell } from './multi_tab/shell.svelte'; import { anyTabDirty } from './multi_tab/closeGuard.svelte'; + import { flushRecents } from './state/recents.svelte'; + import { brushLibrary } from './state/brush_library.svelte'; // Register all tools import './tools/index'; // Register dockable workspace panels (layers, properties) @@ -37,6 +40,11 @@ // close / navigation away. Browsers ignore custom messages — setting // `returnValue` to any non-empty string triggers their native prompt. function onBeforeUnload(e: BeforeUnloadEvent) { + // Land any write still inside its coalescing window, so a brush + // picked or a pack imported a moment before closing is still there + // next launch. + void flushRecents(); + void brushLibrary.flush(); if (anyTabDirty()) { e.preventDefault(); e.returnValue = ''; @@ -57,6 +65,7 @@ + diff --git a/frontend/src/__tests__/iconBundle.test.ts b/frontend/src/__tests__/iconBundle.test.ts index e09fcf2f..94af4a1e 100644 --- a/frontend/src/__tests__/iconBundle.test.ts +++ b/frontend/src/__tests__/iconBundle.test.ts @@ -6,6 +6,22 @@ import { registerActions } from '../actions/index'; import { actions } from '../actions/registry'; import { rustActionDocs } from '../actions/__tests__/rust_action_docs'; import { toolRegistry } from '../tools/registry'; +import { PACK_ICON_FALLBACK } from '../lib/packIcon'; + +/** The pack-icon names Rust declares, read out of the crate source so this + * test and `PACK_ICONS` cannot drift. */ +const PACK_ICONS: string[] = (() => { + const source = Object.entries( + import.meta.glob('../../../crates/darkly/src/brush/pack_icons.rs', { + query: '?raw', + eager: true, + import: 'default', + }) as Record, + )[0]?.[1]; + if (!source) throw new Error('could not read pack_icons.rs'); + const list = source.slice(source.indexOf('PACK_ICONS'), source.indexOf('];')); + return [...list.matchAll(/\("([a-z0-9-]+:[a-z0-9-]+)"/g)].map(m => m[1]); +})(); // Register the menu/palette actions. Tools are imported lazily inside the tool // test instead — registering tool-switch actions needs app methods that aren't @@ -126,6 +142,28 @@ function viewBox(name: string): [number, number, number, number] { // on-screen size is how much of its viewBox the artwork fills. gen-icons // shrink-wraps each viewBox to the inked bounds at build time so all icons — // regardless of source set's built-in margins — render at a uniform optical +// A brush pack's icon comes from the curated list in +// `crates/darkly/src/brush/pack_icons.rs`. That file exists so the generator — +// which scrapes Iconify name literals out of `.ts`/`.svelte`/`.rs` sources — +// finds them: an icon named only in a pack's YAML would be absent from the +// bundle and would draw nothing at all. +describe('brush pack icons', () => { + it('every_pack_icon_resolves_in_the_offline_bundle', () => { + // Guard the extraction itself: an empty list would make the loop below + // pass without checking anything. + expect(PACK_ICONS.length).toBeGreaterThan(10); + for (const name of PACK_ICONS) { + expect(resolves(name), `pack icon ${name} is not bundled`).toBe(true); + } + }); + + it('the_fallback_resolves', () => { + // Drawn whenever an imported pack names an icon we do not have. If it + // were itself missing, the fallback would be a hole too. + expect(resolves(PACK_ICON_FALLBACK)).toBe(true); + }); +}); + // size. These guard that the tightening actually ran and didn't over-crop. describe('icon viewBox tightening (offline)', () => { it('crops the canonical padded icon to its inked bounds', () => { diff --git a/frontend/src/actions/__tests__/menu_actions.test.ts b/frontend/src/actions/__tests__/menu_actions.test.ts index 939849f4..96275062 100644 --- a/frontend/src/actions/__tests__/menu_actions.test.ts +++ b/frontend/src/actions/__tests__/menu_actions.test.ts @@ -185,6 +185,8 @@ describe('menu action registrations', () => { 'saveDocument', 'saveDocumentAs', 'exportTimelapse', + 'importBrushPack', + 'exportBrushPack', ]); }); diff --git a/frontend/src/actions/__tests__/pack_actions.test.ts b/frontend/src/actions/__tests__/pack_actions.test.ts new file mode 100644 index 00000000..0ab56d31 --- /dev/null +++ b/frontend/src/actions/__tests__/pack_actions.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { registerActions } from '../index'; +import { actions } from '../registry'; +import { rustActionDocs } from './rust_action_docs'; +import { buildTopMenus } from '../../ui/menu/menuModel'; +import { PACK_EXTENSION } from '../pack_actions'; + +beforeAll(() => { + actions.setDocs(rustActionDocs()); + registerActions(); +}); + +describe('brush pack actions', () => { + it('import_and_export_actions_are_registered_with_menu_items', () => { + for (const id of ['importBrushPack', 'exportBrushPack']) { + const action = actions.all().find(a => a.id === id); + expect(action, `${id} is registered`).toBeDefined(); + expect(action!.menuPath, `${id} has a menu path`).toBeDefined(); + // Docs come from the Rust `actions` catalog — an action without + // them would render a blank menu label. + expect(action!.displayName, `${id} has a display name`).toBeTruthy(); + expect(action!.icon, `${id} has an icon`).toBeTruthy(); + } + }); + + it('both_land_in_the_file_menu_after_export_timelapse', () => { + const file = buildTopMenus(actions.all()).find(m => m.title === 'File'); + const ids = file!.entries + .filter(e => e.kind === 'action') + .map(e => (e as { actionId: string }).actionId); + + expect(ids).toContain('importBrushPack'); + expect(ids).toContain('exportBrushPack'); + expect(ids.indexOf('importBrushPack')).toBeGreaterThan(ids.indexOf('exportTimelapse')); + expect(ids.indexOf('exportBrushPack')).toBeGreaterThan(ids.indexOf('importBrushPack')); + }); + + it('both_labels_say_pack_not_brush', () => { + // The extension names a container, not a count: one `.darkly-brush` + // may hold twenty brushes, so the user-facing wording must not imply + // one. + for (const id of ['importBrushPack', 'exportBrushPack']) { + const action = actions.all().find(a => a.id === id)!; + expect(action.displayName.toLowerCase()).toContain('pack'); + } + }); + + it('the_extension_is_unchanged', () => { + // One format, and it kept its name. + expect(PACK_EXTENSION).toBe('.darkly-brush'); + }); +}); diff --git a/frontend/src/actions/index.ts b/frontend/src/actions/index.ts index 10d80686..17fda5db 100644 --- a/frontend/src/actions/index.ts +++ b/frontend/src/actions/index.ts @@ -19,6 +19,7 @@ import { registerBrushParamActions } from './brush_params'; import { registerSampleColorAction } from './sample_color'; import { registerCloneSourceAction } from './clone_source_gesture'; import { registerClipboardActions } from './clipboard'; +import { registerPackActions } from './pack_actions'; import { pickOpenFile, type OpenedFile } from '../storage/fileHandle'; import { detectKind, isImageKind, type FileKind } from '../storage/detectKind'; import { saveDocument } from '../storage/saveDocument'; @@ -954,6 +955,9 @@ export function registerActions() { // -- Clone brush set-source gesture (brush-scoped modifier+drag) -- registerCloneSourceAction(); + // -- Brush pack import / export -- + registerPackActions(); + // -- Brush builder -- actions.register({ id: 'addBrushNode', diff --git a/frontend/src/actions/pack_actions.ts b/frontend/src/actions/pack_actions.ts new file mode 100644 index 00000000..fcc97eea --- /dev/null +++ b/frontend/src/actions/pack_actions.ts @@ -0,0 +1,84 @@ +/** + * Importing and exporting brush packs. + * + * Without pack-management UI these two actions are how a pack is reached at + * all. They ship together deliberately: import with no export is a one-way + * door, and the asymmetry would read as a half-built feature. + * + * A `.darkly-brush` is a zip, so it would be indistinguishable from a `.darkly` + * document to `detectKind` — which is magic-byte-only by design. It never has + * to be: the unified Open flow only sees what its picker accepts, and + * `.darkly-brush` is in neither `OPEN_TYPES` nor `OPEN_ACCEPT`. Pack import has + * its own affordance with its own `accept`, so the two flows never meet. + */ +import { actions } from './registry'; +import { app } from '../state/app.svelte'; +import { toast } from '../state/toast.svelte'; +import { brushLibrary } from '../state/brush_library.svelte'; +import { packExport } from '../state/packExport.svelte'; +import { downloadBlob, sanitizeFilename } from '../storage'; +import { newId } from '../lib/id'; + +export const PACK_EXTENSION = '.darkly-brush'; + +/** Prompt for a `.darkly-brush` file and import it as a new pack. */ +export async function importPackFromFile(file: File): Promise { + if (!app.engine) return; + const bytes = new Uint8Array(await file.arrayBuffer()); + const id = newId('pack'); + try { + await app.engine.api.packImport({ id }, bytes); + } catch (e) { + toast.show('error', `Could not import brush pack: ${e instanceof Error ? e.message : e}`); + return; + } + await brushLibrary.refresh(); + // Persist the imported pack and every brush that arrived with it, so the + // import survives a reload. + await brushLibrary.persistImported(id); + const pack = brushLibrary.pack(id); + toast.show('success', `Imported brush pack “${pack?.name ?? 'Untitled'}”.`); +} + +/** Write a pack out as a `.darkly-brush` file. */ +export async function exportPack(id: string): Promise { + if (!app.engine) return; + const pack = brushLibrary.pack(id); + try { + const { bytes } = await app.engine.api.packExport({ id }); + const blob = new Blob([bytes as Uint8Array], { + type: 'application/zip', + }); + downloadBlob(blob, `${sanitizeFilename(pack?.name ?? 'brush-pack')}${PACK_EXTENSION}`); + } catch (e) { + toast.show('error', `Could not export brush pack: ${e instanceof Error ? e.message : e}`); + } +} + +/** Open a one-shot file input for a pack. The input is never mounted — the + * same shape the font browser's upload affordance uses, minus the markup. */ +function promptForPack() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = PACK_EXTENSION; + input.onchange = () => { + const file = input.files?.[0]; + if (file) void importPackFromFile(file); + }; + input.click(); +} + +export function registerPackActions() { + actions.register({ + id: 'importBrushPack', + menuPath: ['File:60'], + handler: promptForPack, + }); + actions.register({ + id: 'exportBrushPack', + menuPath: ['File:61'], + handler: () => { + packExport.open = true; + }, + }); +} diff --git a/frontend/src/config/__tests__/validate.test.ts b/frontend/src/config/__tests__/validate.test.ts new file mode 100644 index 00000000..db947754 --- /dev/null +++ b/frontend/src/config/__tests__/validate.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from 'vitest'; +import { validateOverrides } from '../validate'; +import type { Catalog, ParamInfo } from '../../engine/protocol_gen'; + +/** A pref as `settings_catalogs()` projects it. Only the fields + * `validateOverrides` reads are meaningful; the rest carry schema-shaped + * filler. */ +function pref(name: string, kind: string, widget = 'auto'): ParamInfo { + return { + kind, + name, + label: null, + description: null, + widget, + unit: 'none', + min: null, + max: null, + default: false, + value: null, + options: null, + display: 'normal', + } as unknown as ParamInfo; +} + +/** One `Catalog` per section holding a single entry, matching the shape + * `config_schema()` returns. */ +function schema(...prefs: ParamInfo[]): Catalog[] { + return [ + { + id: 'settings.ui', + title: 'UI', + description: null, + icon: null, + order: 0, + entries: [ + { + typeId: 'ui', + displayName: 'UI', + icon: null, + description: null, + category: null, + hotkeyAction: null, + params: prefs, + supportsPreview: false, + captureKind: null, + }, + ], + } as unknown as Catalog, + ]; +} + +describe('validateOverrides', () => { + it('a_hidden_pref_survives_validation', () => { + // Regression: prefs marked `Hidden` were filtered out of the projected + // schema, so `validateOverrides` saw them as unknown keys, dropped + // them, and the store wrote the cleaned set back — silently erasing + // the brush-builder pane state on every reload. + const sections = schema( + pref('ui.theme', 'enum'), + pref('ui.brushBuilder.previewVisible', 'bool', 'hidden'), + ); + + const { cleaned, changed } = validateOverrides(sections, { + 'ui.brushBuilder.previewVisible': false, + }); + + expect(cleaned).toHaveProperty('ui.brushBuilder.previewVisible', false); + expect(changed).toBe(false); + }); + + it('a_hidden_pref_is_still_not_offered_as_a_setting', () => { + // The invariant that moved out of Rust: hiding is the renderer's job. + // This mirrors `SettingsModal.svelte`'s `visiblePrefs` derivation. + const sections = schema( + pref('ui.theme', 'enum'), + pref('ui.brushBuilder.previewVisible', 'bool', 'hidden'), + ); + + const visible = sections + .flatMap(s => s.entries[0]?.params ?? []) + .filter(p => p.widget !== 'hidden') + .map(p => p.name); + + expect(visible).toEqual(['ui.theme']); + }); + + it('an_unknown_key_is_still_dropped', () => { + // The projection widened to include hidden prefs; it did not stop + // rejecting keys the schema never declared. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { cleaned, changed } = validateOverrides(schema(pref('ui.theme', 'enum')), { + 'ui.nonexistent': 1, + }); + expect(cleaned).toEqual({}); + expect(changed).toBe(true); + warn.mockRestore(); + }); +}); diff --git a/frontend/src/config/store.svelte.ts b/frontend/src/config/store.svelte.ts index 6962e96b..c2141582 100644 --- a/frontend/src/config/store.svelte.ts +++ b/frontend/src/config/store.svelte.ts @@ -28,7 +28,7 @@ import { validateOverrides } from './validate'; * layer, so switching editors is just `config.set('app.baseSettings', ...)`. * * On-disk envelope: `{ "version": , "values": {...} }`. - * Pre-release we discard mismatched-version files outright (per CLAUDE.md + * Pre-release we discard mismatched-version files outright (per CONTRIBUTING.md * "No Migrations"); the field exists so post-release migrations have a * discriminator to key off. */ diff --git a/frontend/src/editor.ts b/frontend/src/editor.ts index c94f6840..ee76973e 100644 --- a/frontend/src/editor.ts +++ b/frontend/src/editor.ts @@ -19,6 +19,7 @@ import { setupHeldModsTracking } from './actions/held_mods'; import { autosave } from './state/autosave.svelte'; import { recovery } from './state/recovery.svelte'; import { processRecording } from './recording/recorder.svelte'; +import { loadRecents } from './state/recents.svelte'; let processInitialized = false; @@ -33,6 +34,9 @@ export async function ensureProcessInit(): Promise { if (processInitialized) return; await init(); await config.init(); + // Recents are painter-scoped and independent of any canvas, so they load + // once here rather than per tab. + await loadRecents(); // Theme subscribes to config in its module; trigger an initial sync so // body class and WASM preview colors match `ui.theme` from startup. theme.syncFromConfig(); diff --git a/frontend/src/engine/protocol_gen.ts b/frontend/src/engine/protocol_gen.ts index d8184c29..0e76c7ae 100644 --- a/frontend/src/engine/protocol_gen.ts +++ b/frontend/src/engine/protocol_gen.ts @@ -91,7 +91,9 @@ export type PreviewBackdrop = "Flat" | "Stripes"; export type BrushDabThumbnailReq = { name: string, }; -export type BrushExportReq = { name: string, }; +export type BrushDeleteReq = { id: string, }; + +export type BrushExportYamlReq = { id: string, }; export type ExposedValue = { "kind": "scalar", /** @@ -167,25 +169,33 @@ export type BrushGraphSetPortRangeReq = { node_id: string, port_name: string, di export type BrushGraphUnexposePortReq = { node_id: string, port_name: string, }; -export type BrushInfo = { name: string, category: string, author: string, description: string, tags: Array, +export type BrushInfo = { +/** + * Opaque identity — what pack member lists and recents hold. + */ +id: string, +/** + * Display name, and the engine's public lookup key. + */ +name: string, author: string, description: string, tags: Array, /** * Iconify icon shown in place of the baked dab/stroke thumbnails — * present when the graph contains a content-dependent node whose * preview bake renders blank (clone, blur, smudge, liquify). See * [`crate::brush::graph_capabilities`]. */ -icon: string | null, }; +icon: string | null, +/** + * Whether the painter may rename or delete this brush, so the UI can grey + * out affordances it would otherwise offer. A hint, not the authority — + * same contract as [`BrushPackInfo::can_edit_members`]. + */ +can_edit: boolean, }; export type BrushLoadReq = { name: string, }; export type BrushNodePreviewReq = { node_id: string, }; -export type InputValue = boolean | number | number | string | Array<[number, number]> | [number, number] | [number, number, number, number]; - -export type PortDir = "Input" | "Output"; - -export type BrushWireType = "Scalar" | "Int" | "Bool" | "Vec2" | "Vec4" | "Enum" | "String" | "Curve"; - export type PortDef = { name: string, dir: PortDir, wire_type: BrushWireType, /** * Slider min when the port is disconnected (UI metadata only). @@ -378,6 +388,12 @@ preview_image: boolean, */ source: boolean, }; +export type InputValue = boolean | number | number | string | Array<[number, number]> | [number, number] | [number, number, number, number]; + +export type PortDir = "Input" | "Output"; + +export type BrushWireType = "Scalar" | "Int" | "Bool" | "Vec2" | "Vec4" | "Enum" | "String" | "Curve"; + export type PreviewStaging = { /** * Iconify glyph shown in the dab slot, where a single stationary sample @@ -449,7 +465,9 @@ supports_erase: boolean, */ preview_staging: PreviewStaging | null, }; -export type BrushSaveReq = { name: string, category: string, }; +export type BrushRenameReq = { id: string, name: string, }; + +export type BrushSaveReq = { id: string, name: string, }; export type BrushSetExposedPortReq = { node_id: string, port_name: string, display_value: number, }; @@ -499,16 +517,14 @@ supportsPreview: boolean, */ captureKind: CaptureKind | null, }; -export type CaptureKind = "camera" | "display" | "stream"; - -export type ParamValue = boolean | number | number | string | Array<[number, number]> | [number, number, number, number, number] | [number, number, number] | [number, number] | Array<{ [key in string]: ParamValue }>; - export type ParamDisplay = { min: string | null, max: string | null, default: string | null, /** * The unit suffix alone, for a column header. Empty for unitless values. */ unit: string, }; +export type ParamValue = boolean | number | number | string | Array<[number, number]> | [number, number, number, number, number] | [number, number, number] | [number, number] | Array<{ [key in string]: ParamValue }>; + export type ParamInfo = { kind: string, name: string, /** * Display label. `None` → the UI title-cases `name`. @@ -526,6 +542,8 @@ widget: string, unit: UnitType, min: number | null, max: number | null, default: */ options: JsonValue | null, display: ParamDisplay, }; +export type CaptureKind = "camera" | "display" | "stream"; + export type Catalog = { id: string, title: string, description: string | null, icon: string | null, /** * Presentation order, for catalogs that declare one. Registry catalogs do @@ -581,6 +599,17 @@ export type HitTestVectorObjectReq = { id: number, x: number, y: number, }; export type LayerTransformCapabilityReq = { id: number, }; +export type ModifierInfo = { id: number, kind: string, name: string, visible: boolean, locked: boolean, +/** + * Whether this modifier participates in transforms with its host. + */ +linkedToHost: boolean, +/** + * See [`LayerInfo::Raster::editable`] — a modifier is editable when + * neither it nor its host (nor any ancestor of the host) is locked. + */ +editable: boolean, }; + export type LayerInfo = { "type": "raster", id: number, name: string, visible: boolean, locked: boolean, /** * Effective editability — `false` when this node *or any ancestor* @@ -633,16 +662,20 @@ pipeline: string, */ params: Array, } | { "type": "vector", id: number, name: string, visible: boolean, locked: boolean, editable: boolean, canHaveMask: boolean, canRename: boolean, hasThumbnail: boolean, icon: string, kindName: string, opacity: number, blendMode: string, modifiers: Array, } | { "type": "group", id: number, name: string, visible: boolean, locked: boolean, editable: boolean, canHaveMask: boolean, canRename: boolean, hasThumbnail: boolean, icon: string, kindName: string, collapsed: boolean, passthrough: boolean, opacity: number, blendMode: string, modifiers: Array, children: Array, }; -export type ModifierInfo = { id: number, kind: string, name: string, visible: boolean, locked: boolean, +export type LibrarySnapshot = { brushes: Array, packs: Array, }; + +export type BrushPackInfo = { id: string, name: string, description: string, icon: string, primary: string, secondary: string, /** - * Whether this modifier participates in transforms with its host. + * Member brush ids, in the pack's order. The authority on membership — + * nothing on [`BrushInfo`] repeats it. */ -linkedToHost: boolean, +members: Array, /** - * See [`LayerInfo::Raster::editable`] — a modifier is editable when - * neither it nor its host (nor any ancestor of the host) is locked. + * What the painter may change, so the UI can grey out affordances it + * would otherwise offer. A hint, not the authority — the engine rejects a + * forbidden edit regardless of what the UI believed. */ -editable: boolean, }; +can_edit_members: boolean, can_edit_identity: boolean, }; export type MaskToSelectionReq = { id: number, }; @@ -662,6 +695,22 @@ export type NodeThumbnailReq = { node_id: number, width: number, height: number, export type OverlayHitTestReq = { screen_x: number, screen_y: number, }; +export type PackAddBrushReq = { pack: string, brush: string, }; + +export type PackCreateReq = { id: string, name: string, description: string, icon: string, primary: string, secondary: string, }; + +export type PackDeleteReq = { id: string, }; + +export type PackEditReq = { id: string, name: string, description: string, icon: string, primary: string, secondary: string, }; + +export type PackExportReq = { id: string, }; + +export type PackImportReq = { id: string, }; + +export type PackRemoveBrushReq = { pack: string, brush: string, }; + +export type PackReorderBrushReq = { pack: string, brush: string, index: number, }; + export type PasteImageReq = { width: number, height: number, offset_x: number, offset_y: number, active_layer_id: number, }; export type PasteResultResp = { id: number, }; @@ -836,7 +885,8 @@ export type RequestKind = | 'brush_active_capabilities' | 'brush_active_dab_preview' | 'brush_dab_thumbnail' - | 'brush_export' + | 'brush_delete' + | 'brush_export_yaml' | 'brush_exposed_ports' | 'brush_graph_active' | 'brush_graph_add_node' @@ -857,11 +907,11 @@ export type RequestKind = | 'brush_graph_set_port_range' | 'brush_graph_unexpose_port' | 'brush_graph_validate' - | 'brush_import' | 'brush_list' | 'brush_load' | 'brush_node_preview' | 'brush_node_types' + | 'brush_rename' | 'brush_save' | 'brush_set_exposed_port' | 'brush_stroke_preview' @@ -917,6 +967,7 @@ export type RequestKind = | 'last_picked_color' | 'layer_transform_capability' | 'layer_tree' + | 'library_list' | 'list_fonts' | 'mark_dirty' | 'mask_to_selection' @@ -928,6 +979,14 @@ export type RequestKind = | 'node_thumbnail' | 'open_document' | 'overlay_hit_test' + | 'pack_add_brush' + | 'pack_create' + | 'pack_delete' + | 'pack_edit' + | 'pack_export' + | 'pack_import' + | 'pack_remove_brush' + | 'pack_reorder_brush' | 'paste_image' | 'paste_image_floating' | 'paste_in_place' @@ -1026,7 +1085,8 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'brush_active_capabilities', 'brush_active_dab_preview', 'brush_dab_thumbnail', - 'brush_export', + 'brush_delete', + 'brush_export_yaml', 'brush_exposed_ports', 'brush_graph_active', 'brush_graph_add_node', @@ -1047,11 +1107,11 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'brush_graph_set_port_range', 'brush_graph_unexpose_port', 'brush_graph_validate', - 'brush_import', 'brush_list', 'brush_load', 'brush_node_preview', 'brush_node_types', + 'brush_rename', 'brush_save', 'brush_set_exposed_port', 'brush_stroke_preview', @@ -1107,6 +1167,7 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'last_picked_color', 'layer_transform_capability', 'layer_tree', + 'library_list', 'list_fonts', 'mark_dirty', 'mask_to_selection', @@ -1118,6 +1179,14 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'node_thumbnail', 'open_document', 'overlay_hit_test', + 'pack_add_brush', + 'pack_create', + 'pack_delete', + 'pack_edit', + 'pack_export', + 'pack_import', + 'pack_remove_brush', + 'pack_reorder_brush', 'paste_image', 'paste_image_floating', 'paste_in_place', @@ -1224,7 +1293,8 @@ export interface EngineApi { brushActiveCapabilities(): Promise; brushActiveDabPreview(): Promise<{ bytes: Uint8Array }>; brushDabThumbnail(req: BrushDabThumbnailReq): Promise<{ bytes: Uint8Array }>; - brushExport(req: BrushExportReq): Promise<{ bytes: Uint8Array }>; + brushDelete(req: BrushDeleteReq): Promise; + brushExportYaml(req: BrushExportYamlReq): Promise; brushExposedPorts(): Promise>; brushGraphActive(): Promise; brushGraphAddNode(req: BrushGraphAddNodeReq): Promise<{ graph: JsonValue, added_node_id: string } | { error: string }>; @@ -1245,11 +1315,11 @@ export interface EngineApi { brushGraphSetPortRange(req: BrushGraphSetPortRangeReq): Promise<{ graph: JsonValue } | { error: string }>; brushGraphUnexposePort(req: BrushGraphUnexposePortReq): Promise<{ graph: JsonValue } | { error: string }>; brushGraphValidate(req: BrushGraphJsonReq): Promise; - brushImport(bytes: Uint8Array): Promise; brushList(): Promise>; brushLoad(req: BrushLoadReq): Promise; brushNodePreview(req: BrushNodePreviewReq): Promise<{ bytes: Uint8Array }>; brushNodeTypes(): Promise>; + brushRename(req: BrushRenameReq): Promise; brushSave(req: BrushSaveReq): Promise; brushSetExposedPort(req: BrushSetExposedPortReq): Promise<{ graph: JsonValue } | { error: string }>; brushStrokePreview(): Promise<{ bytes: Uint8Array }>; @@ -1305,6 +1375,7 @@ export interface EngineApi { lastPickedColor(): Promise<{ bytes: Uint8Array }>; layerTransformCapability(req: LayerTransformCapabilityReq): Promise; layerTree(): Promise>; + libraryList(): Promise; listFonts(): Promise<{ fonts: string[] }>; markDirty(): void; maskToSelection(req: MaskToSelectionReq): void; @@ -1316,6 +1387,14 @@ export interface EngineApi { nodeThumbnail(req: NodeThumbnailReq): Promise<{ bytes: Uint8Array }>; openDocument(bytes: Uint8Array): Promise; overlayHitTest(req: OverlayHitTestReq): Promise; + packAddBrush(req: PackAddBrushReq): Promise; + packCreate(req: PackCreateReq): Promise; + packDelete(req: PackDeleteReq): Promise; + packEdit(req: PackEditReq): Promise; + packExport(req: PackExportReq): Promise<{ bytes: Uint8Array }>; + packImport(req: PackImportReq, bytes: Uint8Array): Promise; + packRemoveBrush(req: PackRemoveBrushReq): Promise; + packReorderBrush(req: PackReorderBrushReq): Promise; pasteImage(req: PasteImageReq, bytes: Uint8Array): Promise; pasteImageFloating(req: PasteImageReq, bytes: Uint8Array): Promise; pasteInPlace(req: PasteInPlaceReq): Promise; @@ -1416,7 +1495,8 @@ export function makeApi(t: Transport): EngineApi { brushActiveCapabilities: () => t.request('brush_active_capabilities'), brushActiveDabPreview: () => t.request('brush_active_dab_preview'), brushDabThumbnail: (req) => t.request('brush_dab_thumbnail', req), - brushExport: (req) => t.request('brush_export', req), + brushDelete: (req) => t.request('brush_delete', req), + brushExportYaml: (req) => t.request('brush_export_yaml', req), brushExposedPorts: () => t.request('brush_exposed_ports'), brushGraphActive: () => t.request('brush_graph_active'), brushGraphAddNode: (req) => t.request('brush_graph_add_node', req), @@ -1437,11 +1517,11 @@ export function makeApi(t: Transport): EngineApi { brushGraphSetPortRange: (req) => t.request('brush_graph_set_port_range', req), brushGraphUnexposePort: (req) => t.request('brush_graph_unexpose_port', req), brushGraphValidate: (req) => t.request('brush_graph_validate', req), - brushImport: (bytes) => t.request('brush_import', {}, bytes), brushList: () => t.request('brush_list'), brushLoad: (req) => t.request('brush_load', req), brushNodePreview: (req) => t.request('brush_node_preview', req), brushNodeTypes: () => t.request('brush_node_types'), + brushRename: (req) => t.request('brush_rename', req), brushSave: (req) => t.request('brush_save', req), brushSetExposedPort: (req) => t.request('brush_set_exposed_port', req), brushStrokePreview: () => t.request('brush_stroke_preview'), @@ -1497,6 +1577,7 @@ export function makeApi(t: Transport): EngineApi { lastPickedColor: () => t.request('last_picked_color'), layerTransformCapability: (req) => t.request('layer_transform_capability', req), layerTree: () => t.request('layer_tree'), + libraryList: () => t.request('library_list'), listFonts: () => t.request('list_fonts'), markDirty: () => t.postFF('mark_dirty'), maskToSelection: (req) => t.postFF('mask_to_selection', req), @@ -1508,6 +1589,14 @@ export function makeApi(t: Transport): EngineApi { nodeThumbnail: (req) => t.request('node_thumbnail', req), openDocument: (bytes) => t.request('open_document', {}, bytes), overlayHitTest: (req) => t.request('overlay_hit_test', req), + packAddBrush: (req) => t.request('pack_add_brush', req), + packCreate: (req) => t.request('pack_create', req), + packDelete: (req) => t.request('pack_delete', req), + packEdit: (req) => t.request('pack_edit', req), + packExport: (req) => t.request('pack_export', req), + packImport: (req, bytes) => t.request('pack_import', req, bytes), + packRemoveBrush: (req) => t.request('pack_remove_brush', req), + packReorderBrush: (req) => t.request('pack_reorder_brush', req), pasteImage: (req, bytes) => t.request('paste_image', req, bytes), pasteImageFloating: (req, bytes) => t.request('paste_image_floating', req, bytes), pasteInPlace: (req) => t.request('paste_in_place', req), diff --git a/frontend/src/icons/bundle.generated.ts b/frontend/src/icons/bundle.generated.ts index 32b10093..6b2d385f 100644 --- a/frontend/src/icons/bundle.generated.ts +++ b/frontend/src/icons/bundle.generated.ts @@ -2,7 +2,7 @@ // Regenerated automatically by the icon-bundle Vite plugin (dev + build) and by // `npm run gen:icons`. Derived from the Iconify icon-name string literals found // in the source, registered for offline rendering. -// 118 icon(s) across 11 collection(s). +// 142 icon(s) across 11 collection(s). /* eslint-disable */ // @ts-nocheck import { addCollection } from '@iconify/svelte/dist/offline-functions.js'; @@ -10,13 +10,13 @@ import { addCollection } from '@iconify/svelte/dist/offline-functions.js'; addCollection({"prefix":"at-icons","icons":{"text":{"body":"","left":0.969,"top":0.969,"width":14.063,"height":14.063}},"lastModified":1784526570}); addCollection({"prefix":"boxicons","icons":{"gradient":{"body":"","left":2.953,"top":2.953,"width":18.094,"height":18.094},"square-dashed":{"body":"","left":2.906,"top":2.953,"width":18.188,"height":18.094}},"lastModified":1771495506,"width":24,"height":24}); addCollection({"prefix":"fa6-brands","icons":{"github":{"body":"","width":496,"left":0,"top":7,"height":486}},"lastModified":1734421834,"width":448,"height":512}); -addCollection({"prefix":"fa6-solid","icons":{"anchor":{"body":"","width":576,"left":0,"top":0,"height":511.875},"arrow-right-arrow-left":{"body":"","width":448,"left":0,"top":0,"height":512},"arrow-up-right-from-square":{"body":"","left":0,"top":0,"width":512,"height":512},"arrows-down-to-line":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"arrows-left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"arrows-up-down":{"body":"","width":258,"left":31,"top":0,"height":512},"arrows-up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"ban":{"body":"","left":0,"top":0,"width":512,"height":512},"bars":{"body":"","width":448,"left":0,"top":63,"height":386},"book":{"body":"","width":448,"left":0,"top":0,"height":512},"border-all":{"body":"","width":448,"left":0,"top":31,"height":450},"chart-line":{"body":"","left":0,"top":31,"width":512,"height":450},"check":{"body":"","width":448,"left":0,"top":95,"height":322},"chevron-down":{"body":"","left":31,"top":159,"width":450,"height":258},"chevron-right":{"body":"","width":257,"left":63,"top":31,"height":450},"chevron-up":{"body":"","left":31,"top":95,"width":450,"height":258},"circle":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-dot":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-half-stroke":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-info":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-notch":{"body":"","left":0,"top":8,"width":512,"height":504},"clipboard":{"body":"","width":384,"left":0,"top":0,"height":512},"clock-rotate-left":{"body":"","left":0,"top":0,"width":512,"height":512},"clone":{"body":"","left":0,"top":0,"width":512,"height":512},"compress":{"body":"","width":448,"left":0,"top":31,"height":450},"copy":{"body":"","width":448,"left":0,"top":0,"height":512},"crop-simple":{"body":"","left":0,"top":0,"width":512,"height":512},"crosshairs":{"body":"","left":0,"top":0,"width":512,"height":512},"diagram-project":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"dice":{"body":"","width":628.75,"left":11.25,"top":11.25,"height":501.25},"display":{"body":"","width":576,"left":0,"top":0,"height":511.875},"down-left-and-up-right-to-center":{"body":"","left":0,"top":0,"width":512,"height":512},"droplet":{"body":"","width":384,"left":0,"top":0,"height":512},"droplet-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"eraser":{"body":"","width":508.5,"left":37.125,"top":37.125,"height":444.375},"expand":{"body":"","width":448,"left":0,"top":31,"height":450},"eye":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"eye-dropper":{"body":"","left":0,"top":0,"width":512,"height":512},"eye-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"feather":{"body":"","left":15,"top":0,"width":497,"height":512},"file":{"body":"","width":384,"left":0,"top":0,"height":512},"file-export":{"body":"","width":576,"left":0,"top":0,"height":511.875},"fill-drip":{"body":"","width":574.875,"left":1.125,"top":0,"height":511.875},"floppy-disk":{"body":"","width":448,"left":0,"top":31,"height":450},"folder":{"body":"","left":0,"top":31,"width":512,"height":450},"folder-open":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"folder-plus":{"body":"","left":0,"top":31,"width":512,"height":450},"gauge-high":{"body":"","left":0,"top":0,"width":512,"height":512},"gear":{"body":"","left":13,"top":0,"width":486,"height":512},"globe":{"body":"","left":0,"top":0,"width":512,"height":512},"grip-lines-vertical":{"body":"","width":192,"left":0,"top":31,"height":450},"grip-vertical":{"body":"","width":320,"left":0,"top":31,"height":450},"keyboard":{"body":"","width":576,"left":0,"top":63,"height":387},"layer-group":{"body":"","width":515.25,"left":30.375,"top":0,"height":511.875},"left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"link":{"body":"","width":607.5,"left":16.25,"top":20,"height":472.5},"link-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"lock":{"body":"","width":448,"left":0,"top":0,"height":512},"lock-open":{"body":"","width":576,"left":0,"top":0,"height":511.875},"magnifying-glass":{"body":"","left":0,"top":0,"width":512,"height":512},"mask":{"body":"","width":576,"left":0,"top":63,"height":387},"maximize":{"body":"","left":31,"top":31,"width":450,"height":450},"minus":{"body":"","width":418,"left":15,"top":223,"height":66},"paint-roller":{"body":"","left":0,"top":0,"width":512,"height":512},"paintbrush":{"body":"","width":545.625,"left":30.375,"top":0,"height":511.875},"palette":{"body":"","left":0,"top":0,"width":512,"height":512},"paste":{"body":"","left":0,"top":0,"width":512,"height":512},"pen":{"body":"","left":0,"top":0,"width":512,"height":512},"pen-nib":{"body":"","left":1,"top":1,"width":510,"height":510},"pen-to-square":{"body":"","left":0,"top":4,"width":508,"height":508},"plus":{"body":"","width":418,"left":15,"top":47,"height":418},"right-left":{"body":"","left":0,"top":0,"width":512,"height":512},"rotate":{"body":"","left":15,"top":31,"width":482,"height":450},"rotate-left":{"body":"","left":15,"top":31,"width":466,"height":450},"rotate-right":{"body":"","left":31,"top":31,"width":466,"height":450},"ruler-horizontal":{"body":"","width":640,"left":0,"top":126.25,"height":260},"scissors":{"body":"","left":0,"top":0,"width":506,"height":512},"screwdriver-wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"sliders":{"body":"","left":0,"top":15,"width":512,"height":482},"square-plus":{"body":"","width":448,"left":0,"top":31,"height":450},"stopwatch":{"body":"","width":418,"left":15,"top":0,"height":512},"sun":{"body":"","left":0,"top":0,"width":512,"height":512},"thumbtack":{"body":"","width":384,"left":0,"top":0,"height":512},"trash":{"body":"","width":448,"left":0,"top":0,"height":512},"triangle-exclamation":{"body":"","left":0,"top":31,"width":512,"height":450},"up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"up-right-and-down-left-from-center":{"body":"","left":0,"top":0,"width":512,"height":512},"vector-square":{"body":"","width":448,"left":0,"top":31,"height":450},"video":{"body":"","width":576,"left":0,"top":63,"height":387},"wand-magic":{"body":"","left":0,"top":0,"width":512,"height":512},"wand-magic-sparkles":{"body":"","width":545.625,"left":0,"top":0,"height":511.875},"wave-square":{"body":"","width":640,"left":0,"top":30,"height":452.5},"wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"xmark":{"body":"","width":322,"left":31,"top":95,"height":322}},"lastModified":1732030010,"width":512,"height":512}); +addCollection({"prefix":"fa6-solid","icons":{"anchor":{"body":"","width":576,"left":0,"top":0,"height":511.875},"arrow-right-arrow-left":{"body":"","width":448,"left":0,"top":0,"height":512},"arrow-up-right-from-square":{"body":"","left":0,"top":0,"width":512,"height":512},"arrows-down-to-line":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"arrows-left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"arrows-spin":{"body":"","left":31,"top":31,"width":450,"height":450},"arrows-up-down":{"body":"","width":258,"left":31,"top":0,"height":512},"arrows-up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"ban":{"body":"","left":0,"top":0,"width":512,"height":512},"bars":{"body":"","width":448,"left":0,"top":63,"height":386},"book":{"body":"","width":448,"left":0,"top":0,"height":512},"border-all":{"body":"","width":448,"left":0,"top":31,"height":450},"chart-line":{"body":"","left":0,"top":31,"width":512,"height":450},"check":{"body":"","width":448,"left":0,"top":95,"height":322},"chevron-down":{"body":"","left":31,"top":159,"width":450,"height":258},"chevron-right":{"body":"","width":257,"left":63,"top":31,"height":450},"chevron-up":{"body":"","left":31,"top":95,"width":450,"height":258},"circle":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-dot":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-half-stroke":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-info":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-notch":{"body":"","left":0,"top":8,"width":512,"height":504},"clipboard":{"body":"","width":384,"left":0,"top":0,"height":512},"clock-rotate-left":{"body":"","left":0,"top":0,"width":512,"height":512},"clone":{"body":"","left":0,"top":0,"width":512,"height":512},"compress":{"body":"","width":448,"left":0,"top":31,"height":450},"copy":{"body":"","width":448,"left":0,"top":0,"height":512},"crop-simple":{"body":"","left":0,"top":0,"width":512,"height":512},"crosshairs":{"body":"","left":0,"top":0,"width":512,"height":512},"diagram-project":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"dice":{"body":"","width":628.75,"left":11.25,"top":11.25,"height":501.25},"display":{"body":"","width":576,"left":0,"top":0,"height":511.875},"down-left-and-up-right-to-center":{"body":"","left":0,"top":0,"width":512,"height":512},"droplet":{"body":"","width":384,"left":0,"top":0,"height":512},"droplet-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"eraser":{"body":"","width":508.5,"left":37.125,"top":37.125,"height":444.375},"expand":{"body":"","width":448,"left":0,"top":31,"height":450},"eye":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"eye-dropper":{"body":"","left":0,"top":0,"width":512,"height":512},"eye-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"feather":{"body":"","left":15,"top":0,"width":497,"height":512},"file":{"body":"","width":384,"left":0,"top":0,"height":512},"file-export":{"body":"","width":576,"left":0,"top":0,"height":511.875},"file-import":{"body":"","left":0,"top":0,"width":512,"height":512},"fill-drip":{"body":"","width":574.875,"left":1.125,"top":0,"height":511.875},"flask":{"body":"","width":448,"left":0,"top":0,"height":512},"floppy-disk":{"body":"","width":448,"left":0,"top":31,"height":450},"folder":{"body":"","left":0,"top":31,"width":512,"height":450},"folder-open":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"folder-plus":{"body":"","left":0,"top":31,"width":512,"height":450},"gauge-high":{"body":"","left":0,"top":0,"width":512,"height":512},"gear":{"body":"","left":13,"top":0,"width":486,"height":512},"globe":{"body":"","left":0,"top":0,"width":512,"height":512},"grip-lines-vertical":{"body":"","width":192,"left":0,"top":31,"height":450},"grip-vertical":{"body":"","width":320,"left":0,"top":31,"height":450},"heart":{"body":"","left":0,"top":41,"width":512,"height":440},"keyboard":{"body":"","width":576,"left":0,"top":63,"height":387},"layer-group":{"body":"","width":515.25,"left":30.375,"top":0,"height":511.875},"left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"link":{"body":"","width":607.5,"left":16.25,"top":20,"height":472.5},"link-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"lock":{"body":"","width":448,"left":0,"top":0,"height":512},"lock-open":{"body":"","width":576,"left":0,"top":0,"height":511.875},"magnifying-glass":{"body":"","left":0,"top":0,"width":512,"height":512},"mask":{"body":"","width":576,"left":0,"top":63,"height":387},"maximize":{"body":"","left":31,"top":31,"width":450,"height":450},"minus":{"body":"","width":418,"left":15,"top":223,"height":66},"paint-roller":{"body":"","left":0,"top":0,"width":512,"height":512},"paintbrush":{"body":"","width":545.625,"left":30.375,"top":0,"height":511.875},"palette":{"body":"","left":0,"top":0,"width":512,"height":512},"paste":{"body":"","left":0,"top":0,"width":512,"height":512},"pen":{"body":"","left":0,"top":0,"width":512,"height":512},"pen-nib":{"body":"","left":1,"top":1,"width":510,"height":510},"pen-to-square":{"body":"","left":0,"top":4,"width":508,"height":508},"plus":{"body":"","width":418,"left":15,"top":47,"height":418},"right-left":{"body":"","left":0,"top":0,"width":512,"height":512},"rotate":{"body":"","left":15,"top":31,"width":482,"height":450},"rotate-left":{"body":"","left":15,"top":31,"width":466,"height":450},"rotate-right":{"body":"","left":31,"top":31,"width":466,"height":450},"ruler-horizontal":{"body":"","width":640,"left":0,"top":126.25,"height":260},"scissors":{"body":"","left":0,"top":0,"width":506,"height":512},"screwdriver-wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"sliders":{"body":"","left":0,"top":15,"width":512,"height":482},"square-plus":{"body":"","width":448,"left":0,"top":31,"height":450},"star":{"body":"","width":531,"left":22.5,"top":0,"height":511.875},"stopwatch":{"body":"","width":418,"left":15,"top":0,"height":512},"sun":{"body":"","left":0,"top":0,"width":512,"height":512},"thumbtack":{"body":"","width":384,"left":0,"top":0,"height":512},"trash":{"body":"","width":448,"left":0,"top":0,"height":512},"triangle-exclamation":{"body":"","left":0,"top":31,"width":512,"height":450},"up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"up-right-and-down-left-from-center":{"body":"","left":0,"top":0,"width":512,"height":512},"vector-square":{"body":"","width":448,"left":0,"top":31,"height":450},"video":{"body":"","width":576,"left":0,"top":63,"height":387},"wand-magic":{"body":"","left":0,"top":0,"width":512,"height":512},"wand-magic-sparkles":{"body":"","width":545.625,"left":0,"top":0,"height":511.875},"wave-square":{"body":"","width":640,"left":0,"top":30,"height":452.5},"wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"xmark":{"body":"","width":322,"left":31,"top":95,"height":322}},"lastModified":1732030010,"width":512,"height":512}); addCollection({"prefix":"file-icons","icons":{"blender":{"body":"","left":0,"top":47,"width":512,"height":418}},"lastModified":1721244157,"width":512,"height":512}); addCollection({"prefix":"lucide","icons":{"circle-dashed":{"body":"","left":0.938,"top":0.938,"width":22.125,"height":22.125},"triangle-dashed":{"body":"","left":0.938,"top":1.922,"width":22.125,"height":20.156}},"lastModified":1784351942,"width":24,"height":24}); addCollection({"prefix":"lucide-lab","icons":{"venn":{"body":"","left":0.938,"top":4.922,"width":22.125,"height":14.156}},"lastModified":1731133495,"width":24,"height":24}); addCollection({"prefix":"material-symbols","icons":{"curtains-rounded":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094}},"lastModified":1784526060,"width":24,"height":24}); -addCollection({"prefix":"mdi","icons":{"blur":{"body":"","left":2.438,"top":2.438,"width":19.125,"height":19.125},"gesture-swipe":{"body":"","left":1.922,"top":0.938,"width":20.156,"height":22.125}},"lastModified":1737398331,"width":24,"height":24}); +addCollection({"prefix":"mdi","icons":{"blur":{"body":"","left":2.438,"top":2.438,"width":19.125,"height":19.125},"brush":{"body":"","left":1.922,"top":2.953,"width":19.125,"height":18.094},"diamond-stone":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"dots-horizontal":{"body":"","left":3.938,"top":9.938,"width":16.125,"height":4.125},"eraser":{"body":"","left":2.156,"top":2.906,"width":19.688,"height":18.844},"fire":{"body":"","left":4.922,"top":2.953,"width":14.109,"height":18.094},"fountain-pen-tip":{"body":"","left":3.422,"top":3.422,"width":17.156,"height":17.156},"gesture-swipe":{"body":"","left":1.922,"top":0.938,"width":20.156,"height":22.125},"grain":{"body":"","left":3.938,"top":3.938,"width":16.125,"height":16.125},"image-filter-vintage":{"body":"","left":2.25,"top":0.75,"width":19.5,"height":22.5},"leaf":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":19.125},"palette":{"body":"","left":2.953,"top":2.953,"width":18.094,"height":18.094},"pencil":{"body":"","left":2.953,"top":2.953,"width":18.094,"height":18.094},"shape":{"body":"","left":2.953,"top":1.922,"width":19.125,"height":20.156},"shimmer":{"body":"","left":1.922,"top":0.938,"width":19.125,"height":21.141},"snowflake":{"body":"","left":2.625,"top":1.922,"width":18.75,"height":20.156},"spray":{"body":"","left":3.938,"top":1.922,"width":16.125,"height":20.156},"texture-box":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"vector-curve":{"body":"","left":1.922,"top":1.922,"width":21.141,"height":21.141},"water":{"body":"","left":5.953,"top":3.188,"width":12.094,"height":16.875},"weather-cloudy":{"body":"","left":0.938,"top":4.922,"width":22.125,"height":14.156}},"lastModified":1737398331,"width":24,"height":24}); addCollection({"prefix":"radix-icons","icons":{"mask-off":{"body":"","left":0,"top":0.967,"width":15,"height":13.066},"mask-on":{"body":"","left":0,"top":0.967,"width":15,"height":13.066}},"lastModified":1766212494,"width":15,"height":15}); addCollection({"prefix":"tabler","icons":{"camera":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094},"flip-horizontal":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"galaxy":{"body":"","left":3.141,"top":1.922,"width":17.719,"height":20.156},"lasso":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"perspective":{"body":"","left":3.938,"top":3.094,"width":16.125,"height":17.813},"ripple":{"body":"","left":1.922,"top":4.453,"width":20.156,"height":15.516},"screen-share":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094},"test":{"body":"","hidden":true,"left":1.922,"top":1.922,"width":20.156,"height":20.156},"vector":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156}},"lastModified":1784526686,"width":24,"height":24}); -export const BUNDLED_ICON_NAMES = ["at-icons:text","boxicons:gradient","boxicons:square-dashed","fa6-brands:github","fa6-solid:anchor","fa6-solid:angles-left-right","fa6-solid:arrow-right-arrow-left","fa6-solid:arrow-up-right-from-square","fa6-solid:arrows-down-to-line","fa6-solid:arrows-left-right","fa6-solid:arrows-up-down","fa6-solid:arrows-up-down-left-right","fa6-solid:ban","fa6-solid:bars","fa6-solid:book","fa6-solid:border-all","fa6-solid:chart-line","fa6-solid:check","fa6-solid:chevron-down","fa6-solid:chevron-right","fa6-solid:chevron-up","fa6-solid:circle","fa6-solid:circle-dot","fa6-solid:circle-half-stroke","fa6-solid:circle-info","fa6-solid:circle-notch","fa6-solid:clipboard","fa6-solid:clock-rotate-left","fa6-solid:clone","fa6-solid:compress","fa6-solid:copy","fa6-solid:crop-simple","fa6-solid:crosshairs","fa6-solid:diagram-project","fa6-solid:dice","fa6-solid:display","fa6-solid:down-left-and-up-right-to-center","fa6-solid:droplet","fa6-solid:droplet-slash","fa6-solid:eraser","fa6-solid:expand","fa6-solid:eye","fa6-solid:eye-dropper","fa6-solid:eye-slash","fa6-solid:feather","fa6-solid:file","fa6-solid:file-export","fa6-solid:fill-drip","fa6-solid:floppy-disk","fa6-solid:folder","fa6-solid:folder-open","fa6-solid:folder-plus","fa6-solid:gauge-high","fa6-solid:gear","fa6-solid:globe","fa6-solid:grip-lines-vertical","fa6-solid:grip-vertical","fa6-solid:icon-name","fa6-solid:keyboard","fa6-solid:layer-group","fa6-solid:left-right","fa6-solid:link","fa6-solid:link-slash","fa6-solid:lock","fa6-solid:lock-open","fa6-solid:magnifying-glass","fa6-solid:mask","fa6-solid:maximize","fa6-solid:minus","fa6-solid:paint-roller","fa6-solid:paintbrush","fa6-solid:palette","fa6-solid:paste","fa6-solid:pen","fa6-solid:pen-nib","fa6-solid:pen-to-square","fa6-solid:plus","fa6-solid:right-left","fa6-solid:rotate","fa6-solid:rotate-left","fa6-solid:rotate-right","fa6-solid:ruler-horizontal","fa6-solid:scissors","fa6-solid:screwdriver-wrench","fa6-solid:sliders","fa6-solid:square-plus","fa6-solid:stopwatch","fa6-solid:sun","fa6-solid:thumbtack","fa6-solid:trash","fa6-solid:triangle-exclamation","fa6-solid:up-down-left-right","fa6-solid:up-right-and-down-left-from-center","fa6-solid:vector-square","fa6-solid:video","fa6-solid:wand-magic","fa6-solid:wand-magic-sparkles","fa6-solid:wave-square","fa6-solid:wrench","fa6-solid:xmark","file-icons:blender","lucide-lab:venn","lucide:circle-dashed","lucide:triangle-dashed","material-symbols:curtains-rounded","mdi:blur","mdi:gesture-swipe","radix-icons:mask-off","radix-icons:mask-on","tabler:camera","tabler:flip-horizontal","tabler:galaxy","tabler:lasso","tabler:perspective","tabler:ripple","tabler:screen-share","tabler:test","tabler:vector"]; +export const BUNDLED_ICON_NAMES = ["at-icons:text","boxicons:gradient","boxicons:square-dashed","fa6-brands:github","fa6-solid:anchor","fa6-solid:angles-left-right","fa6-solid:arrow-right-arrow-left","fa6-solid:arrow-up-right-from-square","fa6-solid:arrows-down-to-line","fa6-solid:arrows-left-right","fa6-solid:arrows-spin","fa6-solid:arrows-up-down","fa6-solid:arrows-up-down-left-right","fa6-solid:ban","fa6-solid:bars","fa6-solid:book","fa6-solid:border-all","fa6-solid:chart-line","fa6-solid:check","fa6-solid:chevron-down","fa6-solid:chevron-right","fa6-solid:chevron-up","fa6-solid:circle","fa6-solid:circle-dot","fa6-solid:circle-half-stroke","fa6-solid:circle-info","fa6-solid:circle-notch","fa6-solid:clipboard","fa6-solid:clock-rotate-left","fa6-solid:clone","fa6-solid:compress","fa6-solid:copy","fa6-solid:crop-simple","fa6-solid:crosshairs","fa6-solid:diagram-project","fa6-solid:dice","fa6-solid:display","fa6-solid:down-left-and-up-right-to-center","fa6-solid:droplet","fa6-solid:droplet-slash","fa6-solid:eraser","fa6-solid:expand","fa6-solid:eye","fa6-solid:eye-dropper","fa6-solid:eye-slash","fa6-solid:feather","fa6-solid:file","fa6-solid:file-export","fa6-solid:file-import","fa6-solid:fill-drip","fa6-solid:flask","fa6-solid:floppy-disk","fa6-solid:folder","fa6-solid:folder-open","fa6-solid:folder-plus","fa6-solid:gauge-high","fa6-solid:gear","fa6-solid:globe","fa6-solid:grip-lines-vertical","fa6-solid:grip-vertical","fa6-solid:heart","fa6-solid:icon-name","fa6-solid:keyboard","fa6-solid:layer-group","fa6-solid:left-right","fa6-solid:link","fa6-solid:link-slash","fa6-solid:lock","fa6-solid:lock-open","fa6-solid:magnifying-glass","fa6-solid:mask","fa6-solid:maximize","fa6-solid:minus","fa6-solid:paint-roller","fa6-solid:paintbrush","fa6-solid:palette","fa6-solid:paste","fa6-solid:pen","fa6-solid:pen-nib","fa6-solid:pen-to-square","fa6-solid:plus","fa6-solid:right-left","fa6-solid:rotate","fa6-solid:rotate-left","fa6-solid:rotate-right","fa6-solid:ruler-horizontal","fa6-solid:scissors","fa6-solid:screwdriver-wrench","fa6-solid:sliders","fa6-solid:square-plus","fa6-solid:star","fa6-solid:stopwatch","fa6-solid:sun","fa6-solid:thumbtack","fa6-solid:trash","fa6-solid:triangle-exclamation","fa6-solid:up-down-left-right","fa6-solid:up-right-and-down-left-from-center","fa6-solid:vector-square","fa6-solid:video","fa6-solid:wand-magic","fa6-solid:wand-magic-sparkles","fa6-solid:wave-square","fa6-solid:wrench","fa6-solid:xmark","file-icons:blender","lucide-lab:venn","lucide:circle-dashed","lucide:triangle-dashed","material-symbols:curtains-rounded","mdi:blur","mdi:brush","mdi:diamond-stone","mdi:dots-horizontal","mdi:eraser","mdi:fire","mdi:fountain-pen-tip","mdi:gesture-swipe","mdi:grain","mdi:image-filter-vintage","mdi:leaf","mdi:palette","mdi:pencil","mdi:shape","mdi:shimmer","mdi:snowflake","mdi:spray","mdi:texture-box","mdi:vector-curve","mdi:water","mdi:weather-cloudy","radix-icons:mask-off","radix-icons:mask-on","tabler:camera","tabler:flip-horizontal","tabler:galaxy","tabler:lasso","tabler:perspective","tabler:ripple","tabler:screen-share","tabler:test","tabler:vector"]; diff --git a/frontend/src/lib/__tests__/color.test.ts b/frontend/src/lib/__tests__/color.test.ts new file mode 100644 index 00000000..23bf4456 --- /dev/null +++ b/frontend/src/lib/__tests__/color.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { hexToColor, colorToHex, colorToHexRgb, hexToRgb01, rgb01ToHex } from '../color'; + +describe('color hex conversions', () => { + it('hex_round_trips_through_color_in_both_widths', () => { + // The 8-digit case is what `hexToRgb01` alone could not express: it + // matched 6 digits only and returned black for anything wider. + expect(colorToHex(hexToColor('#3355ff')!)).toBe('#3355ffff'); + expect(colorToHex(hexToColor('#3355ffaa')!)).toBe('#3355ffaa'); + }); + + it('a_six_digit_hex_is_opaque', () => { + expect(hexToColor('#3355ff')).toEqual({ r: 0x33, g: 0x55, b: 0xff, a: 255 }); + }); + + it('a_malformed_hex_is_null_not_black', () => { + for (const bad of ['#xyz', 'ff00', '#ff00', '#12345', '#1234567', '', 'rebeccapurple']) { + expect(hexToColor(bad), `for ${bad}`).toBeNull(); + } + }); + + it('parsing_accepts_a_missing_hash_and_mixed_case', () => { + expect(hexToColor('3355FF')).toEqual({ r: 0x33, g: 0x55, b: 0xff, a: 255 }); + expect(hexToColor(' #3355ff ')).toEqual({ r: 0x33, g: 0x55, b: 0xff, a: 255 }); + }); + + it('the_display_form_drops_alpha', () => { + expect(colorToHexRgb({ r: 0x33, g: 0x55, b: 0xff, a: 0x80 })).toBe('#3355ff'); + }); + + it('components_are_clamped_and_padded', () => { + expect(colorToHex({ r: -5, g: 300, b: 0, a: 255 })).toBe('#00ff00ff'); + }); + + it('the_rgb01_helpers_still_round_trip', () => { + expect(rgb01ToHex(hexToRgb01('#3355ff'))).toBe('#3355ff'); + // Alpha is discarded rather than corrupting the triple. + expect(hexToRgb01('#3355ffaa')).toEqual(hexToRgb01('#3355ff')); + // The documented fallback for callers that never handled null. + expect(hexToRgb01('nonsense')).toEqual([0, 0, 0]); + }); +}); diff --git a/frontend/src/lib/__tests__/scrollSync.test.ts b/frontend/src/lib/__tests__/scrollSync.test.ts new file mode 100644 index 00000000..7bdc20d3 --- /dev/null +++ b/frontend/src/lib/__tests__/scrollSync.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { ScrollSyncToken } from '../scrollSync'; + +type Side = 'list' | 'wheel'; + +describe('ScrollSyncToken', () => { + it('the first claimer wins and the other side is refused', () => { + const t = new ScrollSyncToken(120); + expect(t.claim('list', 0)).toBe(true); + // The echo of our own write to the wheel. + expect(t.claim('wheel', 5)).toBe(false); + }); + + it('the owner re-claiming restarts the hold, so a fling keeps it', () => { + const t = new ScrollSyncToken(120); + t.claim('list', 0); + // Momentum events every 16ms for well past one hold window. + for (let now = 16; now <= 400; now += 16) { + expect(t.claim('list', now)).toBe(true); + expect(t.claim('wheel', now)).toBe(false); + } + }); + + it('the other side can claim once the owner goes quiet', () => { + const t = new ScrollSyncToken(120); + t.claim('list', 0); + expect(t.claim('wheel', 119)).toBe(false); + expect(t.claim('wheel', 120)).toBe(true); + }); + + it('a pointerdown preempts mid-hold rather than being eaten', () => { + // A pen landing on the wheel during the list's momentum tail. Without + // preemption the token refuses it for the rest of the window and the + // wheel snaps back under the painter's finger. + const t = new ScrollSyncToken(120); + t.claim('list', 0); + expect(t.claim('wheel', 10)).toBe(false); + + t.preempt('wheel', 10); + expect(t.owner).toBe('wheel'); + expect(t.claim('wheel', 11)).toBe(true); + // And the list's remaining momentum is now the echo. + expect(t.claim('list', 12)).toBe(false); + }); + + it('release frees it immediately', () => { + const t = new ScrollSyncToken(120); + t.claim('list', 0); + t.release(); + expect(t.owner).toBeNull(); + expect(t.claim('wheel', 1)).toBe(true); + }); + + it('an unowned token grants whoever asks first', () => { + const t = new ScrollSyncToken(120); + expect(t.claim('wheel', 999)).toBe(true); + expect(t.owner).toBe('wheel'); + }); +}); diff --git a/frontend/src/lib/color.ts b/frontend/src/lib/color.ts index bbb99e32..a131c9ce 100644 --- a/frontend/src/lib/color.ts +++ b/frontend/src/lib/color.ts @@ -1,22 +1,61 @@ /** - * Convert a `#rrggbb` hex string to a normalized sRGB `[r, g, b]` triple in - * `[0, 1]`. + * Hex ↔ color conversions. * * Darkly is display-referred: every color — the picker, `app.foreground`, paint * colors, fill/gradient, filter/veil params, and the stored texels — is the - * same raw sRGB value, and nothing rescales it. So this is a plain byte→[0,1] - * normalization; there is deliberately no gamma/linear conversion anywhere in + * same raw sRGB value, and nothing rescales it. So these are plain byte + * normalizations; there is deliberately no gamma/linear conversion anywhere in * the color path. */ +import type { Color } from '../state/app.svelte'; + +const HEX = /^#?([0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?)$/; + +/** + * Parse `#rrggbb` or `#rrggbbaa` into a byte `Color`. Returns `null` on + * anything else — callers that want a fallback must say so, because silently + * returning black makes a malformed value indistinguishable from a black one. + * A 6-digit input is opaque. + */ +export function hexToColor(hex: string): Color | null { + const m = HEX.exec(hex.trim()); + if (!m) return null; + const d = m[1]; + const n = parseInt(d.slice(0, 6), 16); + return { + r: (n >> 16) & 0xff, + g: (n >> 8) & 0xff, + b: n & 0xff, + a: d.length === 8 ? parseInt(d.slice(6, 8), 16) : 255, + }; +} + +const hx = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'); + +/** A byte `Color` as `#rrggbbaa`, lowercase. Always 8 digits, so a round trip + * through {@link hexToColor} preserves alpha. This is the canonical storage + * form — what recents and pack colors are written as. */ +export function colorToHex(c: Color): string { + return `#${hx(c.r)}${hx(c.g)}${hx(c.b)}${hx(c.a)}`; +} + +/** A byte `Color` as `#rrggbb`, dropping alpha. The form shown to the painter + * in a hex field, where a trailing `ff` on every opaque color is noise. */ +export function colorToHexRgb(c: Color): string { + return `#${hx(c.r)}${hx(c.g)}${hx(c.b)}`; +} + +/** A `#rrggbb`/`#rrggbbaa` hex string as a normalized sRGB `[r, g, b]` triple + * in `[0, 1]`. Alpha is discarded. Malformed input reads as black, which is + * what this helper's callers have always assumed. */ export function hexToRgb01(hex: string): [number, number, number] { - const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()); - if (!m) return [0, 0, 0]; - const n = parseInt(m[1], 16); - return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255]; + const c = hexToColor(hex); + if (!c) return [0, 0, 0]; + return [c.r / 255, c.g / 255, c.b / 255]; } -/** Inverse of {@link hexToRgb01} — a normalized sRGB `[0,1]` triple to `#rrggbb`. - * Components are clamped and rounded; see `hexToRgb01` for why no linear step. */ +/** Inverse of {@link hexToRgb01} — a normalized sRGB `[0,1]` triple to + * `#rrggbb`. Components are clamped and rounded. */ export function rgb01ToHex(rgb: [number, number, number]): string { const to255 = (c: number) => Math.max(0, Math.min(255, Math.round(c * 255))); const hx = (c: number) => to255(c).toString(16).padStart(2, '0'); diff --git a/frontend/src/lib/id.ts b/frontend/src/lib/id.ts new file mode 100644 index 00000000..93058894 --- /dev/null +++ b/frontend/src/lib/id.ts @@ -0,0 +1,22 @@ +/** + * Opaque id generation. + * + * Ids are minted here rather than in Rust because the `darkly` crate has no + * random-number source, and adding one for wasm means the `getrandom/js` + * feature dance. The browser already has `crypto.randomUUID`; Rust's job is + * to reject an empty or duplicate id, which is deterministic and testable. + */ + +/** A fresh opaque id, prefixed so it reads clearly wherever it surfaces. + * + * Falls back to `Math.random` where `crypto.randomUUID` is unavailable + * (non-secure contexts, older embedders). The fallback is not + * cryptographically strong and does not need to be: these are local + * identifiers, not secrets. */ +export function newId(prefix: string): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return `${prefix}-${crypto.randomUUID()}`; + } + const rand = () => Math.random().toString(36).slice(2); + return `${prefix}-${rand()}${rand()}`; +} diff --git a/frontend/src/lib/inView.ts b/frontend/src/lib/inView.ts new file mode 100644 index 00000000..df8deb23 --- /dev/null +++ b/frontend/src/lib/inView.ts @@ -0,0 +1,52 @@ +/** + * Svelte action: report whether an element is near the scrollport. + * + * For gating work that is only worth doing for what the painter can actually + * see. The brush explorer's preview strips are the motivating case: each + * mounted one runs a throttled engine round trip plus a bounded + * `requestAnimationFrame` poll that asks for a frame every tick, so mounting + * every tile of every pack at once is a render storm at exactly the moment the + * view opens. + * + * `rootMargin` deliberately overshoots the viewport so a tile is ready by the + * time it scrolls in rather than popping blank. + */ +export interface InViewOptions { + /** Called with `true` once the element is near the scrollport, and `false` + * when it leaves. */ + onChange: (visible: boolean) => void; + /** How far outside the scrollport still counts as visible. */ + rootMargin?: string; +} + +export function inView(node: HTMLElement, options: InViewOptions) { + let opts = options; + + // No IntersectionObserver (jsdom, an old embedder) means no gating: report + // visible and let everything mount, which is the pre-existing behaviour. + if (typeof IntersectionObserver === 'undefined') { + opts.onChange(true); + return { + update(next: InViewOptions) { + opts = next; + }, + }; + } + + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) opts.onChange(entry.isIntersecting); + }, + { rootMargin: options.rootMargin ?? '300px' }, + ); + observer.observe(node); + + return { + update(next: InViewOptions) { + opts = next; + }, + destroy() { + observer.disconnect(); + }, + }; +} diff --git a/frontend/src/lib/packIcon.ts b/frontend/src/lib/packIcon.ts new file mode 100644 index 00000000..9666b05e --- /dev/null +++ b/frontend/src/lib/packIcon.ts @@ -0,0 +1,24 @@ +/** + * Resolving a pack's icon to something that will actually draw. + * + * The icon bundle is generated offline by scraping Iconify name literals out of + * the source (`frontend/scripts/gen-icon-bundle.mjs`), so a name that appears + * only in an imported pack's manifest is not in it and would render nothing. + * Rust validates a pack icon's *shape* — `collection:name` — and deliberately + * stops there: whether an icon renders is the renderer's question, and the + * renderer's answer is to fall back rather than show a hole. + */ +import { generateIcon } from '@iconify/svelte/dist/offline-functions.js'; + +/** Drawn in place of an icon the bundle lacks. Mirrors + * `PACK_ICON_FALLBACK` in `crates/darkly/src/brush/pack_icons.rs`, which is + * where the set a pack may choose from is declared. */ +export const PACK_ICON_FALLBACK = 'fa6-solid:folder'; + +/** `name` if the offline bundle has it, the fallback otherwise. */ +export function packIcon(name: string | null | undefined): string { + if (name && generateIcon({ icon: name } as Parameters[0]) !== null) { + return name; + } + return PACK_ICON_FALLBACK; +} diff --git a/frontend/src/lib/scrollSync.ts b/frontend/src/lib/scrollSync.ts new file mode 100644 index 00000000..c824493d --- /dev/null +++ b/frontend/src/lib/scrollSync.ts @@ -0,0 +1,76 @@ +/** + * Ownership arbitration for two scrollports that drive each other. + * + * Two-way scroll sync oscillates the obvious way: A's `scroll` handler writes + * `B.scrollTop`, which fires B's handler, which writes back to A, and sub-pixel + * rounding keeps them ping-ponging. The fix is an ownership token, not an + * epsilon comparison: whichever side the painter is actually driving owns the + * pair, and a `scroll` event from the other side is discarded as an echo. + * + * Generic by name and by shape — nothing here knows about brushes or wheels. + */ + +/** + * Which of two coupled scrollports is currently driving. + * + * Ownership is claimed on the first `scroll` event from a side and released + * `holdMs` after that side's last one, so a fling's whole momentum tail stays + * owned. A trailing timer rather than one animation frame because a + * programmatic `scrollTop` write fires its `scroll` event before the next + * frame — a one-frame token suppresses the immediate echo, but native momentum + * runs for hundreds of milliseconds, and writing back mid-momentum fights the + * browser's own scrolling. `scrollend` would be tidier but Safari lacks it, and + * pen tablets are the point. + * + * The clock is injected, so this is testable in the node environment. + */ +export class ScrollSyncToken { + #owner: Side | null = null; + #lastActivity = 0; + readonly #holdMs: number; + + constructor(holdMs = 120) { + this.#holdMs = holdMs; + } + + /** + * Whether `side` may write the other pane right now. + * + * An unowned pair is claimed by whoever asks first. The owner keeps it, and + * each call refreshes the hold, so a continuous stream of scroll events + * never lets go mid-gesture. + */ + claim(side: Side, now: number): boolean { + if (this.#owner !== null && this.#owner !== side && now - this.#lastActivity < this.#holdMs) { + return false; + } + this.#owner = side; + this.#lastActivity = now; + return true; + } + + /** + * Take ownership for `side` regardless of who holds it. + * + * For a `pointerdown`: a deliberate touch is never an echo, where a + * `scroll` event is ambiguous. Without this, a pen landing on one pane + * during the other's momentum tail is refused for the whole hold window and + * the pane snaps back under the painter's finger — the token would convert + * oscillation into eaten input. + */ + preempt(side: Side, now: number): void { + this.#owner = side; + this.#lastActivity = now; + } + + /** Who holds the pair, if anyone. */ + get owner(): Side | null { + return this.#owner; + } + + /** Drop ownership immediately. For teardown, and for tests. */ + release(): void { + this.#owner = null; + this.#lastActivity = 0; + } +} diff --git a/frontend/src/state/__tests__/brush_library_store.test.ts b/frontend/src/state/__tests__/brush_library_store.test.ts new file mode 100644 index 00000000..5d0e016f --- /dev/null +++ b/frontend/src/state/__tests__/brush_library_store.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { DarklyStorage, DirEntry } from '../../storage/types'; +import { app, DarklyInstance, setActiveInstance } from '../app.svelte'; +import { BrushLibraryStore } from '../brush_library.svelte'; + +/** In-memory storage. */ +class FakeStorage implements DarklyStorage { + files = new Map(); + async read(path: string) { return this.files.get(path) ?? null; } + async write(path: string, data: Uint8Array) { this.files.set(path, data); } + async list(dir: string): Promise { + const prefix = dir ? `${dir}/` : ''; + const out: DirEntry[] = []; + for (const p of this.files.keys()) { + if (!p.startsWith(prefix)) continue; + const rest = p.slice(prefix.length); + if (rest.length === 0 || rest.includes('/')) continue; + out.push({ name: rest, kind: 'file' }); + } + return out; + } + async remove(path: string) { this.files.delete(path); } + async exists(path: string) { return this.files.has(path); } + + put(path: string, value: unknown) { + this.files.set(path, new TextEncoder().encode(JSON.stringify(value))); + } + putRaw(path: string, text: string) { + this.files.set(path, new TextEncoder().encode(text)); + } + json(path: string): Record | null { + const b = this.files.get(path); + return b ? JSON.parse(new TextDecoder().decode(b)) : null; + } + paths(prefix: string): string[] { + return [...this.files.keys()].filter(p => p.startsWith(prefix)).sort(); + } +} + +/** A fake engine holding a library the way the real one does: brushes keyed + * by id, packs owning member lists. Shipped entries seed it, exactly as the + * real engine rebuilds them from YAML each boot. */ +function fakeEngine() { + const brushes = new Map([ + ['ink_pen', { id: 'ink_pen', name: 'Ink Pen' }], + ]); + const packs = new Map([ + ['basic', { + id: 'basic', name: 'Basic', description: '', icon: 'mdi:brush', + primary: '#000000', secondary: '#ffffff', members: ['ink_pen'], + can_edit_members: false, can_edit_identity: false, + }], + ]); + + const api = { + libraryList: vi.fn(async () => ({ + brushes: [...brushes.values()].map(b => ({ ...b, author: '', description: '', tags: [], icon: null })), + packs: [...packs.values()].map(p => ({ ...p, members: [...p.members] })), + })), + brushGraphImportYaml: vi.fn(async ({ yaml }: { yaml: string }) => { + if (yaml === 'CORRUPT') throw new Error('bad graph'); + return null; + }), + brushSave: vi.fn(async ({ id, name }: { id: string; name: string }) => { + brushes.set(id, { id, name }); + return null; + }), + brushExportYaml: vi.fn(async ({ id }: { id: string }) => `yaml-for-${id}`), + brushRename: vi.fn(async ({ id, name }: { id: string; name: string }) => { + const b = brushes.get(id); + if (b) b.name = name; + return null; + }), + brushDelete: vi.fn(async ({ id }: { id: string }) => { + brushes.delete(id); + for (const p of packs.values()) p.members = p.members.filter(m => m !== id); + return null; + }), + packCreate: vi.fn(async (r: { + id: string; name: string; description: string; + icon: string; primary: string; secondary: string; + }) => { + if (packs.has(r.id)) throw new Error('duplicate pack id'); + packs.set(r.id, { + ...r, members: [], + can_edit_members: true, can_edit_identity: true, + }); + return null; + }), + packAddBrush: vi.fn(async ({ pack, brush }: { pack: string; brush: string }) => { + const p = packs.get(pack); + if (!p) throw new Error('no such pack'); + if (!brushes.has(brush)) throw new Error('no such brush'); + if (!p.members.includes(brush)) p.members.push(brush); + return null; + }), + packDelete: vi.fn(async ({ id }: { id: string }) => { + packs.delete(id); + return null; + }), + }; + return { engine: { api } as unknown as NonNullable, brushes, packs, api }; +} + +let s: FakeStorage; +let store: BrushLibraryStore; +let fake: ReturnType; + +beforeEach(() => { + s = new FakeStorage(); + fake = fakeEngine(); + // `app` is a proxy onto the active instance, so one must exist before + // `app.engine` can be set. + setActiveInstance(new DarklyInstance()); + app.engine = fake.engine; + store = new BrushLibraryStore(s); +}); + +afterEach(() => { + setActiveInstance(null); +}); + +describe('brush library persistence', () => { + it('a_fresh_install_writes_only_the_seeded_favorites', async () => { + // Shipped brushes and packs come back from YAML every boot; storing a + // copy would shadow them. Favorites is the exception because it is not + // shipped: it is the painter's, created here so they have somewhere to + // put a brush on the first day. + await store.hydrate(); + await store.flush(); + + const favorites = store.packs.find(p => p.name === 'Favorites')!; + expect(s.paths('')).toEqual([`packs/${favorites.id}.json`]); + }); + + it('hydrate_imports_every_stored_record', async () => { + s.put('brushes/b1.json', { id: 'b1', name: 'Mine', yaml: 'nodes: {}' }); + s.put('packs/p1.json', { + id: 'p1', name: 'My Pack', description: 'd', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: ['b1'], + }); + + await store.hydrate(); + + expect(store.brushes.map(b => b.id).sort()).toEqual(['b1', 'ink_pen']); + const p1 = store.pack('p1'); + expect(p1?.name).toBe('My Pack'); + expect(p1?.members).toEqual(['b1']); + }); + + it('hydrate_is_idempotent_across_reloads', async () => { + s.put('brushes/b1.json', { id: 'b1', name: 'Mine', yaml: 'nodes: {}' }); + s.put('packs/p1.json', { + id: 'p1', name: 'My Pack', description: '', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: ['b1'], + }); + + await store.hydrate(); + const first = { brushes: store.brushes.length, name: store.pack('p1')?.name }; + + // A second boot against the same files, and a fresh engine. + fake = fakeEngine(); + app.engine = fake.engine; + const second = new BrushLibraryStore(s); + await second.hydrate(); + + expect(second.brushes.length).toBe(first.brushes); + // No "(2)" accretion: hydration replays with the stored id, which is + // not the import-a-stranger's-file path. + expect(second.pack('p1')?.name).toBe(first.name); + expect(second.pack('p1')?.name).toBe('My Pack'); + }); + + it('a_record_that_fails_to_import_is_skipped_not_fatal', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + s.put('brushes/good.json', { id: 'good', name: 'Good', yaml: 'nodes: {}' }); + s.put('brushes/bad.json', { id: 'bad', name: 'Bad', yaml: 'CORRUPT' }); + s.putRaw('brushes/unreadable.json', 'not json at all'); + + await store.hydrate(); + + expect(store.brushes.some(b => b.id === 'good')).toBe(true); + expect(store.brushes.some(b => b.id === 'bad')).toBe(false); + warn.mockRestore(); + }); + + it('a_member_naming_a_missing_brush_is_dropped_on_hydrate', async () => { + s.put('packs/p1.json', { + id: 'p1', name: 'My Pack', description: '', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: ['ink_pen', 'ghost'], + }); + + await store.hydrate(); + await store.flush(); + + expect(store.pack('p1')?.members).toEqual(['ink_pen']); + // Self-healing converges: the rewritten record no longer names it. + expect(s.json('packs/p1.json')?.members).toEqual(['ink_pen']); + }); + + it('renaming_a_pack_leaves_no_stale_file', async () => { + s.put('packs/p1.json', { + id: 'p1', name: 'Before', description: '', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: [], + }); + await store.hydrate(); + + // Rename in the engine, then write through. + fake.packs.get('p1')!.name = 'After'; + await store.refresh(); + store.persistPack('p1'); + await store.flush(); + + expect(s.paths('packs/')).toEqual(['packs/p1.json']); + expect(s.json('packs/p1.json')?.name).toBe('After'); + }); + + it('deleting_a_pack_removes_its_file_and_no_other', async () => { + for (const id of ['p1', 'p2']) { + s.put(`packs/${id}.json`, { + id, name: id, description: '', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: [], + }); + } + await store.hydrate(); + + await store.deletePack('p1'); + await store.flush(); + + expect(s.paths('packs/')).toEqual(['packs/p2.json']); + }); + + it('two_packs_with_names_that_sanitize_alike_both_persist', async () => { + // Ids, not slugs: `"A/B"` and `"A:B"` would collide as filenames. + await store.hydrate(); + await fake.api.packCreate({ + id: 'id-one', name: 'A/B', description: '', icon: 'mdi:water', + primary: '#000000', secondary: '#ffffff', + }); + await fake.api.packCreate({ + id: 'id-two', name: 'A:B', description: '', icon: 'mdi:water', + primary: '#000000', secondary: '#ffffff', + }); + await store.refresh(); + store.persistPack('id-one'); + store.persistPack('id-two'); + await store.flush(); + + // Both survive as separate files. The seeded Favorites is also on + // disk, so this asserts the two in question rather than the whole + // directory. + expect(s.paths('packs/')).toEqual( + expect.arrayContaining(['packs/id-one.json', 'packs/id-two.json']), + ); + expect(s.json('packs/id-one.json')?.name).toBe('A/B'); + expect(s.json('packs/id-two.json')?.name).toBe('A:B'); + }); + + it('a_shipped_pack_is_never_written', async () => { + await store.hydrate(); + await store.flush(); + const before = s.paths('packs/'); + + store.persistPack('basic'); + await store.flush(); + + expect(s.paths('packs/')).toEqual(before); + expect(s.json('packs/basic.json')).toBeNull(); + }); + + // ---- Favorites ---- + + it('a_brush_added_to_favorites_survives_a_reload', async () => { + await store.hydrate(); + const favorites = store.packs.find(p => p.name === 'Favorites'); + expect(favorites, 'the painter has a Favorites pack').toBeDefined(); + + await fake.api.packAddBrush({ pack: favorites!.id, brush: 'ink_pen' }); + await store.refresh(); + store.persistPack(favorites!.id); + await store.flush(); + + // A second boot against the same files and a fresh engine, exactly as + // `hydrate_is_idempotent_across_reloads` does. + fake = fakeEngine(); + app.engine = fake.engine; + const second = new BrushLibraryStore(s); + await second.hydrate(); + + const reloaded = second.packs.find(p => p.name === 'Favorites'); + expect(reloaded?.members).toEqual(['ink_pen']); + }); + + it('favorites_is_seeded_once_and_not_recreated', async () => { + await store.hydrate(); + const seeded = store.packs.filter(p => p.name === 'Favorites'); + expect(seeded).toHaveLength(1); + + fake = fakeEngine(); + app.engine = fake.engine; + const second = new BrushLibraryStore(s); + await second.hydrate(); + + expect(second.packs.filter(p => p.name === 'Favorites')).toHaveLength(1); + }); + + it('a_painter_who_deleted_favorites_does_not_get_it_back', async () => { + await store.hydrate(); + const favorites = store.packs.find(p => p.name === 'Favorites')!; + await store.deletePack(favorites.id); + await store.flush(); + + // Storage still holds a pack, so the seed does not fire again. + s.put('packs/keep.json', { + id: 'keep', name: 'Keep', description: '', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: [], + }); + fake = fakeEngine(); + app.engine = fake.engine; + const second = new BrushLibraryStore(s); + await second.hydrate(); + + expect(second.packs.find(p => p.name === 'Favorites')).toBeUndefined(); + }); + + it('deleting_a_brush_removes_its_file_and_rewrites_the_packs_that_held_it', async () => { + s.put('brushes/b1.json', { id: 'b1', name: 'Mine', yaml: 'nodes: {}' }); + s.put('packs/p1.json', { + id: 'p1', name: 'My Pack', description: '', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: ['b1'], + }); + await store.hydrate(); + + await store.deleteBrush('b1'); + await store.flush(); + + expect(s.paths('brushes/')).toEqual([]); + expect(s.json('packs/p1.json')?.members).toEqual([]); + }); + + it('renaming_a_brush_rewrites_its_record_and_touches_no_pack', async () => { + s.put('brushes/b1.json', { id: 'b1', name: 'Before', yaml: 'nodes: {}' }); + s.put('packs/p1.json', { + id: 'p1', name: 'My Pack', description: '', icon: 'mdi:water', + primary: '#3355ff', secondary: '#ffffff', members: ['b1'], + }); + await store.hydrate(); + await store.flush(); + const packBefore = s.json('packs/p1.json'); + + await store.renameBrush('b1', 'After'); + await store.flush(); + + expect(s.json('brushes/b1.json')?.name).toBe('After'); + expect(s.json('packs/p1.json')).toEqual(packBefore); + }); + + it('persistImported_stores_new_brushes_but_not_shipped_ones', async () => { + await store.hydrate(); + // An import brought in one new brush and reused a shipped one. + await fake.api.brushSave({ id: 'imported', name: 'Imported' }); + await fake.api.packCreate({ + id: 'p-new', name: 'Theirs', description: '', icon: 'mdi:water', + primary: '#000000', secondary: '#ffffff', + }); + await fake.api.packAddBrush({ pack: 'p-new', brush: 'imported' }); + await fake.api.packAddBrush({ pack: 'p-new', brush: 'ink_pen' }); + await store.refresh(); + + await store.persistImported('p-new'); + await store.flush(); + + expect(s.paths('brushes/')).toEqual(['brushes/imported.json']); + expect(s.json('brushes/imported.json')?.yaml).toBe('yaml-for-imported'); + expect(s.json('packs/p-new.json')?.members).toEqual(['imported', 'ink_pen']); + }); +}); diff --git a/frontend/src/state/__tests__/foreground_recording.test.ts b/frontend/src/state/__tests__/foreground_recording.test.ts new file mode 100644 index 00000000..8b06c7b7 --- /dev/null +++ b/frontend/src/state/__tests__/foreground_recording.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { app, DarklyInstance, setActiveInstance } from '../app.svelte'; +import { BrushGraphState, type BrushGraph } from '../brush_graph.svelte'; +import { recentBrushes, recentColors } from '../recents.svelte'; + +const emptyGraph: BrushGraph = { nodes: {}, connections: [] }; + +/** Engine stub covering `loadBrush`'s refresh chain. `brushLoad` either + * resolves or rejects, which is the branch under test. */ +function fakeEngine(loadOk: boolean) { + return { + api: { + brushLoad: async () => { + if (!loadOk) throw new Error('no such brush'); + return null; + }, + brushGraphActive: async () => emptyGraph, + brushExposedPorts: async () => [], + brushActiveCapabilities: async () => ({}), + brushTopologyVersion: async () => ({ value: 0 }), + }, + } as unknown as NonNullable; +} + +let inst: DarklyInstance; + +beforeEach(() => { + inst = new DarklyInstance(); + setActiveInstance(inst); +}); +afterEach(() => { + setActiveInstance(null); +}); + +describe('recording what was actually used', () => { + it('consuming_the_foreground_records_it', () => { + inst.foreground = { r: 0x33, g: 0x55, b: 0xff, a: 255 }; + + const got = inst.consumeForeground(); + + expect(got).toEqual({ r: 0x33, g: 0x55, b: 0xff, a: 255 }); + expect(recentColors.items[0]).toBe('#3355ffff'); + }); + + it('consuming_the_same_color_twice_leaves_one_entry', () => { + inst.foreground = { r: 1, g: 2, b: 3, a: 255 }; + inst.consumeForeground(); + inst.consumeForeground(); + inst.consumeForeground(); + + expect(recentColors.items.filter(c => c === '#010203ff')).toHaveLength(1); + }); + + it('loading_a_brush_records_it', async () => { + const state = new BrushGraphState(); + app.engine = fakeEngine(true); + + await state.loadBrush('Ink Pen', 'ink_pen'); + + // The name is what the engine loads by and what the UI shows; the id + // is what recents keeps, so a rename cannot drop the entry. + expect(state.activeBrush).toBe('Ink Pen'); + expect(recentBrushes.items[0]).toBe('ink_pen'); + }); + + it('a_failed_brush_load_records_nothing', async () => { + const state = new BrushGraphState(); + app.engine = fakeEngine(true); + await state.loadBrush('Ink Pen', 'ink_pen'); + + app.engine = fakeEngine(false); + await state.loadBrush('Nonexistent', 'nonexistent'); + + // The failed load left the front alone — a brush that never loaded + // was never used. + expect(state.error).not.toBeNull(); + expect(recentBrushes.items).not.toContain('nonexistent'); + expect(recentBrushes.items[0]).toBe('ink_pen'); + }); +}); diff --git a/frontend/src/state/__tests__/recents.test.ts b/frontend/src/state/__tests__/recents.test.ts new file mode 100644 index 00000000..ecfa7888 --- /dev/null +++ b/frontend/src/state/__tests__/recents.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { DarklyStorage, DirEntry } from '../../storage/types'; +import { createRecents } from '../recents.svelte'; + +class FakeStorage implements DarklyStorage { + files = new Map(); + writes = 0; + + async read(path: string) { return this.files.get(path) ?? null; } + async write(path: string, data: Uint8Array) { this.files.set(path, data); this.writes++; } + async list(): Promise { return []; } + async remove(path: string) { this.files.delete(path); } + async exists(path: string) { return this.files.has(path); } + + json(): { brushes?: string[]; colors?: string[] } | null { + const b = this.files.get('recents.json'); + return b ? JSON.parse(new TextDecoder().decode(b)) : null; + } + put(text: string) { + this.files.set('recents.json', new TextEncoder().encode(text)); + } +} + +describe('recents', () => { + let s: FakeStorage; + beforeEach(() => { s = new FakeStorage(); }); + + it('push_moves_an_existing_entry_to_the_front', async () => { + const r = createRecents(s); + r.brushes.use('a'); + r.brushes.use('b'); + r.brushes.use('c'); + r.brushes.use('a'); + + expect(r.brushes.items).toEqual(['a', 'c', 'b']); + }); + + it('push_evicts_the_oldest_beyond_the_cap', async () => { + const r = createRecents(s); + // Cap is 12; push 15 distinct brushes. + for (let i = 0; i < 15; i++) r.brushes.use(`b${i}`); + + expect(r.brushes.items).toHaveLength(12); + expect(r.brushes.items[0]).toBe('b14'); + expect(r.brushes.items).not.toContain('b0'); + expect(r.brushes.items).not.toContain('b2'); + }); + + it('pushing_the_current_front_writes_nothing', async () => { + const r = createRecents(s); + r.brushes.use('a'); + await r.flush(); + const after = s.writes; + + r.brushes.use('a'); + r.brushes.use('a'); + await r.flush(); + + expect(s.writes).toBe(after); + }); + + it('a_malformed_stored_value_reads_as_empty', async () => { + for (const stored of ['not json', '{"a":1}', '{"brushes":5}', '[]']) { + const fake = new FakeStorage(); + fake.put(stored); + const r = createRecents(fake); + await r.load(); + expect(r.brushes.items, `for ${stored}`).toEqual([]); + expect(r.colors.items, `for ${stored}`).toEqual([]); + } + }); + + it('non_string_members_are_dropped_on_read', async () => { + s.put('{"brushes":["ok",5,null,"also"],"colors":[]}'); + const r = createRecents(s); + await r.load(); + expect(r.brushes.items).toEqual(['ok', 'also']); + }); + + it('colors_dedupe_on_rgb_ignoring_alpha', async () => { + const r = createRecents(s); + r.colors.use('#ff0000ff'); + r.colors.use('#00ff00ff'); + r.colors.use('#ff000080'); + + // One red entry, carrying the alpha it was last used at. + expect(r.colors.items).toEqual(['#ff000080', '#00ff00ff']); + }); + + it('both_lists_share_one_file', async () => { + const r = createRecents(s); + r.brushes.use('ink_pen'); + r.colors.use('#3355ffff'); + await r.flush(); + + expect(s.json()).toEqual({ brushes: ['ink_pen'], colors: ['#3355ffff'] }); + }); + + it('a_stored_list_survives_a_reload', async () => { + const first = createRecents(s); + first.brushes.use('ink_pen'); + first.colors.use('#3355ffff'); + await first.flush(); + + const second = createRecents(s); + await second.load(); + expect(second.brushes.items).toEqual(['ink_pen']); + expect(second.colors.items).toEqual(['#3355ffff']); + }); + + it('retain_drops_entries_that_no_longer_resolve', async () => { + const r = createRecents(s); + r.brushes.use('gone'); + r.brushes.use('kept'); + await r.flush(); + + r.brushes.retain(id => id !== 'gone'); + await r.flush(); + + expect(r.brushes.items).toEqual(['kept']); + expect(s.json()?.brushes).toEqual(['kept']); + }); + + it('retain_that_drops_nothing_writes_nothing', async () => { + const r = createRecents(s); + r.brushes.use('kept'); + await r.flush(); + const after = s.writes; + + r.brushes.retain(() => true); + await r.flush(); + + expect(s.writes).toBe(after); + }); +}); diff --git a/frontend/src/state/__tests__/recents_identity.test.ts b/frontend/src/state/__tests__/recents_identity.test.ts new file mode 100644 index 00000000..3c4ec985 --- /dev/null +++ b/frontend/src/state/__tests__/recents_identity.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { app, DarklyInstance, setActiveInstance } from '../app.svelte'; +import { BrushGraphState, type BrushGraph } from '../brush_graph.svelte'; +import { brushLibrary } from '../brush_library.svelte'; +import { recentBrushes } from '../recents.svelte'; + +const emptyGraph: BrushGraph = { nodes: {}, connections: [] }; + +/** Ids are YAML file stems, names are display strings, and the two differ. + * That difference is the whole subject of this file: recents is id-keyed + * (`recents.svelte.ts`) and the pruner in `BrushLibraryStore.refresh` retains + * against live ids, so anything recorded under a display name is dropped by + * the next refresh — which `hydrate` runs at boot. */ +function fakeEngine() { + return { + api: { + libraryList: async () => ({ + brushes: [ + { id: 'ink_pen', name: 'Ink Pen', author: '', description: '', tags: [], icon: null }, + ], + packs: [], + }), + brushNodeTypes: async () => [], + brushLoad: async () => null, + brushGraphActive: async () => emptyGraph, + brushExposedPorts: async () => [], + brushActiveCapabilities: async () => ({}), + brushTopologyVersion: async () => ({ value: 0 }), + }, + } as unknown as NonNullable; +} + +beforeEach(async () => { + setActiveInstance(new DarklyInstance()); + app.engine = fakeEngine(); + // The pruner and the writer both live on module singletons, so the + // singletons are what this drives. Clear the ring between cases. + recentBrushes.retain(() => false); + await brushLibrary.refresh(); +}); + +afterEach(() => { + setActiveInstance(null); +}); + +describe('recents identity', () => { + it('a_loaded_brush_survives_a_library_refresh', async () => { + const state = new BrushGraphState(); + + await state.loadBrush('Ink Pen', 'ink_pen'); + // What `hydrate` does at boot, and what any library mutation does in + // between. + await brushLibrary.refresh(); + + expect(recentBrushes.items).toEqual(['ink_pen']); + }); + + it('a_failed_load_records_nothing', async () => { + const state = new BrushGraphState(); + const engine = fakeEngine(); + engine.api.brushLoad = async () => { + throw new Error('no such brush'); + }; + app.engine = engine; + + await state.loadBrush('Nonexistent', 'nonexistent'); + + expect(state.error).not.toBeNull(); + expect(recentBrushes.items).toEqual([]); + }); + + it('the_boot_selection_is_not_a_recent', async () => { + // `init` picks a brush so `activeBrush` renders something; the painter + // did not reach for it, so it must not take the top recents slot from + // whatever they last used. + const state = new BrushGraphState(); + await state.init(); + + expect(state.activeBrush).toBe('Ink Pen'); + expect(recentBrushes.items).toEqual([]); + }); +}); diff --git a/frontend/src/state/app.svelte.ts b/frontend/src/state/app.svelte.ts index f610c0c3..08d9a603 100644 --- a/frontend/src/state/app.svelte.ts +++ b/frontend/src/state/app.svelte.ts @@ -13,6 +13,9 @@ import { HttpStreamSource } from '../lib/httpStreamSource'; import type { FrameSource, CaptureKind } from '../lib/frameSource'; import { processRecording } from '../recording/recorder.svelte'; import { freshDocument } from './freshDocument'; +import { recentColors } from './recents.svelte'; +import { colorToHex } from '../lib/color'; +import { newId } from '../lib/id'; import { appearedRoots, collapsedAncestorsOf, @@ -73,10 +76,7 @@ function unpackSaveBundle(p: PackedSaveResult): SaveBundle { */ export class DarklyInstance { /** Stable id, useful as a `{#each}` key in the multi-tab shell. */ - readonly id: string = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `instance-${Math.random().toString(36).slice(2)}`; + readonly id: string = newId('instance'); engine = $state(null); @@ -84,10 +84,7 @@ export class DarklyInstance { * `id` so it reads clearly at the recovery-store boundary; repeated * autosaves overwrite one snapshot file per tab. A tab restored from * a snapshot gets a fresh `recoveryId` (it's a new live tab). */ - readonly recoveryId: string = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `recovery-${Math.random().toString(36).slice(2)}`; + readonly recoveryId: string = newId('recovery'); /** Initial document name to apply once the WASM handle finishes * bootstrapping. The shell uses this to thread "Untitled N" @@ -131,6 +128,23 @@ export class DarklyInstance { foreground = $state({ ...freshDocument.foreground }); background = $state({ ...freshDocument.background }); + /** + * The foreground color, recorded as recently used. + * + * Tools call this at the point they are about to paint with the color, + * which is what "recent" means here — as distinct from "scrubbed past in + * the picker", which the picker's per-`pointermove` writes to + * `foreground` would otherwise record dozens of times a drag. + * + * Reading the color and recording it are the same act, so there is no + * flag for a tool to forget to set: a new color-using tool records + * because it needs the color. + */ + consumeForeground(): Color { + recentColors.use(colorToHex(this.foreground)); + return this.foreground; + } + // Active tool activeToolId = $state('brush'); diff --git a/frontend/src/state/brush_graph.svelte.ts b/frontend/src/state/brush_graph.svelte.ts index cbf6b4ac..b32937da 100644 --- a/frontend/src/state/brush_graph.svelte.ts +++ b/frontend/src/state/brush_graph.svelte.ts @@ -9,6 +9,8 @@ */ import { app } from './app.svelte'; import { freshDocument } from './freshDocument'; +import { recentBrushes } from './recents.svelte'; +import { brushLibrary } from './brush_library.svelte'; import type { BrushInfo, JsonValue, ExposedValue, ExposedPortInfo } from '../engine/protocol_gen'; export type { BrushInfo }; @@ -206,9 +208,6 @@ export class BrushGraphState { /** Cached image thumbnails for Image nodes, keyed by resource_name. */ imageThumbnails = new Map(); - /** Available brushes. */ - brushes = $state([]); - /** Currently loaded brush name (null = custom/modified). */ activeBrush = $state(null); @@ -345,16 +344,22 @@ export class BrushGraphState { this.initStarted = true; const types = await app.engine.api.brushNodeTypes(); this.nodeTypes = (Array.isArray(types) ? types : []) as unknown as NodeTypeInfo[]; - await this.refreshBrushes(); + // The library — brushes and packs alike — has one home, and it is + // `brushLibrary`. Hydration replays the painter's stored records + // first, so the boot selection below can land on one of them. + await brushLibrary.hydrate(); // Boot with a real library brush selected so the brush picker // trigger (and anywhere else that reads `activeBrush`) has a named // brush to render. The engine's procedural default graph would // leave `activeBrush` null and the trigger would fall back to "Custom". + const brushes = brushLibrary.brushes; const defaultBrush = - this.brushes.find(b => b.name === freshDocument.defaultBrushName) ?? this.brushes[0]; + brushes.find(b => b.name === freshDocument.defaultBrushName) ?? brushes[0]; if (defaultBrush) { - await this.loadBrush(defaultBrush.name); + // Deliberately not `loadBrush`: the painter did not reach for this + // one, so it must not take the top slot in their recents. + await this.#load(defaultBrush.name); } else { // No library brushes available — fall through to the engine's // default graph as a degenerate fallback. @@ -408,13 +413,6 @@ export class BrushGraphState { return null; } - /** Refresh the brush list from WASM. */ - async refreshBrushes() { - if (!app.engine) return; - const list = await app.engine.api.brushList(); - this.brushes = Array.isArray(list) ? list : []; - } - /** Refresh exposed ports from the active brush graph. */ async refreshExposedPorts() { if (!app.engine) return; @@ -491,15 +489,32 @@ export class BrushGraphState { await this.applyResult(await app.engine.api.brushGraphReorderExposedPort({ key, new_index: newIndex })); } - /** Load a brush by name. */ - async loadBrush(name: string) { - if (!app.engine) return; + /** + * Load a brush the painter chose, and record it as recently used. + * + * `id` is the brush's identity and `name` is what the engine looks it up + * by — `brush_load` is the one name-keyed call in the library API. Recents + * stores the id, so a later rename does not drop the entry. + */ + async loadBrush(name: string, id: string) { + // Only a successful load counts as use: a brush that never loaded was + // never used. + if (await this.#load(name)) recentBrushes.use(id); + } + + /** Load a brush by name, without recording it. Returns whether it loaded. + * + * `loadBrush` is the painter-facing entry point; this is the mechanism + * under it, so a selection the painter did not make (the boot default) + * can reach the engine without claiming the top of their recents. */ + async #load(name: string): Promise { + if (!app.engine) return false; // brush_load rejects on error (old Result throw path). try { await app.engine.api.brushLoad({ name }); } catch (e) { this.error = String(e instanceof Error ? e.message : e); - return; + return false; } this.activeBrush = name; // `fetchGraph` begins a new layout generation atomically with the @@ -512,6 +527,7 @@ export class BrushGraphState { // brush_load is a Topology change — snapshot here so the next // exposed-port scrub doesn't see a delta and clear `activeBrush`. await this.snapshotTopologyVersion(); + return true; } /** Begin a new layout generation: clear node positions and bump diff --git a/frontend/src/state/brush_library.svelte.ts b/frontend/src/state/brush_library.svelte.ts new file mode 100644 index 00000000..cd81367b --- /dev/null +++ b/frontend/src/state/brush_library.svelte.ts @@ -0,0 +1,313 @@ +/** + * The painter's brushes and packs — reactive mirror, and durable store. + * + * The engine is the authority on what the library *is*; this module is the + * frontend's view of it plus the persistence the engine cannot do for itself. + * Shipped brushes and packs are rebuilt from embedded YAML on every boot and + * are **never written** — only what the painter creates or imports is stored. + * The one thing a fresh install writes is the Favorites pack it seeds, which is + * the painter's from the moment it exists (see `#seedFavorites`). + * + * One file per record, no index. The filename is the id and the id never + * changes, so a rename rewrites one file in place, a delete removes one file, + * and nothing can be orphaned or left disagreeing with an index — the + * reasoning `storage/recovery.ts` states for crash snapshots. + */ +import { app } from './app.svelte'; +import { jsonDir } from '../storage/jsonStore'; +import type { DarklyStorage } from '../storage/types'; +import type { BrushInfo, BrushPackInfo } from '../engine/protocol_gen'; +import { recentBrushes } from './recents.svelte'; +import { newId } from '../lib/id'; + +/** A painter-created brush, as stored. The graph lives in the engine; what we + * persist is enough to put it back. */ +export interface StoredBrush { + id: string; + name: string; + /** The brush's node graph, as `brushGraphExportYaml` produces it. */ + yaml: string; +} + +/** A painter-created pack, as stored. */ +export interface StoredPack { + id: string; + name: string; + description: string; + icon: string; + primary: string; + secondary: string; + members: string[]; +} + +function validBrush(raw: unknown): StoredBrush | null { + const o = raw as Partial | null; + if (!o || typeof o.id !== 'string' || typeof o.name !== 'string') return null; + if (typeof o.yaml !== 'string') return null; + return { id: o.id, name: o.name, yaml: o.yaml }; +} + +function validPack(raw: unknown): StoredPack | null { + const o = raw as Partial | null; + if (!o || typeof o.id !== 'string' || typeof o.name !== 'string') return null; + if (typeof o.icon !== 'string' || typeof o.primary !== 'string') return null; + if (typeof o.secondary !== 'string') return null; + const members = Array.isArray(o.members) + ? o.members.filter((m): m is string => typeof m === 'string') + : []; + return { + id: o.id, + name: o.name, + description: typeof o.description === 'string' ? o.description : '', + icon: o.icon, + primary: o.primary, + secondary: o.secondary, + members, + }; +} + +export class BrushLibraryStore { + /** Every brush the engine knows about, shipped and painter-created. */ + brushes = $state([]); + /** Every pack, in the engine's order: shipped first, painter's after. */ + packs = $state([]); + + readonly #brushDir; + readonly #packDir; + /** Ids the painter owns — the ones that get written back. A shipped + * brush or pack is regenerated from YAML each boot and must never be + * persisted, or deleting it from the shipped set would leave a copy. */ + #ownBrushes = new Set(); + #ownPacks = new Set(); + /** Brush ids the engine had before hydration replayed anything — the + * shipped set. Storing one would shadow the YAML it is rebuilt from. */ + #shipped = new Set(); + + constructor(storage?: DarklyStorage) { + this.#brushDir = jsonDir('brushes', validBrush, storage); + this.#packDir = jsonDir('packs', validPack, storage); + } + + /** Pull the engine's current library into the reactive mirror. */ + async refresh(): Promise { + if (!app.engine) return; + const snap = await app.engine.api.libraryList(); + this.brushes = snap.brushes ?? []; + this.packs = snap.packs ?? []; + // A brush the painter deleted must not linger in the recents ring. + const live = new Set(this.brushes.map(b => b.id)); + recentBrushes.retain(id => live.has(id)); + } + + /** The pack with `id`, if it exists. */ + pack(id: string): BrushPackInfo | undefined { + return this.packs.find(p => p.id === id); + } + + /** Packs the painter may export — every one, since exporting reads only. */ + get exportablePacks(): BrushPackInfo[] { + return this.packs; + } + + // ---- hydration ---- + + /** + * Replay the painter's stored brushes and packs into the engine. + * + * Runs once at boot, not per canvas handle, because the engine's library + * is process-global. Idempotent: records are replayed **with their stored + * ids**, so hydrating twice yields the same ids and names rather than + * accreting `(2)` suffixes the way a re-import would. + * + * A record that fails to load is skipped with a warning rather than being + * fatal — one corrupt file must not cost the painter their library. + */ + async hydrate(): Promise { + if (!app.engine) return; + const api = app.engine.api; + + // Whatever the engine holds before we replay anything is the shipped + // set, rebuilt from embedded YAML on every boot. + await this.refresh(); + this.#shipped = new Set(this.brushes.map(b => b.id)); + + const storedBrushes = await this.#brushDir.readAll(); + for (const [id, record] of storedBrushes) { + try { + // Restoring a brush means installing its graph as the active + // one and saving it under its stored id. + await api.brushGraphImportYaml({ yaml: record.yaml }); + await api.brushSave({ id, name: record.name }); + this.#ownBrushes.add(id); + } catch (e) { + console.warn(`[brush library] skipping stored brush '${id}'`, e); + } + } + + const storedPacks = await this.#packDir.readAll(); + for (const [id, record] of storedPacks) { + try { + await api.packCreate({ + id, + name: record.name, + description: record.description, + icon: record.icon, + primary: record.primary, + secondary: record.secondary, + }); + this.#ownPacks.add(id); + } catch (e) { + console.warn(`[brush library] skipping stored pack '${id}'`, e); + continue; + } + // Members naming a brush that no longer exists are dropped, and + // the pack rewritten once. The only self-healing path, and it + // converges: the next boot has nothing left to drop. + let dropped = false; + for (const member of record.members) { + try { + await api.packAddBrush({ pack: id, brush: member }); + } catch { + dropped = true; + } + } + if (dropped) { + await this.refresh(); + this.persistPack(id); + } + } + + await this.refresh(); + if (storedPacks.size === 0) await this.#seedFavorites(); + } + + /** + * Give a painter with no packs of their own a Favorites pack to fill. + * + * Favorites is not shipped. A shipped pack is rebuilt from embedded YAML + * on every boot and so cannot hold an edit, which is why shipped packs are + * locked; Favorites is the painter's list and has to be able to hold one. + * So it is an ordinary pack they own, created once when there is nothing + * stored, and from then on it renames, restyles, deletes and persists like + * any other. + * + * Keyed off "the painter has no stored packs" rather than "no pack is + * named Favorites", so deleting it is a decision that sticks. + */ + async #seedFavorites(): Promise { + if (!app.engine) return; + const id = newId('pack'); + try { + await app.engine.api.packCreate({ + id, + name: 'Favorites', + description: 'The brushes you reach for most.', + icon: 'fa6-solid:star', + primary: '#f5c542', + secondary: '#2b2213', + }); + } catch (e) { + console.warn('[brush library] could not seed Favorites', e); + return; + } + await this.refresh(); + this.persistPack(id); + } + + // ---- write-through ---- + + /** Record `id` as the painter's and write it. Called after any successful + * engine mutation that created or changed a pack. */ + persistPack(id: string): void { + const pack = this.pack(id); + // Only the painter's packs are stored; a shipped pack comes back from + // YAML on the next boot and writing it would shadow the shipped one. + if (!pack || !pack.can_edit_identity) return; + this.#ownPacks.add(id); + this.#packDir.write(id, { + id: pack.id, + name: pack.name, + description: pack.description, + icon: pack.icon, + primary: pack.primary, + secondary: pack.secondary, + members: pack.members, + }); + } + + /** + * Persist a freshly-imported pack and every brush that arrived with it. + * + * An import can bring in brushes the library did not have, and those are + * the painter's now — without this the pack would come back on reload + * naming brushes that did not. + * + * Brushes the import *reused* are already stored (if painter-owned) or + * come back from shipped YAML (if not), so only genuinely new ones need + * writing. Call after `refresh()`. + */ + async persistImported(packId: string): Promise { + const pack = this.pack(packId); + if (!pack || !app.engine) return; + + for (const member of pack.members) { + // A brush already stored, or one that ships with the app and comes + // back from YAML each boot, needs nothing: storing a copy of a + // shipped brush would shadow the shipped one. + if (this.#ownBrushes.has(member) || this.#shipped.has(member)) continue; + const brush = this.brushes.find(b => b.id === member); + if (brush) await this.persistBrush(member, brush.name); + } + this.persistPack(packId); + } + + /** Persist a brush's graph under its id, read without disturbing whatever + * the painter currently has loaded. */ + async persistBrush(id: string, name: string): Promise { + if (!app.engine) return; + try { + const yaml = await app.engine.api.brushExportYaml({ id }); + this.#ownBrushes.add(id); + this.#brushDir.write(id, { id, name, yaml }); + } catch (e) { + console.warn(`[brush library] could not persist brush '${id}'`, e); + } + } + + /** Rewrite a brush's stored record after a rename. No pack is touched — + * membership is id-keyed. */ + async renameBrush(id: string, name: string): Promise { + if (!app.engine) return; + await app.engine.api.brushRename({ id, name }); + await this.refresh(); + const stored = (await this.#brushDir.readAll()).get(id); + if (stored) this.#brushDir.write(id, { ...stored, name }); + } + + /** Delete a brush, its stored record, and its membership everywhere. */ + async deleteBrush(id: string): Promise { + if (!app.engine) return; + await app.engine.api.brushDelete({ id }); + await this.#brushDir.remove(id); + this.#ownBrushes.delete(id); + await this.refresh(); + // Packs that held it changed, so their records are now stale. + for (const packId of this.#ownPacks) this.persistPack(packId); + } + + /** Delete a pack and its stored record. Its brushes survive. */ + async deletePack(id: string): Promise { + if (!app.engine) return; + await app.engine.api.packDelete({ id }); + await this.#packDir.remove(id); + this.#ownPacks.delete(id); + await this.refresh(); + } + + /** Write anything pending immediately — for `beforeunload`. */ + async flush(): Promise { + await Promise.all([this.#brushDir.flush(), this.#packDir.flush()]); + } +} + +export const brushLibrary = new BrushLibraryStore(); diff --git a/frontend/src/state/packExport.svelte.ts b/frontend/src/state/packExport.svelte.ts new file mode 100644 index 00000000..e55b0c72 --- /dev/null +++ b/frontend/src/state/packExport.svelte.ts @@ -0,0 +1,16 @@ +/** + * Whether the "which pack do you want to export?" chooser is open. + * + * Import needs no chooser — the OS file picker is the chooser — but export + * does, and without pack-management UI there is nowhere else to invoke it + * from. Follows `layerPicker`'s shape: the action sets a flag, a component + * mounts the modal. + * + * Superseded when the pack-management push lands: the affordance moves onto + * the pack row and this goes away. + */ +class PackExportState { + open = $state(false); +} + +export const packExport = new PackExportState(); diff --git a/frontend/src/state/recents.svelte.ts b/frontend/src/state/recents.svelte.ts new file mode 100644 index 00000000..4d86980d --- /dev/null +++ b/frontend/src/state/recents.svelte.ts @@ -0,0 +1,143 @@ +/** + * Recently-used brushes and colors. + * + * Two bounded, deduplicated, most-recently-used lists sharing one + * `recents.json`. They belong to the painter, not to a canvas: they are not + * document state (they must not ride a `.darkly` file into someone else's + * hands), not session state (they survive reload), and not derivable from + * anything. So they are a file in the Darkly directory, alongside + * `user_settings.json`, and they travel with `exportRootAsZip`. + * + * Both producers and both consumers live in the frontend, so nothing here + * crosses the wasm boundary. + */ +import { jsonFile } from '../storage/jsonStore'; +import type { DarklyStorage } from '../storage/types'; + +/** How many of each we keep. Deep enough to be worth reaching for, shallow + * enough that a radial widget can show them all without paging. */ +const BRUSH_CAP = 12; +const COLOR_CAP = 16; + +const RECENTS_FILE = 'recents.json'; + +interface RecentsFile { + brushes: string[]; + colors: string[]; +} + +const EMPTY = (): RecentsFile => ({ brushes: [], colors: [] }); + +/** A stored file is arbitrary JSON — possibly hand-edited, possibly from an + * older shape. Anything that is not a list of strings reads as empty rather + * than propagating a bad value into the UI. */ +function strings(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + +function validate(raw: unknown): RecentsFile { + if (typeof raw !== 'object' || raw === null) return EMPTY(); + const o = raw as Record; + return { brushes: strings(o.brushes), colors: strings(o.colors) }; +} + +export interface RecentList { + /** The list, newest first. */ + readonly items: string[]; + /** Record `value` as just-used: moved to the front if present, prepended + * if not, and truncated to the cap. Writes nothing when `value` is + * already at the front, which is what makes calling this per pointer + * event free. */ + use(value: string): void; + /** Drop entries that no longer resolve — a brush that has been deleted. + * Rewrites only if something was actually dropped. */ + retain(keep: (value: string) => boolean): void; +} + +export interface Recents { + brushes: RecentList; + colors: RecentList; + /** Read `recents.json` into memory. Idempotent — the first call does the + * read and every later one awaits the same promise. */ + load(): Promise; + /** Write anything pending immediately — for `beforeunload`. */ + flush(): Promise; +} + +/** + * Build a recents store over one `recents.json`. + * + * Exported so tests can drive it against an in-memory storage; the app uses + * the module-level singleton below. + */ +export function createRecents(storage?: DarklyStorage): Recents { + const file = jsonFile(RECENTS_FILE, EMPTY, validate, storage); + + /** The in-memory mirror. `$state` so the picker and the future radial + * widget re-derive when a list changes; the file is the durable copy. */ + const state = $state(EMPTY()); + let loaded: Promise | null = null; + + /** + * A bounded, deduplicated MRU list backed by one field of the file. + * + * `key` collapses values that should count as the same entry; it defaults + * to identity. Colors use it to dedupe on RGB while storing the alpha they + * were last used at, so scrubbing opacity does not flood the list with one + * hue. + */ + function list( + field: keyof RecentsFile, + cap: number, + key: (v: string) => string = v => v, + ): RecentList { + return { + get items() { + return state[field]; + }, + use(value: string): void { + const current = state[field]; + if (current.length > 0 && key(current[0]) === key(value)) { + // Already the most recent: nothing to reorder, nothing to + // write. This is what makes a per-`pointermove` call free. + return; + } + const k = key(value); + state[field] = [value, ...current.filter(v => key(v) !== k)].slice(0, cap); + file.write({ brushes: state.brushes, colors: state.colors }); + }, + retain(keep: (value: string) => boolean): void { + const current = state[field]; + const next = current.filter(keep); + if (next.length === current.length) return; + state[field] = next; + file.write({ brushes: state.brushes, colors: state.colors }); + }, + }; + } + + return { + brushes: list('brushes', BRUSH_CAP), + colors: list('colors', COLOR_CAP, c => c.slice(0, 7).toLowerCase()), + load(): Promise { + loaded ??= file.read().then(v => { + state.brushes = v.brushes; + state.colors = v.colors; + }); + return loaded; + }, + flush: () => file.flush(), + }; +} + +const recents = createRecents(); + +/** Recently used brushes, keyed by brush id. */ +export const recentBrushes = recents.brushes; + +/** Recently used colors as canonical `#rrggbbaa`. Deduplicated on the RGB + * half: the same hue at a different opacity is the same swatch. */ +export const recentColors = recents.colors; + +export const loadRecents = () => recents.load(); +export const flushRecents = () => recents.flush(); diff --git a/frontend/src/state/recoverySession.ts b/frontend/src/state/recoverySession.ts index 5bc018f4..019c17cc 100644 --- a/frontend/src/state/recoverySession.ts +++ b/frontend/src/state/recoverySession.ts @@ -22,6 +22,7 @@ */ import { listSnapshots, removeSnapshot, type RecoveryEntry } from '../storage/recovery'; import { storage as defaultStorage, type DarklyStorage } from '../storage'; +import { newId } from '../lib/id'; const REGISTRY_KEY = 'darkly.recovery.sessions'; /** Heartbeat cadence — how often a live session refreshes its timestamp. */ @@ -37,10 +38,7 @@ export interface KeyValueStore { type Registry = Record; -function genId(): string { - if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); - return `session-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; -} +const genId = () => newId('session'); export function readRegistry(ls: KeyValueStore): Registry { const raw = ls.getItem(REGISTRY_KEY); diff --git a/frontend/src/storage/__tests__/jsonStore.test.ts b/frontend/src/storage/__tests__/jsonStore.test.ts new file mode 100644 index 00000000..52c312d2 --- /dev/null +++ b/frontend/src/storage/__tests__/jsonStore.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { DarklyStorage, DirEntry } from '../types'; +import { jsonFile, jsonDir } from '../jsonStore'; + +/** In-memory DarklyStorage, counting writes so coalescing is observable. */ +class FakeStorage implements DarklyStorage { + files = new Map(); + writes: string[] = []; + /** Resolves each write after a tick, so overlapping writes can interleave + * if the lock does not hold them apart. */ + slow = false; + + async read(path: string) { return this.files.get(path) ?? null; } + async write(path: string, data: Uint8Array) { + if (this.slow) await new Promise(r => setTimeout(r, 5)); + this.files.set(path, data); + this.writes.push(path); + } + async list(dir: string): Promise { + const prefix = dir ? `${dir}/` : ''; + const out: DirEntry[] = []; + for (const p of this.files.keys()) { + if (!p.startsWith(prefix)) continue; + const rest = p.slice(prefix.length); + if (rest.length === 0 || rest.includes('/')) continue; + out.push({ name: rest, kind: 'file' }); + } + return out; + } + async remove(path: string) { this.files.delete(path); } + async exists(path: string) { return this.files.has(path); } + + json(path: string): unknown { + const b = this.files.get(path); + return b ? JSON.parse(new TextDecoder().decode(b)) : null; + } + put(path: string, text: string) { + this.files.set(path, new TextEncoder().encode(text)); + } +} + +describe('jsonFile', () => { + let s: FakeStorage; + beforeEach(() => { s = new FakeStorage(); }); + + it('a_burst_of_writes_coalesces_into_one', async () => { + const f = jsonFile<{ n: number }>('t.json', () => ({ n: 0 }), undefined, s); + f.write({ n: 1 }); + f.write({ n: 2 }); + f.write({ n: 3 }); + await f.flush(); + + expect(s.writes).toEqual(['t.json']); + expect(s.json('t.json')).toEqual({ n: 3 }); + }); + + it('writes_do_not_interleave', async () => { + s.slow = true; + const f = jsonFile<{ n: number }>('t.json', () => ({ n: 0 }), undefined, s); + + f.write({ n: 1 }); + const first = f.flush(); + f.write({ n: 2 }); + const second = f.flush(); + await Promise.all([first, second]); + + // Both landed, in issue order, so the later value is what survives. + expect(s.writes).toEqual(['t.json', 't.json']); + expect(s.json('t.json')).toEqual({ n: 2 }); + }); + + it('a_missing_file_reads_as_the_fallback', async () => { + const f = jsonFile<{ n: number }>('gone.json', () => ({ n: 42 }), undefined, s); + await expect(f.read()).resolves.toEqual({ n: 42 }); + }); + + it('malformed_json_reads_as_the_fallback', async () => { + s.put('t.json', 'not json at all'); + const f = jsonFile<{ n: number }>('t.json', () => ({ n: 7 }), undefined, s); + await expect(f.read()).resolves.toEqual({ n: 7 }); + }); + + it('a_value_the_validator_rejects_reads_as_the_fallback', async () => { + s.put('t.json', '{"n":"nope"}'); + const f = jsonFile<{ n: number }>( + 't.json', + () => ({ n: 7 }), + raw => { + const o = raw as { n?: unknown }; + return typeof o.n === 'number' ? { n: o.n } : null; + }, + s, + ); + await expect(f.read()).resolves.toEqual({ n: 7 }); + }); +}); + +describe('jsonDir', () => { + let s: FakeStorage; + beforeEach(() => { s = new FakeStorage(); }); + + it('readAll_skips_a_record_that_fails_to_parse', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + s.put('packs/a.json', '{"name":"A"}'); + s.put('packs/b.json', 'corrupt{{{'); + s.put('packs/c.json', '{"name":"C"}'); + + const d = jsonDir<{ name: string }>('packs', undefined, s); + const all = await d.readAll(); + + expect([...all.keys()].sort()).toEqual(['a', 'c']); + expect(all.get('a')).toEqual({ name: 'A' }); + warn.mockRestore(); + }); + + it('remove_deletes_exactly_one_record', async () => { + s.put('packs/a.json', '{"name":"A"}'); + s.put('packs/b.json', '{"name":"B"}'); + + const d = jsonDir<{ name: string }>('packs', undefined, s); + await d.remove('a'); + + const all = await d.readAll(); + expect([...all.keys()]).toEqual(['b']); + }); + + it('remove_cancels_a_queued_write_so_the_file_stays_gone', async () => { + const d = jsonDir<{ name: string }>('packs', undefined, s); + d.write('a', { name: 'A' }); + await d.remove('a'); + await d.flush(); + + expect(await d.readAll()).toEqual(new Map()); + }); + + it('writes_land_under_the_id_as_filename', async () => { + const d = jsonDir<{ name: string }>('packs', undefined, s); + d.write('9f1c', { name: 'Watercolors' }); + await d.flush(); + + expect(s.json('packs/9f1c.json')).toEqual({ name: 'Watercolors' }); + }); + + it('ids_that_sanitize_alike_stay_distinct_records', async () => { + // Ids are opaque and filename-safe by construction, so two packs whose + // *names* would collapse to one slug still get their own file. + const d = jsonDir<{ name: string }>('packs', undefined, s); + d.write('id-one', { name: 'A/B' }); + d.write('id-two', { name: 'A:B' }); + await d.flush(); + + const all = await d.readAll(); + expect(all.size).toBe(2); + expect(all.get('id-one')).toEqual({ name: 'A/B' }); + expect(all.get('id-two')).toEqual({ name: 'A:B' }); + }); +}); diff --git a/frontend/src/storage/index.ts b/frontend/src/storage/index.ts index 6c9537e6..27da1254 100644 --- a/frontend/src/storage/index.ts +++ b/frontend/src/storage/index.ts @@ -27,27 +27,38 @@ const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); /** Read a UTF-8 text file. Returns null if not found. */ -export async function readText(path: string): Promise { - const bytes = await storage.read(path); +export async function readText(path: string, s: DarklyStorage = storage): Promise { + const bytes = await s.read(path); return bytes ? textDecoder.decode(bytes) : null; } /** Read JSON. Returns null if file not found or content fails to parse. */ -export async function readJson(path: string): Promise { - const text = await readText(path); +export async function readJson( + path: string, + s: DarklyStorage = storage, +): Promise { + const text = await readText(path, s); if (text === null) return null; try { return JSON.parse(text) as T; } catch { return null; } } /** Write a UTF-8 text file. */ -export async function writeText(path: string, contents: string): Promise { - await storage.write(path, textEncoder.encode(contents)); +export async function writeText( + path: string, + contents: string, + s: DarklyStorage = storage, +): Promise { + await s.write(path, textEncoder.encode(contents)); } /** Write a JSON file (pretty-printed). */ -export async function writeJson(path: string, value: unknown): Promise { - await writeText(path, JSON.stringify(value, null, 2)); +export async function writeJson( + path: string, + value: unknown, + s: DarklyStorage = storage, +): Promise { + await writeText(path, JSON.stringify(value, null, 2), s); } /** Sanitize a user-supplied name into something safe to use as a filename diff --git a/frontend/src/storage/jsonStore.ts b/frontend/src/storage/jsonStore.ts new file mode 100644 index 00000000..94737d94 --- /dev/null +++ b/frontend/src/storage/jsonStore.ts @@ -0,0 +1,225 @@ +/** + * JSON records in the Darkly directory. + * + * Two shapes, one write discipline: + * - `jsonFile` — a single document (`recents.json`). + * - `jsonDir` — a directory of id-keyed records (`packs/`, `brushes/`). + * + * Writes are coalesced on a trailing edge, so a burst of mutations costs one + * write, and serialized against each other per path, so two writes can never + * interleave and leave a torn file. Both properties already existed in this + * codebase — the coalescing in `config/store.svelte.ts`'s `#scheduleWrite` and + * the serialization in `recording/recorder.svelte.ts`'s `withScratchLock` — + * and are factored here rather than copied a third and fourth time. + * + * Everything under this directory rides `exportRootAsZip`, so a record written + * here travels with the painter's settings when they export. + */ +import { readJson, writeJson, storage as defaultStorage } from './index'; +import type { DarklyStorage } from './types'; + +/** Trailing-edge window. Matches `config/store.svelte.ts`'s user-settings + * write, for the same reason: long enough to absorb a drag, short enough that + * a reload immediately after a change keeps it. */ +const WRITE_DEBOUNCE_MS = 200; + +/** One in-flight write chain per path. Keyed globally so two handles on the + * same file share the chain rather than racing. */ +const writeLocks = new Map>(); + +/** Run `fn` exclusively against `path`. FIFO — writes land in issue order. */ +function withWriteLock(path: string, fn: () => Promise): Promise { + const prev = writeLocks.get(path) ?? Promise.resolve(); + const next = prev.then(fn, fn); + writeLocks.set(path, next.then(() => undefined, () => undefined)); + return next; +} + +/** A pending trailing-edge write: the timer, and the value it will write. */ +interface Pending { + timer: ReturnType; + value: T; + settled: Promise; + resolve: () => void; +} + +/** Schedule `value` to be written to `path`, coalescing with any write already + * pending for it. Returns a promise that settles when the write lands. */ +function commit(path: string, entry: Pending, s: DarklyStorage): void { + void withWriteLock(path, async () => { + try { + await writeJson(path, entry.value, s); + } catch (e) { + console.error(`[storage] write failed for ${path}`, e); + } + }).finally(() => entry.resolve()); +} + +function schedule( + pending: Map>, + path: string, + value: T, + s: DarklyStorage, +): void { + const existing = pending.get(path); + if (existing) { + // Coalesce: the last value in the window wins, and the timer already + // running keeps its deadline so a steady stream still drains. + existing.value = value; + return; + } + + let resolve!: () => void; + const settled = new Promise(r => { resolve = r; }); + + const timer = setTimeout(() => { + const entry = pending.get(path); + pending.delete(path); + if (entry) commit(path, entry, s); + }, WRITE_DEBOUNCE_MS); + + pending.set(path, { timer, value, settled, resolve }); +} + +/** Flush every pending write in `pending` immediately, and wait for them. */ +async function flushAll( + pending: Map>, + s: DarklyStorage, +): Promise { + const entries = [...pending.entries()]; + for (const [path, entry] of entries) { + clearTimeout(entry.timer); + pending.delete(path); + commit(path, entry, s); + } + await Promise.all(entries.map(([, e]) => e.settled)); +} + +export interface JsonFile { + /** Read the file. A missing file, malformed JSON, or a value that fails + * `validate` all read as the fallback — never a throw. */ + read(): Promise; + /** Queue a coalesced write. Fire-and-forget by design. */ + write(value: T): void; + /** Write anything pending now and wait for it to land. */ + flush(): Promise; +} + +/** + * A single JSON file in the Darkly directory. + * + * `fallback` supplies the value for a file that is missing or unreadable, and + * `validate` (when given) has the last word on whether what was read is usable + * — a stored file may be arbitrarily old or hand-edited. + */ +export function jsonFile( + path: string, + fallback: () => T, + validate?: (raw: unknown) => T | null, + s: DarklyStorage = defaultStorage, +): JsonFile { + const pending = new Map>(); + + return { + async read(): Promise { + let raw: unknown; + try { + raw = await readJson(path, s); + } catch (e) { + console.warn(`[storage] read failed for ${path}`, e); + return fallback(); + } + if (raw === null || raw === undefined) return fallback(); + if (validate) return validate(raw) ?? fallback(); + return raw as T; + }, + write(value: T): void { + schedule(pending, path, value, s); + }, + flush(): Promise { + return flushAll(pending, s); + }, + }; +} + +export interface JsonDir { + /** Every record that parses, keyed by id. Records that fail to parse are + * skipped with a warning — one corrupt file must not cost the caller the + * whole directory. */ + readAll(): Promise>; + /** Queue a coalesced write of one record. */ + write(id: string, value: T): void; + /** Delete one record. Idempotent. */ + remove(id: string): Promise; + /** Write anything pending now and wait for it to land. */ + flush(): Promise; +} + +/** + * A directory of id-keyed JSON records, one file per record. + * + * There is deliberately no index file. The filename is the id and the id never + * changes, so a rename rewrites one file in place, a delete removes one file, + * and nothing can be orphaned or left disagreeing with an index. This is the + * same reasoning `storage/recovery.ts` states for crash snapshots. + */ +export function jsonDir( + dir: string, + validate?: (raw: unknown) => T | null, + s: DarklyStorage = defaultStorage, +): JsonDir { + const pending = new Map>(); + const pathOf = (id: string) => `${dir}/${id}.json`; + + return { + async readAll(): Promise> { + const out = new Map(); + let entries; + try { + entries = await s.list(dir); + } catch (e) { + console.warn(`[storage] list failed for ${dir}`, e); + return out; + } + for (const entry of entries) { + if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue; + const id = entry.name.slice(0, -'.json'.length); + let raw: unknown; + try { + raw = await readJson(`${dir}/${entry.name}`, s); + } catch (e) { + console.warn(`[storage] skipping unreadable record ${dir}/${entry.name}`, e); + continue; + } + if (raw === null || raw === undefined) { + console.warn(`[storage] skipping malformed record ${dir}/${entry.name}`); + continue; + } + const value = validate ? validate(raw) : (raw as T); + if (value === null) { + console.warn(`[storage] skipping invalid record ${dir}/${entry.name}`); + continue; + } + out.set(id, value); + } + return out; + }, + write(id: string, value: T): void { + schedule(pending, pathOf(id), value, s); + }, + async remove(id: string): Promise { + const path = pathOf(id); + // Drop any queued write first, or it would recreate the file. + const entry = pending.get(path); + if (entry) { + clearTimeout(entry.timer); + pending.delete(path); + entry.resolve(); + } + await withWriteLock(path, () => s.remove(path)); + }, + flush(): Promise { + return flushAll(pending, s); + }, + }; +} diff --git a/frontend/src/themes/dark.css b/frontend/src/themes/dark.css index 29acbfcb..9cfd17b8 100644 --- a/frontend/src/themes/dark.css +++ b/frontend/src/themes/dark.css @@ -11,4 +11,5 @@ --danger: #848484; --thumb-bg: #333333; --canvas-bg: #2a2a2a; + --scrim: rgba(235, 235, 235, 0.22); } diff --git a/frontend/src/themes/light.css b/frontend/src/themes/light.css index a3ef9cd3..a20b7397 100644 --- a/frontend/src/themes/light.css +++ b/frontend/src/themes/light.css @@ -11,4 +11,5 @@ --danger: #747474; --thumb-bg: #cccccc; --canvas-bg: #cfcfcf; + --scrim: rgba(20, 20, 20, 0.45); } diff --git a/frontend/src/tools/__tests__/text_tool_create.test.ts b/frontend/src/tools/__tests__/text_tool_create.test.ts index 5397a730..488d047c 100644 --- a/frontend/src/tools/__tests__/text_tool_create.test.ts +++ b/frontend/src/tools/__tests__/text_tool_create.test.ts @@ -21,6 +21,9 @@ const { fakeApp } = vi.hoisted(() => ({ activeLayerId: null as number | null, activeNode: null as { id: number; type: string } | null, foreground: { r: 0, g: 0, b: 0, a: 255 }, + // Tools read the color through the accessor that also records it as + // recently used; the fake returns the same value without the recording. + consumeForeground() { return this.foreground; }, toolCursor: null as string | null, }, })); diff --git a/frontend/src/tools/brush.svelte.ts b/frontend/src/tools/brush.svelte.ts index d7bc7c8f..c2ac187a 100644 --- a/frontend/src/tools/brush.svelte.ts +++ b/frontend/src/tools/brush.svelte.ts @@ -242,7 +242,7 @@ class BrushTool extends ToolBase { engine.api.clearBrushCursorPreviewPose(); this.clearHover(); this.inst.toolCursor = 'none'; - const params = brushStrokeParams(e, cx, cy, this.inst.foreground); + const params = brushStrokeParams(e, cx, cy, this.inst.consumeForeground()); engine.api.beginStroke({ id: layerId }); engine.api.strokeTo({ op: { op: 'brush_stroke', ...params } }); // Capture the clone dest anchor so the source marker tracks the cursor @@ -256,7 +256,7 @@ class BrushTool extends ToolBase { const engine = this.engine; if (!engine) return; if (e.buttons & 1) { - const params = brushStrokeParams(e, cx, cy, this.inst.foreground); + const params = brushStrokeParams(e, cx, cy, this.inst.consumeForeground()); engine.api.strokeTo({ op: { op: 'brush_stroke', ...params } }); strokeRecorder.addEvent(params); onCloneStrokeMove(cx, cy); diff --git a/frontend/src/tools/fill.svelte.ts b/frontend/src/tools/fill.svelte.ts index b931b987..4e5e43c3 100644 --- a/frontend/src/tools/fill.svelte.ts +++ b/frontend/src/tools/fill.svelte.ts @@ -15,7 +15,7 @@ class FillTool extends ToolBase { const layerId = this.inst.activeLayerId; if (!layerId || !engine) return; - const c = this.inst.foreground; + const c = this.inst.consumeForeground(); engine.api.beginStroke({ id: layerId }); engine.api.strokeTo({ diff --git a/frontend/src/tools/gradient.svelte.ts b/frontend/src/tools/gradient.svelte.ts index 4fcf9dc8..4799a3ad 100644 --- a/frontend/src/tools/gradient.svelte.ts +++ b/frontend/src/tools/gradient.svelte.ts @@ -27,7 +27,7 @@ class GradientTool extends ToolBase { const layerId = this.inst.activeLayerId; if (!layerId || !engine) return; - const c = this.inst.foreground; + const c = this.inst.consumeForeground(); const bg = this.inst.background; engine.api.beginStroke({ id: layerId }); diff --git a/frontend/src/tools/text.svelte.ts b/frontend/src/tools/text.svelte.ts index f4bd5794..6dd835cc 100644 --- a/frontend/src/tools/text.svelte.ts +++ b/frontend/src/tools/text.svelte.ts @@ -118,7 +118,7 @@ class TextTool extends ToolBase { } private foregroundTuple(): Rgba { - const c = this.inst.foreground; + const c = this.inst.consumeForeground(); return [c.r, c.g, c.b, c.a]; } diff --git a/frontend/src/ui/BrushOptions.svelte b/frontend/src/ui/BrushOptions.svelte index 498832b0..faa446ae 100644 --- a/frontend/src/ui/BrushOptions.svelte +++ b/frontend/src/ui/BrushOptions.svelte @@ -4,16 +4,16 @@ import type { BrushInfo, ExposedPortInfo } from '../state/brush_graph.svelte'; import { unitFor } from '../lib/units'; import { brushSession, focusedBrushTool } from '../tools/brush.svelte'; - import BrushPicker from './brush_picker/BrushPicker.svelte'; - import LiveBrushPreviewStrip from './brush_picker/LiveBrushPreviewStrip.svelte'; + import LiveBrushPreviewStrip from './brush_library/LiveBrushPreviewStrip.svelte'; import Scrub from './Scrub.svelte'; import ToolBarLayout from './ToolBarLayout.svelte'; import Icon from '../icons/Icon.svelte'; import { tooltipForAction } from '../config/store.svelte'; - import { watchDismiss } from '../lib/dismiss'; + import BrushExplorer from './brush_explorer/BrushExplorer.svelte'; - let brushPickerOpen = $state(false); - let brushPickerTrigger: HTMLButtonElement | undefined = $state(); + /** The explorer's open flag. Local: the trigger owns the dialog, and + * picking a brush closes it, so nothing else needs to reach it. */ + let explorerOpen = $state(false); function ensureInit() { if (!brushGraph.graph && app.engine) brushGraph.init(); @@ -27,12 +27,6 @@ if (!brushGraph.isOpen) brushGraph.fullscreen = false; } - function selectBrush(brush: BrushInfo) { - ensureInit(); - brushGraph.loadBrush(brush.name); - brushPickerOpen = false; - } - /** Transient feedback while a scrub is being dragged. Local only — the * engine recompiles the graph and re-derives its previews on every * exposed-port write, which is work the values a drag passes through @@ -67,10 +61,6 @@ brushGraph.setInput(port.nodeId, port.portName, 'enum', index); } - // A pointerdown outside the brush picker (trigger + panel, both tagged - // data-keep-open="brush-picker") closes it. - $effect(() => watchDismiss('brush-picker', () => (brushPickerOpen = false))); - function toggleEraseMode() { brushSession.eraseMode = !brushSession.eraseMode; app.engine?.api.setBrushBlendMode({ mode: brushSession.eraseMode ? 1 : 0 }); @@ -92,16 +82,15 @@ {#snippet center()} - +
+ {/each} +
+ + + diff --git a/frontend/src/ui/RecoveryModal.svelte b/frontend/src/ui/RecoveryModal.svelte index e89b0394..5234b2f0 100644 --- a/frontend/src/ui/RecoveryModal.svelte +++ b/frontend/src/ui/RecoveryModal.svelte @@ -29,7 +29,7 @@ function onDiscardAll() { void recovery.discardAll(); } - +

Darkly didn't shut down cleanly. These documents had unsaved changes — restore the ones you want to keep. diff --git a/frontend/src/ui/ResizeCanvasModal.svelte b/frontend/src/ui/ResizeCanvasModal.svelte index 1d834c31..76ecd528 100644 --- a/frontend/src/ui/ResizeCanvasModal.svelte +++ b/frontend/src/ui/ResizeCanvasModal.svelte @@ -261,7 +261,7 @@ const ANCHORS = [0, 0.5, 1]; - +