From 6dd45192849267f801a4e1716fc1af3dfa703cb1 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 17 Aug 2026 00:40:50 -0400 Subject: [PATCH 01/13] readme veil previews --- AGENTS.md | 39 + CONTRIBUTING.md | 7 + README.md | 17 +- crates/darkly/Cargo.toml | 8 +- crates/darkly/brushes/sponge.yaml | 36 +- crates/darkly/build.rs | 5 + crates/darkly/src/bin/render_docs.rs | 35 +- crates/darkly/src/bin/sync_docs.rs | 105 +++ .../src/docs_md/fragments/catalog_table.rs | 64 ++ crates/darkly/src/docs_md/fragments/mod.rs | 14 + crates/darkly/src/docs_md/mod.rs | 685 ++++++++++++++++++ crates/darkly/src/docs_render/mod.rs | 250 +++++-- crates/darkly/src/lib.rs | 5 + crates/darkly/tests/docs_md.rs | 112 +++ crates/darkly/tests/docs_render.rs | 56 +- .../images/previews/veils/black_and_white.jpg | Bin 0 -> 22907 bytes .../previews/veils/chromatic_aberration.jpg | Bin 0 -> 24671 bytes docs/images/previews/veils/frozen.jpg | Bin 0 -> 51994 bytes docs/images/previews/veils/grain.jpg | Bin 0 -> 74879 bytes docs/images/previews/veils/lens_blur.jpg | Bin 0 -> 15871 bytes docs/images/previews/veils/painting.jpg | Bin 0 -> 21890 bytes docs/images/previews/veils/pixelate.jpg | Bin 0 -> 3805 bytes docs/images/previews/veils/rainy_glass.jpg | Bin 0 -> 51100 bytes docs/images/previews/veils/vhs.jpg | Bin 0 -> 31639 bytes docs/images/previews/veils/watercolor.jpg | Bin 0 -> 13393 bytes frontend/src/ui/Modal.svelte | 12 + 26 files changed, 1370 insertions(+), 80 deletions(-) create mode 100644 crates/darkly/src/bin/sync_docs.rs create mode 100644 crates/darkly/src/docs_md/fragments/catalog_table.rs create mode 100644 crates/darkly/src/docs_md/fragments/mod.rs create mode 100644 crates/darkly/src/docs_md/mod.rs create mode 100644 crates/darkly/tests/docs_md.rs create mode 100644 docs/images/previews/veils/black_and_white.jpg create mode 100644 docs/images/previews/veils/chromatic_aberration.jpg create mode 100644 docs/images/previews/veils/frozen.jpg create mode 100644 docs/images/previews/veils/grain.jpg create mode 100644 docs/images/previews/veils/lens_blur.jpg create mode 100644 docs/images/previews/veils/painting.jpg create mode 100644 docs/images/previews/veils/pixelate.jpg create mode 100644 docs/images/previews/veils/rainy_glass.jpg create mode 100644 docs/images/previews/veils/vhs.jpg create mode 100644 docs/images/previews/veils/watercolor.jpg diff --git a/AGENTS.md b/AGENTS.md index 3f9d71ea..4bfd1c85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,8 @@ crates/darkly/src/ 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 ``` @@ -251,6 +253,43 @@ Every PR body has **two parts**: a human-written preamble explaining *why* the w 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 +scripts/install-hooks.sh # once — pre-commit refills regions +cargo run -p darkly --bin sync-docs # by hand +cargo run -p darkly --bin sync-docs -- --check +``` + +`tests/docs_md.rs` fails if a committed region is stale, so CI is the gate; the +hook only saves you a round trip. 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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c61d2a3..3804cbe5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,15 @@ cargo test --workspace --exclude darkly-wasm -- --test-threads=1 # Frontend (cd frontend && npm install && npm run dev) + +# Git hooks — keeps the generated parts of the docs in sync (see AGENTS.md) +scripts/install-hooks.sh ``` +Some of the markdown here is generated from Darkly's registries and marked with +`` comments. Don't edit inside those regions; edit the +registration the text comes from. See [AGENTS.md](AGENTS.md#generated-markdown). + 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). ## Questions diff --git a/README.md b/README.md index 25faa373..04db922a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,22 @@ Darkly's unique brushes live inside a node-based system. This enables infinite c 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 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. +Veils are where Darkly gets its name; *"For now we see through a glass, darkly"*. They're a special type of layer that sits above the viewport, visible only to the artist. By shrouding your canvas behind a mysterious pane, it invites you to see something that maybe wasn't there before. + + +| | 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 have practical uses too: diff --git a/crates/darkly/Cargo.toml b/crates/darkly/Cargo.toml index ebbf1cce..888aa734 100644 --- a/crates/darkly/Cargo.toml +++ b/crates/darkly/Cargo.toml @@ -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/sponge.yaml b/crates/darkly/brushes/sponge.yaml index 89ae73b5..8d1ea2f6 100644 --- a/crates/darkly/brushes/sponge.yaml +++ b/crates/darkly/brushes/sponge.yaml @@ -15,9 +15,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 +26,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 +58,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 +74,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 +98,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..9490774e 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", 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..4f875328 --- /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 run -p darkly --bin sync-docs # rewrite +//! cargo run -p darkly --bin sync-docs -- --check # report only +//! ``` +//! +//! `--check` is what `tests/docs_md.rs` asserts and what CI therefore enforces; +//! the writing mode is what `.githooks/pre-commit` runs so a working tree fixes +//! itself. 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/docs_md/fragments/catalog_table.rs b/crates/darkly/src/docs_md/fragments/catalog_table.rs new file mode 100644 index 00000000..0ccc0148 --- /dev/null +++ b/crates/darkly/src/docs_md/fragments/catalog_table.rs @@ -0,0 +1,64 @@ +//! `` — 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 256 px +/// squares; 120 keeps ten rows readable without turning the page into a gallery. +const STILL_WIDTH: u32 = 120; + +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..44f6c79b --- /dev/null +++ b/crates/darkly/src/docs_md/mod.rs @@ -0,0 +1,685 @@ +//! 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 run -p darkly --bin sync-docs` +//! is the writer, `tests/docs_md.rs` is the checker, and `.githooks/pre-commit` +//! runs the writer so a working tree fixes itself. +//! +//! 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 `AGENTS.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 — +/// `AGENTS.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. `CLAUDE.md` is a link to `AGENTS.md`, and + // rewriting a file twice under two 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`, `.githooks`); + // 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 + /// `AGENTS.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() + ); + } + } + + /// `CLAUDE.md` is a symlink to `AGENTS.md`. One file, one entry — otherwise + /// a report names the same content twice 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("AGENTS.md"))); + assert!( + !files.iter().any(|p| p == Path::new("CLAUDE.md")), + "the 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..cb3d589a 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(), @@ -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/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/docs_md.rs b/crates/darkly/tests/docs_md.rs new file mode 100644 index 00000000..2c8e5e47 --- /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 run -p darkly --bin 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/docs/images/previews/veils/black_and_white.jpg b/docs/images/previews/veils/black_and_white.jpg new file mode 100644 index 0000000000000000000000000000000000000000..59413cb12fd35d986228b6a0c8ed28477d6100f4 GIT binary patch literal 22907 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 literal 0 HcmV?d00001 diff --git a/docs/images/previews/veils/frozen.jpg b/docs/images/previews/veils/frozen.jpg new file mode 100644 index 0000000000000000000000000000000000000000..af20405233fa6fad16215033d2c9221418ba0860 GIT binary patch literal 51994 zcmbUIcT^MK7e0)FsDL0UT|iLjRf_ax`2Yd}f^-N7h?LMC%-dQX+&TQj*Yv z^xi@dLg+ORLQClQ^8LNHtabmo_wJcl>#UhGv(9tM%-;KX=HK6c^HjI>v~{(qsIO2_ zQD1IU|5~W-(EYDOLw$$p${lK&JJkPhRL`ksu3Y)werZ=P_iJ?5u3f!)?FKFFbvniy zjEoF77#MCcG2gz)bccz7;Wqp2J1nehY;26TI5^o^Ihk46SpWABYMRS?u3n?Rc8#9( zCc{nE|DWw&I~Ct$JFijyAEWwz81IyXt&6TS(|7+r9ZSduF zs;hUdF+Y%bdY#4Cf%c&CqXIFPm?dspt*DP+5?&E%ukJJ9ei0H z%6_0@eU?&K`G=lg&SZzp(Qo7iyTIcG!QKBs`#;G3zXKNa{};0V7qI^~u4yVp8tThw zM01Bqlj=w+VETpAKJSmXb3%<|pyZHg_NZj-Lp_hVdPRA8d+V|QhgV0uVnEq8tPNocLhbNU;$J%-k#iF0)YaDDcWcIx`;+uiN@ zD~Y2tQK}bgt9lvML%7;a@v{rmXB!n|zU1AKFQkjUB;IUJx zNm*?#^_($ky*!VLvax=CBAo z>ucm|K;t&;O1U*#jG$S}9Wu?-qDyp^-l3U2Cv^Cza|r z3{*NN)oFlpq#ty;ONP5AHd*E7b>}ZEts6n))1XS>5*b1<+{wzFJF+TwUJnv9gL!gh zV(d66hw||TUJr8KFCClCxYQH4=wc5;64rR$Hz^?q67TeTq`ZKb=QBQ)8CT9AuJ4|X z`F>T0u9bBVHt=05ky+%uhn-2VU;0;{;hhp%T0DJp=Yy0_EYR6b&{w6uv_=1vxWEob zVZa}X4gvq=|5u;Y$5;uc#&wGHh)$%7o37NGZxEDbttk(xsuSkmtCmGws ljVGLX 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 MwS{Rup>}`&*&eohm;e9( literal 0 HcmV?d00001 diff --git a/docs/images/previews/veils/grain.jpg b/docs/images/previews/veils/grain.jpg new file mode 100644 index 0000000000000000000000000000000000000000..20a0d5fc557d2c48a4345d5e530ae2e5da5d55d2 GIT binary patch literal 74879 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 literal 0 HcmV?d00001 diff --git a/docs/images/previews/veils/lens_blur.jpg b/docs/images/previews/veils/lens_blur.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6544ac874003f2cb92c799e2a8c37393b24e157a GIT binary patch literal 15871 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≶)gE&32{Q<;IQ>Po4a1^<%8o)w@ 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 TP?1c|!wro!#@$wyzYG5xfvh%a literal 0 HcmV?d00001 diff --git a/docs/images/previews/veils/painting.jpg b/docs/images/previews/veils/painting.jpg new file mode 100644 index 0000000000000000000000000000000000000000..44005c075b9ef69275aa0f365fabbe47e6c380d9 GIT binary patch literal 21890 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 Va1?P?kOMAxq~ZXFXr#0Q|Je~f)?WYs literal 0 HcmV?d00001 diff --git a/docs/images/previews/veils/pixelate.jpg b/docs/images/previews/veils/pixelate.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8c8d886a9b213407e66d8e588d3d7e0a5cc11840 GIT binary patch literal 3805 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

dTO`7dO; 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%0ArO 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 J@6`Xz{vXg#xPAZt literal 0 HcmV?d00001 diff --git a/docs/images/previews/veils/watercolor.jpg b/docs/images/previews/veils/watercolor.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a035c1e52ce3216a275cc765c54d932a9d8c1ceb GIT binary patch literal 13393 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 EAMa);5C8xG literal 0 HcmV?d00001 diff --git a/frontend/src/ui/Modal.svelte b/frontend/src/ui/Modal.svelte index 9f46ce29..5e6e8f50 100644 --- a/frontend/src/ui/Modal.svelte +++ b/frontend/src/ui/Modal.svelte @@ -74,6 +74,17 @@ // Escape handling still runs (it doesn't depend on bubbling). e.stopPropagation(); } + + function onWheel(e: WheelEvent) { + // `showModal()` promotes the dialog to the top layer visually, but it + // stays a DOM descendant of whatever mounted it — so a wheel event + // inside the modal still bubbles to ancestor handlers. The brush + // builder's node canvas is the case that bites: its wheel handler + // preventDefaults and pans, so the modal body never scrolls. Nothing + // beneath a modal should see its wheel events; the body's own + // `overflow: auto` scrolling is unaffected (no preventDefault here). + e.stopPropagation(); + }

Date: Mon, 17 Aug 2026 00:44:25 -0400 Subject: [PATCH 02/13] bigger veil readme previews --- README.md | 20 +++++++++---------- .../src/docs_md/fragments/catalog_table.rs | 9 ++++++--- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 04db922a..3d56cf84 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,16 @@ Veils are where Darkly gets its name; *"For now we see through a glass, darkly"* | | 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. | +| 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 have practical uses too: diff --git a/crates/darkly/src/docs_md/fragments/catalog_table.rs b/crates/darkly/src/docs_md/fragments/catalog_table.rs index 0ccc0148..ef5214d1 100644 --- a/crates/darkly/src/docs_md/fragments/catalog_table.rs +++ b/crates/darkly/src/docs_md/fragments/catalog_table.rs @@ -13,9 +13,12 @@ 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 256 px -/// squares; 120 keeps ten rows readable without turning the page into a gallery. -const STILL_WIDTH: u32 = 120; +/// 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 { From 5595b5d340046f923ff6b8d786c4dabaab383ca0 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 17 Aug 2026 16:48:27 -0400 Subject: [PATCH 03/13] polygon brush node perf optimization --- README.md | 8 +- crates/darkly/src/brush/nodes/polygon.rs | 83 ++++++++++++++++-- crates/darkly/tests/wgsl.rs | 104 ++++++++++++++++++++++- 3 files changed, 181 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3d56cf84..1c72c15e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ Darkly's unique brushes live inside a node-based system. This enables infinite c 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 type of layer that sits above the viewport, visible only to the artist. By shrouding your canvas behind a mysterious pane, it invites 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 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 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 | @@ -62,14 +64,14 @@ Veils are where Darkly gets its name; *"For now we see through a glass, darkly"* | Watercolor | **Watercolor** | Bleed the view outward into soft watercolor washes. | -Veils have practical uses too: +Besides their nebulous effects on the imagination, veils are fun to play around with, and 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. > [!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 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/tests/wgsl.rs b/crates/darkly/tests/wgsl.rs index b803281c..9d1d1cdb 100644 --- a/crates/darkly/tests/wgsl.rs +++ b/crates/darkly/tests/wgsl.rs @@ -1193,16 +1193,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%). @@ -1633,3 +1640,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, + ); +} From daecab0785fdde23accfa513fdf1e6695e12e4dd Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 19 Aug 2026 11:45:00 -0400 Subject: [PATCH 04/13] restructure contrib docs --- .cargo/config.toml | 6 + AGENTS.md | 329 +---------------- CLAUDE.md | 2 +- CONTRIBUTING.md | 334 +++++++++++++++++- README.md | 6 +- crates/darkly/Cargo.toml | 2 +- crates/darkly/brushes/hair.yaml | 2 +- crates/darkly/src/bin/sync_docs.rs | 10 +- crates/darkly/src/brush/builtin_brushes.rs | 2 +- crates/darkly/src/brush/node.rs | 2 +- crates/darkly/src/config/mod.rs | 4 +- crates/darkly/src/docs_md/mod.rs | 48 +-- crates/darkly/src/docs_render/mod.rs | 4 +- crates/darkly/src/document/filter.rs | 4 +- crates/darkly/src/document/filters/mask.rs | 4 +- .../darkly/src/document/filters/selection.rs | 4 +- .../darkly/src/document/layer_kinds/group.rs | 2 +- .../darkly/src/document/layer_kinds/raster.rs | 2 +- crates/darkly/src/engine/canvas_resize.rs | 6 +- crates/darkly/src/engine/preview.rs | 5 +- crates/darkly/src/gpu/atlas.rs | 2 +- crates/darkly/src/gpu/flood_fill.rs | 4 +- crates/darkly/src/gpu/selection.rs | 2 +- crates/darkly/tests/brush_erase.rs | 2 +- crates/darkly/tests/docs_md.rs | 2 +- docs/architecture-history.md | 10 +- docs/brush-preview-and-overlays.md | 2 +- docs/getting-started-rust.md | 2 +- frontend/src/config/store.svelte.ts | 2 +- 29 files changed, 401 insertions(+), 405 deletions(-) mode change 100644 => 120000 AGENTS.md 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 4bfd1c85..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,328 +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) - 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. 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 -scripts/install-hooks.sh # once — pre-commit refills regions -cargo run -p darkly --bin sync-docs # by hand -cargo run -p darkly --bin sync-docs -- --check -``` - -`tests/docs_md.rs` fails if a committed region is stale, so CI is the gate; the -hook only saves you a round trip. 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 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 3804cbe5..26edfadd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,31 +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, 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. -# Git hooks — keeps the generated parts of the docs in sync (see AGENTS.md) -scripts/install-hooks.sh +### 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 ``` -Some of the markdown here is generated from Darkly's registries and marked with -`` comments. Don't edit inside those regions; edit the -registration the text comes from. See [AGENTS.md](AGENTS.md#generated-markdown). +`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 +``` -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). +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 1c72c15e..d8376e0c 100644 --- a/README.md +++ b/README.md @@ -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. Finally craft your chaotic 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. @@ -248,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 @@ -256,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 888aa734..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 = [] diff --git a/crates/darkly/brushes/hair.yaml b/crates/darkly/brushes/hair.yaml index 777b2853..f420eec9 100644 --- a/crates/darkly/brushes/hair.yaml +++ b/crates/darkly/brushes/hair.yaml @@ -9,7 +9,7 @@ nodes: inputs: size: 0.2 spacing: 0.01 - stabilize: 1.0 + stabilize: 0.5 circle: type: circle curve: diff --git a/crates/darkly/src/bin/sync_docs.rs b/crates/darkly/src/bin/sync_docs.rs index 4f875328..6896f01c 100644 --- a/crates/darkly/src/bin/sync_docs.rs +++ b/crates/darkly/src/bin/sync_docs.rs @@ -1,14 +1,14 @@ //! Re-render every generated region in the repository's markdown. //! //! ```text -//! cargo run -p darkly --bin sync-docs # rewrite -//! cargo run -p darkly --bin sync-docs -- --check # report only +//! 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 `.githooks/pre-commit` runs so a working tree fixes -//! itself. Needs no GPU — every fragment builds from `&'static` registration -//! data, the same property that lets the check live in the ordinary test suite. +//! 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; diff --git a/crates/darkly/src/brush/builtin_brushes.rs b/crates/darkly/src/brush/builtin_brushes.rs index 0bd53118..3b72ace3 100644 --- a/crates/darkly/src/brush/builtin_brushes.rs +++ b/crates/darkly/src/brush/builtin_brushes.rs @@ -4,7 +4,7 @@ //! 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; 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/config/mod.rs b/crates/darkly/src/config/mod.rs index 04d0c770..983a2c32 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/mod.rs b/crates/darkly/src/docs_md/mod.rs index 44f6c79b..97ed1545 100644 --- a/crates/darkly/src/docs_md/mod.rs +++ b/crates/darkly/src/docs_md/mod.rs @@ -16,9 +16,9 @@ //! ``` //! //! [`sync`] walks the tree, re-renders every region it finds and either writes -//! the result back or reports the drift. `cargo run -p darkly --bin sync-docs` -//! is the writer, `tests/docs_md.rs` is the checker, and `.githooks/pre-commit` -//! runs the writer so a working tree fixes itself. +//! 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. @@ -38,8 +38,8 @@ use std::path::{Path, PathBuf}; 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 `AGENTS.md` asks for, tens -/// of thousands of markdown files that are not ours to rewrite. +/// 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", @@ -220,8 +220,8 @@ fn open_marker(line: &str) -> Option<(&str, &str)> { /// /// 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 — -/// `AGENTS.md` does, and without this it would rewrite its own explanation into -/// a table of veils. +/// `CONTRIBUTING.md` does, and without this it would rewrite its own +/// explanation into a table of veils. #[derive(Default)] struct Fence(Option<(char, usize)>); @@ -401,15 +401,16 @@ fn walk(root: &Path, rel: &Path, found: &mut Vec) -> Result<(), std::io let name = entry.file_name(); let name = name.to_string_lossy(); let path = rel.join(name.as_ref()); - // Symlinks are never followed. `CLAUDE.md` is a link to `AGENTS.md`, and - // rewriting a file twice under two 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. + // 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`, `.githooks`); + // 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; @@ -543,8 +544,9 @@ mod tests { } /// A marker in a code block is documentation *about* the syntax, and - /// `AGENTS.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. + /// `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"); @@ -670,16 +672,18 @@ mod tests { } } - /// `CLAUDE.md` is a symlink to `AGENTS.md`. One file, one entry — otherwise - /// a report names the same content twice and a link out of the tree would be - /// followed out of it. + /// `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("AGENTS.md"))); - assert!( - !files.iter().any(|p| p == Path::new("CLAUDE.md")), - "the link was walked as well as its target" - ); + 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 cb3d589a..6175ea0a 100644 --- a/crates/darkly/src/docs_render/mod.rs +++ b/crates/darkly/src/docs_render/mod.rs @@ -573,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"))] 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/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/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/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 e2484d07..e8bca718 100644 --- a/crates/darkly/src/gpu/flood_fill.rs +++ b/crates/darkly/src/gpu/flood_fill.rs @@ -34,8 +34,8 @@ use crate::gpu::readback::{self, ReadbackRequest}; /// 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/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/docs_md.rs b/crates/darkly/tests/docs_md.rs index 2c8e5e47..bd8edcd0 100644 --- a/crates/darkly/tests/docs_md.rs +++ b/crates/darkly/tests/docs_md.rs @@ -34,7 +34,7 @@ fn generated_regions_are_up_to_date() { ); assert!( report.changed.is_empty(), - "out of date: {}\nrun `cargo run -p darkly --bin sync-docs`", + "out of date: {}\nrun `cargo sync-docs`", report .changed .iter() 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/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. */ From ceca0f1037b88ea380f86b1bf29e6a15691e8971 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 19 Aug 2026 11:47:52 -0400 Subject: [PATCH 05/13] strange --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d8376e0c..8db4a200 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-Strange_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) From 37ac56b2c96ccae1e22f75611d64526f3d863105 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 19 Aug 2026 18:43:26 -0400 Subject: [PATCH 06/13] small subtractive reorg --- README.md | 6 +- crates/darkly/brushes/liquify.yaml | 1 - crates/darkly/src/brush/checkpoint_ring.rs | 147 +++---- crates/darkly/src/brush/gpu_context.rs | 56 ++- crates/darkly/src/brush/interpolation.rs | 57 ++- .../darkly/src/brush/nodes/brush_settings.rs | 61 +++ crates/darkly/src/brush/nodes/liquify.rs | 21 +- crates/darkly/src/brush/nodes/paint.rs | 39 +- crates/darkly/src/brush/nodes/pen_input.rs | 2 +- crates/darkly/src/brush/nodes/watercolor.rs | 39 +- crates/darkly/src/brush/paint_info.rs | 7 +- crates/darkly/src/brush/preview_renderer.rs | 1 + .../darkly/src/brush/read_mirror_terminal.rs | 38 +- crates/darkly/src/brush/save_points.rs | 1 + crates/darkly/src/brush/stroke_engine.rs | 370 ++++++++++++++++-- crates/darkly/src/engine/mod.rs | 9 + crates/darkly/src/engine/painting.rs | 13 + crates/darkly/tests/liquify.rs | 8 +- frontend/src/icons/bundle.generated.ts | 6 +- 19 files changed, 657 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index 8db4a200..0cef2365 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-Strange_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. Finally craft your chaotic masterpiece. 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,7 +43,7 @@ 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 +![veil-demo](https://github.com/user-attachments/assets/95d0ca79-a56e-4998-9459-d0fe73fa08eb) 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. diff --git a/crates/darkly/brushes/liquify.yaml b/crates/darkly/brushes/liquify.yaml index e8e14140..c4802076 100644 --- a/crates/darkly/brushes/liquify.yaml +++ b/crates/darkly/brushes/liquify.yaml @@ -14,7 +14,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/src/brush/checkpoint_ring.rs b/crates/darkly/src/brush/checkpoint_ring.rs index d4132d39..7c244c03 100644 --- a/crates/darkly/src/brush/checkpoint_ring.rs +++ b/crates/darkly/src/brush/checkpoint_ring.rs @@ -66,6 +66,7 @@ impl CheckpointSlot { last_dab_size: [0.0, 0.0], last_dab_pos: None, dab_count: 0, + stamp_angle: None, }, valid: false, } @@ -263,55 +264,65 @@ 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 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(), - ); - 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, + if let Some((layer_rect, _)) = region { + slot.ensure_texture( + device, + layer_rect.width, + layer_rect.height, + stroke.texture.format(), + ); + 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, }, - 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, - }, - ); + wgpu::TexelCopyTextureInfo { + texture: slot + .texture + .as_ref() + .expect("ensure_texture just allocated the slot"), + 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 // must sit at or below the divergence boundary. If this fires, the @@ -401,37 +412,41 @@ 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, + // 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, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: layer_rect.width, - height: layer_rect.height, - depth_or_array_layers: 1, - }, - ); + 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, + }, + ); + } Some(CheckpointRestore { save_point_index: slot.save_point_index, diff --git a/crates/darkly/src/brush/gpu_context.rs b/crates/darkly/src/brush/gpu_context.rs index ee65fbd4..1288c105 100644 --- a/crates/darkly/src/brush/gpu_context.rs +++ b/crates/darkly/src/brush/gpu_context.rs @@ -225,11 +225,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 @@ -310,7 +311,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) } @@ -328,10 +329,51 @@ 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(); } + /// 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/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 869e613f..b8505a02 100644 --- a/crates/darkly/src/brush/nodes/paint.rs +++ b/crates/darkly/src/brush/nodes/paint.rs @@ -455,32 +455,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); @@ -497,9 +480,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/watercolor.rs b/crates/darkly/src/brush/nodes/watercolor.rs index ad772063..dce5324a 100644 --- a/crates/darkly/src/brush/nodes/watercolor.rs +++ b/crates/darkly/src/brush/nodes/watercolor.rs @@ -692,32 +692,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); @@ -734,9 +717,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/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/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 029f98fb..ed92f331 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 326a9559..21c86c57 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 @@ -126,6 +139,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, @@ -198,6 +213,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, } } @@ -209,6 +225,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. @@ -223,6 +240,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; @@ -238,6 +258,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 @@ -403,6 +437,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 @@ -450,6 +492,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(); @@ -463,25 +509,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. @@ -495,6 +543,7 @@ impl StrokeEngine { last_dab_size: [0.0, 0.0], last_dab_pos: None, dab_count: 0, + stamp_angle: None, }, ); @@ -618,9 +667,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` @@ -663,4 +775,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/engine/mod.rs b/crates/darkly/src/engine/mod.rs index e1d66a55..996277ba 100644 --- a/crates/darkly/src/engine/mod.rs +++ b/crates/darkly/src/engine/mod.rs @@ -861,6 +861,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 ec139bc6..110c015b 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/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/frontend/src/icons/bundle.generated.ts b/frontend/src/icons/bundle.generated.ts index 3f902362..e185fcde 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. -// 119 icon(s) across 11 collection(s). +// 120 icon(s) across 11 collection(s). /* eslint-disable */ // @ts-nocheck import { addCollection } from '@iconify/svelte/dist/offline-functions.js'; @@ -10,7 +10,7 @@ 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},"image":{"body":"","left":0,"top":31,"width":512,"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},"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},"image":{"body":"","left":0,"top":31,"width":512,"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":"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}); @@ -19,4 +19,4 @@ addCollection({"prefix":"mdi","icons":{"blur":{"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:image","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: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:image","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"]; From ddcba5a60fbf53fe8d5ae57981e7c19b794d56ff Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 20 Aug 2026 08:41:40 -0400 Subject: [PATCH 07/13] veil demo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cef2365..31a43038 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Darkly's unique brushes live inside a node-based system. This enables infinite c ### Veils -![veil-demo](https://github.com/user-attachments/assets/95d0ca79-a56e-4998-9459-d0fe73fa08eb) +![veil-demo](https://github.com/user-attachments/assets/df05c881-4572-46a1-9a31-366236fabbd3) 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. From 798aadb3e11438cab04e9c090bbfb5058cc9e10e Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Fri, 21 Aug 2026 22:06:29 -0400 Subject: [PATCH 08/13] recent brushes, brush packs --- README.md | 8 +- crates/darkly/brushes/airbrush.yaml | 1 - crates/darkly/brushes/blur.yaml | 1 - crates/darkly/brushes/calligraphy.yaml | 1 - crates/darkly/brushes/charcoal.yaml | 1 - crates/darkly/brushes/clone.yaml | 1 - crates/darkly/brushes/hair.yaml | 1 - crates/darkly/brushes/ink_pen.yaml | 1 - crates/darkly/brushes/liquify.yaml | 1 - crates/darkly/brushes/rough_ink.yaml | 1 - crates/darkly/brushes/rough_watercolor.yaml | 1 - crates/darkly/brushes/smooth_watercolor.yaml | 1 - crates/darkly/brushes/smudge.yaml | 1 - crates/darkly/brushes/sponge.yaml | 1 - crates/darkly/build.rs | 237 +++-- crates/darkly/packs/basic.yaml | 6 + crates/darkly/packs/dry_media.yaml | 6 + crates/darkly/packs/effects.yaml | 6 + crates/darkly/packs/favorites.yaml | 7 + crates/darkly/packs/misc.yaml | 6 + crates/darkly/packs/wet_media.yaml | 6 + crates/darkly/src/actions/brush.rs | 14 + crates/darkly/src/brush/builtin_brushes.rs | 37 +- crates/darkly/src/brush/bundle.rs | 272 ------ crates/darkly/src/brush/library.rs | 924 ++++++++++++++---- crates/darkly/src/brush/metadata.rs | 137 +++ crates/darkly/src/brush/mod.rs | 6 +- crates/darkly/src/brush/pack.rs | 359 +++++++ crates/darkly/src/brush/pack_file.rs | 365 +++++++ crates/darkly/src/brush/pack_icons.rs | 76 ++ crates/darkly/src/brush/packs.rs | 222 +++++ crates/darkly/src/brush/portable.rs | 23 +- crates/darkly/src/catalog.rs | 54 +- crates/darkly/src/engine/brush_graph.rs | 2 +- crates/darkly/src/engine/brush_library.rs | 233 +++-- crates/darkly/src/engine/mod.rs | 22 +- .../engine/protocol/handlers/brush_library.rs | 27 +- crates/darkly/src/engine/rendering.rs | 12 +- crates/darkly/src/format/mod.rs | 1 - crates/darkly/src/format/tests.rs | 9 +- crates/darkly/src/format/zip_io.rs | 201 ++-- crates/darkly/tests/brush_editor_preview.rs | 20 +- crates/darkly/tests/brush_packs.rs | 321 ++++++ crates/darkly/tests/docs_export.rs | 30 +- frontend/public/favicon.ico | Bin 0 -> 8776 bytes frontend/src/App.svelte | 9 + frontend/src/__tests__/iconBundle.test.ts | 38 + .../actions/__tests__/menu_actions.test.ts | 2 + .../actions/__tests__/pack_actions.test.ts | 52 + frontend/src/actions/index.ts | 4 + frontend/src/actions/pack_actions.ts | 84 ++ .../src/config/__tests__/validate.test.ts | 98 ++ frontend/src/editor.ts | 4 + frontend/src/engine/protocol_gen.ts | 137 ++- frontend/src/icons/bundle.generated.ts | 8 +- frontend/src/lib/__tests__/color.test.ts | 42 + frontend/src/lib/color.ts | 59 +- frontend/src/lib/id.ts | 22 + frontend/src/lib/packIcon.ts | 24 + .../__tests__/brush_library_store.test.ts | 312 ++++++ .../__tests__/foreground_recording.test.ts | 78 ++ frontend/src/state/__tests__/recents.test.ts | 135 +++ frontend/src/state/app.svelte.ts | 30 +- frontend/src/state/brush_graph.svelte.ts | 24 +- frontend/src/state/brush_library.svelte.ts | 277 ++++++ frontend/src/state/packExport.svelte.ts | 16 + frontend/src/state/recents.svelte.ts | 143 +++ frontend/src/state/recoverySession.ts | 6 +- .../src/storage/__tests__/jsonStore.test.ts | 157 +++ frontend/src/storage/index.ts | 27 +- frontend/src/storage/jsonStore.ts | 225 +++++ .../tools/__tests__/text_tool_create.test.ts | 3 + frontend/src/tools/brush.svelte.ts | 4 +- frontend/src/tools/fill.svelte.ts | 2 +- frontend/src/tools/gradient.svelte.ts | 2 +- frontend/src/tools/text.svelte.ts | 2 +- frontend/src/ui/ColorPicker.svelte | 28 +- frontend/src/ui/PackExportModal.svelte | 81 ++ .../src/ui/brush_picker/BrushPicker.svelte | 85 +- .../brush_picker/__tests__/grouping.test.ts | 180 ++++ frontend/src/ui/brush_picker/grouping.ts | 116 +++ frontend/wasm/src/config_bridge.rs | 6 +- 82 files changed, 5249 insertions(+), 935 deletions(-) create mode 100644 crates/darkly/packs/basic.yaml create mode 100644 crates/darkly/packs/dry_media.yaml create mode 100644 crates/darkly/packs/effects.yaml create mode 100644 crates/darkly/packs/favorites.yaml create mode 100644 crates/darkly/packs/misc.yaml create mode 100644 crates/darkly/packs/wet_media.yaml delete mode 100644 crates/darkly/src/brush/bundle.rs create mode 100644 crates/darkly/src/brush/metadata.rs create mode 100644 crates/darkly/src/brush/pack.rs create mode 100644 crates/darkly/src/brush/pack_file.rs create mode 100644 crates/darkly/src/brush/pack_icons.rs create mode 100644 crates/darkly/src/brush/packs.rs create mode 100644 crates/darkly/tests/brush_packs.rs create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/src/actions/__tests__/pack_actions.test.ts create mode 100644 frontend/src/actions/pack_actions.ts create mode 100644 frontend/src/config/__tests__/validate.test.ts create mode 100644 frontend/src/lib/__tests__/color.test.ts create mode 100644 frontend/src/lib/id.ts create mode 100644 frontend/src/lib/packIcon.ts create mode 100644 frontend/src/state/__tests__/brush_library_store.test.ts create mode 100644 frontend/src/state/__tests__/foreground_recording.test.ts create mode 100644 frontend/src/state/__tests__/recents.test.ts create mode 100644 frontend/src/state/brush_library.svelte.ts create mode 100644 frontend/src/state/packExport.svelte.ts create mode 100644 frontend/src/state/recents.svelte.ts create mode 100644 frontend/src/storage/__tests__/jsonStore.test.ts create mode 100644 frontend/src/storage/jsonStore.ts create mode 100644 frontend/src/ui/PackExportModal.svelte create mode 100644 frontend/src/ui/brush_picker/__tests__/grouping.test.ts create mode 100644 frontend/src/ui/brush_picker/grouping.ts diff --git a/README.md b/README.md index 31a43038..7760e822 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ Darkly's unique brushes live inside a node-based system. This enables infinite c ### Veils -![veil-demo](https://github.com/user-attachments/assets/df05c881-4572-46a1-9a31-366236fabbd3) - 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. +![veil-demo](https://github.com/user-attachments/assets/df05c881-4572-46a1-9a31-366236fabbd3) + Veils are nondestructive. You can paint as usual, behind the veil, and when you disable it, you'll see the full-res result. @@ -64,11 +64,11 @@ Veils are nondestructive. You can paint as usual, behind the veil, and when you | Watercolor | **Watercolor** | Bleed the view outward into soft watercolor washes. | -Besides their nebulous effects on the imagination, veils are fun to play around with, and have practical uses too: +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] > 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. 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 f420eec9..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: 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 c4802076..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: 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 8d1ea2f6..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: diff --git a/crates/darkly/build.rs b/crates/darkly/build.rs index 9490774e..89274261 100644 --- a/crates/darkly/build.rs +++ b/crates/darkly/build.rs @@ -150,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); @@ -511,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"); @@ -646,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/favorites.yaml b/crates/darkly/packs/favorites.yaml new file mode 100644 index 00000000..80b8a6ce --- /dev/null +++ b/crates/darkly/packs/favorites.yaml @@ -0,0 +1,7 @@ +name: Favorites +description: The brushes you reach for most. +icon: fa6-solid:star +primary: "#f5c542" +secondary: "#2b2213" +mutability: members +members: [] 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/brush/builtin_brushes.rs b/crates/darkly/src/brush/builtin_brushes.rs index 3b72ace3..29a4a9b6 100644 --- a/crates/darkly/src/brush/builtin_brushes.rs +++ b/crates/darkly/src/brush/builtin_brushes.rs @@ -8,8 +8,8 @@ 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,7 +34,7 @@ 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) + .into_brush(registry, stem) .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")); (stem, brush) }) @@ -124,7 +124,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 +194,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); } } 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/library.rs b/crates/darkly/src/brush/library.rs index d7c60955..66bc3824 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, BrushMetadata}; +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, @@ -28,8 +46,8 @@ pub struct BrushInfo { impl From<&BrushMetadata> for BrushInfo { fn from(p: &BrushMetadata) -> Self { 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(), @@ -38,25 +56,104 @@ impl From<&BrushMetadata> for BrushInfo { } } -/// In-memory library of brushes. +/// 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(), + } + } +} + +/// 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 @@ -67,39 +164,120 @@ impl BrushLibrary { 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) } - /// 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) + /// 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) } - /// Add or replace a brush in the library. + /// 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, 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 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. Returns whether the brush existed. + pub fn delete_brush(&mut self, id: &str) -> bool { + if self.brushes.shift_remove(id).is_none() { + return false; + } + self.dab_thumbnails.remove(id); + for pack in self.packs.values_mut() { + pack.members.retain(|m| m != id); + } + true + } + + /// 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()); + } + if !self.brushes.contains_key(id) { + return Err(format!("brush '{id}' not found")); + } + if self + .brushes + .values() + .any(|b| b.id() != id && b.name() == new_name) + { + return Err(format!("a brush named '{new_name}' already exists")); + } + if let Some(brush) = self.brushes.get_mut(id) { + brush.metadata.name = new_name.to_string(); + } + Ok(()) + } + + /// 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)) + } + + pub fn len(&self) -> usize { + self.brushes.len() } - /// Remove a brush by name. Returns true if it existed. - pub fn remove(&mut self, name: &str) -> bool { - self.brushes.remove(name).is_some() + pub fn is_empty(&self) -> bool { + self.brushes.is_empty() } - /// 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]> { + // ---- thumbnails ---- + + /// A brush's baked stroke thumbnail, if one has been baked. + pub fn thumbnail_png(&self, id: &str) -> Option<&[u8]> { self.brushes - .get(name) + .get(id) .and_then(|b| b.thumbnail_png.as_deref()) } - /// 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. + /// 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 + } + None => false, + } + } + + pub fn dab_thumbnail_png(&self, id: &str) -> Option<&[u8]> { + self.dab_thumbnails.get(id).map(|v| v.as_slice()) + } + + pub fn set_dab_thumbnail(&mut self, id: &str, png: Vec) { + self.dab_thumbnails.insert(id.to_string(), png); + } + + /// 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; @@ -107,98 +285,175 @@ impl BrushLibrary { 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()) + // ---- packs ---- + + pub fn packs(&self) -> impl Iterator { + self.packs.values() } - /// 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); + pub fn pack(&self, id: &str) -> Option<&BrushPack> { + self.packs.get(id) } - /// 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) { - Some(brush) => { - brush.thumbnail_png = Some(png); - true - } - None => false, + 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")); } + 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(()) } - /// 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) + /// Change a pack's name, description, icon or colors. + pub fn edit_pack( + &mut self, + id: &str, + name: &str, + 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(()) } - /// 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() + /// 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(()) } - /// Number of brushes in the library. - pub fn len(&self) -> usize { - self.brushes.len() + /// 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 is_empty(&self) -> bool { - self.brushes.is_empty() + pub fn remove_from_pack(&mut self, pack: &str, brush: &str) -> Result<(), String> { + self.pack_mut(pack)?.remove(brush) } - /// 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 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)); } - Ok(count) - } - /// Save a brush to a directory as `.darkly-brush`. - #[cfg(not(target_arch = "wasm32"))] - pub fn save_to_directory( - &self, - 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) + 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 +463,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 +533,356 @@ 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 Favorites or 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)); + } - let exported = lib.export_bytes("Roundtrip").unwrap(); - let reloaded = Brush::from_bytes(&exported).unwrap(); - assert_eq!(reloaded.metadata.name, "Roundtrip"); + #[test] + fn favorites_accepts_a_shipped_brush() { + let mut lib = BrushLibrary::builtin(); + let member = lib.pack("basic").unwrap().members[0].clone(); + lib.add_to_pack("favorites", &member).unwrap(); + assert!(lib.pack("favorites").unwrap().contains(&member)); + assert!(lib.pack("basic").unwrap().contains(&member)); } #[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 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(); - assert!(lib.remove("ToRemove")); - assert!(lib.is_empty()); - assert!(!lib.remove("ToRemove")); + assert!(lib.add_to_pack("basic", "mine").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 removing_from_a_locked_pack_is_rejected() { + let mut lib = BrushLibrary::builtin(); + let before = lib.pack("basic").unwrap().members.clone(); - // Also write a non-brush file (should be ignored). - std::fs::write(dir.join("readme.txt"), "not a brush").unwrap(); + assert!(lib.remove_from_pack("basic", &before[0]).is_err()); + assert_eq!(lib.pack("basic").unwrap().members, before); + } + + #[test] + 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")); + } + #[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()); + + // Favorites is shipped and its members are the painter's, but its + // identity — including its existence — is not. + assert!(lib.delete_pack("favorites").is_err()); + } + + #[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(); + + assert!(lib.delete_brush("a")); + + 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"), "deleting twice is not an error"); + } + + #[test] + fn deleting_a_brush_clears_it_from_a_locked_pack_too() { + // Not an edit to the pack — the library declining to name something + // that no longer exists. + let mut lib = BrushLibrary::builtin(); + let member = lib.pack("basic").unwrap().members[0].clone(); + + assert!(lib.delete_brush(&member)); + 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"); + } - let _ = std::fs::remove_dir_all(&dir); + #[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()); + } + + #[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"); + 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..1ec74804 --- /dev/null +++ b/crates/darkly/src/brush/metadata.rs @@ -0,0 +1,137 @@ +//! 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>, +} + +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 from metadata. + pub fn from_metadata(metadata: BrushMetadata) -> Self { + Brush { + metadata, + thumbnail_png: None, + } + } + + 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/pack.rs b/crates/darkly/src/brush/pack.rs new file mode 100644 index 00000000..f6db5156 --- /dev/null +++ b/crates/darkly/src/brush/pack.rs @@ -0,0 +1,359 @@ +//! 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 "Favorites is the built-in the painter may +/// fill" a fact of one line of YAML 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 and fixed: no brush may be added or removed, and the pack may + /// not be renamed, restyled or deleted. + #[default] + Locked, + /// Shipped, but the painter chooses what is in it. Favorites. + Members, + /// 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::Members | 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 | PackMutability::Members => 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::Members, true, 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 favorites_takes_members_but_not_a_rename() { + let mut p = pack(PackMutability::Members); + p.add("c".into()).unwrap(); + assert_eq!(p.members, vec!["a", "b", "c"]); + assert!(p.ensure_identity_editable().is_err()); + } + + #[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..6ce3fd88 --- /dev/null +++ b/crates/darkly/src/brush/packs.rs @@ -0,0 +1,222 @@ +//! 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, + /// Defaults to [`PackMutability::Locked`]: a shipped pack is fixed unless + /// it says otherwise, so Favorites is the one file that has to declare + /// anything. + #[serde(default)] + mutability: PackMutability, + #[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, + mutability: y.mutability, + 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_except_favorites() { + // The immutability rule stated once, in data: Favorites is the one + // built-in whose members the painter chooses, and no shipped pack is + // fully editable. + for pack in docs() { + let expected = if pack.id == "favorites" { + PackMutability::Members + } else { + PackMutability::Locked + }; + assert_eq!(pack.mutability, expected, "`{}` mutability", pack.id); + } + } + + #[test] + fn favorites_ships_empty() { + let favorites = docs() + .iter() + .find(|p| p.id == "favorites") + .expect("a Favorites pack ships"); + assert!( + favorites.members.is_empty(), + "Favorites is the painter's to fill" + ); + } + + #[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/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/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/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..08673f9e 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,56 @@ 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 metadata = BrushMetadata::from_graph(id, name, self.active_brush_graph()); + library::with_mut(|lib| lib.insert(Brush::from_metadata(metadata))); // 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 +115,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 +124,105 @@ 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) + .then_some(()) + .ok_or_else(|| format!("brush '{id}' not found")) + }) + } + + /// 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 +231,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 +274,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/mod.rs b/crates/darkly/src/engine/mod.rs index 6a4d29e2..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(), 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/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_packs.rs b/crates/darkly/tests/brush_packs.rs new file mode 100644 index 00000000..f3179c53 --- /dev/null +++ b/crates/darkly/tests/brush_packs.rs @@ -0,0 +1,321 @@ +//! 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", + "favorites", + ] { + 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); + + let favorites = snap.packs.iter().find(|p| p.id == "favorites").unwrap(); + assert!( + favorites.can_edit_members, + "Favorites is the painter's to fill" + ); + assert!( + !favorites.can_edit_identity, + "but not theirs to rename or delete" + ); +} + +#[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_save_rejects_an_empty_id() { + let mut engine = fresh_engine(); + assert!(engine.brush_save(" ", "Nameless").is_err()); +} + +#[test] +fn a_shipped_brush_can_be_copied_into_favorites() { + let mut engine = fresh_engine(); + engine.pack_add_brush("favorites", "ink_pen").unwrap(); + + let snap = engine.library_list(); + let favorites = snap.packs.iter().find(|p| p.id == "favorites").unwrap(); + let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); + + assert!(favorites.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 + .pack_create("mine", "Mine", "", "mdi:water", "#3355ff", "#ffffff") + .unwrap(); + engine.pack_add_brush("mine", "ink_pen").unwrap(); + let bytes = engine.pack_export("mine").unwrap(); + + let before = engine.library_list().brushes.len(); + engine.brush_rename("ink_pen", "My Ink Pen").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 == "ink_pen") + .unwrap() + .name, + "My Ink Pen", + "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(); + let before = engine + .library_list() + .packs + .iter() + .find(|p| p.id == "basic") + .unwrap() + .members + .clone(); + + engine.brush_rename("ink_pen", "Fancy Nib").unwrap(); + + let snap = engine.library_list(); + let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); + assert_eq!(basic.members, before, "membership is id-keyed"); + assert_eq!( + snap.brushes + .iter() + .find(|b| b.id == "ink_pen") + .unwrap() + .name, + "Fancy Nib" + ); +} + +#[test] +fn deleting_a_brush_removes_it_from_every_pack_through_the_engine() { + let mut engine = fresh_engine(); + engine.pack_add_brush("favorites", "ink_pen").unwrap(); + + engine.brush_delete("ink_pen").unwrap(); + + let snap = engine.library_list(); + assert!(!snap.brushes.iter().any(|b| b.id == "ink_pen")); + for pack in &snap.packs { + assert!( + !pack.members.contains(&"ink_pen".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_rename("ink_pen", "Fancy Nib").unwrap(); + engine.brush_load("Fancy Nib").unwrap(); + assert!( + engine.brush_load("Ink Pen").is_err(), + "the old name no longer resolves" + ); +} 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/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4616d845b7548d681930eece7438da0986b3dc8f GIT binary patch literal 8776 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 hHI!Kw+O@11ba-#s + 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/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..b585471e 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,7 +169,15 @@ 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 @@ -180,12 +190,12 @@ export type BrushLoadReq = { name: string, }; export type BrushNodePreviewReq = { node_id: string, }; +export type BrushWireType = "Scalar" | "Int" | "Bool" | "Vec2" | "Vec4" | "Enum" | "String" | "Curve"; + 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). @@ -449,7 +459,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, }; @@ -501,14 +513,6 @@ 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 ParamInfo = { kind: string, name: string, /** * Display label. `None` → the UI title-cases `name`. @@ -526,6 +530,14 @@ widget: string, unit: UnitType, min: number | null, max: number | null, default: */ options: JsonValue | null, display: ParamDisplay, }; +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 Catalog = { id: string, title: string, description: string | null, icon: string | null, /** * Presentation order, for catalogs that declare one. Registry catalogs do @@ -581,6 +593,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 +656,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 +689,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 +879,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 +901,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 +961,7 @@ export type RequestKind = | 'last_picked_color' | 'layer_transform_capability' | 'layer_tree' + | 'library_list' | 'list_fonts' | 'mark_dirty' | 'mask_to_selection' @@ -928,6 +973,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 +1079,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 +1101,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 +1161,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 +1173,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 +1287,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 +1309,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 +1369,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 +1381,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 +1489,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 +1511,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 +1571,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 +1583,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 b55cf057..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. -// 119 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-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},"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-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: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/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/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/state/__tests__/brush_library_store.test.ts b/frontend/src/state/__tests__/brush_library_store.test.ts new file mode 100644 index 00000000..a6a3e968 --- /dev/null +++ b/frontend/src/state/__tests__/brush_library_store.test.ts @@ -0,0 +1,312 @@ +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_nothing', async () => { + // Shipped brushes and packs come back from YAML every boot; storing a + // copy would shadow them. + await store.hydrate(); + expect(s.paths('')).toEqual([]); + }); + + 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(); + + expect(s.paths('packs/')).toEqual(['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(); + store.persistPack('basic'); + await store.flush(); + + expect(s.paths('packs/')).toEqual([]); + }); + + 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..ebc776ae --- /dev/null +++ b/frontend/src/state/__tests__/foreground_recording.test.ts @@ -0,0 +1,78 @@ +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'); + + 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'); + + app.engine = fakeEngine(false); + await state.loadBrush('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/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..b05f4295 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,14 +344,18 @@ 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); } else { @@ -408,13 +411,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; @@ -502,6 +498,10 @@ export class BrushGraphState { return; } this.activeBrush = name; + // The single funnel every brush selection passes through, and only + // reached on a successful load — a brush that failed to load was + // never used. + recentBrushes.use(name); // `fetchGraph` begins a new layout generation atomically with the // graph swap, so the canvas effect re-runs auto-layout for the // freshly-loaded graph. diff --git a/frontend/src/state/brush_library.svelte.ts b/frontend/src/state/brush_library.svelte.ts new file mode 100644 index 00000000..0dd8c986 --- /dev/null +++ b/frontend/src/state/brush_library.svelte.ts @@ -0,0 +1,277 @@ +/** + * 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, + * so a fresh install writes nothing at all. + * + * 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'; + +/** 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(); + } + + // ---- 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/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/ColorPicker.svelte b/frontend/src/ui/ColorPicker.svelte index e5c43c3c..bb53da39 100644 --- a/frontend/src/ui/ColorPicker.svelte +++ b/frontend/src/ui/ColorPicker.svelte @@ -1,5 +1,6 @@
{ if (e.key === 'Escape') onclose(); }} role="dialog" tabindex="-1"> diff --git a/frontend/src/ui/PackExportModal.svelte b/frontend/src/ui/PackExportModal.svelte new file mode 100644 index 00000000..6758a669 --- /dev/null +++ b/frontend/src/ui/PackExportModal.svelte @@ -0,0 +1,81 @@ + + + + +
+ {#each brushLibrary.packs as pack (pack.id)} + + {/each} +
+
+ + diff --git a/frontend/src/ui/brush_picker/BrushPicker.svelte b/frontend/src/ui/brush_picker/BrushPicker.svelte index 7c48670c..50d7d2cf 100644 --- a/frontend/src/ui/brush_picker/BrushPicker.svelte +++ b/frontend/src/ui/brush_picker/BrushPicker.svelte @@ -2,8 +2,12 @@ import { tick } from 'svelte'; import { brushGraph } from '../../state/brush_graph.svelte'; import type { BrushInfo } from '../../state/brush_graph.svelte'; + import { brushLibrary } from '../../state/brush_library.svelte'; + import Icon from '../../icons/Icon.svelte'; + import { packIcon, PACK_ICON_FALLBACK } from '../../lib/packIcon'; import BrushTile from './BrushTile.svelte'; import { brushPickerPlacement } from './placement'; + import { groupByPack, matchesQuery, packNamesByBrush } from './grouping'; interface Props { onSelect: (brush: BrushInfo) => void; @@ -52,46 +56,29 @@ let searchInput: HTMLInputElement | undefined = $state(); let highlightIndex = $state(0); - /** Whitespace-tokenized substring match — `"soft round"` matches - * "Soft Round" but `"soft xxx"` does not. Searches across name, - * category, and tags so users can find brushes by any facet. */ - function matches(brush: BrushInfo, q: string): boolean { - if (!q) return true; - const haystack = ( - brush.name + - ' ' + - brush.category + - ' ' + - (brush.tags || []).join(' ') - ).toLowerCase(); - const tokens = q.toLowerCase().trim().split(/\s+/).filter(t => t.length > 0); - return tokens.every(t => haystack.includes(t)); - } + /** Pack names per brush, for search. */ + const packNames = $derived(packNamesByBrush(brushLibrary.packs)); const filtered = $derived( - brushGraph.brushes.filter(b => matches(b, query)) + brushLibrary.brushes.filter(b => matchesQuery(b, query, packNames)) ); - /** Group filtered brushes by category, preserving first-seen order - * for both groups and members. Empty categories collapse into - * "Uncategorised" so loose brushes always have a home. */ - const groups = $derived.by(() => { - const map = new Map(); - for (const brush of filtered) { - const key = brush.category || 'Uncategorised'; - const existing = map.get(key); - if (existing) { - existing.push(brush); - } else { - map.set(key, [brush]); - } - } - return [...map.entries()].map(([category, brushes]) => ({ category, brushes })); - }); + /** Brushes grouped under their packs, plus a trailing "in no pack" + * section. See `grouping.ts`. */ + const groups = $derived( + groupByPack(filtered, brushLibrary.packs, packIcon, PACK_ICON_FALLBACK) + ); + + /** The rendered cells, in render order. + * + * Keyboard navigation indexes *this*, not `filtered`: a brush in two packs + * renders in two cells, so a flat index into the filter would highlight + * the wrong one. */ + const cells = $derived(groups.flatMap(g => g.brushes)); // Keep the keyboard highlight in range as the filter changes. $effect(() => { - const len = filtered.length; + const len = cells.length; if (highlightIndex >= len) highlightIndex = Math.max(0, len - 1); }); @@ -109,7 +96,7 @@ return; } const cols = 2; // matches grid-template-columns: repeat(2, 1fr) - const len = filtered.length; + const len = cells.length; if (len === 0) return; switch (e.key) { case 'ArrowDown': @@ -130,7 +117,7 @@ break; case 'Enter': e.preventDefault(); - if (filtered[highlightIndex]) onSelect(filtered[highlightIndex]); + if (cells[highlightIndex]) onSelect(cells[highlightIndex]); break; } } @@ -163,16 +150,22 @@
No brushes match “{query}”.
{:else}
- {#each groups as group, gi (group.category)} + {#each groups as group, gi (group.id)} {@const offset = groups .slice(0, gi) .reduce((sum, g) => sum + g.brushes.length, 0)}
- {group.category} + + + {group.label}
- {#each group.brushes as brush, bi (brush.name)} + {#each group.brushes as brush, bi (brush.id)}
icon; +const FALLBACK = 'fa6-solid:folder'; + +describe('groupByPack', () => { + it('a_brush_in_two_packs_renders_in_both_groups', () => { + const ink = brush('ink_pen', 'Ink Pen'); + const groups = groupByPack( + [ink], + [pack('basic', 'Basic', ['ink_pen']), pack('favorites', 'Favorites', ['ink_pen'])], + asIs, + FALLBACK, + ); + + expect(groups.map(g => g.id)).toEqual(['basic', 'favorites']); + expect(groups[0].brushes).toEqual([ink]); + expect(groups[1].brushes).toEqual([ink]); + }); + + it('a_pack_with_no_visible_members_renders_nothing', () => { + // What keeps an empty Favorites from showing as a broken heading. + const groups = groupByPack( + [brush('a')], + [pack('basic', 'Basic', ['a']), pack('favorites', 'Favorites', [])], + asIs, + FALLBACK, + ); + expect(groups.map(g => g.id)).toEqual(['basic']); + }); + + it('a_pack_whose_members_are_all_filtered_out_renders_nothing', () => { + const groups = groupByPack( + [brush('a')], + [pack('p1', 'One', ['a']), pack('p2', 'Two', ['b'])], + asIs, + FALLBACK, + ); + expect(groups.map(g => g.id)).toEqual(['p1']); + }); + + it('brushes_in_no_pack_render_in_their_own_section', () => { + const groups = groupByPack( + [brush('a'), brush('loose')], + [pack('p1', 'One', ['a'])], + asIs, + FALLBACK, + ); + expect(groups.map(g => g.label)).toEqual(['One', NO_PACK_LABEL]); + expect(groups[1].brushes.map(b => b.id)).toEqual(['loose']); + expect(groups[1].id).toBe(''); + }); + + it('there_is_no_empty_no_pack_section_when_every_brush_is_grouped', () => { + const groups = groupByPack([brush('a')], [pack('p1', 'One', ['a'])], asIs, FALLBACK); + expect(groups).toHaveLength(1); + }); + + it('groups_follow_pack_order_and_members_follow_member_order', () => { + const groups = groupByPack( + [brush('x'), brush('y'), brush('z')], + [pack('second', 'Second', ['z', 'x']), pack('first', 'First', ['y'])], + asIs, + FALLBACK, + ); + expect(groups.map(g => g.id)).toEqual(['second', 'first']); + expect(groups[0].brushes.map(b => b.id)).toEqual(['z', 'x']); + }); + + it('a_member_naming_a_brush_that_is_gone_is_skipped', () => { + const groups = groupByPack([brush('a')], [pack('p1', 'One', ['a', 'ghost'])], asIs, FALLBACK); + expect(groups[0].brushes.map(b => b.id)).toEqual(['a']); + }); + + it('the_icon_is_run_through_the_resolver', () => { + const groups = groupByPack( + [brush('a')], + [pack('p1', 'One', ['a'])], + () => 'resolved:icon', + FALLBACK, + ); + expect(groups[0].icon).toBe('resolved:icon'); + }); +}); + +describe('keyboard cell indexing', () => { + it('keyboard_navigation_indexes_rendered_cells', () => { + // A brush in two packs renders twice, so the flat cell list is longer + // than the filtered brush list. Indexing the filter would highlight + // the wrong cell. + const filtered = [brush('ink_pen', 'Ink Pen'), brush('charcoal', 'Charcoal')]; + const groups = groupByPack( + filtered, + [ + pack('basic', 'Basic', ['ink_pen']), + pack('dry', 'Dry Media', ['charcoal']), + pack('favorites', 'Favorites', ['ink_pen']), + ], + asIs, + FALLBACK, + ); + const cells = groups.flatMap(g => g.brushes); + + expect(cells.map(b => b.id)).toEqual(['ink_pen', 'charcoal', 'ink_pen']); + expect(cells).toHaveLength(3); + expect(filtered).toHaveLength(2); + + // The last cell is the Favorites copy of Ink Pen — under the old flat + // indexing, index 2 did not exist at all. + expect(cells[2].id).toBe('ink_pen'); + }); +}); + +describe('search', () => { + const packs = [pack('wet', 'Wet Media', ['rw'])]; + const names = packNamesByBrush(packs); + + it('an_empty_query_matches_everything', () => { + expect(matchesQuery(brush('rw', 'Rough Watercolor'), '', names)).toBe(true); + expect(matchesQuery(brush('rw', 'Rough Watercolor'), ' ', names)).toBe(true); + }); + + it('matches_on_name_tokens_in_any_order', () => { + const b = brush('rw', 'Rough Watercolor'); + expect(matchesQuery(b, 'rough water', names)).toBe(true); + expect(matchesQuery(b, 'water rough', names)).toBe(true); + expect(matchesQuery(b, 'rough xxx', names)).toBe(false); + }); + + it('matches_on_pack_name', () => { + // Searching "wet" should find the brushes in Wet Media — the facet + // that moved from the deleted `category` field onto the pack. + expect(matchesQuery(brush('rw', 'Rough Watercolor'), 'wet', names)).toBe(true); + expect(matchesQuery(brush('other', 'Other'), 'wet', names)).toBe(false); + }); + + it('matches_on_tags', () => { + const b = brush('rw', 'Rough Watercolor', ['textured']); + expect(matchesQuery(b, 'textured', names)).toBe(true); + }); +}); + +describe('packNamesByBrush', () => { + it('collects_every_pack_a_brush_is_in', () => { + const map = packNamesByBrush([ + pack('basic', 'Basic', ['ink_pen']), + pack('favorites', 'Favorites', ['ink_pen', 'charcoal']), + ]); + expect(map.get('ink_pen')).toEqual(['Basic', 'Favorites']); + expect(map.get('charcoal')).toEqual(['Favorites']); + expect(map.get('nope')).toBeUndefined(); + }); +}); diff --git a/frontend/src/ui/brush_picker/grouping.ts b/frontend/src/ui/brush_picker/grouping.ts new file mode 100644 index 00000000..9c11a1d0 --- /dev/null +++ b/frontend/src/ui/brush_picker/grouping.ts @@ -0,0 +1,116 @@ +/** + * How the brush picker lays brushes out under their packs. + * + * Pure, so it is testable without a DOM — the same reason `placement.ts` sits + * beside the component rather than inside it. + * + * A brush may be in any number of packs and renders under each of them: packs + * are groupings, not folders. That is what makes the rendered cell list differ + * from the filtered brush list, and why keyboard navigation must index the + * cells rather than the filter. + */ +import type { BrushInfo, BrushPackInfo } from '../../engine/protocol_gen'; + +/** Label shown over the brushes no pack holds. */ +export const NO_PACK_LABEL = 'In no pack'; + +export interface BrushGroup { + /** The pack's id, or `''` for the derived "in no pack" section. */ + id: string; + label: string; + icon: string; + primary: string; + secondary: string; + brushes: BrushInfo[]; +} + +/** + * Group `filtered` by pack, in the packs' own order, then append the brushes + * no pack holds. + * + * A pack with no visible members yields no group at all — which is what keeps + * an empty Favorites from rendering as a broken-looking empty heading, and + * what hides packs the current search excludes entirely. + * + * `resolveIcon` maps a pack's declared icon to one the renderer actually has; + * an imported pack may name anything. + */ +export function groupByPack( + filtered: BrushInfo[], + packs: BrushPackInfo[], + resolveIcon: (icon: string) => string, + noPackIcon: string, +): BrushGroup[] { + const visible = new Map(filtered.map(b => [b.id, b])); + const out: BrushGroup[] = []; + const grouped = new Set(); + + for (const pack of packs) { + const brushes: BrushInfo[] = []; + for (const id of pack.members) { + const brush = visible.get(id); + if (!brush) continue; + brushes.push(brush); + grouped.add(id); + } + if (brushes.length === 0) continue; + out.push({ + id: pack.id, + label: pack.name, + icon: resolveIcon(pack.icon), + primary: pack.primary, + secondary: pack.secondary, + brushes, + }); + } + + // The complement of the one membership relation, computed rather than + // stored — a brush does not depend on a pack to exist. + const loose = filtered.filter(b => !grouped.has(b.id)); + if (loose.length > 0) { + out.push({ + id: '', + label: NO_PACK_LABEL, + icon: noPackIcon, + primary: 'transparent', + secondary: 'transparent', + brushes: loose, + }); + } + return out; +} + +/** Every brush id mapped to the names of the packs holding it. Membership + * lives on the pack, so searching by pack name means reading it from that + * side. */ +export function packNamesByBrush(packs: BrushPackInfo[]): Map { + const map = new Map(); + for (const pack of packs) { + for (const member of pack.members) { + const existing = map.get(member); + if (existing) existing.push(pack.name); + else map.set(member, [pack.name]); + } + } + return map; +} + +/** Whitespace-tokenized substring match — `"soft round"` matches "Soft Round" + * but `"soft xxx"` does not. Searches name, the packs a brush is in, and its + * tags, so a brush is findable by any facet. */ +export function matchesQuery( + brush: BrushInfo, + query: string, + packNames: Map, +): boolean { + const tokens = query.toLowerCase().trim().split(/\s+/).filter(t => t.length > 0); + if (tokens.length === 0) return true; + const haystack = [ + brush.name, + ...(packNames.get(brush.id) ?? []), + ...(brush.tags ?? []), + ] + .join(' ') + .toLowerCase(); + return tokens.every(t => haystack.includes(t)); +} diff --git a/frontend/wasm/src/config_bridge.rs b/frontend/wasm/src/config_bridge.rs index 9e74e2f0..0057e841 100644 --- a/frontend/wasm/src/config_bridge.rs +++ b/frontend/wasm/src/config_bridge.rs @@ -78,7 +78,11 @@ pub fn config_kind_is_int(key: &str) -> bool { /// Get the full preferences schema as JSON: one `Catalog` per settings /// section, each holding a single entry whose `params` are that section's /// prefs as `ParamInfo` — the same shape the effect panels consume. Sorted by -/// section order. Prefs marked `Hidden` are not settings and are omitted. +/// section order. +/// +/// Carries every declared pref, including those marked `Hidden`, which arrive +/// with `widget: "hidden"` for the UI to skip. This is the schema stored prefs +/// are validated against, so omitting one would erase it from disk on reload. #[wasm_bindgen] pub fn config_schema() -> String { serde_json::to_string(&darkly::catalog::settings_catalogs()).unwrap_or_else(|_| "[]".into()) From 71759099dea0223226d8aa0e5466346af8ef5711 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 25 Aug 2026 01:06:40 -0400 Subject: [PATCH 09/13] brush explorer prereqs --- crates/darkly/packs/favorites.yaml | 7 - crates/darkly/src/brush/builtin_brushes.rs | 7 +- crates/darkly/src/brush/library.rs | 111 +++++++------ crates/darkly/src/brush/metadata.rs | 23 ++- crates/darkly/src/brush/pack.rs | 23 +-- crates/darkly/src/brush/packs.rs | 42 ++--- crates/darkly/src/engine/brush_library.rs | 16 +- crates/darkly/tests/brush_packs.rs | 109 ++++++++----- crates/darkly/tests/brush_preview_staging.rs | 2 +- frontend/src/engine/protocol_gen.ts | 36 +++-- .../__tests__/brush_library_store.test.ts | 79 ++++++++- .../__tests__/foreground_recording.test.ts | 14 +- .../state/__tests__/recents_identity.test.ts | 82 ++++++++++ frontend/src/state/brush_graph.svelte.ts | 34 ++-- frontend/src/state/brush_library.svelte.ts | 40 ++++- frontend/src/ui/BrushOptions.svelte | 2 +- frontend/src/ui/IconPicker.svelte | 86 ++++++++++ frontend/src/ui/SaveBrushModal.svelte | 150 ++++++++++++++++++ .../brush_builder/BrushBarEntryModal.svelte | 58 +------ .../src/ui/brush_builder/BrushBuilder.svelte | 8 + .../__tests__/saveTarget.test.ts | 38 +++++ frontend/src/ui/brush_builder/saveTarget.ts | 34 ++++ 22 files changed, 761 insertions(+), 240 deletions(-) delete mode 100644 crates/darkly/packs/favorites.yaml create mode 100644 frontend/src/state/__tests__/recents_identity.test.ts create mode 100644 frontend/src/ui/IconPicker.svelte create mode 100644 frontend/src/ui/SaveBrushModal.svelte create mode 100644 frontend/src/ui/brush_builder/__tests__/saveTarget.test.ts create mode 100644 frontend/src/ui/brush_builder/saveTarget.ts diff --git a/crates/darkly/packs/favorites.yaml b/crates/darkly/packs/favorites.yaml deleted file mode 100644 index 80b8a6ce..00000000 --- a/crates/darkly/packs/favorites.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: Favorites -description: The brushes you reach for most. -icon: fa6-solid:star -primary: "#f5c542" -secondary: "#2b2213" -mutability: members -members: [] diff --git a/crates/darkly/src/brush/builtin_brushes.rs b/crates/darkly/src/brush/builtin_brushes.rs index 29a4a9b6..618e8b16 100644 --- a/crates/darkly/src/brush/builtin_brushes.rs +++ b/crates/darkly/src/brush/builtin_brushes.rs @@ -35,7 +35,8 @@ fn parsed() -> Vec<(&'static str, Brush)> { .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")); let brush = portable .into_brush(registry, stem) - .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}")); + .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() @@ -263,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/library.rs b/crates/darkly/src/brush/library.rs index 66bc3824..9dad33c6 100644 --- a/crates/darkly/src/brush/library.rs +++ b/crates/darkly/src/brush/library.rs @@ -21,7 +21,7 @@ use std::collections::HashMap; use indexmap::IndexMap; -use super::metadata::{Brush, BrushMetadata}; +use super::metadata::Brush; use crate::brush::pack::{validate_pack, BrushId, BrushPack, PackId, PackMutability}; use crate::brush::pack_file::PackFile; @@ -41,10 +41,15 @@ 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(), @@ -52,6 +57,7 @@ impl From<&BrushMetadata> for BrushInfo { description: p.description.clone(), tags: p.tags.clone(), icon: crate::brush::graph_capabilities(&p.graph).preview_fallback_icon, + can_edit: b.can_edit(), } } } @@ -155,11 +161,7 @@ impl BrushLibrary { /// 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 } @@ -191,21 +193,52 @@ impl BrushLibrary { self.brushes.insert(brush.metadata.id.clone(), brush); } + /// 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(()) + } + /// 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. Returns whether the brush existed. - pub fn delete_brush(&mut self, id: &str) -> bool { - if self.brushes.shift_remove(id).is_none() { - return false; - } + /// 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); } - true + Ok(()) + } + + /// 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(()), + } } /// Rename a brush. No pack and no recents entry is touched, because both @@ -215,16 +248,8 @@ impl BrushLibrary { if new_name.is_empty() { return Err("a brush needs a name".into()); } - if !self.brushes.contains_key(id) { - return Err(format!("brush '{id}' not found")); - } - if self - .brushes - .values() - .any(|b| b.id() != id && b.name() == new_name) - { - return Err(format!("a brush named '{new_name}' already exists")); - } + 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(); } @@ -574,7 +599,7 @@ mod tests { #[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 Favorites or any pack the painter makes. + // into any pack the painter makes. let mut lib = BrushLibrary::builtin(); let locked = lib .packs() @@ -591,15 +616,6 @@ mod tests { assert!(lib.pack(&locked_id).unwrap().contains(&member)); } - #[test] - fn favorites_accepts_a_shipped_brush() { - let mut lib = BrushLibrary::builtin(); - let member = lib.pack("basic").unwrap().members[0].clone(); - lib.add_to_pack("favorites", &member).unwrap(); - assert!(lib.pack("favorites").unwrap().contains(&member)); - assert!(lib.pack("basic").unwrap().contains(&member)); - } - #[test] fn adding_to_a_locked_pack_is_rejected() { let mut lib = BrushLibrary::builtin(); @@ -642,10 +658,6 @@ mod tests { let mut lib = BrushLibrary::builtin(); assert!(lib.delete_pack("basic").is_err()); assert!(lib.pack("basic").is_some()); - - // Favorites is shipped and its members are the painter's, but its - // identity — including its existence — is not. - assert!(lib.delete_pack("favorites").is_err()); } #[test] @@ -658,23 +670,30 @@ mod tests { lib.add_to_pack("p1", "a").unwrap(); lib.add_to_pack("p2", "a").unwrap(); - assert!(lib.delete_brush("a")); + 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"), "deleting twice is not an error"); + assert!( + lib.delete_brush("a").is_err(), + "a brush that is gone cannot be deleted again" + ); } #[test] - fn deleting_a_brush_clears_it_from_a_locked_pack_too() { - // Not an edit to the pack — the library declining to name something - // that no longer exists. + 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.delete_brush(&member)); - assert!(!lib.pack("basic").unwrap().contains(&member)); + 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] @@ -863,7 +882,7 @@ mod tests { 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"); + lib.delete_brush("a").unwrap(); assert!(lib.dab_thumbnail_png("a").is_none()); } diff --git a/crates/darkly/src/brush/metadata.rs b/crates/darkly/src/brush/metadata.rs index 1ec74804..f1f8504d 100644 --- a/crates/darkly/src/brush/metadata.rs +++ b/crates/darkly/src/brush/metadata.rs @@ -50,6 +50,13 @@ pub struct Brush { /// 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 { @@ -77,14 +84,28 @@ impl BrushMetadata { } impl Brush { - /// Create a brush from metadata. + /// 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 } diff --git a/crates/darkly/src/brush/pack.rs b/crates/darkly/src/brush/pack.rs index f6db5156..de6d374f 100644 --- a/crates/darkly/src/brush/pack.rs +++ b/crates/darkly/src/brush/pack.rs @@ -23,17 +23,15 @@ 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 "Favorites is the built-in the painter may -/// fill" a fact of one line of YAML rather than a condition at a call site. +/// 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 and fixed: no brush may be added or removed, and the pack may - /// not be renamed, restyled or deleted. + /// Shipped curation: it is rebuilt from embedded YAML on every boot, so it + /// cannot hold an edit and none is accepted. #[default] Locked, - /// Shipped, but the painter chooses what is in it. Favorites. - Members, /// The painter's own. Everything about it is theirs. Full, } @@ -85,7 +83,7 @@ impl BrushPack { "brush pack '{}' is built in — its brushes cannot be changed", self.name )), - PackMutability::Members | PackMutability::Full => Ok(()), + PackMutability::Full => Ok(()), } } @@ -93,7 +91,7 @@ impl BrushPack { /// existence. pub fn ensure_identity_editable(&self) -> Result<(), String> { match self.mutability { - PackMutability::Locked | PackMutability::Members => Err(format!( + PackMutability::Locked => Err(format!( "brush pack '{}' is built in and cannot be renamed, restyled or deleted", self.name )), @@ -245,7 +243,6 @@ mod tests { // place to declare itself rather than three tests to be forgotten in. let cases = [ (PackMutability::Locked, false, false), - (PackMutability::Members, true, false), (PackMutability::Full, true, true), ]; for (mutability, members, identity) in cases { @@ -292,14 +289,6 @@ mod tests { assert_eq!(p.members, vec!["a", "b"]); } - #[test] - fn favorites_takes_members_but_not_a_rename() { - let mut p = pack(PackMutability::Members); - p.add("c".into()).unwrap(); - assert_eq!(p.members, vec!["a", "b", "c"]); - assert!(p.ensure_identity_editable().is_err()); - } - #[test] fn reorder_moves_a_member_and_clamps_the_index() { let mut p = pack(PackMutability::Full); diff --git a/crates/darkly/src/brush/packs.rs b/crates/darkly/src/brush/packs.rs index 6ce3fd88..f632c3be 100644 --- a/crates/darkly/src/brush/packs.rs +++ b/crates/darkly/src/brush/packs.rs @@ -29,11 +29,6 @@ struct PackYaml { icon: String, primary: String, secondary: String, - /// Defaults to [`PackMutability::Locked`]: a shipped pack is fixed unless - /// it says otherwise, so Favorites is the one file that has to declare - /// anything. - #[serde(default)] - mutability: PackMutability, #[serde(default)] members: Vec, } @@ -59,7 +54,10 @@ fn parsed() -> Vec { icon: y.icon, primary: y.primary, secondary: y.secondary, - mutability: y.mutability, + // 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) @@ -163,32 +161,20 @@ mod tests { } #[test] - fn shipped_packs_are_locked_except_favorites() { - // The immutability rule stated once, in data: Favorites is the one - // built-in whose members the painter chooses, and no shipped pack is - // fully editable. + 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() { - let expected = if pack.id == "favorites" { - PackMutability::Members - } else { - PackMutability::Locked - }; - assert_eq!(pack.mutability, expected, "`{}` mutability", pack.id); + assert_eq!( + pack.mutability, + PackMutability::Locked, + "`{}` mutability", + pack.id + ); } } - #[test] - fn favorites_ships_empty() { - let favorites = docs() - .iter() - .find(|p| p.id == "favorites") - .expect("a Favorites pack ships"); - assert!( - favorites.members.is_empty(), - "Favorites is the painter's to fill" - ); - } - #[test] fn every_shipped_pack_icon_is_a_pack_icon() { // What makes a shipped pack's icon render at all: the offline icon diff --git a/crates/darkly/src/engine/brush_library.rs b/crates/darkly/src/engine/brush_library.rs index 08673f9e..cc20f9ee 100644 --- a/crates/darkly/src/engine/brush_library.rs +++ b/crates/darkly/src/engine/brush_library.rs @@ -100,8 +100,16 @@ impl DarklyEngine { 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.insert(Brush::from_metadata(metadata))); + 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(); @@ -152,11 +160,7 @@ impl DarklyEngine { /// 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) - .then_some(()) - .ok_or_else(|| format!("brush '{id}' not found")) - }) + library::with_mut(|lib| lib.delete_brush(id)) } /// Create a brush pack under a caller-supplied id. diff --git a/crates/darkly/tests/brush_packs.rs b/crates/darkly/tests/brush_packs.rs index f3179c53..4adb8ad6 100644 --- a/crates/darkly/tests/brush_packs.rs +++ b/crates/darkly/tests/brush_packs.rs @@ -22,14 +22,7 @@ fn library_list_reports_every_shipped_pack_with_its_members() { 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", - "favorites", - ] { + for expected in ["basic", "dry_media", "wet_media", "effects", "misc"] { assert!(ids.contains(&expected), "pack '{expected}' is listed"); } @@ -56,16 +49,6 @@ fn pack_info_reports_permissions_matching_the_pack() { 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); - - let favorites = snap.packs.iter().find(|p| p.id == "favorites").unwrap(); - assert!( - favorites.can_edit_members, - "Favorites is the painter's to fill" - ); - assert!( - !favorites.can_edit_identity, - "but not theirs to rename or delete" - ); } #[test] @@ -86,6 +69,28 @@ fn brush_save_then_library_list_shows_it() { 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(); @@ -93,15 +98,34 @@ fn brush_save_rejects_an_empty_id() { } #[test] -fn a_shipped_brush_can_be_copied_into_favorites() { +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_add_brush("favorites", "ink_pen").unwrap(); + engine + .pack_create("mine", "Mine", "", "mdi:star", "#f5c542", "#2b2213") + .unwrap(); + engine.pack_add_brush("mine", "ink_pen").unwrap(); let snap = engine.library_list(); - let favorites = snap.packs.iter().find(|p| p.id == "favorites").unwrap(); + let mine = snap.packs.iter().find(|p| p.id == "mine").unwrap(); let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); - assert!(favorites.members.contains(&"ink_pen".to_string())); + 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" @@ -202,14 +226,15 @@ fn pack_export_import_round_trip_through_the_engine() { #[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", "ink_pen").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("ink_pen", "My Ink Pen").unwrap(); + engine.brush_rename("my_brush", "My Renamed Brush").unwrap(); engine.pack_import("theirs", &bytes).unwrap(); let snap = engine.library_list(); @@ -217,10 +242,10 @@ fn importing_a_pack_holding_a_brush_we_have_reuses_ours() { assert_eq!( snap.brushes .iter() - .find(|b| b.id == "ink_pen") + .find(|b| b.id == "my_brush") .unwrap() .name, - "My Ink Pen", + "My Renamed Brush", "our copy wins over the sender's" ); let theirs = snap.packs.iter().find(|p| p.id == "theirs").unwrap(); @@ -242,24 +267,29 @@ fn importing_corrupt_bytes_is_rejected_and_changes_nothing() { #[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 == "basic") + .find(|p| p.id == "mine") .unwrap() .members .clone(); - engine.brush_rename("ink_pen", "Fancy Nib").unwrap(); + engine.brush_rename("my_brush", "Fancy Nib").unwrap(); let snap = engine.library_list(); - let basic = snap.packs.iter().find(|p| p.id == "basic").unwrap(); - assert_eq!(basic.members, before, "membership is id-keyed"); + 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 == "ink_pen") + .find(|b| b.id == "my_brush") .unwrap() .name, "Fancy Nib" @@ -269,15 +299,19 @@ fn renaming_a_brush_leaves_pack_membership_intact() { #[test] fn deleting_a_brush_removes_it_from_every_pack_through_the_engine() { let mut engine = fresh_engine(); - engine.pack_add_brush("favorites", "ink_pen").unwrap(); + 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("ink_pen").unwrap(); + engine.brush_delete("my_brush").unwrap(); let snap = engine.library_list(); - assert!(!snap.brushes.iter().any(|b| b.id == "ink_pen")); + assert!(!snap.brushes.iter().any(|b| b.id == "my_brush")); for pack in &snap.packs { assert!( - !pack.members.contains(&"ink_pen".to_string()), + !pack.members.contains(&"my_brush".to_string()), "pack '{}' still names the deleted brush", pack.id ); @@ -312,10 +346,11 @@ fn brush_load_still_takes_a_name() { engine.brush_load("Ink Pen").unwrap(); assert!(engine.brush_load("No Such Brush").is_err()); - engine.brush_rename("ink_pen", "Fancy Nib").unwrap(); + 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("Ink Pen").is_err(), + 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/frontend/src/engine/protocol_gen.ts b/frontend/src/engine/protocol_gen.ts index b585471e..0e76c7ae 100644 --- a/frontend/src/engine/protocol_gen.ts +++ b/frontend/src/engine/protocol_gen.ts @@ -184,18 +184,18 @@ name: string, author: string, description: string, tags: Array, * 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 BrushWireType = "Scalar" | "Int" | "Bool" | "Vec2" | "Vec4" | "Enum" | "String" | "Curve"; - -export type InputValue = boolean | number | number | string | Array<[number, number]> | [number, number] | [number, number, number, number]; - -export type PortDir = "Input" | "Output"; - export type PortDef = { name: string, dir: PortDir, wire_type: BrushWireType, /** * Slider min when the port is disconnected (UI metadata only). @@ -388,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 @@ -511,7 +517,13 @@ supportsPreview: boolean, */ captureKind: CaptureKind | null, }; -export type CaptureKind = "camera" | "display" | "stream"; +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, /** @@ -530,13 +542,7 @@ widget: string, unit: UnitType, min: number | null, max: number | null, default: */ options: JsonValue | null, display: ParamDisplay, }; -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 CaptureKind = "camera" | "display" | "stream"; export type Catalog = { id: string, title: string, description: string | null, icon: string | null, /** diff --git a/frontend/src/state/__tests__/brush_library_store.test.ts b/frontend/src/state/__tests__/brush_library_store.test.ts index a6a3e968..5d0e016f 100644 --- a/frontend/src/state/__tests__/brush_library_store.test.ts +++ b/frontend/src/state/__tests__/brush_library_store.test.ts @@ -125,11 +125,16 @@ afterEach(() => { }); describe('brush library persistence', () => { - it('a_fresh_install_writes_nothing', async () => { + 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. + // 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(); - expect(s.paths('')).toEqual([]); + 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 () => { @@ -245,17 +250,81 @@ describe('brush library persistence', () => { store.persistPack('id-two'); await store.flush(); - expect(s.paths('packs/')).toEqual(['packs/id-one.json', 'packs/id-two.json']); + // 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([]); + 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 () => { diff --git a/frontend/src/state/__tests__/foreground_recording.test.ts b/frontend/src/state/__tests__/foreground_recording.test.ts index ebc776ae..8b06c7b7 100644 --- a/frontend/src/state/__tests__/foreground_recording.test.ts +++ b/frontend/src/state/__tests__/foreground_recording.test.ts @@ -55,24 +55,26 @@ describe('recording what was actually used', () => { const state = new BrushGraphState(); app.engine = fakeEngine(true); - await state.loadBrush('Ink Pen'); + 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'); + 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'); + await state.loadBrush('Ink Pen', 'ink_pen'); app.engine = fakeEngine(false); - await state.loadBrush('Nonexistent'); + 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'); + expect(recentBrushes.items).not.toContain('nonexistent'); + expect(recentBrushes.items[0]).toBe('ink_pen'); }); }); 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/brush_graph.svelte.ts b/frontend/src/state/brush_graph.svelte.ts index b05f4295..b32937da 100644 --- a/frontend/src/state/brush_graph.svelte.ts +++ b/frontend/src/state/brush_graph.svelte.ts @@ -357,7 +357,9 @@ export class BrushGraphState { const defaultBrush = 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. @@ -487,21 +489,34 @@ 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; - // The single funnel every brush selection passes through, and only - // reached on a successful load — a brush that failed to load was - // never used. - recentBrushes.use(name); // `fetchGraph` begins a new layout generation atomically with the // graph swap, so the canvas effect re-runs auto-layout for the // freshly-loaded graph. @@ -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 index 0dd8c986..cd81367b 100644 --- a/frontend/src/state/brush_library.svelte.ts +++ b/frontend/src/state/brush_library.svelte.ts @@ -4,8 +4,9 @@ * 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, - * so a fresh install writes nothing at all. + * 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, @@ -17,6 +18,7 @@ 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. */ @@ -176,6 +178,40 @@ export class BrushLibraryStore { } 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 ---- diff --git a/frontend/src/ui/BrushOptions.svelte b/frontend/src/ui/BrushOptions.svelte index 498832b0..e8f49b21 100644 --- a/frontend/src/ui/BrushOptions.svelte +++ b/frontend/src/ui/BrushOptions.svelte @@ -29,7 +29,7 @@ function selectBrush(brush: BrushInfo) { ensureInit(); - brushGraph.loadBrush(brush.name); + brushGraph.loadBrush(brush.name, brush.id); brushPickerOpen = false; } diff --git a/frontend/src/ui/IconPicker.svelte b/frontend/src/ui/IconPicker.svelte new file mode 100644 index 00000000..b4bb4587 --- /dev/null +++ b/frontend/src/ui/IconPicker.svelte @@ -0,0 +1,86 @@ + + +
+ {#if allowNone} + + {/if} + {#each BUNDLED_ICON_NAMES as name (name)} + + {/each} +
+ + diff --git a/frontend/src/ui/SaveBrushModal.svelte b/frontend/src/ui/SaveBrushModal.svelte new file mode 100644 index 00000000..2c56f0c8 --- /dev/null +++ b/frontend/src/ui/SaveBrushModal.svelte @@ -0,0 +1,150 @@ + + + +
+ + +
+ +
+ + {#if target} + + {/if} +
+
+
+ + diff --git a/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte b/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte index 5a4891d9..e791b5b8 100644 --- a/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte +++ b/frontend/src/ui/brush_builder/BrushBarEntryModal.svelte @@ -1,7 +1,7 @@ + + + + diff --git a/frontend/src/ui/brush_explorer/PackWheel.svelte b/frontend/src/ui/brush_explorer/PackWheel.svelte new file mode 100644 index 00000000..285cc22f --- /dev/null +++ b/frontend/src/ui/brush_explorer/PackWheel.svelte @@ -0,0 +1,84 @@ + + + +
+ {#each groups as group, i (group.id)} + onPick(i)} + /> + {/each} +
+ + diff --git a/frontend/src/ui/brush_explorer/__tests__/wheel.test.ts b/frontend/src/ui/brush_explorer/__tests__/wheel.test.ts new file mode 100644 index 00000000..c5364bfe --- /dev/null +++ b/frontend/src/ui/brush_explorer/__tests__/wheel.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; +import { + sectionAt, + listToWheel, + wheelToList, + scrollTopForSection, + focusedSection, + cardCurve, + listMax, + wheelMax, + type WheelGeometry, + type SectionExtent, +} from '../wheel'; + +/** Sections of deliberately uneven height, which is the whole point of a + * piecewise map: 100 / 400 / 100 against a uniform 60px card. */ +const SECTIONS: SectionExtent[] = [ + { id: 'a', top: 0, height: 100 }, + { id: 'b', top: 100, height: 400 }, + { id: 'c', top: 500, height: 100 }, +]; + +/** Both panes scrollable: 600px of list content in a 200px port (max 400), and + * 180px of wheel content in a 120px port (max 60). The scroll maxima are given + * rather than derived, as the component reads them from the DOM. */ +const G: WheelGeometry = { + cardAdvance: 60, + wheelViewport: 120, + listViewport: 200, + listScrollMax: 400, + wheelScrollMax: 60, + sections: SECTIONS, +}; + +/** A wheel whose cards fit, so it has no scroll range of its own. */ +const SHORT: WheelGeometry = { ...G, wheelViewport: 400, wheelScrollMax: 0 }; + +const EMPTY: WheelGeometry = { ...G, sections: [], listScrollMax: 0, wheelScrollMax: 0 }; + +describe('sectionAt', () => { + it('a boundary belongs to the section that starts there', () => { + expect(sectionAt(100, SECTIONS)).toEqual({ index: 1, fraction: 0 }); + expect(sectionAt(500, SECTIONS)).toEqual({ index: 2, fraction: 0 }); + }); + + it('clamps past either end rather than returning null', () => { + expect(sectionAt(-50, SECTIONS)).toEqual({ index: 0, fraction: 0 }); + expect(sectionAt(9999, SECTIONS)).toEqual({ index: 2, fraction: 1 }); + }); + + it('is null only when there are no sections', () => { + expect(sectionAt(0, [])).toBeNull(); + }); + + it('reports how far through a section it is', () => { + expect(sectionAt(300, SECTIONS)).toEqual({ index: 1, fraction: 0.5 }); + }); +}); + +describe('content and scroll extents', () => { + it('reports the measured range of each pane', () => { + expect(listMax(G)).toBe(400); + expect(wheelMax(G)).toBe(60); + }); + + it('a pane whose content fits has no scroll range', () => { + expect(wheelMax(SHORT)).toBe(0); + expect(listMax(EMPTY)).toBe(0); + }); + + it('never reports a negative range', () => { + // A scrollport measured mid-layout can report a content box smaller + // than its client box; the mapping must clamp rather than invert. + expect(listMax({ ...G, listScrollMax: -30 })).toBe(0); + expect(wheelMax({ ...G, wheelScrollMax: -30 })).toBe(0); + }); +}); + +describe('listToWheel', () => { + it('maps uneven sections onto uniform cards', () => { + // Centre of section 1 (list content 300) is card index 1.5, so the + // wheel wants 90 under its centre: 90 - 60 = 30. + const listScrollTop = 300 - G.listViewport / 2; + expect(listToWheel(listScrollTop, G)).toBeCloseTo(30, 5); + }); + + it('never decreases as the list scrolls down', () => { + let prev = -Infinity; + for (let y = 0; y <= listMax(G); y += 7) { + const w = listToWheel(y, G); + expect(w).toBeGreaterThanOrEqual(prev); + prev = w; + } + }); + + it('stays inside the wheel scroll range', () => { + for (let y = -200; y <= listMax(G) + 200; y += 11) { + const w = listToWheel(y, G); + expect(w).toBeGreaterThanOrEqual(0); + expect(w).toBeLessThanOrEqual(wheelMax(G)); + } + }); + + it('is constantly zero when the wheel needs no scrolling', () => { + // The one-group search result, and decision 11's inert case: with no + // scroll range the wheel simply does not move. + for (let y = 0; y <= listMax(SHORT); y += 25) { + expect(listToWheel(y, SHORT)).toBe(0); + } + }); + + it('returns zero rather than NaN with no sections', () => { + expect(listToWheel(0, EMPTY)).toBe(0); + expect(wheelToList(0, EMPTY)).toBe(0); + expect(listToWheel(50, EMPTY)).not.toBeNaN(); + }); +}); + +describe('wheelToList', () => { + it('round-trips on the interior, where neither pane is clamped', () => { + for (let y = 120; y <= 260; y += 5) { + expect(wheelToList(listToWheel(y, G), G)).toBeCloseTo(y, 5); + } + }); + + it('does not round-trip where a clamp binds, and that is the mapping', () => { + // A tall *first* section: the list's centre sits inside it while the + // wheel is already pinned at 0, so a whole range of list positions + // share one wheel position and the trip back cannot recover which. + // Asserting an unqualified round trip would be asserting a falsehood. + const TALL: WheelGeometry = { + cardAdvance: 60, + wheelViewport: 120, + listViewport: 200, + listScrollMax: 400, + wheelScrollMax: 60, + sections: [ + { id: 'a', top: 0, height: 400 }, + { id: 'b', top: 400, height: 100 }, + { id: 'c', top: 500, height: 100 }, + ], + }; + expect(listToWheel(0, TALL)).toBe(0); + expect(listToWheel(50, TALL)).toBe(0); + expect(wheelToList(0, TALL)).not.toBeCloseTo(50, 5); + }); + + it('round-trips exactly at both ends when no clamp binds', () => { + expect(wheelToList(listToWheel(0, G), G)).toBeCloseTo(0, 5); + expect(wheelToList(listToWheel(listMax(G), G), G)).toBeCloseTo(listMax(G), 5); + }); + + it('stays inside the list scroll range', () => { + for (let w = -50; w <= wheelMax(G) + 50; w += 3) { + const y = wheelToList(w, G); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(listMax(G)); + } + }); +}); + +describe('scrollTopForSection', () => { + it('aligns a section heading to the top of the list viewport', () => { + expect(scrollTopForSection(1, G)).toBe(100); + }); + + it('clamps the last section so it can still be reached', () => { + expect(scrollTopForSection(2, G)).toBe(400); + expect(scrollTopForSection(99, G)).toBe(400); + }); + + it('is zero with no sections', () => { + expect(scrollTopForSection(0, EMPTY)).toBe(0); + }); +}); + +describe('focusedSection', () => { + it('tracks the viewport centre, not its top', () => { + // scrollTop 0 shows content 0..200; its centre (100) is section 1. + expect(focusedSection(0, G)).toBe(1); + }); + + it('agrees with listToWheel about which section is focused', () => { + // The regression for a mapping anchored on the top while the highlight + // reads the centre: the highlighted card must be the one the wheel + // scrolled to. + for (let y = 0; y <= listMax(G); y += 13) { + const focused = focusedSection(y, G)!; + const wheelCentre = listToWheel(y, G) + G.wheelViewport / 2; + const cardUnderCentre = Math.floor(wheelCentre / G.cardAdvance); + // Equal except where the wheel is clamped at an end and cannot + // travel far enough to centre the focused card. + const clamped = listToWheel(y, G) === 0 || listToWheel(y, G) === wheelMax(G); + if (!clamped) expect(cardUnderCentre).toBe(focused); + } + }); + + it('is null with no sections', () => { + expect(focusedSection(0, EMPTY)).toBeNull(); + }); +}); + +describe('cardCurve', () => { + it('is flat at the scrollport centre', () => { + // Card 1 spans 60..120, centred at 90; a 120px port at scrollTop 30 is + // centred at 90 too. + const c = cardCurve(1, 30, G); + expect(c.t).toBeCloseTo(0, 5); + expect(c.rotateX).toBeCloseTo(0, 5); + expect(c.scale).toBeCloseTo(1, 5); + }); + + it('is symmetric about the centre', () => { + const above = cardCurve(0, 30, G); + const below = cardCurve(2, 30, G); + expect(above.t).toBeCloseTo(-below.t, 5); + expect(above.scale).toBeCloseTo(below.scale, 5); + expect(above.opacity).toBeCloseTo(below.opacity, 5); + }); + + it('never inverts or disappears a card entirely', () => { + for (let i = 0; i < 3; i++) { + for (let w = 0; w <= wheelMax(G); w += 5) { + const c = cardCurve(i, w, G); + expect(c.scale).toBeGreaterThan(0); + expect(c.opacity).toBeGreaterThan(0); + expect(Math.abs(c.t)).toBeLessThanOrEqual(1); + } + } + }); +}); diff --git a/frontend/src/ui/brush_explorer/wheel.ts b/frontend/src/ui/brush_explorer/wheel.ts new file mode 100644 index 00000000..5058ffc1 --- /dev/null +++ b/frontend/src/ui/brush_explorer/wheel.ts @@ -0,0 +1,167 @@ +/** + * How the pack wheel's position relates to the brush list's. + * + * Pure — no DOM, no `$state`, no reactive imports — so it is testable in + * Vitest's node environment. Same reason `grouping.ts` sits beside its + * component rather than inside it. + * + * The wheel has uniform cards; the list has sections whose heights follow their + * contents, so a 40-brush pack is ten times taller than a 4-brush one. The + * relation between them is therefore **piecewise linear**, with a knot at every + * section boundary: section `i`'s list extent maps onto the wheel's slot + * `[i·cardAdvance, (i+1)·cardAdvance)`. A uniform wheel that still points at + * the right place. + * + * **Everything anchors on the viewport centre.** Mapping a viewport *top* + * coordinate while highlighting the section under the *centre* puts the + * highlighted card half a list-viewport away from where the wheel is scrolled + * to, which for a tall list scrolls it out of view entirely. + */ + +/** One group's vertical extent within the list's scroll content, measured from + * the rendered DOM by the component. */ +export interface SectionExtent { + /** Group id: a pack id, `''` for "in no pack", `RECENTS_ID` for recents. */ + id: string; + /** Distance from the top of the scroll content to this section's top, px. */ + top: number; + /** Height, px. Always > 0 — `groupByPack` drops empty groups, so a zero + * here is a measurement fault, and these functions clamp rather than + * divide by it. */ + height: number; +} + +export interface WheelGeometry { + /** Uniform per-card advance (card height + gap), px. */ + cardAdvance: number; + /** The wheel scrollport's height, px. */ + wheelViewport: number; + /** The list scrollport's height, px. */ + listViewport: number; + /** + * How far each pane can actually scroll, read from the DOM. + * + * **Measured, not inferred.** Deriving these from `sections` looks + * equivalent and is not: a scrollport's real range includes padding, gaps + * and trailing space that section extents know nothing about, and any + * disagreement makes the mapping quietly stop moving the pane it drives + * (`wheelMax` reading 0 pins the wheel; a short `listMax` clamps every jump + * to the same place). If it comes from the DOM, it cannot drift from it. + */ + listScrollMax: number; + wheelScrollMax: number; + /** Sections in render order. Empty when the search matches nothing. */ + sections: SectionExtent[]; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +/** The furthest either pane can be scrolled. Zero when its content fits, which + * is what makes a short wheel inert rather than a special case. */ +export function listMax(g: WheelGeometry): number { + return Math.max(0, g.listScrollMax); +} +export function wheelMax(g: WheelGeometry): number { + return Math.max(0, g.wheelScrollMax); +} + +/** + * Which section contains list *content* coordinate `y`, and how far through it + * in `[0, 1)`. The shared core of both directions. + * + * Clamps at both ends: a `y` above the first section reads as its start, below + * the last as its end. `null` only when there are no sections at all. + */ +export function sectionAt( + y: number, + sections: SectionExtent[], +): { index: number; fraction: number } | null { + if (sections.length === 0) return null; + if (y < sections[0].top) return { index: 0, fraction: 0 }; + for (let i = 0; i < sections.length; i++) { + const s = sections[i]; + // A boundary belongs to the section that starts there. + if (y >= s.top && y < s.top + s.height) { + return { index: i, fraction: s.height > 0 ? (y - s.top) / s.height : 0 }; + } + } + return { index: sections.length - 1, fraction: 1 }; +} + +/** The list content coordinate currently under the list viewport's centre. */ +function listFocus(listScrollTop: number, g: WheelGeometry): number { + return listScrollTop + g.listViewport / 2; +} + +/** + * List scrollTop → the wheel scrollTop that puts the same section under the + * wheel's centre. + * + * Clamped into `[0, wheelMax]`, so when the wheel's cards fit their viewport + * this is constantly 0 and the wheel simply does not move. + */ +export function listToWheel(listScrollTop: number, g: WheelGeometry): number { + const at = sectionAt(listFocus(listScrollTop, g), g.sections); + if (!at) return 0; + const centre = (at.index + at.fraction) * g.cardAdvance; + return clamp(centre - g.wheelViewport / 2, 0, wheelMax(g)); +} + +/** + * The inverse of {@link listToWheel} on the interior where neither side is + * clamped. **Not a total inverse**: wherever either pane saturates, the round + * trip lands at the clamp instead of where it started, because the mapping is a + * compression (a tall section occupies one card either way). + */ +export function wheelToList(wheelScrollTop: number, g: WheelGeometry): number { + if (g.sections.length === 0) return 0; + const centre = wheelScrollTop + g.wheelViewport / 2; + const raw = g.cardAdvance > 0 ? centre / g.cardAdvance : 0; + const index = clamp(Math.floor(raw), 0, g.sections.length - 1); + const fraction = clamp(raw - index, 0, 1); + const s = g.sections[index]; + const listCentre = s.top + fraction * s.height; + return clamp(listCentre - g.listViewport / 2, 0, listMax(g)); +} + +/** The list scrollTop putting section `index`'s top at the top of the list + * viewport. What a tap on a card commands — tapping means "take me to this + * pack", so it aligns the heading rather than centring the section. */ +export function scrollTopForSection(index: number, g: WheelGeometry): number { + const s = g.sections[clamp(index, 0, g.sections.length - 1)]; + if (!s) return 0; + return clamp(s.top, 0, listMax(g)); +} + +/** The section under the list viewport's centre, for highlighting its card. + * `null` when there are no sections. */ +export function focusedSection(listScrollTop: number, g: WheelGeometry): number | null { + return sectionAt(listFocus(listScrollTop, g), g.sections)?.index ?? null; +} + +/** + * The rolodex transform for card `index` at a given wheel position. + * + * Derived arithmetically rather than from a per-card `getBoundingClientRect`, + * so styling every card costs no layout. `t` is the card centre's signed + * distance from the scrollport centre, normalized so ±1 is the scrollport edge. + */ +export function cardCurve( + index: number, + wheelScrollTop: number, + g: WheelGeometry, +): { t: number; rotateX: number; scale: number; opacity: number } { + const cardCentre = (index + 0.5) * g.cardAdvance; + const portCentre = wheelScrollTop + g.wheelViewport / 2; + const half = g.wheelViewport / 2; + const t = half > 0 ? clamp((cardCentre - portCentre) / half, -1, 1) : 0; + const away = Math.abs(t); + return { + t, + // Cards tilt away from the viewer toward the ends, the way a physical + // rolodex reads. + rotateX: -t * 34, + scale: 1 - away * 0.16, + opacity: 1 - away * 0.45, + }; +} diff --git a/frontend/src/ui/brush_library/__tests__/grouping.test.ts b/frontend/src/ui/brush_library/__tests__/grouping.test.ts index 556716a0..9a9628c5 100644 --- a/frontend/src/ui/brush_library/__tests__/grouping.test.ts +++ b/frontend/src/ui/brush_library/__tests__/grouping.test.ts @@ -4,7 +4,10 @@ import { groupByPack, matchesQuery, packNamesByBrush, + withRecents, NO_PACK_LABEL, + RECENTS_ID, + RECENTS_LABEL, } from '../grouping'; function brush(id: string, name = id, tags: string[] = []): BrushInfo { @@ -178,3 +181,50 @@ describe('packNamesByBrush', () => { expect(map.get('nope')).toBeUndefined(); }); }); + +describe('withRecents', () => { + const visible = [brush('a', 'Alpha'), brush('b', 'Beta'), brush('c', 'Gamma')]; + const base = groupByPack(visible, [pack('p', 'Pack', ['a', 'b', 'c'])], i => i, 'x'); + + it('prepends at most `limit`, newest first', () => { + const out = withRecents(base, ['c', 'b', 'a'], visible, 2, 'star'); + expect(out[0].id).toBe(RECENTS_ID); + expect(out[0].label).toBe(RECENTS_LABEL); + expect(out[0].brushes.map(b => b.id)).toEqual(['c', 'b']); + }); + + it('skips ids that no longer resolve rather than placeholding them', () => { + // A deleted brush, or one the current search excludes. + const out = withRecents(base, ['gone', 'a'], visible, 5, 'star'); + expect(out[0].brushes.map(b => b.id)).toEqual(['a']); + }); + + it('yields no group at all when nothing resolves', () => { + expect(withRecents(base, ['gone'], visible, 5, 'star')).toEqual(base); + expect(withRecents(base, [], visible, 5, 'star')).toEqual(base); + }); + + it('leaves the same brushes in their packs too', () => { + const out = withRecents(base, ['a'], visible, 5, 'star'); + expect(out[0].brushes.map(b => b.id)).toEqual(['a']); + expect(out[1].brushes.map(b => b.id)).toEqual(['a', 'b', 'c']); + }); + + it('carries no pack, so nothing about it is editable', () => { + const out = withRecents(base, ['a'], visible, 5, 'star'); + expect(out[0].pack).toBeNull(); + }); +}); + +describe('BrushGroup.pack', () => { + it('is the pack for a real group and null for a derived one', () => { + const p = pack('p', 'Pack', ['a']); + const groups = groupByPack([brush('a'), brush('loose')], [p], i => i, 'x'); + expect(groups[0].pack).toBe(p); + // The "in no pack" section is computed, not stored: it has no pack to + // ask about permissions, which is what stops a consumer from having to + // recognise its sentinel id. + expect(groups[1].label).toBe(NO_PACK_LABEL); + expect(groups[1].pack).toBeNull(); + }); +}); diff --git a/frontend/src/ui/brush_library/grouping.ts b/frontend/src/ui/brush_library/grouping.ts index 9c11a1d0..f1ef354a 100644 --- a/frontend/src/ui/brush_library/grouping.ts +++ b/frontend/src/ui/brush_library/grouping.ts @@ -15,15 +15,36 @@ import type { BrushInfo, BrushPackInfo } from '../../engine/protocol_gen'; export const NO_PACK_LABEL = 'In no pack'; export interface BrushGroup { - /** The pack's id, or `''` for the derived "in no pack" section. */ + /** The pack's id, `''` for the derived "in no pack" section, or + * {@link RECENTS_ID}. Use it as a list key, never to decide behaviour. */ id: string; label: string; icon: string; primary: string; secondary: string; brushes: BrushInfo[]; + /** + * The pack behind this group, or `null` for a derived one (Recents, "in no + * pack"). + * + * Carried here so a consumer never has to look a pack up by group id and + * branch on whether it found one — that is the consumer-side classification + * the Modularity Principle bans, and it is what the permission booleans on + * `BrushPackInfo` exist to make unnecessary. A card reads + * `group.pack?.can_edit_members` and never sees an id. + */ + pack: BrushPackInfo | null; } +/** Id of the pinned Recents group. + * + * Recents is **not** a pack: `recents.svelte.ts` is frontend-only and never + * crosses the wasm boundary, while packs are engine-owned and persisted. It is + * synthesized here so the list and the wheel can treat it like any other + * group, with `pack: null` marking that nothing may be edited about it. */ +export const RECENTS_ID = 'recents'; +export const RECENTS_LABEL = 'Recent'; + /** * Group `filtered` by pack, in the packs' own order, then append the brushes * no pack holds. @@ -61,6 +82,7 @@ export function groupByPack( primary: pack.primary, secondary: pack.secondary, brushes, + pack, }); } @@ -75,11 +97,53 @@ export function groupByPack( primary: 'transparent', secondary: 'transparent', brushes: loose, + pack: null, }); } return out; } +/** + * Prepend a Recents group of at most `limit` brushes, newest first. + * + * `recentIds` is newest-first and id-keyed. Ids that no longer resolve — a + * deleted brush, or one the current search excludes — are skipped rather than + * placeheld, and nothing resolving yields no group at all, the same rule + * `groupByPack` applies to an empty pack. That is what keeps an empty Recents + * from rendering as a broken heading on a first run. + * + * A brush here also appears under its packs, which is a third source of the + * duplicate-cell hazard this module's header warns about. + */ +export function withRecents( + groups: BrushGroup[], + recentIds: string[], + visible: BrushInfo[], + limit: number, + icon: string, +): BrushGroup[] { + const byId = new Map(visible.map(b => [b.id, b])); + const brushes: BrushInfo[] = []; + for (const id of recentIds) { + if (brushes.length >= limit) break; + const brush = byId.get(id); + if (brush) brushes.push(brush); + } + if (brushes.length === 0) return groups; + return [ + { + id: RECENTS_ID, + label: RECENTS_LABEL, + icon, + primary: 'transparent', + secondary: 'transparent', + brushes, + pack: null, + }, + ...groups, + ]; +} + /** Every brush id mapped to the names of the packs holding it. Membership * lives on the pack, so searching by pack name means reading it from that * side. */ diff --git a/frontend/src/ui/workspace/__tests__/panelTypes.test.ts b/frontend/src/ui/workspace/__tests__/panelTypes.test.ts index a41e6d2c..c970c283 100644 --- a/frontend/src/ui/workspace/__tests__/panelTypes.test.ts +++ b/frontend/src/ui/workspace/__tests__/panelTypes.test.ts @@ -18,7 +18,6 @@ const PANELS: { type: PanelType; movable: boolean }[] = [ { type: 'document', movable: false }, { type: 'layers', movable: true }, { type: 'properties', movable: true }, - { type: 'brushes', movable: true }, ]; for (const { type, movable } of PANELS) { @@ -36,7 +35,7 @@ describe('the anchor rule agrees with the panel registry', () => { it('a group is an anchor if any of its tabs is', () => { expect(isAnchorGroup(['layers', 'document'])).toBe(true); - expect(isAnchorGroup(['layers', 'brushes'])).toBe(false); + expect(isAnchorGroup(['layers', 'properties'])).toBe(false); expect(isAnchorGroup([])).toBe(false); }); }); diff --git a/frontend/src/ui/workspace/__tests__/tree.test.ts b/frontend/src/ui/workspace/__tests__/tree.test.ts index 42f25f04..f8902cf6 100644 --- a/frontend/src/ui/workspace/__tests__/tree.test.ts +++ b/frontend/src/ui/workspace/__tests__/tree.test.ts @@ -23,7 +23,7 @@ import { /** What `defaultMainLayout` ships, sorted. One constant so adding a default * panel is one edit here rather than five. */ -const DEFAULT_PANELS: PanelType[] = ['brushes', 'document', 'layers', 'properties']; +const DEFAULT_PANELS: PanelType[] = ['document', 'layers', 'properties']; // --- builders -------------------------------------------------------------- @@ -325,14 +325,15 @@ describe('firstDockableGroupId', () => { expect(firstDockableGroupId(root)).toBeNull(); }); - it('a revealed panel lands beside Layers, not in the canvas', () => { + it('a panel folded in from a closed pop-out lands beside Layers', () => { + // The bug this exists for: `firstGroupId` returns the canvas group, and + // a tab docked there renders with no tab bar and replaces the canvas. const { root } = loadOrDefault(null); const target = firstDockableGroupId(root)!; - insertTab(root, target, 'brushes'); + insertTab(root, target, 'properties'); - const canvas = collectPanelTypes(root).indexOf('document'); - expect(canvas).toBe(0); - expect(findGroup(root, target)!.state.tabs).toContain('brushes'); + expect(collectPanelTypes(root).indexOf('document')).toBe(0); expect(findGroup(root, target)!.state.tabs).toContain('layers'); + expect(findGroup(root, target)!.state.tabs).not.toContain('document'); }); }); diff --git a/frontend/src/ui/workspace/registerPanels.ts b/frontend/src/ui/workspace/registerPanels.ts index 50581ef2..d9a7c0e7 100644 --- a/frontend/src/ui/workspace/registerPanels.ts +++ b/frontend/src/ui/workspace/registerPanels.ts @@ -7,7 +7,6 @@ import { registerPanel } from './panelTypes'; import LayerPanel from '../layers/LayerPanel.svelte'; import PropertiesPanel from '../properties/PropertiesPanel.svelte'; import DocumentPanel from '../../multi_tab/DocumentPanel.svelte'; -import BrushExplorer from '../brush_explorer/BrushExplorer.svelte'; // The canvas: a fixed anchor. Non-movable → no tab, can't be dragged or tabbed // into; other panels dock around its edges. Non-poppable (WebGPU can't migrate @@ -15,7 +14,3 @@ import BrushExplorer from '../brush_explorer/BrushExplorer.svelte'; registerPanel('document', { title: 'Document', component: DocumentPanel, closable: false, poppable: false, movable: false }); registerPanel('layers', { title: 'Layers', component: LayerPanel, closable: false, poppable: true, movable: true }); registerPanel('properties', { title: 'Properties', component: PropertiesPanel, closable: false, poppable: true, movable: true }); -// Closable, unlike Layers and Properties: the explorer is somewhere a painter -// goes to pick a brush, not chrome they always want the width for. Closing is -// via the View menu — nothing renders a tab close button today. -registerPanel('brushes', { title: 'Brushes', component: BrushExplorer, closable: true, poppable: true, movable: true }); diff --git a/frontend/src/ui/workspace/tree.ts b/frontend/src/ui/workspace/tree.ts index 1192560d..c6153e5d 100644 --- a/frontend/src/ui/workspace/tree.ts +++ b/frontend/src/ui/workspace/tree.ts @@ -21,7 +21,7 @@ * Graphite), so the whole window tiles and horizontal splitting (canvas | * panels) is meaningful. It is a non-closable, non-poppable singleton kept * present by {@link ensureDocument}. */ -export type PanelType = 'document' | 'layers' | 'properties' | 'brushes'; +export type PanelType = 'document' | 'layers' | 'properties'; export interface PanelGroupState { tabs: PanelType[]; @@ -88,7 +88,7 @@ export function defaultMainLayout(docId: number, layersId: number, propsId: numb subdivision: { kind: 'split', children: [ - { size: 0.6, subdivision: makeGroup(layersId, ['layers', 'brushes']) }, + { size: 0.6, subdivision: makeGroup(layersId, ['layers']) }, { size: 0.4, subdivision: makeGroup(propsId, ['properties']) }, ], }, @@ -459,7 +459,7 @@ export function renumber(node: Subdivision, start = 0): number { return next; } -const KNOWN_PANEL_TYPES: readonly PanelType[] = ['document', 'layers', 'properties', 'brushes']; +const KNOWN_PANEL_TYPES: readonly PanelType[] = ['document', 'layers', 'properties']; function stripUnknownTabs(node: Subdivision): void { if (node.kind === 'group') { diff --git a/frontend/src/ui/workspace/workspaces.svelte.ts b/frontend/src/ui/workspace/workspaces.svelte.ts index 4202d639..a4798fc5 100644 --- a/frontend/src/ui/workspace/workspaces.svelte.ts +++ b/frontend/src/ui/workspace/workspaces.svelte.ts @@ -31,10 +31,8 @@ import { prune, cloneSubdivision, collectPanelTypes, - groupHolding, isEmptyLayout, foldPanelsIntoMain, - firstDockableGroupId, loadOrDefault, resolveSplitByPath, } from './tree'; @@ -180,52 +178,6 @@ class WorkspaceStore { }); } - // ---- panel visibility -------------------------------------------------- - - /** Whether `type` is docked in any open workspace, including pop-outs. */ - hasPanel(type: PanelType): boolean { - return this.workspaces.some((w) => collectPanelTypes(w.layout.root).includes(type)); - } - - /** - * Make `type` visible and focused. - * - * Activates it wherever it is already docked — including in a pop-out - * window, which is raised rather than duplicated — and otherwise inserts it - * into the main window's first *dockable* group. Idempotent: calling it on - * an already-active panel focuses its window and changes nothing else, - * which is what makes it safe to wire to a toolbar button that a painter - * may press at any time. - */ - revealPanel(type: PanelType): void { - for (const w of this.workspaces) { - const group = groupHolding(w.layout.root, type); - if (!group) continue; - this.setActiveTab(w.id, group.id, type); - try { - this.#windows.get(w.id)?.focus(); - } catch { - // A pop-out that has gone away; the layout still holds it and - // the next fold will recover it. - } - return; - } - this.#mutate(MAIN_ID, (root) => { - const target = firstDockableGroupId(root); - if (target !== null) insertTab(root, target, type); - }); - } - - /** Remove `type` from wherever it is docked. A no-op if it is not. */ - hidePanel(type: PanelType): void { - for (const w of this.workspaces) { - const group = groupHolding(w.layout.root, type); - if (!group) continue; - this.#mutate(w.id, (root) => removeTab(root, group.id, type)); - return; - } - } - // ---- drag coordinator -------------------------------------------------- get dragging(): boolean { From 9325fb7ed2decccafdecd193aada9d2c91913451 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 25 Aug 2026 16:40:45 -0400 Subject: [PATCH 12/13] darker modals --- frontend/src/themes/dark.css | 1 + frontend/src/themes/light.css | 1 + frontend/src/ui/Modal.svelte | 7 +++++-- frontend/src/ui/menu/CommandPalette.svelte | 4 ++-- 4 files changed, 9 insertions(+), 4 deletions(-) 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/ui/Modal.svelte b/frontend/src/ui/Modal.svelte index c9d40df3..21286f81 100644 --- a/frontend/src/ui/Modal.svelte +++ b/frontend/src/ui/Modal.svelte @@ -126,7 +126,10 @@ * `display: none` for closed dialogs, leaving the modal visible * permanently. */ dialog.modal { - background: var(--bg-active); + /* The base surface, not a raised one: a dialog is the darkest thing on + * screen and the scrim behind it lifts, rather than the other way + * around. `--scrim` carries that inversion per theme. */ + background: var(--bg); color: var(--text); border: 1px solid var(--bg-hover); border-radius: 8px; @@ -146,7 +149,7 @@ } dialog.modal::backdrop { - background: rgba(0, 0, 0, 0.65); + background: var(--scrim); } /* Non-dimming: the canvas stays fully visible (for live-preview panels). diff --git a/frontend/src/ui/menu/CommandPalette.svelte b/frontend/src/ui/menu/CommandPalette.svelte index 93bd7199..3d75973c 100644 --- a/frontend/src/ui/menu/CommandPalette.svelte +++ b/frontend/src/ui/menu/CommandPalette.svelte @@ -124,7 +124,7 @@ diff --git a/frontend/src/ui/brush_explorer/PackWheel.svelte b/frontend/src/ui/brush_explorer/PackWheel.svelte index 285cc22f..acd46852 100644 --- a/frontend/src/ui/brush_explorer/PackWheel.svelte +++ b/frontend/src/ui/brush_explorer/PackWheel.svelte @@ -4,17 +4,19 @@ * brush list beside it. * * A native `overflow-y: auto` scrollport, so pen and touch momentum come - * from the platform rather than a hand-rolled inertia integrator. That also - * makes fling **self-enabling**: when the cards fit, there is no scroll - * range, this element's `scroll` never fires, and only tap-to-jump is - * reachable. Nothing branches on how many packs exist. + * from the platform rather than a hand-rolled inertia integrator. Half a + * viewport of pad above and below the stack keeps the focused card in the + * middle of the column and gives the wheel a scroll range of its own + * whenever there are two packs — one card of wheel travel per pack, which + * is what makes it a minimap you can flick through rather than a second + * copy of the list's own scrolling. * * Bounded, not circular. A wheel that wrapped could not be honestly synced * to a list that has a real top and bottom. */ import PackCard from './PackCard.svelte'; import type { BrushGroup } from '../brush_library/grouping'; - import { cardCurve, type WheelGeometry } from './wheel'; + import { cardCurve, wheelPad, type WheelGeometry } from './wheel'; interface Props { groups: BrushGroup[]; @@ -43,6 +45,7 @@
@@ -71,12 +74,15 @@ /* An overscroll fling stops here rather than chaining out to the * modal or the page behind it. */ overscroll-behavior: contain; - padding: 0 10px; + padding-inline: 10px; scrollbar-width: none; - /* The wheel's scroll content is exactly its cards, which is what - * `wheelContentHeight` models. Anything else in here (spacers, padding - * that scrolls) would make the DOM's scroll range disagree with the - * mapping's, and the sync would silently stop moving. */ + /* `measure()` reads each card's `offsetTop` to find the leading pad, + * and `offsetTop` is relative to the nearest positioned ancestor. */ + position: relative; + /* The block padding is set inline from `wheelPad`, and every card + * position the mapping computes is measured back off the DOM + * (`wheelLead`, `wheelScrollMax`) rather than assumed — the two must + * agree or the sync silently stops moving. */ } .pack-wheel::-webkit-scrollbar { display: none; diff --git a/frontend/src/ui/brush_explorer/__tests__/wheel.test.ts b/frontend/src/ui/brush_explorer/__tests__/wheel.test.ts index c5364bfe..f5c18cd1 100644 --- a/frontend/src/ui/brush_explorer/__tests__/wheel.test.ts +++ b/frontend/src/ui/brush_explorer/__tests__/wheel.test.ts @@ -8,6 +8,7 @@ import { cardCurve, listMax, wheelMax, + wheelPad, type WheelGeometry, type SectionExtent, } from '../wheel'; @@ -25,6 +26,7 @@ const SECTIONS: SectionExtent[] = [ * rather than derived, as the component reads them from the DOM. */ const G: WheelGeometry = { cardAdvance: 60, + wheelLead: 0, wheelViewport: 120, listViewport: 200, listScrollMax: 400, @@ -37,6 +39,10 @@ const SHORT: WheelGeometry = { ...G, wheelViewport: 400, wheelScrollMax: 0 }; const EMPTY: WheelGeometry = { ...G, sections: [], listScrollMax: 0, wheelScrollMax: 0 }; +/** The same wheel as `G` with its leading pad applied: 30px above and below + * three 60px cards in a 120px port, so the content is 240 and the range 120. */ +const PADDED: WheelGeometry = { ...G, wheelLead: 30, wheelScrollMax: 120 }; + describe('sectionAt', () => { it('a boundary belongs to the section that starts there', () => { expect(sectionAt(100, SECTIONS)).toEqual({ index: 1, fraction: 0 }); @@ -130,6 +136,7 @@ describe('wheelToList', () => { // Asserting an unqualified round trip would be asserting a falsehood. const TALL: WheelGeometry = { cardAdvance: 60, + wheelLead: 0, wheelViewport: 120, listViewport: 200, listScrollMax: 400, @@ -200,6 +207,48 @@ describe('focusedSection', () => { }); }); +describe('the leading pad', () => { + it('is half a viewport less half a card', () => { + expect(wheelPad(G)).toBe(30); + }); + + it('is zero when a card is as tall as the port, rather than negative', () => { + expect(wheelPad({ ...G, cardAdvance: 400 })).toBe(0); + }); + + it('lets the first and last cards reach the centre', () => { + // The end cards are exactly what an unpadded wheel cannot centre: it + // runs out of scroll range first and leaves the stack against the top + // of the column. + expect(cardCurve(0, 0, PADDED).t).toBeCloseTo(0, 5); + expect(cardCurve(2, wheelMax(PADDED), PADDED).t).toBeCloseTo(0, 5); + expect(cardCurve(0, 0, G).t).not.toBeCloseTo(0, 5); + }); + + it('gives one card of wheel travel per pack', () => { + // What makes the wheel a minimap: a flick moves it pack-by-pack rather + // than mirroring the list's own much longer scroll. + expect(wheelMax(PADDED) / PADDED.cardAdvance).toBe(PADDED.sections.length - 1); + }); + + it('shifts the mapping by the pad in both directions', () => { + for (let y = 120; y <= 260; y += 5) { + expect(wheelToList(listToWheel(y, PADDED), PADDED)).toBeCloseTo(y, 5); + } + }); + + it('centres the focused card wherever the list is, both ends included', () => { + // The unclamped-interior caveat the unpadded mapping needs does not + // apply here: with the pad there is always range left to travel. + for (let y = 0; y <= listMax(PADDED); y += 9) { + const focused = focusedSection(y, PADDED)!; + const centre = listToWheel(y, PADDED) + PADDED.wheelViewport / 2; + const under = Math.floor((centre - PADDED.wheelLead) / PADDED.cardAdvance); + expect(under).toBe(focused); + } + }); +}); + describe('cardCurve', () => { it('is flat at the scrollport centre', () => { // Card 1 spans 60..120, centred at 90; a 120px port at scrollTop 30 is diff --git a/frontend/src/ui/brush_explorer/wheel.ts b/frontend/src/ui/brush_explorer/wheel.ts index 5058ffc1..44510213 100644 --- a/frontend/src/ui/brush_explorer/wheel.ts +++ b/frontend/src/ui/brush_explorer/wheel.ts @@ -34,6 +34,19 @@ export interface SectionExtent { export interface WheelGeometry { /** Uniform per-card advance (card height + gap), px. */ cardAdvance: number; + /** + * Distance from the top of the wheel's scroll content to the first card's + * top, px — the wheel's leading pad. + * + * The pad is half a viewport minus half a card, which is what lets the + * *first* and *last* cards reach the centre. Without it the wheel can only + * centre the cards in its middle, and every mapping near an end lands on a + * clamp instead: the stack sits against the top of the column, and the + * focused card drifts away from the centre exactly when the list is at a + * boundary. Measured from the DOM alongside `cardAdvance`, so the mapping + * cannot disagree with the layout it describes. + */ + wheelLead: number; /** The wheel scrollport's height, px. */ wheelViewport: number; /** The list scrollport's height, px. */ @@ -93,6 +106,20 @@ function listFocus(listScrollTop: number, g: WheelGeometry): number { return listScrollTop + g.listViewport / 2; } +/** Where card `slot` sits in the wheel's scroll content. Fractional slots are + * meaningful: 1.5 is the boundary between the second and third cards, which is + * what a section's progress maps onto. */ +function cardCentre(slot: number, g: WheelGeometry): number { + return g.wheelLead + (slot + 0.5) * g.cardAdvance; +} + +/** The pad the wheel needs above and below its cards for the first and last to + * reach the centre. The component applies it; `wheelLead` is the measurement + * of the result, which is what the mapping reads. */ +export function wheelPad(g: WheelGeometry): number { + return Math.max(0, (g.wheelViewport - g.cardAdvance) / 2); +} + /** * List scrollTop → the wheel scrollTop that puts the same section under the * wheel's centre. @@ -103,7 +130,9 @@ function listFocus(listScrollTop: number, g: WheelGeometry): number { export function listToWheel(listScrollTop: number, g: WheelGeometry): number { const at = sectionAt(listFocus(listScrollTop, g), g.sections); if (!at) return 0; - const centre = (at.index + at.fraction) * g.cardAdvance; + // `index + fraction - 0.5` because `cardCentre` measures to a card's middle + // while a section's progress is measured from its leading edge. + const centre = cardCentre(at.index + at.fraction - 0.5, g); return clamp(centre - g.wheelViewport / 2, 0, wheelMax(g)); } @@ -115,7 +144,7 @@ export function listToWheel(listScrollTop: number, g: WheelGeometry): number { */ export function wheelToList(wheelScrollTop: number, g: WheelGeometry): number { if (g.sections.length === 0) return 0; - const centre = wheelScrollTop + g.wheelViewport / 2; + const centre = wheelScrollTop + g.wheelViewport / 2 - g.wheelLead; const raw = g.cardAdvance > 0 ? centre / g.cardAdvance : 0; const index = clamp(Math.floor(raw), 0, g.sections.length - 1); const fraction = clamp(raw - index, 0, 1); @@ -145,16 +174,22 @@ export function focusedSection(listScrollTop: number, g: WheelGeometry): number * Derived arithmetically rather than from a per-card `getBoundingClientRect`, * so styling every card costs no layout. `t` is the card centre's signed * distance from the scrollport centre, normalized so ±1 is the scrollport edge. + * + * The card at the centre is the one the list is showing, so it alone is at full + * strength; the rest recede. `opacity` is how they recede, and against the + * picker's black slab that reads as sinking into the background rather than + * merely going faint — which is the point, since a column of saturated pack + * colours at equal weight has no focus at all. The falloff is superlinear so + * the neighbours stay legible while the far ends genuinely go dark. */ export function cardCurve( index: number, wheelScrollTop: number, g: WheelGeometry, ): { t: number; rotateX: number; scale: number; opacity: number } { - const cardCentre = (index + 0.5) * g.cardAdvance; const portCentre = wheelScrollTop + g.wheelViewport / 2; const half = g.wheelViewport / 2; - const t = half > 0 ? clamp((cardCentre - portCentre) / half, -1, 1) : 0; + const t = half > 0 ? clamp((cardCentre(index, g) - portCentre) / half, -1, 1) : 0; const away = Math.abs(t); return { t, @@ -162,6 +197,6 @@ export function cardCurve( // rolodex reads. rotateX: -t * 34, scale: 1 - away * 0.16, - opacity: 1 - away * 0.45, + opacity: 1 - Math.pow(away, 1.5) * 0.82, }; } diff --git a/frontend/src/ui/brush_library/BrushTile.svelte b/frontend/src/ui/brush_library/BrushTile.svelte index 8277b444..dc30ddfc 100644 --- a/frontend/src/ui/brush_library/BrushTile.svelte +++ b/frontend/src/ui/brush_library/BrushTile.svelte @@ -21,10 +21,22 @@