diff --git a/.github/workflows/docs-artifact.yml b/.github/workflows/docs-artifact.yml
new file mode 100644
index 00000000..e33dbeec
--- /dev/null
+++ b/.github/workflows/docs-artifact.yml
@@ -0,0 +1,101 @@
+name: Docs artifact
+
+# Publishes the documentation artifact for a release: the hand-written manual
+# under `docs/manual/` plus `metadata.json`, the projection of every registry
+# Darkly has. Both go in one tarball under one `version`, so a consumer can
+# never pair prose from one build with metadata from another.
+#
+# The consumer is pull-based and is not named here on purpose — this repo is
+# public and knows nothing about who reads the artifact. Whoever wants it
+# resolves this asset off the release and unpacks it.
+#
+# `docs/manual/` is **pure markdown**. It must not reference components from any
+# consumer, or the public repo acquires an invisible dependency on private code.
+# Anything needing a component is a generated page on the consumer side.
+#
+# No GPU and no `render-docs` here: `export-docs` builds from `&'static`
+# registration data alone. Rendered previews are a separate, later artifact.
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: >-
+ Existing release tag to attach the artifact to. Leave blank to build
+ and upload for inspection without touching any release.
+ required: false
+
+jobs:
+ docs-artifact:
+ name: export-docs + manual → release asset
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ # Full checkout (with .git) so build.rs's `git describe --tags` resolves —
+ # that string is what stamps `version`, and a shallow checkout breaks it.
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ - name: Install fontconfig (native font enumeration via fontique)
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libfontconfig1-dev
+ - uses: dtolnay/rust-toolchain@stable
+
+ - name: Export registry metadata
+ run: cargo run -p darkly --bin export-docs -- --out out/metadata.json
+
+ # An icon name is not self-describing, and `local:` icons exist nowhere but
+ # this repo — so the artifact carries resolved SVG rather than asking a
+ # consumer to own an icon toolchain. Needs the frontend's @iconify deps.
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ - name: npm ci
+ working-directory: frontend
+ run: npm ci
+ - name: Resolve icons
+ run: node frontend/scripts/export-doc-icons.mjs --metadata out/metadata.json --out out/icons.json
+
+ - name: Stage the manual
+ run: cp -r docs/manual out/manual
+
+ # Which release this ends up on: the pushed tag, or the one a manual run
+ # names. A dispatch with no tag names nothing and stops after the upload
+ # below — a real dry run of the export that touches no release.
+ - name: Resolve target release
+ env:
+ INPUT_TAG: ${{ inputs.tag }}
+ run: |
+ if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
+ echo "TARGET=$GITHUB_REF_NAME" >> "$GITHUB_ENV"
+ else
+ echo "TARGET=$INPUT_TAG" >> "$GITHUB_ENV"
+ fi
+
+ - name: Pack
+ run: |
+ NAME="darkly-docs-${TARGET:-${GITHUB_REF_NAME//\//-}}.tar.gz"
+ echo "ARTIFACT=$NAME" >> "$GITHUB_ENV"
+ tar czf "$NAME" -C out .
+ tar tzf "$NAME"
+
+ - name: Upload for inspection
+ uses: actions/upload-artifact@v4
+ with:
+ name: docs-artifact
+ path: ${{ env.ARTIFACT }}
+
+ # The release object for a tag is produced outside this repo, so this job
+ # may run before or after it exists — create-if-missing plus --clobber
+ # makes either order work, and makes a re-run idempotent.
+ - name: Attach to the release
+ if: env.TARGET != ''
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release view "$TARGET" >/dev/null 2>&1 \
+ || gh release create "$TARGET" --generate-notes
+ gh release upload "$TARGET" "$ARTIFACT" --clobber
diff --git a/.gitignore b/.gitignore
index 33047852..0c0d0df9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ target
Graphite
/krita
/gimp
+/gegl
.claude
frontend/wasm/pkg
frontend/dist
diff --git a/AGENTS.md b/AGENTS.md
index 307c755f..3f9d71ea 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,8 +41,8 @@ flowchart LR
```
crates/darkly/src/
document/ Authoritative model (layer tree, canvas, ...)
- layer_kinds/ ★ group, raster, void
- modifiers/ ★ mask, selection
+ 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, …)
@@ -71,8 +71,6 @@ crates/darkly/src/
nodegraph/ Generic node-graph (graph, compiler, layout)
frontend/wasm/ WASM bridge (wasm-bindgen) — single API surface
frontend/src/ Svelte UI
-shared/styles/ @darkly/styles — tokens + themes (UI + website)
-website/ Astro + Starlight site (splash, docs, /demo/)
```
### Coordinate Systems
diff --git a/README.md b/README.md
index b65d3e3a..25faa373 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@ Darkly is a Photoshop alternative where painters are first-class citizens. It ha
**Try the demo [here](https://demo.darkly.art).**
+Documentation [here](https://darkly.art/docs/).
+
### Darkly pledges to:
- 🛐 Honor human imagination
@@ -35,24 +37,7 @@ https://github.com/user-attachments/assets/1fc0632d-5846-4c64-bac8-e39b0794b8b5

-Darkly features a unified node-based brush system. Every brush type -- clone, liquify, watercolor, etc. -- all live in a single engine. This enables infinite customizability, mixing and matching of brush features, and on-the-fly creation of custom brushes.
-
-### Familiar Hotkeys
-
-
-
-On first launch, Darkly will ask you which editor preset you want. Currently we support GIMP, Krita, and Photoshop. I come from Krita, so that one's gotten the most TLC. But we want everyone to feel at home no matter which editor they come from. If you find any gaps, please let us know!
-
-### Hotkey Cheatsheet
-
-Full documentation is on the way; however, Darkly is mostly self-documenting, meaning if you can't find something, you can quickly search with `CTRL+F` and immediately see its hotkey, description, etc. - without leaving the app.
-
-
-
-If you like using hotkeys, we also have a cheat sheet just for you. You can print it or put it on a second screen.
-
-
-
+Darkly's unique brushes live inside a node-based system. This enables infinite customizability, mixing and matching of brush features, and on-the-fly creation of custom brushes.
## Dark Arts
@@ -246,7 +231,7 @@ See the [crate README](crates/darkly/README.md) for a runnable example, and the
## Contribution
-We love hackers as much as artists. Contributions are welcome! Please see [AGENTS.md](./AGENTS.md) for details.
+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.
### Use of AI
diff --git a/crates/darkly/Cargo.toml b/crates/darkly/Cargo.toml
index c6cca5e1..ebbf1cce 100644
--- a/crates/darkly/Cargo.toml
+++ b/crates/darkly/Cargo.toml
@@ -61,6 +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.
+[[bin]]
+name = "export-docs"
+path = "src/bin/export_docs.rs"
+
+[[bin]]
+name = "render_docs"
+required-features = ["testing"]
+
[[bin]]
name = "stroke_replay_bench"
required-features = ["testing"]
diff --git a/crates/darkly/brushes/airbrush.yaml b/crates/darkly/brushes/airbrush.yaml
index bb0b4ded..c96df70d 100644
--- a/crates/darkly/brushes/airbrush.yaml
+++ b/crates/darkly/brushes/airbrush.yaml
@@ -1,5 +1,6 @@
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:
type: pen_input
diff --git a/crates/darkly/brushes/blur.yaml b/crates/darkly/brushes/blur.yaml
index a241d617..793562f4 100644
--- a/crates/darkly/brushes/blur.yaml
+++ b/crates/darkly/brushes/blur.yaml
@@ -1,5 +1,6 @@
name: Blur
category: Effects
+description: Softens whatever is already on the layer instead of laying down color.
nodes:
pen_input:
type: pen_input
diff --git a/crates/darkly/brushes/calligraphy.yaml b/crates/darkly/brushes/calligraphy.yaml
index 06334bd1..ed4299f3 100644
--- a/crates/darkly/brushes/calligraphy.yaml
+++ b/crates/darkly/brushes/calligraphy.yaml
@@ -1,5 +1,6 @@
name: Calligraphy
category: Basic
+description: A broad elliptical nib held at a fixed angle, so strokes thicken and thin with direction.
nodes:
pen_input:
type: pen_input
diff --git a/crates/darkly/brushes/charcoal.yaml b/crates/darkly/brushes/charcoal.yaml
index 9c6cd62e..0b8b8a43 100644
--- a/crates/darkly/brushes/charcoal.yaml
+++ b/crates/darkly/brushes/charcoal.yaml
@@ -1,5 +1,6 @@
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:
type: brush_settings
diff --git a/crates/darkly/brushes/clone.yaml b/crates/darkly/brushes/clone.yaml
index 948f8625..ee2d577b 100644
--- a/crates/darkly/brushes/clone.yaml
+++ b/crates/darkly/brushes/clone.yaml
@@ -1,5 +1,6 @@
name: Clone
category: Misc
+description: Paints with pixels sampled from elsewhere on the canvas rather than with the current color.
nodes:
pen_input:
type: pen_input
diff --git a/crates/darkly/brushes/hair.yaml b/crates/darkly/brushes/hair.yaml
index 18486e52..af4106d5 100644
--- a/crates/darkly/brushes/hair.yaml
+++ b/crates/darkly/brushes/hair.yaml
@@ -1,5 +1,6 @@
name: Hair
category: Dry Media
+description: A bundle of fine strands that breaks each dab into separate hairs, for fur, grass and dry-bristle texture.
nodes:
add:
type: add
@@ -18,10 +19,10 @@ nodes:
curve:
- - 0.0
- 1.0
- - - 0.682973
- - 0.5305826
+ - - 0.48145637
+ - 0.795719
- - 1.0
- - 0.0
+ - 0.6756355
divide:
type: divide
levels:
@@ -34,10 +35,18 @@ nodes:
type: multiply
inputs:
a: 0.5
+ ranges:
+ a:
+ - -1.0
+ - 1.0
multiply_3:
type: multiply
inputs:
- b: 0.085
+ b: 0.1217829
+ ranges:
+ b:
+ - 0.03296951
+ - 0.21059628
noise:
type: noise
inputs:
@@ -54,7 +63,7 @@ nodes:
subtract:
type: subtract
inputs:
- a: 0.12
+ a: 0.11999999731779099
connections:
- add.result -> levels.input
- brush_settings.size -> divide.b
diff --git a/crates/darkly/brushes/ink_pen.yaml b/crates/darkly/brushes/ink_pen.yaml
index 792a9f72..eff1617e 100644
--- a/crates/darkly/brushes/ink_pen.yaml
+++ b/crates/darkly/brushes/ink_pen.yaml
@@ -1,43 +1,33 @@
name: Ink Pen
category: Basic
+description: A crisp-edged nib with a slow pressure ramp, for confident line work that holds its weight.
nodes:
- pen_input:
- type: pen_input
brush_settings:
type: brush_settings
inputs:
stabilize: 0.6
- paint_color:
- type: paint_color
circle:
type: circle
inputs:
softness: 0.1
- stamp:
- type: stamp
paint:
type: paint
- curve:
- type: curve
- inputs:
- curve:
- - - 0.0
- - 0.0
- - - 0.4
- - 0.7
- - - 1.0
- - 1.0
+ paint_color:
+ type: paint_color
+ pen_input:
+ type: pen_input
+ stamp:
+ type: stamp
connections:
-- 'pen_input.position -> paint.position'
-- 'pen_input.pressure -> paint.flow'
-- 'pen_input.pressure -> curve.input'
-- 'paint_color.color -> stamp.color'
-- 'circle.mask -> stamp.tip'
-- 'stamp.dab -> paint.rgba'
-- 'curve.output -> paint.size'
+- circle.mask -> stamp.tip
+- paint_color.color -> stamp.color
+- pen_input.position -> paint.position
+- pen_input.pressure -> paint.flow
+- pen_input.pressure -> paint.size
+- stamp.dab -> paint.rgba
exposed_ports:
- "brush_settings.stabilize": {}
- "brush_settings.size": {}
- "circle.softness": {}
- "paint.flow": {}
- "paint.opacity": {}
+ brush_settings.stabilize: {}
+ brush_settings.size: {}
+ circle.softness: {}
+ paint.flow: {}
+ paint.opacity: {}
diff --git a/crates/darkly/brushes/liquify.yaml b/crates/darkly/brushes/liquify.yaml
index a0b9a36f..e8e14140 100644
--- a/crates/darkly/brushes/liquify.yaml
+++ b/crates/darkly/brushes/liquify.yaml
@@ -1,13 +1,14 @@
name: Liquify
category: Effects
+description: Pushes the pixels under the cursor along the stroke, warping the image without repainting it.
nodes:
pen_input:
type: pen_input
brush_settings:
type: brush_settings
inputs:
- spacing: 0.0
- spacing_min_px: 4.0
+ spacing: 0.05
+ spacing_min_px: 0.0
size: 0.3
liquify:
type: liquify
diff --git a/crates/darkly/brushes/rough_ink.yaml b/crates/darkly/brushes/rough_ink.yaml
index 11e98ec7..274f7795 100644
--- a/crates/darkly/brushes/rough_ink.yaml
+++ b/crates/darkly/brushes/rough_ink.yaml
@@ -1,54 +1,44 @@
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:
- pen_input:
- type: pen_input
brush_settings:
type: brush_settings
inputs:
stabilize: 0.6
+ circle:
+ type: circle
+ inputs:
+ algorithm: 1
+ frequency: 8.0
+ octaves: 4.0
+ softness: 0.1
+ paint:
+ type: paint
paint_color:
type: paint_color
- curve:
- type: curve
- inputs:
- curve:
- - - 0.0
- - 0.0
- - - 0.4
- - 0.7
- - - 1.0
- - 1.0
+ pen_input:
+ type: pen_input
random:
type: random
random_2:
type: random
random_3:
type: random
- circle:
- type: circle
- inputs:
- algorithm: 1
- frequency: 8.0
- octaves: 4.0
- softness: 0.1
stamp:
type: stamp
- paint:
- type: paint
connections:
-- 'pen_input.position -> paint.position'
-- 'pen_input.pressure -> curve.input'
-- 'pen_input.pressure -> paint.flow'
-- 'paint_color.color -> stamp.color'
-- 'curve.output -> paint.size'
-- 'random.value -> circle.amplitude'
-- 'random_2.value -> circle.rotation_input'
-- 'random_3.value -> circle.seed'
-- 'circle.mask -> stamp.tip'
-- 'stamp.dab -> paint.rgba'
+- circle.mask -> stamp.tip
+- paint_color.color -> stamp.color
+- pen_input.position -> paint.position
+- pen_input.pressure -> paint.flow
+- pen_input.pressure -> paint.size
+- random.value -> circle.amplitude
+- random_2.value -> circle.rotation_input
+- random_3.value -> circle.seed
+- stamp.dab -> paint.rgba
exposed_ports:
- "brush_settings.stabilize": {}
- "brush_settings.size": {}
- "paint.flow": {}
- "paint.opacity": {}
+ brush_settings.stabilize: {}
+ brush_settings.size: {}
+ paint.flow: {}
+ paint.opacity: {}
diff --git a/crates/darkly/brushes/rough_watercolor.yaml b/crates/darkly/brushes/rough_watercolor.yaml
index 28d9e293..11248dd3 100644
--- a/crates/darkly/brushes/rough_watercolor.yaml
+++ b/crates/darkly/brushes/rough_watercolor.yaml
@@ -1,5 +1,6 @@
name: Rough Watercolor
category: Wet Media
+description: The same bleeding pigment over a rougher paper — granulated, with a broken edge.
nodes:
pen_input:
type: pen_input
diff --git a/crates/darkly/brushes/round.yaml b/crates/darkly/brushes/round.yaml
index 1eb39b9c..7b35fd48 100644
--- a/crates/darkly/brushes/round.yaml
+++ b/crates/darkly/brushes/round.yaml
@@ -1,5 +1,6 @@
name: Round
category: Basic
+description: A plain soft disc that grows with pressure — the default brush, and the starting point for most of the others.
nodes:
pen_input:
type: pen_input
diff --git a/crates/darkly/brushes/smooth_watercolor.yaml b/crates/darkly/brushes/smooth_watercolor.yaml
index aa036168..70632a74 100644
--- a/crates/darkly/brushes/smooth_watercolor.yaml
+++ b/crates/darkly/brushes/smooth_watercolor.yaml
@@ -1,5 +1,6 @@
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:
type: pen_input
diff --git a/crates/darkly/brushes/smudge.yaml b/crates/darkly/brushes/smudge.yaml
index 3ecebeb3..bead1893 100644
--- a/crates/darkly/brushes/smudge.yaml
+++ b/crates/darkly/brushes/smudge.yaml
@@ -1,5 +1,6 @@
name: Smudge
category: Effects
+description: Drags existing pigment along the stroke, the way a finger pulls through wet paint.
nodes:
pen_input:
type: pen_input
diff --git a/crates/darkly/brushes/sponge.yaml b/crates/darkly/brushes/sponge.yaml
index 714a4c77..72578adf 100644
--- a/crates/darkly/brushes/sponge.yaml
+++ b/crates/darkly/brushes/sponge.yaml
@@ -1,5 +1,6 @@
name: Sponge
category: Dry Media
+description: Hair strands that coil as the stroke moves, for curled fur, smoke and windblown texture.
nodes:
add:
type: add
diff --git a/crates/darkly/build.rs b/crates/darkly/build.rs
index 67ef4739..7d01cbbc 100644
--- a/crates/darkly/build.rs
+++ b/crates/darkly/build.rs
@@ -68,20 +68,50 @@ fn main() {
generate_handler_registry(&src.join("engine"));
- generate_registry(&src.join("gpu/veils"), "crate::gpu::veil::VeilRegistration");
+ // Registries whose variants are browsable metadata. `catalog_sources` is
+ // what `crate::catalog` is generated from — see `generate_catalog_registry`.
+ let mut catalog_sources: Vec<(String, String)> = Vec::new();
+
+ generate_catalog_registry(
+ &src.join("actions"),
+ "crate::action::ActionCategory",
+ &src,
+ &mut catalog_sources,
+ );
- generate_registry(&src.join("gpu/voids"), "crate::gpu::void::VoidRegistration");
+ generate_catalog_registry(
+ &src.join("gpu/veils"),
+ "crate::gpu::veil::VeilRegistration",
+ &src,
+ &mut catalog_sources,
+ );
- generate_registry(
+ generate_catalog_registry(
+ &src.join("gpu/voids"),
+ "crate::gpu::void::VoidRegistration",
+ &src,
+ &mut catalog_sources,
+ );
+
+ generate_catalog_registry(
&src.join("gpu/filters"),
"crate::gpu::filter::FilterPipelineRegistration",
+ &src,
+ &mut catalog_sources,
);
- generate_registry(&src.join("tools"), "crate::tool::ToolRegistration");
+ generate_catalog_registry(
+ &src.join("tools"),
+ "crate::tool::ToolRegistration",
+ &src,
+ &mut catalog_sources,
+ );
- generate_registry(
+ generate_catalog_registry(
&src.join("brush/nodes"),
"crate::brush::BrushNodeRegistration",
+ &src,
+ &mut catalog_sources,
);
generate_registry(
@@ -94,32 +124,181 @@ fn main() {
"crate::config::schema::SchemaSection",
);
- generate_registry(
+ generate_catalog_registry(
&src.join("document/filters"),
"crate::document::filter::FilterEntityRegistration",
+ &src,
+ &mut catalog_sources,
);
- generate_registry(
+ generate_catalog_registry(
&src.join("document/layer_kinds"),
"crate::document::layer_kind::LayerKindRegistration",
+ &src,
+ &mut catalog_sources,
);
- generate_registry(
+ generate_catalog_registry(
&src.join("gpu/blend_modes"),
"crate::gpu::blend_mode::BlendModeRegistration",
+ &src,
+ &mut catalog_sources,
);
- generate_yaml_presets(&PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("presets"));
-
+ // 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.
generate_builtin_brushes(
&PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("brushes"),
+ &mut catalog_sources,
);
+ generate_catalog_sources(catalog_sources, &src);
+
+ generate_yaml_presets(&PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("presets"));
+
generate_texture_registry(
&PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("resources/textures"),
);
}
+/// [`generate_registry`], additionally recording the directory as a source of
+/// browsable catalog metadata.
+///
+/// Which function a registry directory is scanned by *is* the decision about
+/// whether its variants are documentation. Both `catalogs()` and
+/// `catalog_sources()` are generated from what this records, so a registry
+/// cannot be projected in one and forgotten in the other — and a directory
+/// scanned by plain [`generate_registry`] (brush nodes, stabilizers, request
+/// handler groups) contributes to neither.
+///
+/// The registry module — the parent module of the registration type — must
+/// export `CATALOG_ID` and `catalog()`, and may export `preview_mechanism()`.
+fn generate_catalog_registry(
+ dir: &Path,
+ registration_type: &str,
+ src: &Path,
+ sources: &mut Vec<(String, String)>,
+) {
+ generate_registry(dir, registration_type);
+ let rel = dir
+ .strip_prefix(src)
+ .unwrap_or(dir)
+ .to_str()
+ .unwrap()
+ .replace('\\', "/");
+ record_catalog_source(
+ &rel,
+ registration_type.rsplit_once("::").unwrap().0,
+ sources,
+ );
+}
+
+/// Record a scanned directory as a source of browsable catalog metadata.
+///
+/// Split out from [`generate_catalog_registry`] for the scans whose directory
+/// holds data rather than `register()` modules — `brushes/` is a directory of
+/// YAML, but its catalog is documentation on the same footing as a registry's.
+/// `module` must export `CATALOG_ID` and `catalog()`, and may export a preview
+/// mechanism.
+fn record_catalog_source(dir: &str, module: &str, sources: &mut Vec<(String, String)>) {
+ sources.push((dir.to_string(), module.to_string()));
+}
+
+/// Resolve a module path (`crate::a::b`) to the file that holds it, trying
+/// `src/a/b.rs` then `src/a/b/mod.rs`. `None` when neither exists.
+fn module_source(module: &str, src: &Path) -> Option {
+ let rel = module.trim_start_matches("crate::").replace("::", "/");
+ let flat = src.join(format!("{rel}.rs"));
+ if flat.exists() {
+ return Some(flat);
+ }
+ let dir = src.join(&rel).join("mod.rs");
+ dir.exists().then_some(dir)
+}
+
+/// Emit `OUT_DIR/catalog_sources_gen.rs`: the list of scanned catalog-producing
+/// registry directories, the `catalogs()` projection over them, and the
+/// `preview_mechanisms()` projection over the subset that has one. Generated
+/// rather than hand-written so the export and the test that checks the export
+/// is complete both read from what the build actually found on disk.
+fn generate_catalog_sources(mut sources: Vec<(String, String)>, src: &Path) {
+ sources.sort();
+
+ let mut code = String::new();
+ code.push_str("// @generated by build.rs — do not edit manually.\n");
+ code.push_str(
+ "// One entry per registry directory scanned by `generate_catalog_registry`.\n\n",
+ );
+
+ code.push_str("/// A registry directory the build scan found to produce a catalog.\n");
+ code.push_str("pub struct CatalogSource {\n");
+ code.push_str(" /// The directory the build scan walked, as that scan named it:\n");
+ code.push_str(" /// `gpu/veils` and friends relative to `crates/darkly/src`,\n");
+ code.push_str(" /// `brushes` beside it.\n");
+ code.push_str(" pub dir: &'static str,\n");
+ code.push_str(" /// Id of the catalog the registry in that directory produces.\n");
+ code.push_str(" pub id: &'static str,\n");
+ code.push_str("}\n\n");
+
+ code.push_str("/// Every module directory the build scan found that produces a catalog.\n");
+ code.push_str("#[rustfmt::skip]\n");
+ code.push_str("pub fn catalog_sources() -> Vec {\n");
+ code.push_str(" vec![\n");
+ for (dir, module) in &sources {
+ code.push_str(&format!(
+ " CatalogSource {{ dir: \"{dir}\", id: {module}::CATALOG_ID }},\n"
+ ));
+ }
+ code.push_str(" ]\n");
+ code.push_str("}\n\n");
+
+ code.push_str("/// Every registry, projected. Requires no GPU device.\n");
+ code.push_str("#[rustfmt::skip]\n");
+ code.push_str("pub fn catalogs() -> Vec {\n");
+ code.push_str(" vec![\n");
+ for (_, module) in &sources {
+ code.push_str(&format!(" {module}::catalog(),\n"));
+ }
+ code.push_str(" ]\n");
+ code.push_str("}\n\n");
+
+ // One row per catalog whose registry module exports a preview mechanism.
+ // A catalog that has none writes nothing — which is what keeps the
+ // document-layer registries free of a `wgpu`-taking trait rather than
+ // making each of them hand-write a negative.
+ code.push_str(
+ "/// Every catalog that can render a preview, keyed by catalog id. A catalog\n\
+ /// whose registry module exports no `preview_mechanism` is absent, which is\n\
+ /// how a non-previewable catalog answers without writing anything.\n",
+ );
+ code.push_str("#[rustfmt::skip]\n");
+ code.push_str(
+ "pub fn preview_mechanisms() -> Vec<(&'static str, &'static dyn crate::gpu::preview::PreviewMechanism)> {\n",
+ );
+ code.push_str(" vec![\n");
+ for (_, module) in &sources {
+ let Some(path) = module_source(module, src) else {
+ continue;
+ };
+ println!("cargo:rerun-if-changed={}", path.display());
+ let Ok(text) = fs::read_to_string(&path) else {
+ continue;
+ };
+ if text.contains("pub fn preview_mechanism") {
+ code.push_str(&format!(
+ " ({module}::CATALOG_ID, {module}::preview_mechanism()),\n"
+ ));
+ }
+ }
+ code.push_str(" ]\n");
+ code.push_str("}\n");
+
+ let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
+ let out_path = PathBuf::from(out_dir).join("catalog_sources_gen.rs");
+ fs::write(&out_path, code).unwrap();
+}
+
/// Scan a directory for .rs module files (excluding mod.rs) and generate
/// a mod.rs that re-exports all modules and provides a `registrations()`
/// function collecting each module's `register()` return value.
@@ -448,7 +627,20 @@ fn titlecase(s: &str) -> String {
/// brushes are loaded by `crate::brush::builtin_brushes::all()` at
/// engine startup — adding a new one is "drop a `.yaml` file in the
/// directory" with no other code touched.
-fn generate_builtin_brushes(dir: &Path) {
+///
+/// Also records the directory as a catalog source, from the scan itself
+/// rather than from a second hand-written name — the same "derived from
+/// what is on disk" property [`generate_catalog_registry`] gives the
+/// module directories, and what `every_catalog_source_is_exported` rests on.
+fn generate_builtin_brushes(dir: &Path, catalog_sources: &mut Vec<(String, String)>) {
+ record_catalog_source(
+ dir.file_name()
+ .and_then(|s| s.to_str())
+ .expect("brush directory has a name"),
+ "crate::brush::builtin_brushes",
+ catalog_sources,
+ );
+
let mut brushes: Vec<(String, PathBuf)> = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
diff --git a/crates/darkly/src/action.rs b/crates/darkly/src/action.rs
new file mode 100644
index 00000000..e74cb06b
--- /dev/null
+++ b/crates/darkly/src/action.rs
@@ -0,0 +1,118 @@
+//! Every command the editor can run, as data.
+//!
+//! An action has two halves. The documentable half — its id, label, icon and
+//! one-line description — is static data, and lives here beside the
+//! `presets/*.yaml` bindings that name the same ids. The behavioural half —
+//! what running it does — closes over Svelte runes and lives in
+//! `frontend/src/actions/`. The two join by id.
+//!
+//! Actions group one file per category (`actions/edit.rs`, `actions/view.rs`,
+//! …), each a `const ACTIONS` table plus one `register()` — the same
+//! many-items-per-file shape `config/sections/` uses, and the shape GIMP's
+//! per-domain `GimpActionEntry` tables use. `build.rs` discovers the files, so
+//! a new category is a new file and nothing else.
+
+use crate::catalog::{Catalog, CatalogEntry};
+
+/// One action's documentation.
+pub struct ActionDef {
+ /// Stable id, named by the bindings in `presets/*.yaml` and by the handler
+ /// that implements the action.
+ pub id: &'static str,
+ pub display_name: &'static str,
+ /// One sentence describing what running the action does. The command
+ /// palette's substring search indexes it, so it should carry the words a
+ /// user would reach for.
+ pub description: &'static str,
+ /// Iconify name, rendered in the menu gutter, the command-palette row and
+ /// the reference manual's table.
+ pub icon: &'static str,
+}
+
+/// What each file in `actions/` returns from its `register()`: a category's id
+/// and every action in it. A category is a group rather than an item, so one
+/// file carries many actions and states the grouping once.
+pub struct ActionCategory {
+ /// Grouping id, also the label the cheat sheet and the hotkeys tab show.
+ pub id: &'static str,
+ pub actions: &'static [ActionDef],
+}
+
+/// Id of the catalog this registry projects into.
+pub const CATALOG_ID: &str = "actions";
+
+impl ActionDef {
+ pub fn catalog_entry(&self, category: &'static str) -> CatalogEntry {
+ CatalogEntry::new(self.id, self.display_name)
+ .with_icon(self.icon)
+ .with_description(self.description)
+ .with_category(category)
+ // An action *is* the thing a binding names, so the id it binds is
+ // its own. Declaring it means one rule — "the entry whose
+ // `hotkey_action` matches" — resolves a bound chord to its
+ // documentation for tools, filters and actions alike.
+ .with_hotkey_action(self.id)
+ }
+}
+
+/// The action catalog — every registered action, grouped by category.
+pub fn catalog() -> Catalog {
+ let categories = crate::actions::registrations();
+ Catalog::new(
+ CATALOG_ID,
+ "Actions",
+ categories
+ .iter()
+ .flat_map(|cat| cat.actions.iter().map(|a| a.catalog_entry(cat.id)))
+ .collect(),
+ )
+ .with_description(
+ "Every command the editor can run — from a menu, the command palette, or a hotkey.",
+ )
+ .with_shared_icons()
+}
+
+#[cfg(test)]
+mod tests {
+ /// The category is the grouping the cheat sheet and the hotkeys tab render,
+ /// and it is declared once per file — so two files claiming the same one
+ /// would silently merge into a section with no single owner.
+ #[test]
+ fn every_category_declares_a_unique_id() {
+ let categories = crate::actions::registrations();
+ let mut seen: Vec<&str> = Vec::new();
+ for cat in &categories {
+ assert!(!cat.id.is_empty(), "an action category has an empty id");
+ assert!(
+ !cat.actions.is_empty(),
+ "action category `{}` declares no actions",
+ cat.id
+ );
+ assert!(
+ !seen.contains(&cat.id),
+ "two action categories claim the id `{}`",
+ cat.id
+ );
+ seen.push(cat.id);
+ }
+ }
+
+ /// Every action carries the four columns the reference manual's table has.
+ /// `catalog.rs` demands the name and the description of every catalog
+ /// entry, but not the icon — that is `Option` there because other
+ /// registries decline it, whereas an action always has one to show in the
+ /// menu gutter.
+ #[test]
+ fn every_action_declares_an_id_and_an_icon() {
+ for cat in crate::actions::registrations() {
+ for a in cat.actions {
+ assert!(
+ !a.id.is_empty(),
+ "an action in `{}` has an empty id",
+ cat.id
+ );
+ assert!(!a.icon.is_empty(), "action `{}` declares no icon", a.id);
+ }
+ }
+ }
+}
diff --git a/crates/darkly/src/actions/brush.rs b/crates/darkly/src/actions/brush.rs
new file mode 100644
index 00000000..5a3147d3
--- /dev/null
+++ b/crates/darkly/src/actions/brush.rs
@@ -0,0 +1,41 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "brushSizeUp",
+ display_name: "Increase Brush Size",
+ description: "Step the active brush's size up one notch.",
+ icon: "fa6-solid:plus",
+ },
+ ActionDef {
+ id: "brushSizeDown",
+ display_name: "Decrease Brush Size",
+ description: "Step the active brush's size down one notch.",
+ icon: "fa6-solid:minus",
+ },
+ ActionDef {
+ id: "brushSizeAdjust",
+ display_name: "Adjust Brush Size (drag)",
+ description: "Hold the modifier and drag sideways to scrub the brush size continuously.",
+ icon: "fa6-solid:up-right-and-down-left-from-center",
+ },
+ ActionDef {
+ id: "setCloneSource",
+ display_name: "Set Clone Source",
+ description: "Hold the modifier and click on the canvas to set the point the Clone brush copies from.",
+ icon: "fa6-solid:crosshairs",
+ },
+ ActionDef {
+ id: "addBrushNode",
+ display_name: "Add Brush Node",
+ description: "Open the add-node menu at the cursor (brush builder).",
+ icon: "fa6-solid:diagram-project",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "brush",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/colors.rs b/crates/darkly/src/actions/colors.rs
new file mode 100644
index 00000000..3a905744
--- /dev/null
+++ b/crates/darkly/src/actions/colors.rs
@@ -0,0 +1,30 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "swapColors",
+ display_name: "Swap Colors",
+ description: "Swap the foreground and background colors.",
+ icon: "fa6-solid:right-left",
+ },
+ ActionDef {
+ id: "resetColors",
+ display_name: "Reset Colors",
+ description: "Reset the foreground/background to black and white.",
+ icon: "fa6-solid:circle-half-stroke",
+ },
+ ActionDef {
+ id: "sampleColor",
+ display_name: "Sample Color",
+ description:
+ "Hold the modifier and drag on the canvas to sample a color into the foreground swatch.",
+ icon: "fa6-solid:eye-dropper",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "colors",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/edit.rs b/crates/darkly/src/actions/edit.rs
new file mode 100644
index 00000000..21ddd88d
--- /dev/null
+++ b/crates/darkly/src/actions/edit.rs
@@ -0,0 +1,95 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "undo",
+ display_name: "Undo",
+ description: "Undo the last action.",
+ icon: "fa6-solid:rotate-left",
+ },
+ ActionDef {
+ id: "redo",
+ display_name: "Redo",
+ description: "Redo the last undone action.",
+ icon: "fa6-solid:rotate-right",
+ },
+ ActionDef {
+ id: "cut",
+ display_name: "Cut",
+ description: "Cut the active layer to the clipboard.",
+ icon: "fa6-solid:scissors",
+ },
+ ActionDef {
+ id: "copy",
+ display_name: "Copy",
+ description: "Copy the active layer to the clipboard.",
+ icon: "fa6-solid:copy",
+ },
+ ActionDef {
+ id: "paste",
+ display_name: "Paste",
+ description: "Paste an image or layer from the clipboard.",
+ icon: "fa6-solid:paste",
+ },
+ ActionDef {
+ id: "pasteInPlace",
+ display_name: "Paste in Place",
+ description: "Paste from the clipboard at its original position.",
+ icon: "fa6-solid:clipboard",
+ },
+ ActionDef {
+ id: "resizeCanvas",
+ display_name: "Resize Canvas",
+ description: "Resize the canvas with a 9-point anchor.",
+ icon: "fa6-solid:up-right-and-down-left-from-center",
+ },
+ ActionDef {
+ id: "rescaleImage",
+ display_name: "Scale Image to New Size",
+ description: "Resize all layers to new document dimensions.",
+ icon: "fa6-solid:expand",
+ },
+ ActionDef {
+ id: "cropToSelection",
+ display_name: "Crop to Selection",
+ description: "Crop the canvas to the current selection bounds.",
+ icon: "fa6-solid:crop-simple",
+ },
+ ActionDef {
+ id: "flipCanvasH",
+ display_name: "Flip Canvas Horizontally",
+ description: "Mirror the whole canvas left-to-right.",
+ icon: "fa6-solid:arrows-left-right",
+ },
+ ActionDef {
+ id: "flipCanvasV",
+ display_name: "Flip Canvas Vertically",
+ description: "Mirror the whole canvas top-to-bottom.",
+ icon: "fa6-solid:arrows-up-down",
+ },
+ ActionDef {
+ id: "rotateCanvasCW",
+ display_name: "Rotate Canvas 90° CW",
+ description: "Rotate the whole canvas a quarter turn clockwise.",
+ icon: "fa6-solid:rotate-right",
+ },
+ ActionDef {
+ id: "rotateCanvasCCW",
+ display_name: "Rotate Canvas 90° CCW",
+ description: "Rotate the whole canvas a quarter turn counter-clockwise.",
+ icon: "fa6-solid:rotate-left",
+ },
+ ActionDef {
+ id: "rotateCanvas180",
+ display_name: "Rotate Canvas 180°",
+ description: "Rotate the whole canvas a half turn.",
+ icon: "fa6-solid:rotate",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "edit",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/file.rs b/crates/darkly/src/actions/file.rs
new file mode 100644
index 00000000..9264022a
--- /dev/null
+++ b/crates/darkly/src/actions/file.rs
@@ -0,0 +1,41 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "newDocument",
+ display_name: "New",
+ description: "Open a fresh document in a new tab. Prompts for canvas size and background color.",
+ icon: "fa6-solid:file",
+ },
+ ActionDef {
+ id: "open",
+ display_name: "Open",
+ description: "Open a `.darkly` document or image (PNG / JPEG / WebP) in a new tab.",
+ icon: "fa6-solid:folder-open",
+ },
+ ActionDef {
+ id: "saveDocument",
+ display_name: "Save",
+ description: "Save the current document. Re-saves to the same `.darkly` file after the first Save As; otherwise opens the Save picker (`.darkly`, or PNG / JPEG / WebP to export the canvas).",
+ icon: "fa6-solid:floppy-disk",
+ },
+ ActionDef {
+ id: "saveDocumentAs",
+ display_name: "Save As",
+ description: "Save the current document to a new file — `.darkly`, or PNG / JPEG / WebP to export the canvas.",
+ icon: "fa6-solid:file-export",
+ },
+ ActionDef {
+ id: "exportTimelapse",
+ display_name: "Export Timelapse…",
+ description: "Export the process recording as an MP4 or GIF timelapse.",
+ icon: "fa6-solid:video",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "file",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/layers.rs b/crates/darkly/src/actions/layers.rs
new file mode 100644
index 00000000..3d5f5135
--- /dev/null
+++ b/crates/darkly/src/actions/layers.rs
@@ -0,0 +1,83 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "newLayer",
+ display_name: "New Layer",
+ description: "Add a new layer above the active one.",
+ icon: "fa6-solid:square-plus",
+ },
+ ActionDef {
+ id: "newGroup",
+ display_name: "New Group",
+ description: "Group the selected layers together, or add an empty group if nothing is selected.",
+ icon: "fa6-solid:folder-plus",
+ },
+ ActionDef {
+ id: "duplicateLayer",
+ display_name: "Duplicate Layer",
+ description: "Make a copy of each selected layer.",
+ icon: "fa6-solid:clone",
+ },
+ ActionDef {
+ id: "deleteLayer",
+ display_name: "Delete Layer",
+ description: "Delete the selected layers.",
+ icon: "fa6-solid:trash",
+ },
+ ActionDef {
+ id: "flipLayerH",
+ display_name: "Flip Horizontally",
+ description: "Mirror the active layer (or selection) left-to-right.",
+ icon: "fa6-solid:arrows-left-right",
+ },
+ ActionDef {
+ id: "flipLayerV",
+ display_name: "Flip Vertically",
+ description: "Mirror the active layer (or selection) top-to-bottom.",
+ icon: "fa6-solid:arrows-up-down",
+ },
+ ActionDef {
+ id: "toggleVisibility",
+ display_name: "Toggle Layer Visibility",
+ description: "Show or hide the active layer.",
+ icon: "fa6-solid:eye",
+ },
+ ActionDef {
+ id: "toggleLock",
+ display_name: "Toggle Layer Lock",
+ description: "Lock or unlock the active layer.",
+ icon: "fa6-solid:lock",
+ },
+ ActionDef {
+ id: "isolateLayer",
+ display_name: "Isolate Layer",
+ description: "Solo a layer so only it shows in the canvas. Press again to bring everything else back.",
+ icon: "fa6-solid:circle-dot",
+ },
+ ActionDef {
+ id: "addMask",
+ display_name: "Add Mask",
+ description: "Add a mask modifier to the active layer or group and activate it for painting.",
+ icon: "radix-icons:mask-on",
+ },
+ ActionDef {
+ id: "mergeDown",
+ display_name: "Merge Down",
+ description: "Merge the active layer into the one below it, or combine multiple selected layers into a single layer.",
+ icon: "fa6-solid:arrows-down-to-line",
+ },
+ ActionDef {
+ id: "flatten",
+ display_name: "Flatten",
+ description: "Bake modifiers into the layer (apply mask), or flatten a group into a single raster that inherits the group’s blend props.",
+ icon: "fa6-solid:layer-group",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "layers",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/mod.rs b/crates/darkly/src/actions/mod.rs
new file mode 100644
index 00000000..dcc0a54f
--- /dev/null
+++ b/crates/darkly/src/actions/mod.rs
@@ -0,0 +1,30 @@
+// @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::action::ActionCategory`.
+
+pub mod brush;
+pub mod colors;
+pub mod edit;
+pub mod file;
+pub mod layers;
+pub mod selection;
+pub mod tools;
+pub mod transform;
+pub mod view;
+
+use crate::action::ActionCategory;
+
+#[rustfmt::skip]
+pub fn registrations() -> Vec {
+ vec![
+ brush::register(),
+ colors::register(),
+ edit::register(),
+ file::register(),
+ layers::register(),
+ selection::register(),
+ tools::register(),
+ transform::register(),
+ view::register(),
+ ]
+}
diff --git a/crates/darkly/src/actions/selection.rs b/crates/darkly/src/actions/selection.rs
new file mode 100644
index 00000000..60507596
--- /dev/null
+++ b/crates/darkly/src/actions/selection.rs
@@ -0,0 +1,77 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "selectAll",
+ display_name: "Select All",
+ description: "Select the entire canvas.",
+ icon: "fa6-solid:vector-square",
+ },
+ ActionDef {
+ id: "clearSelection",
+ display_name: "Deselect",
+ description: "Clear the active selection.",
+ icon: "fa6-solid:ban",
+ },
+ ActionDef {
+ id: "invertSelection",
+ display_name: "Invert Selection",
+ description: "Invert the current selection.",
+ icon: "tabler:flip-horizontal",
+ },
+ ActionDef {
+ id: "maskToSelection",
+ display_name: "Mask to Selection",
+ description: "Load the active layer's mask as the selection.",
+ icon: "radix-icons:mask-off",
+ },
+ ActionDef {
+ id: "clearSelectionContents",
+ display_name: "Clear Selection Contents",
+ description: "Erase the pixels inside the selection.",
+ icon: "fa6-solid:eraser",
+ },
+ ActionDef {
+ id: "growSelection",
+ display_name: "Grow Selection",
+ description: "Expand the selection edge outward by a number of pixels.",
+ icon: "fa6-solid:up-right-and-down-left-from-center",
+ },
+ ActionDef {
+ id: "shrinkSelection",
+ display_name: "Shrink Selection",
+ description: "Contract the selection edge inward by a number of pixels.",
+ icon: "fa6-solid:down-left-and-up-right-to-center",
+ },
+ ActionDef {
+ id: "borderSelection",
+ display_name: "Border Selection",
+ description: "Replace the selection with a band straddling its edge.",
+ icon: "fa6-solid:border-all",
+ },
+ ActionDef {
+ id: "smoothSelection",
+ display_name: "Smooth Selection",
+ description: "Round off jagged edges and remove small specks.",
+ icon: "fa6-solid:wand-magic-sparkles",
+ },
+ ActionDef {
+ id: "featherSelection",
+ display_name: "Feather Selection",
+ description: "Soften the selection edge with a Gaussian blur.",
+ icon: "fa6-solid:feather",
+ },
+ ActionDef {
+ id: "antialiasSelection",
+ display_name: "Antialias Selection",
+ description: "Soften the staircase of a hard-edged selection.",
+ icon: "fa6-solid:wand-magic",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "selection",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/tools.rs b/crates/darkly/src/actions/tools.rs
new file mode 100644
index 00000000..f7390287
--- /dev/null
+++ b/crates/darkly/src/actions/tools.rs
@@ -0,0 +1,19 @@
+use crate::action::{ActionCategory, ActionDef};
+
+/// Selecting a tool is not declared here: each tool names the action that
+/// selects it on its own `ToolRegistration` (`hotkey_action`), so the `tools`
+/// catalog already documents those twelve. What is left is the tool state a
+/// hotkey can flip without being a tool of its own.
+const ACTIONS: &[ActionDef] = &[ActionDef {
+ id: "toggleEraseMode",
+ display_name: "Toggle Erase Mode",
+ description: "Toggle erase mode on the brush tool. Switches to the brush tool first if another tool is active.",
+ icon: "fa6-solid:eraser",
+}];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "tools",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/transform.rs b/crates/darkly/src/actions/transform.rs
new file mode 100644
index 00000000..ee3bc904
--- /dev/null
+++ b/crates/darkly/src/actions/transform.rs
@@ -0,0 +1,23 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "commitFloating",
+ display_name: "Commit Floating",
+ description: "Stamp the floating content down into its layer, ending the transform.",
+ icon: "fa6-solid:check",
+ },
+ ActionDef {
+ id: "cancelFloating",
+ display_name: "Cancel Floating",
+ description: "Discard the floating content and leave the layer as it was.",
+ icon: "fa6-solid:xmark",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "transform",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/actions/view.rs b/crates/darkly/src/actions/view.rs
new file mode 100644
index 00000000..1b247ed8
--- /dev/null
+++ b/crates/darkly/src/actions/view.rs
@@ -0,0 +1,77 @@
+use crate::action::{ActionCategory, ActionDef};
+
+const ACTIONS: &[ActionDef] = &[
+ ActionDef {
+ id: "openSettings",
+ display_name: "Settings",
+ description: "Show the preferences modal.",
+ icon: "fa6-solid:gear",
+ },
+ ActionDef {
+ id: "commandPalette",
+ display_name: "Command Palette",
+ description: "Search and run any command.",
+ icon: "fa6-solid:magnifying-glass",
+ },
+ ActionDef {
+ id: "mirrorViewH",
+ display_name: "Mirror View",
+ description: "Flip the canvas horizontally for fresh-eyes review. View-only — the document is unchanged.",
+ icon: "fa6-solid:left-right",
+ },
+ ActionDef {
+ id: "resetView",
+ display_name: "Reset View",
+ description: "Reset rotation, mirror, pan, and zoom-to-fit. View-only — the document is unchanged.",
+ icon: "fa6-solid:expand",
+ },
+ ActionDef {
+ id: "fitToScreen",
+ display_name: "Fit to Screen",
+ description: "Zoom and recenter so the whole canvas fills the viewport, keeping the current rotation and mirror. View-only — the document is unchanged.",
+ icon: "fa6-solid:maximize",
+ },
+ ActionDef {
+ id: "centerView",
+ display_name: "Center View",
+ description: "Recenter the canvas in the viewport without changing zoom, rotation, or mirror. View-only — the document is unchanged.",
+ icon: "fa6-solid:crosshairs",
+ },
+ ActionDef {
+ id: "openCheatsheet",
+ display_name: "Hotkey Cheat Sheet",
+ description: "Open a searchable, printable list of every keyboard shortcut.",
+ icon: "fa6-solid:keyboard",
+ },
+ ActionDef {
+ id: "openDocs",
+ display_name: "Documentation",
+ description: "Open the Darkly documentation in a new tab.",
+ icon: "fa6-solid:book",
+ },
+ ActionDef {
+ id: "openWebsite",
+ display_name: "Website",
+ description: "Open the Darkly website in a new tab.",
+ icon: "fa6-solid:globe",
+ },
+ ActionDef {
+ id: "openGithub",
+ display_name: "GitHub Repository",
+ description: "Open the Darkly source repository on GitHub.",
+ icon: "fa6-brands:github",
+ },
+ ActionDef {
+ id: "aboutDarkly",
+ display_name: "About Darkly",
+ description: "Show version and credits.",
+ icon: "fa6-solid:circle-info",
+ },
+];
+
+pub fn register() -> ActionCategory {
+ ActionCategory {
+ id: "view",
+ actions: ACTIONS,
+ }
+}
diff --git a/crates/darkly/src/bin/export_docs.rs b/crates/darkly/src/bin/export_docs.rs
new file mode 100644
index 00000000..654c21e5
--- /dev/null
+++ b/crates/darkly/src/bin/export_docs.rs
@@ -0,0 +1,125 @@
+//! Write one JSON file describing everything Darkly registers.
+//!
+//! The file is self-describing: a consumer needs no Darkly source, no shared
+//! helpers, no knowledge of Darkly's hotkey resolution, and no particular
+//! renderer. Everything requiring Darkly knowledge to compute — layered preset
+//! resolution, chord rendering for both platform conventions, unit-suffixed
+//! display strings — is computed here.
+//!
+//! Needs no GPU: every registry constructor is pure, so the catalogs build from
+//! `&'static` registration data alone.
+//!
+//! ```text
+//! cargo run -p darkly --bin export-docs -- --out /tmp/darkly-docs/metadata.json
+//! ```
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use darkly::catalog::{catalogs, settings_catalogs, Catalog};
+use darkly::config::{preset_bindings, Binding, OVERLAYS};
+
+/// The whole artifact. `schema` is the shape's own version — bumped when a
+/// consumer would need to change to keep reading it.
+#[derive(serde::Serialize)]
+struct DocsManifest {
+ schema: u32,
+ /// `git describe` of the build that wrote this, which is also what
+ /// `render-docs` stamps — the pairing key between the two artifacts.
+ version: &'static str,
+ catalogs: Vec,
+ /// Preset name → action id → the chords it resolves to. Keyed by the
+ /// preset's own name, with `defaults` for the editor-agnostic baseline.
+ bindings: BTreeMap>>,
+}
+
+const SCHEMA_VERSION: u32 = 1;
+
+const HELP: &str = "\
+export-docs — write Darkly's registry metadata to a JSON file
+
+USAGE:
+ export-docs --out
+
+OPTIONS:
+ --out File to write. Parent directories are created.
+ -h, --help Show this message.
+";
+
+fn parse_args() -> Result {
+ let mut out: Option = None;
+ let mut argv = std::env::args().skip(1);
+ while let Some(a) = argv.next() {
+ match a.as_str() {
+ "--out" => {
+ let v = argv.next().ok_or("--out needs a path")?;
+ out = Some(PathBuf::from(v));
+ }
+ "-h" | "--help" => {
+ print!("{HELP}");
+ std::process::exit(0);
+ }
+ other => return Err(format!("unrecognized argument `{other}`")),
+ }
+ }
+ out.ok_or_else(|| "--out is required".to_string())
+}
+
+fn main() -> ExitCode {
+ let out = match parse_args() {
+ Ok(p) => p,
+ Err(e) => {
+ eprintln!("export-docs: {e}\n\n{HELP}");
+ return ExitCode::FAILURE;
+ }
+ };
+
+ let mut all = catalogs();
+ all.extend(settings_catalogs());
+
+ let mut bindings = BTreeMap::new();
+ bindings.insert("defaults".to_string(), preset_bindings(None));
+ for (name, _) in OVERLAYS {
+ bindings.insert((*name).to_string(), preset_bindings(Some(name)));
+ }
+
+ let entries: usize = all.iter().map(|c| c.entries.len()).sum();
+ let manifest = DocsManifest {
+ schema: SCHEMA_VERSION,
+ version: darkly::VERSION,
+ catalogs: all,
+ bindings,
+ };
+
+ let json = match serde_json::to_string_pretty(&manifest) {
+ Ok(j) => j,
+ Err(e) => {
+ eprintln!("export-docs: failed to serialize: {e}");
+ return ExitCode::FAILURE;
+ }
+ };
+
+ if let Some(parent) = out.parent() {
+ if !parent.as_os_str().is_empty() {
+ if let Err(e) = std::fs::create_dir_all(parent) {
+ eprintln!("export-docs: cannot create {}: {e}", parent.display());
+ return ExitCode::FAILURE;
+ }
+ }
+ }
+ if let Err(e) = std::fs::write(&out, &json) {
+ eprintln!("export-docs: cannot write {}: {e}", out.display());
+ return ExitCode::FAILURE;
+ }
+
+ println!(
+ "{} — {} catalogs, {} entries, {} presets, {} KB",
+ out.display(),
+ manifest.catalogs.len(),
+ entries,
+ manifest.bindings.len(),
+ json.len() / 1024,
+ );
+ ExitCode::SUCCESS
+}
diff --git a/crates/darkly/src/bin/render_docs.rs b/crates/darkly/src/bin/render_docs.rs
new file mode 100644
index 00000000..0715498f
--- /dev/null
+++ b/crates/darkly/src/bin/render_docs.rs
@@ -0,0 +1,57 @@
+//! Writes an animated preview — a PNG frame sequence — for every previewable
+//! registry entry, plus a small JSON index of what it wrote.
+//!
+//! ```text
+//! cargo run -p darkly --bin render_docs --features testing -- --out
+//! ```
+//!
+//! 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.
+//!
+//! Everything except argument handling and reporting lives in
+//! [`darkly::docs_render`], which the integration tests call directly —
+//! coverage tooling runs test targets and never executes a `[[bin]]`.
+
+use std::process::ExitCode;
+
+use darkly::docs_render::{self, Args};
+
+fn main() -> ExitCode {
+ let args = match docs_render::parse_args(std::env::args().skip(1)) {
+ Ok(a) => a,
+ 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;
+ };
+
+ match docs_render::render_all(&out) {
+ Ok(manifest) => {
+ for (catalog, entries) in &manifest.assets {
+ let frames: u32 = entries.values().map(|a| a.frames).sum();
+ // Every entry in a catalog is rendered by one renderer, so one
+ // entry's size describes the whole catalog.
+ let size = entries
+ .values()
+ .next()
+ .map(|a| format!("{} × {}", a.width, a.height))
+ .unwrap_or_default();
+ println!(
+ "{catalog}: {} assets, {frames} frames, {size}",
+ entries.len()
+ );
+ }
+ println!("{} — version {}", out.display(), manifest.version);
+ ExitCode::SUCCESS
+ }
+ Err(e) => {
+ eprintln!("render_docs: {e}");
+ ExitCode::FAILURE
+ }
+ }
+}
diff --git a/crates/darkly/src/brush/builtin_brushes.rs b/crates/darkly/src/brush/builtin_brushes.rs
index b7059dd6..9097f233 100644
--- a/crates/darkly/src/brush/builtin_brushes.rs
+++ b/crates/darkly/src/brush/builtin_brushes.rs
@@ -6,32 +6,143 @@
//! that directory at compile time — adding a new brush is "drop a
//! file, no code changes." See the modularity rules in CLAUDE.md.
+use std::sync::OnceLock;
+
use crate::brush::bundle::Brush;
+use crate::brush::library::BrushInfo;
use crate::brush::portable::PortableBrush;
+use crate::catalog::{Catalog, CatalogEntry};
+use crate::gpu::preview::PreviewAnim;
// `BUILTIN_BRUSHES_YAML: &[(filename, yaml_source)]` — generated by
// `crates/darkly/build.rs` from `crates/darkly/brushes/*.yaml`.
include!(concat!(env!("OUT_DIR"), "/builtin_brushes_gen.rs"));
-/// All built-in brushes, parsed from their YAML sources.
+/// Every built-in brush, paired with its YAML file stem.
///
/// Parse and import failures panic — a built-in brush failing to load
/// is a build-time bug in the shipped YAML, not a runtime error the
/// caller can recover from.
-pub fn all() -> Vec {
+fn parsed() -> Vec<(&'static str, Brush)> {
let registry = crate::brush::registry();
BUILTIN_BRUSHES_YAML
.iter()
.map(|(filename, yaml)| {
+ let stem = filename
+ .strip_suffix(".yaml")
+ .expect("build.rs names every embedded brush `.yaml`");
let portable: PortableBrush = serde_yaml_ng::from_str(yaml)
.unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}"));
- portable
+ let brush = portable
.into_brush(registry)
- .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}"))
+ .unwrap_or_else(|e| panic!("invalid built-in brush '{filename}': {e}"));
+ (stem, brush)
})
.collect()
}
+/// All built-in brushes, parsed from their YAML sources.
+pub fn all() -> Vec {
+ parsed().into_iter().map(|(_, brush)| brush).collect()
+}
+
+/// The shipped brushes' listable metadata, parsed once for the process,
+/// each paired with its YAML file stem.
+///
+/// Every brush 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. Holding the parse here is what lets a
+/// [`CatalogEntry`]'s `&'static str` fields borrow strings that arrived as
+/// owned YAML values — without leaking, and without widening the catalog
+/// types to `Cow` for the one registry that is not wholly static.
+///
+/// The row is [`BrushInfo`] itself rather than a parallel doc struct, so the
+/// picker and the documentation read the identical summary by construction.
+/// The file stem is the only thing `BrushInfo` does not carry, so it rides
+/// alongside.
+pub fn docs() -> &'static [(&'static str, BrushInfo)] {
+ static DOCS: OnceLock> = OnceLock::new();
+ DOCS.get_or_init(|| {
+ parsed()
+ .into_iter()
+ .map(|(stem, brush)| (stem, BrushInfo::from(&brush.metadata)))
+ .collect()
+ })
+ .as_slice()
+}
+
+/// Id of the catalog the shipped brushes project into.
+pub const CATALOG_ID: &str = "brushes";
+
+/// How a brush's preview plays back: it does not. A brush's preview is one
+/// finished stroke, and the finished stroke is the answer to "what does this
+/// brush look like" — watching the line appear adds nothing the way watching a
+/// veil's parameter sweep does, and it would cost forty-eight full re-renders
+/// per brush for it. Krita's live preview repaints the whole stroke on every
+/// change and never animates it either.
+///
+/// Declared on the catalog rather than on any single brush for the same reason
+/// blend modes declare theirs there: the motion — or the lack of it — is the
+/// same for every entry, so a fifteenth brush is still one YAML file and
+/// inherits this for free. A brush that ever wants motion is a `preview` field
+/// on its metadata and a fallback to this in [`preview`], local to this file.
+pub static PREVIEW: PreviewAnim = PreviewAnim::STILL;
+
+/// How long a brush's preview runs. Every shipped brush inherits [`PREVIEW`];
+/// an unknown stem gets `None`.
+///
+/// The single authority on whether a brush is previewable — [`catalog`] derives
+/// the flag by asking this rather than asserting it, so declaring an animation
+/// stays the one way to say an entry has a preview.
+pub fn preview(type_id: &str) -> Option {
+ docs()
+ .iter()
+ .any(|(stem, _)| *stem == type_id)
+ .then_some(PREVIEW)
+}
+
+/// The brush catalog — every shipped built-in, sorted by `type_id`.
+///
+/// `type_id` is the YAML file stem (`rough_watercolor`), not the brush's
+/// name: it is snake_case like every other catalog's type ids, and
+/// `docs_render` uses `catalog.id / entry.type_id` as a real directory path,
+/// which `"Rough Watercolor"` would not survive. `displayName` carries the
+/// name, and the name is what a consumer keys by against the running app —
+/// [`BrushLibrary`](crate::brush::library::BrushLibrary) stores brushes under
+/// it.
+///
+/// Alone among the catalogs this one parses on its first call, so it inherits
+/// [`parsed`]'s panic on malformed shipped YAML — which the whole of
+/// `catalogs()` then carries. That is the same bug `all()` already panics on
+/// at engine init, surfacing one call earlier.
+pub fn catalog() -> Catalog {
+ Catalog::new(
+ CATALOG_ID,
+ "Brushes",
+ docs()
+ .iter()
+ .map(|(stem, info)| {
+ let entry = CatalogEntry::new(stem, info.name.as_str())
+ .with_description(info.description.as_str())
+ .with_category(info.category.as_str())
+ // 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.
+ .with_supports_preview(preview(stem).is_some());
+ match info.icon {
+ Some(icon) => entry.with_icon(icon),
+ None => entry,
+ }
+ })
+ .collect(),
+ )
+ .with_description(
+ "The brushes Darkly ships with, grouped by the medium they imitate. \
+ This is the shipped set — a running session's library also holds \
+ whatever the painter has loaded.",
+ )
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -105,12 +216,12 @@ mod tests {
}
/// Content-dependent brushes (their graphs sample existing canvas
- /// pixels, so the flat preview bake renders blank) must carry a
- /// preview fallback icon on their `BrushInfo`; content-free brushes
- /// must not. Rough Watercolor is the discriminator that proves the
- /// trigger is `preview_fallback_icon` and not `supports_erase` —
- /// its terminal sets `supports_erase = false` yet its preview bake
- /// is meaningful, so it gets no icon.
+ /// pixels, so a still dab over the flat preview bake renders blank)
+ /// must carry a preview fallback icon on their `BrushInfo`;
+ /// content-free brushes must not. Rough Watercolor is the
+ /// discriminator that proves the trigger is `preview_staging` and not
+ /// `supports_erase` — its terminal sets `supports_erase = false` yet
+ /// its preview bake is meaningful, so it gets no icon.
#[test]
fn content_dependent_brushes_get_preview_icons() {
use crate::brush::library::BrushInfo;
@@ -138,6 +249,21 @@ mod tests {
}
}
+ /// The catalog projects exactly the shipped set, keyed by file stem.
+ /// Catches a brush file that stops being projected — that the
+ /// descriptions are non-empty is
+ /// `catalog::tests::every_catalog_entry_is_documented`'s job, for every
+ /// catalog at once.
+ #[test]
+ fn the_brush_catalog_covers_every_shipped_brush() {
+ let projected: Vec<&str> = catalog().entries.iter().map(|e| e.type_id).collect();
+ let stems: Vec<&str> = BUILTIN_BRUSHES_YAML
+ .iter()
+ .map(|(filename, _)| filename.strip_suffix(".yaml").unwrap())
+ .collect();
+ assert_eq!(projected, stems, "brushes is not the shipped set");
+ }
+
#[test]
fn builtin_brushes_unique_names() {
let brushes = all();
diff --git a/crates/darkly/src/brush/checkpoint_ring.rs b/crates/darkly/src/brush/checkpoint_ring.rs
index 8d6d6874..d4132d39 100644
--- a/crates/darkly/src/brush/checkpoint_ring.rs
+++ b/crates/darkly/src/brush/checkpoint_ring.rs
@@ -31,6 +31,11 @@ struct CheckpointSlot {
/// Dimensions of the allocated texture (may be larger than bbox).
tex_w: u32,
tex_h: u32,
+ /// Format the slot was allocated in. The ring snapshots the stroke
+ /// scratch, whose format is the terminal's business — colour for most
+ /// brushes, a float displacement field for warp terminals — and
+ /// `copy_texture_to_texture` requires the two to match.
+ tex_format: wgpu::TextureFormat,
/// The bbox region this checkpoint covers, in canvas pixel coords.
/// Stable across mid-stroke layer growth.
canvas_bbox: CanvasRect,
@@ -50,6 +55,7 @@ impl CheckpointSlot {
texture: None,
tex_w: 0,
tex_h: 0,
+ tex_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
canvas_bbox: CanvasRect::from_xywh(0, 0, 0, 0),
save_point_index: 0,
vector_index: 0,
@@ -65,9 +71,18 @@ impl CheckpointSlot {
}
}
- /// Ensure the texture is at least `w × h`. Reallocate if needed.
- fn ensure_texture(&mut self, device: &wgpu::Device, w: u32, h: u32) {
- if self.tex_w >= w && self.tex_h >= h && self.texture.is_some() {
+ /// Ensure the texture is at least `w × h` and in `format`.
+ /// Reallocate if needed — including on a format change, since a
+ /// slot cached from a colour stroke cannot receive a warp field.
+ fn ensure_texture(
+ &mut self,
+ device: &wgpu::Device,
+ w: u32,
+ h: u32,
+ format: wgpu::TextureFormat,
+ ) {
+ if self.tex_w >= w && self.tex_h >= h && self.tex_format == format && self.texture.is_some()
+ {
return;
}
// Allocate with some headroom to reduce reallocation frequency.
@@ -83,12 +98,13 @@ impl CheckpointSlot {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
- format: wgpu::TextureFormat::Rgba8Unorm,
+ format,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
}));
self.tex_w = alloc_w;
self.tex_h = alloc_h;
+ self.tex_format = format;
}
}
@@ -260,7 +276,12 @@ impl CheckpointRing {
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);
+ slot.ensure_texture(
+ device,
+ layer_rect.width,
+ layer_rect.height,
+ stroke.texture.format(),
+ );
slot.canvas_bbox = clipped_canvas;
slot.save_point_index = save_point_index;
slot.vector_index = vector_index;
diff --git a/crates/darkly/src/brush/eval.rs b/crates/darkly/src/brush/eval.rs
index cdfe1dab..2d29c034 100644
--- a/crates/darkly/src/brush/eval.rs
+++ b/crates/darkly/src/brush/eval.rs
@@ -725,6 +725,26 @@ impl BrushGraphRunner {
self.plan.steps.iter().any(|step| step.is_terminal)
}
+ /// Texel format the stroke scratch must be allocated in for this
+ /// brush — the terminal's declared
+ /// [`scratch_format`](crate::brush::node::BrushNodeRegistration::scratch_format).
+ ///
+ /// Type-owned dispatch, same shape as [`Self::has_terminal`]: the
+ /// terminal answers what its scratch holds, and callers
+ /// (`StrokeBuffer::new`, the preview renderer) just pass the answer
+ /// through. A terminal-less graph gets the colour default; it never
+ /// renders anyway.
+ pub fn scratch_format(&self) -> wgpu::TextureFormat {
+ let registry = crate::brush::registry();
+ self.plan
+ .steps
+ .iter()
+ .filter(|step| step.is_terminal)
+ .find_map(|step| registry.get(&step.type_id))
+ .map(|reg| reg.scratch_format)
+ .unwrap_or(crate::brush::node::COLOR_SCRATCH_FORMAT)
+ }
+
/// Build a name → value map of every output slot in the graph,
/// keyed by `n{node_id}_{port_name}` (matching the convention
/// [`crate::brush::wgsl::CompileWgslCtx::dab_field_name`]
diff --git a/crates/darkly/src/brush/gpu_context.rs b/crates/darkly/src/brush/gpu_context.rs
index ae05a295..ee65fbd4 100644
--- a/crates/darkly/src/brush/gpu_context.rs
+++ b/crates/darkly/src/brush/gpu_context.rs
@@ -360,6 +360,14 @@ impl<'a> StrokeResources<'a> {
pub fn source_texture(&self) -> &'a wgpu::Texture {
self.source_override.unwrap_or(self.pre_stroke_texture)
}
+
+ /// A view over [`Self::source_texture`], for consumers that bind the
+ /// source without a pre-built bind group — the warp-field resolve
+ /// builds its own two-texture group per commit.
+ pub fn source_view(&self) -> wgpu::TextureView {
+ self.source_texture()
+ .create_view(&wgpu::TextureViewDescriptor::default())
+ }
}
/// Everything a GPU brush node needs to record render passes.
diff --git a/crates/darkly/src/brush/mod.rs b/crates/darkly/src/brush/mod.rs
index 3d243650..88f36d7a 100644
--- a/crates/darkly/src/brush/mod.rs
+++ b/crates/darkly/src/brush/mod.rs
@@ -30,12 +30,14 @@ pub mod state;
pub mod stroke_buffer;
pub mod stroke_engine;
pub mod texture_source;
+pub mod warp_field;
pub mod wgsl;
pub mod wire;
use std::collections::HashMap;
use std::sync::OnceLock;
+use crate::gpu::preview::PreviewBackdrop;
use crate::nodegraph::NodeRegistration;
use wire::BrushWireType;
@@ -104,9 +106,14 @@ impl BrushNodeRegistry {
self.map.get(type_id)
}
- /// All registered node types.
- pub fn types(&self) -> impl Iterator {
- self.map.values()
+ /// All registered node types, sorted by `type_id` for deterministic
+ /// output. Sorting here rather than at each consumer is what makes both
+ /// the exported catalog and the frontend's node palette reproducible —
+ /// `map` is a `HashMap`, so its iteration order differs between processes.
+ pub fn types(&self) -> Vec<&BrushNodeRegistration> {
+ let mut v: Vec<&BrushNodeRegistration> = self.map.values().collect();
+ v.sort_by_key(|reg| reg.node.type_id);
+ v
}
/// The bare `NodeRegistration` map for the nodegraph compiler
@@ -140,6 +147,30 @@ pub fn registry() -> &'static BrushNodeRegistry {
REGISTRY.get_or_init(BrushNodeRegistry::build)
}
+/// Id of the catalog this registry projects into.
+pub const CATALOG_ID: &str = "brushNodes";
+
+/// The brush-node catalog — every registered node type, sorted by `type_id`.
+///
+/// Entries carry no `params`: see
+/// [`NodeRegistration::catalog_entry`](crate::nodegraph::NodeRegistration::catalog_entry)
+/// for why a port is not a parameter.
+pub fn catalog() -> crate::catalog::Catalog {
+ crate::catalog::Catalog::new(
+ CATALOG_ID,
+ "Brush Nodes",
+ registry()
+ .types()
+ .into_iter()
+ .map(|reg| reg.node.catalog_entry())
+ .collect(),
+ )
+ .with_description(
+ "The signal blocks a brush graph is built from — inputs, math, shapes, \
+ and the terminals that put pigment down.",
+ )
+}
+
/// Convenience over [`crate::nodegraph::Graph::find_terminal`] for
/// brush graphs: builds a fresh [`BrushNodeRegistry`] and delegates.
/// Use this from any graph-only call site (benches, tests, the WASM
@@ -161,20 +192,24 @@ pub struct BrushGraphCapabilities {
/// terminal registers `supports_erase = false`. The brush-tool
/// options bar hides the erase toggle when false.
pub supports_erase: bool,
- /// Iconify icon to show in place of baked dab/stroke thumbnails,
- /// contributed by the first node whose registration sets
- /// `preview_fallback_icon` — content-dependent nodes (clone, blur,
- /// smudge, liquify) whose preview bake renders blank.
+ /// Iconify icon to show in the dab slot in place of a baked thumbnail,
+ /// contributed by the first node whose registration declares
+ /// `preview_staging` — content-dependent nodes (clone, blur, smudge,
+ /// liquify) whose still-dab bake renders blank.
pub preview_fallback_icon: Option<&'static str>,
+ /// Field the stroke preview is rendered over, from the same declaration
+ /// the icon comes from. [`PreviewBackdrop::Flat`] for a brush that deposits
+ /// pigment and so needs nothing staged under it.
+ pub preview_backdrop: PreviewBackdrop,
}
/// Derive [`BrushGraphCapabilities`] from a graph in one registry walk.
///
/// Type-owned dispatch — each node's `register()` declares its own
-/// `supports_erase` / `preview_fallback_icon`; nothing here knows which
+/// `supports_erase` / `preview_staging`; nothing here knows which
/// node types exist. Nodes are visited terminals-first, then ascending
/// id ([`Graph::nodes`] is a HashMap, so raw iteration order would make
-/// the "first icon wins" rule nondeterministic on multi-icon graphs).
+/// the "first staging wins" rule nondeterministic on multi-staging graphs).
pub fn graph_capabilities(
graph: &crate::nodegraph::Graph,
) -> BrushGraphCapabilities {
@@ -195,13 +230,17 @@ pub fn graph_capabilities(
let mut caps = BrushGraphCapabilities {
supports_erase: true,
preview_fallback_icon: None,
+ preview_backdrop: PreviewBackdrop::Flat,
};
+ let mut staged = false;
for (_, reg) in nodes {
if reg.is_terminal && !reg.supports_erase {
caps.supports_erase = false;
}
- if caps.preview_fallback_icon.is_none() {
- caps.preview_fallback_icon = reg.preview_fallback_icon;
+ if let (false, Some(staging)) = (staged, reg.preview_staging) {
+ caps.preview_fallback_icon = Some(staging.icon);
+ caps.preview_backdrop = staging.backdrop;
+ staged = true;
}
}
caps
@@ -620,4 +659,27 @@ mod tests {
runner.err()
);
}
+
+ /// The documentation projection covers the registry exactly, in the
+ /// registry's own order. Catches a node type that stops being projected,
+ /// and the `HashMap` iteration order that would otherwise make
+ /// `metadata.json` differ between the exporter process and any other.
+ #[test]
+ fn the_node_catalog_covers_every_registered_node() {
+ let catalog = super::catalog();
+ let registered: Vec<&str> = super::registry()
+ .types()
+ .into_iter()
+ .map(|reg| reg.node.type_id)
+ .collect();
+ let projected: Vec<&str> = catalog.entries.iter().map(|e| e.type_id).collect();
+ assert_eq!(projected, registered, "brushNodes is not the registry");
+
+ let mut sorted = projected.clone();
+ sorted.sort_unstable();
+ assert_eq!(
+ projected, sorted,
+ "brushNodes entries are not sorted by type id"
+ );
+ }
}
diff --git a/crates/darkly/src/brush/node.rs b/crates/darkly/src/brush/node.rs
index 2bdb8e6c..5a98ab2b 100644
--- a/crates/darkly/src/brush/node.rs
+++ b/crates/darkly/src/brush/node.rs
@@ -62,8 +62,26 @@ pub struct BrushNodeRegistration {
pub evaluator: fn() -> Box,
/// Framework-managed stroke prologue. See [`Lifecycle`].
pub lifecycle: Lifecycle,
+ /// Texel format of the stroke scratch this terminal renders into.
+ ///
+ /// Color terminals leave this at [`COLOR_SCRATCH_FORMAT`]. Warp
+ /// terminals accumulate a displacement field rather than pixels and
+ /// declare a two-channel float format instead — the scratch *is* the
+ /// field, so everything that already tracks the scratch (grow, rebase,
+ /// read mirror, checkpoint ring) tracks the field for free. See
+ /// [`crate::brush::warp_field`].
+ ///
+ /// The stroke buffer pairs the format with the matching canvas-copy
+ /// bind group layout via
+ /// [`BrushPipelines::canvas_copy_layout_for`](crate::brush::pipeline::BrushPipelines::canvas_copy_layout_for),
+ /// so a terminal never has to think about filterability.
+ pub scratch_format: wgpu::TextureFormat,
}
+/// Scratch format for terminals that accumulate colour — the default, and
+/// what every terminal but `liquify` uses.
+pub const COLOR_SCRATCH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
impl BrushNodeRegistration {
/// Construct a compute-only node (no GPU pipelines, no lifecycle).
pub fn compute(
@@ -75,6 +93,7 @@ impl BrushNodeRegistration {
pipelines: Vec::new(),
evaluator,
lifecycle: Lifecycle::None,
+ scratch_format: COLOR_SCRATCH_FORMAT,
}
}
diff --git a/crates/darkly/src/brush/nodes/blur.rs b/crates/darkly/src/brush/nodes/blur.rs
index 1bcfba33..40f33739 100644
--- a/crates/darkly/src/brush/nodes/blur.rs
+++ b/crates/darkly/src/brush/nodes/blur.rs
@@ -40,6 +40,7 @@ use crate::brush::read_mirror_terminal::{
};
use crate::brush::wgsl::{CompileWgslCtx, DabField, NodeWgsl, WgslType};
use crate::brush::wire::{BrushWireType, ScalarValue};
+use crate::gpu::preview::{PreviewBackdrop, PreviewStaging};
use crate::nodegraph::{NodeRegistration, PortDef, UnitType};
/// Per-dab strength below which the dab is dropped — `mix(orig, blurred, 0)`
@@ -68,6 +69,7 @@ pub fn register() -> BrushNodeRegistration {
pipelines: vec![read_mirror_pipeline_reg("blur")],
evaluator: || Box::new(BlurEvaluator),
lifecycle: crate::brush::node::Lifecycle::SeedScratchFromPreStroke,
+ scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
@@ -91,6 +93,16 @@ pub fn register() -> BrushNodeRegistration {
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:gauge-high")
.exposed()
+ // A preview stroke is read at a canonical strength, not at
+ // the brush's own. The default's kernel is
+ // `0.05 * 36 * MAX_KERNEL_FRACTION` ≈ 0.45 px against a
+ // preview dab radius of ~36 px, so at the shipped value the
+ // stroke changes a 194 x 35 patch of a 1024 x 768 canvas and
+ // the thumbnail framer crops that fragment and blows it up
+ // to fill the tile — two stripes and no stroke. Pinned, the
+ // stroke spans the S-curve and the tile is a picture of it.
+ // The smallest value measured to do that with margin.
+ .with_preview_value(0.6)
.with_description(
"How wide a neighborhood each touch averages, as a fraction of the brush \
radius. Higher values soften more per touch.",
@@ -115,7 +127,10 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: true,
supports_erase: false,
- preview_fallback_icon: Some("mdi:blur"),
+ preview_staging: Some(PreviewStaging {
+ icon: "mdi:blur",
+ backdrop: PreviewBackdrop::Stripes,
+ }),
},
}
}
diff --git a/crates/darkly/src/brush/nodes/brush_settings.rs b/crates/darkly/src/brush/nodes/brush_settings.rs
index ac5296f8..f2016bc5 100644
--- a/crates/darkly/src/brush/nodes/brush_settings.rs
+++ b/crates/darkly/src/brush/nodes/brush_settings.rs
@@ -163,7 +163,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
|| Box::new(BrushSettingsEvaluator),
)
diff --git a/crates/darkly/src/brush/nodes/circle.rs b/crates/darkly/src/brush/nodes/circle.rs
index 88c702de..cef31af5 100644
--- a/crates/darkly/src/brush/nodes/circle.rs
+++ b/crates/darkly/src/brush/nodes/circle.rs
@@ -44,6 +44,7 @@ pub fn register() -> BrushNodeRegistration {
pipelines: vec![],
evaluator: || Box::new(ShapeEvaluator),
lifecycle: crate::brush::node::Lifecycle::None,
+ scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
category: "shape",
@@ -177,7 +178,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
}
}
diff --git a/crates/darkly/src/brush/nodes/clone_source.rs b/crates/darkly/src/brush/nodes/clone_source.rs
index 4133e4c9..3995acbb 100644
--- a/crates/darkly/src/brush/nodes/clone_source.rs
+++ b/crates/darkly/src/brush/nodes/clone_source.rs
@@ -63,6 +63,7 @@ use crate::brush::wgsl::{
sample_graph_texture, CompileWgslCtx, InputBinding, NodeWgsl, UniformField, WgslType,
};
use crate::brush::wire::{BrushWireType, ScalarValue};
+use crate::gpu::preview::{PreviewBackdrop, PreviewStaging};
use crate::nodegraph::{NodeRegistration, PortDef};
pub const TYPE_ID: &str = "clone_source";
@@ -73,7 +74,7 @@ pub fn register() -> BrushNodeRegistration {
type_id: TYPE_ID,
category: "texture",
display_name: "Clone Source",
- description: "Samples pixels from a set source point onto the canvas under your cursor. Set the source with the clone set-source gesture, then paint. Feed into a Stamp Tip's colour input.",
+ description: "Samples pixels from a set source point onto the canvas under your cursor. Set the source with the clone set-source gesture, then paint. Feed into a Stamp Tip's color input.",
ports: vec
@@ -7,26 +7,68 @@
//! file owns only what's liquify-specific: the read half-extent and the
//! variant WGSL (including the softness falloff helper).
//!
-//! Per dab the fragment shader samples the scratch read mirror at a
-//! *displaced* UV inside a circular brush disc and writes the warped
-//! sample back into the scratch. Successive dabs compound because each
-//! reads the cumulatively-warped scratch — the per-dab serialization is
-//! semantically required, not a perf bug.
+//! Unlike its read-mirror siblings, liquify's scratch holds a
+//! [warp field](crate::brush::warp_field) rather than colour. Per dab the
+//! fragment shader advects the accumulated displacement and adds this
+//! dab's own — it never touches a pixel. The picture is produced once, at
+//! commit, by sampling the pre-stroke snapshot through the finished
+//! field.
+//!
+//! That is not a shortcut around per-dab compounding, it is how the
+//! compounding is made lossless. Later dabs still displace content
+//! earlier dabs displaced: the `field(p + nv)` term reads the previous
+//! field at the displaced location and carries it along, which composes
+//! the two maps exactly. What it does *not* do is resample the picture
+//! each time — at 4 px spacing under a 77 px brush that was ~38 chained
+//! bilinear filters per swipe, and a chain of bilinear filters is a
+//! low-pass cascade. Detail is now independent of dab count.
+//!
+//! The per-dab serialization is therefore still semantically required,
+//! not a perf bug: dab *n+1* must read dab *n*'s field.
+//!
+//! Inherited caveat, unchanged by the field model: the read-mirror fetch
+//! addresses the mirror by `textureDimensions`, while only the copied
+//! `copy_w × copy_h` sub-rect of that lazily-grown texture is valid
+//! (`scratch.rs`), so a dab clipped at the layer edge can address stale
+//! texels. The helper clamps to the texture, not to the valid rect.
//!
//! Displacement magnitude is `strength × |pen.motion|` — the cursor's
-//! per-dab travel scaled by strength. With the Liquify brush's fixed
-//! `pen_input.spacing_min_px = LIQUIFY_SPACING_PX`, `|motion|` is the
-//! same constant at any brush size, so:
+//! per-dab travel scaled by strength. So:
//! * `strength = 1` locks pixels to the cursor (per-dab push =
//! per-dab cursor motion);
-//! * `strength < 1` produces a strength-fraction drag;
-//! * brush size controls only the warped *extent* (the disc), never
-//! the *intensity*.
+//! * `strength < 1` produces a strength-fraction drag.
//!
//! Pen speed enters only via dab density along the path; the per-dab
-//! push is identical for slow and fast drags. **Liquify is deliberately
-//! size-invariant** — the size slider scales the warped extent, not the
-//! push strength.
+//! push is identical for slow and fast drags.
+//!
+//! ## Why spacing is proportional, not pinned
+//!
+//! Dab spacing uses the ordinary proportional rule
+//! ([`SpacingConfig`](crate::brush::spacing::SpacingConfig)) at
+//! [`LIQUIFY_SPACING_RATIO`], rather than the flat pixel floor it once
+//! carried. Total displacement over a drag does not depend on spacing:
+//! per dab it is `strength × |motion| = strength × spacing`, and a drag
+//! of length `L` places `L / spacing` dabs, so the total is
+//! `strength × L` — spacing cancels. It only sets how finely the warp is
+//! discretised.
+//!
+//! That is a property of accumulating a *field*. Under the per-dab image
+//! warp this replaced, spacing also cancelled geometrically, but each dab
+//! cost a resample — so the dab count could not be traded for performance
+//! without trading away detail, and the spacing was pinned flat at 4 px.
+//! Pinned spacing makes cost `O(radius²)` per unit of travel: dab count
+//! stays constant while each dab's mirror copy and fragment pass grow
+//! with the disc. Proportional spacing makes it `O(radius)`.
+//!
+//! The ratio is bounded by banding, not by intensity. Measured on a
+//! straight drag at radius 76.8: peak displacement moves 45.01 → 45.11 px
+//! (+0.2 %) from 4 px to 8 px spacing, then 45.52 at 16 px and 48.71
+//! (+8 %, visibly stepped) at 32 px. Spacing up to ~0.1 × radius is
+//! faithful; beyond that the discretisation starts showing.
+//!
+//! GIMP's warp tool reaches the same place — `step = effect_size ×
+//! stroke_spacing / 100` (`app/tools/gimpwarptool.c:432`), spacing
+//! proportional to brush size, at a comparable default density.
//!
//! ## Softness waveshape
//!
@@ -49,28 +91,28 @@ use crate::brush::read_mirror_terminal::{
};
use crate::brush::wgsl::{CompileWgslCtx, NodeWgsl};
use crate::brush::wire::{BrushWireType, ScalarValue};
+use crate::gpu::preview::{PreviewBackdrop, PreviewStaging};
use crate::nodegraph::{NodeRegistration, PortDef, UnitType};
// ── Constants ───────────────────────────────────────────────────────────
-/// Dab spacing for the Liquify brush, in canvas pixels. The brush
-/// pins `pen_input.spacing_min_px` to this value (and sets ratio to
-/// zero) so spacing stays fixed at any brush size. Per-dab
-/// displacement is then `strength × |pen.motion| ≈ strength ×
-/// LIQUIFY_SPACING_PX`, which makes:
-/// * `strength = 1` lock pixels to the cursor (per-dab push equals
-/// per-dab cursor motion);
-/// * `strength = 0.5` lag the cursor by 50% (the "drag" feel);
-/// * the absolute pixel push size-invariant — the size slider
-/// controls the warped *extent* (the disc), not the *intensity*.
+/// Dab spacing for the Liquify brush, as a fraction of dab **diameter**
+/// — the value `brushes/liquify.yaml` sets on `brush_settings.spacing`.
+///
+/// 0.05 of diameter is 0.1 of radius, the density the module doc's
+/// measurements put at the edge of faithful: at that spacing the warp is
+/// within 0.2 % of a 4 px reference, and it holds the per-unit-travel
+/// cost at `O(radius)` instead of the `O(radius²)` a pinned pixel
+/// spacing forced.
///
-/// Tuned to 4 px: tight enough for smooth-looking warps without dab
-/// banding, large enough not to blow up the dab count at huge
-/// brushes (perf scales with `diameter / spacing`).
-pub const LIQUIFY_SPACING_PX: f32 = 4.0;
+/// Declared here rather than only in the YAML because it is a property
+/// of how this terminal behaves, and because the module doc's reasoning
+/// is what justifies the number. Keep the two in step.
+pub const LIQUIFY_SPACING_RATIO: f32 = 0.05;
-/// Per-dab strength below which the dab is dropped — `mix(orig, warped, _·sel)`
-/// collapses to identity and the per-dab pass would be a no-op.
+/// Per-dab strength below which the dab is dropped — the dab's
+/// displacement collapses to zero, so advecting the field by it and
+/// adding it back is an identity write.
const STRENGTH_EPSILON: f32 = 1.0e-4;
/// Brush radius below which the dab is dropped — sub-pixel discs warp
@@ -88,7 +130,12 @@ pub fn register() -> BrushNodeRegistration {
BrushNodeRegistration {
pipelines: vec![read_mirror_pipeline_reg("liquify")],
evaluator: || Box::new(LiquifyEvaluator),
- lifecycle: crate::brush::node::Lifecycle::SeedScratchFromPreStroke,
+ // A transparent clear *is* a zero field: no displacement
+ // anywhere, so the first resolve reproduces the pre-stroke image
+ // exactly. Seeding from pre-stroke would be meaningless — the
+ // scratch holds offsets, not colour.
+ lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent,
+ scratch_format: crate::brush::warp_field::FIELD_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
@@ -156,7 +203,10 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: true,
supports_erase: false,
- preview_fallback_icon: Some("tabler:ripple"),
+ preview_staging: Some(PreviewStaging {
+ icon: "tabler:ripple",
+ backdrop: PreviewBackdrop::Stripes,
+ }),
},
}
}
@@ -230,17 +280,28 @@ impl ReadMirrorTerminal for LiquifyEvaluator {
\x20 let t = (softness - sine_break) / (1.0 - sine_break);\n\
\x20 return mix(sine, 1.0, t);\n\
\x20 }}\n\
- }}\n"
+ }}\n\
+ {}\n",
+ crate::brush::warp_field::FIELD_HELPERS_WGSL,
);
// Fragment body: `local_dist` and `target_pos` come from the
// framework wrapper; the framework already discards past
// `d.bbox_target_px`. We additionally discard past
- // `local_dist >= 1.0` so the warp stays inside the disc.
+ // `local_dist >= 1.0` so the warp stays outside the disc alone.
// The falloff helper takes `0 = spike` / `1 = square`. The
// user-facing slider is labelled "Softness" with the opposite
// intuition — `1 = soft / feathery`, `0 = hard / sharp`. Invert
// before passing to the helper so the slider matches the label.
+ //
+ // `sel` and `warp_mask` scale the *displacement*, never the
+ // result. A geometric operation that cross-faded warped against
+ // unwarped colour would be a literal double exposure inside a
+ // soft selection edge; here every output pixel remains exactly
+ // 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";
wgsl.body = format!(
" if (local_dist >= 1.0) {{ discard; }}\n\
\x20 let warp_mask = clamp({mask_expr}, 0.0, 1.0);\n\
@@ -251,14 +312,8 @@ impl ReadMirrorTerminal for LiquifyEvaluator {
\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\
- \x20 let displacement = length(motion_vec) * strength;\n\
- \x20 let source_pos = target_pos - dir * displacement * f;\n\
- \x20 let mirror_dims = vec2(textureDimensions(scratch_mirror_tex));\n\
- \x20 let copy_uv = (source_pos - d.{copy_origin_field}) / mirror_dims;\n\
- \x20 let warped = textureSampleLevel(scratch_mirror_tex, scratch_mirror_smp, copy_uv, 0.0);\n\
- \x20 let original_uv = (target_pos - d.{copy_origin_field}) / mirror_dims;\n\
- \x20 let original = textureSampleLevel(scratch_mirror_tex, scratch_mirror_smp, original_uv, 0.0);\n\
- \x20 return mix(original, warped, sel * warp_mask);\n",
+ {}",
+ crate::brush::warp_field::advect_wgsl(offset_expr, copy_origin_field),
);
Ok(wgsl)
diff --git a/crates/darkly/src/brush/nodes/noise.rs b/crates/darkly/src/brush/nodes/noise.rs
index 9d53a881..798b0b54 100644
--- a/crates/darkly/src/brush/nodes/noise.rs
+++ b/crates/darkly/src/brush/nodes/noise.rs
@@ -141,7 +141,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
|| Box::new(NoiseEvaluator),
)
diff --git a/crates/darkly/src/brush/nodes/paint.rs b/crates/darkly/src/brush/nodes/paint.rs
index 1c05aaab..869e613f 100644
--- a/crates/darkly/src/brush/nodes/paint.rs
+++ b/crates/darkly/src/brush/nodes/paint.rs
@@ -359,6 +359,7 @@ pub fn register() -> BrushNodeRegistration {
pipelines: vec![paint_pipeline_reg()],
evaluator: || Box::new(PaintEvaluator),
lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent,
+ scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
@@ -406,7 +407,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: true,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
}
}
diff --git a/crates/darkly/src/brush/nodes/paint_color.rs b/crates/darkly/src/brush/nodes/paint_color.rs
index c6f1b554..50492815 100644
--- a/crates/darkly/src/brush/nodes/paint_color.rs
+++ b/crates/darkly/src/brush/nodes/paint_color.rs
@@ -26,7 +26,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
|| Box::new(PaintColorEvaluator),
)
diff --git a/crates/darkly/src/brush/nodes/pen_input.rs b/crates/darkly/src/brush/nodes/pen_input.rs
index f8a09fa2..5a7cc653 100644
--- a/crates/darkly/src/brush/nodes/pen_input.rs
+++ b/crates/darkly/src/brush/nodes/pen_input.rs
@@ -86,7 +86,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
|| Box::new(PenInputEvaluator),
)
diff --git a/crates/darkly/src/brush/nodes/polygon.rs b/crates/darkly/src/brush/nodes/polygon.rs
index 8152d46f..54ca60a3 100644
--- a/crates/darkly/src/brush/nodes/polygon.rs
+++ b/crates/darkly/src/brush/nodes/polygon.rs
@@ -32,6 +32,7 @@ pub fn register() -> BrushNodeRegistration {
pipelines: vec![],
evaluator: || Box::new(PolygonEvaluator),
lifecycle: crate::brush::node::Lifecycle::None,
+ scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
// Shared UI grouping with `circle` and `stamp` — the tip
@@ -127,7 +128,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
}
}
diff --git a/crates/darkly/src/brush/nodes/random.rs b/crates/darkly/src/brush/nodes/random.rs
index ffa7d9e3..fb911294 100644
--- a/crates/darkly/src/brush/nodes/random.rs
+++ b/crates/darkly/src/brush/nodes/random.rs
@@ -48,7 +48,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
|| Box::new(RandomEvaluator),
)
diff --git a/crates/darkly/src/brush/nodes/smudge.rs b/crates/darkly/src/brush/nodes/smudge.rs
index 22851f44..d6c722be 100644
--- a/crates/darkly/src/brush/nodes/smudge.rs
+++ b/crates/darkly/src/brush/nodes/smudge.rs
@@ -28,6 +28,7 @@ use crate::brush::read_mirror_terminal::{
};
use crate::brush::wgsl::{CompileWgslCtx, NodeWgsl};
use crate::brush::wire::{BrushWireType, ScalarValue};
+use crate::gpu::preview::{PreviewBackdrop, PreviewStaging};
use crate::nodegraph::{NodeRegistration, PortDef, UnitType};
/// Motion magnitude (canvas pixels) below which the dab is treated as
@@ -42,6 +43,7 @@ pub fn register() -> BrushNodeRegistration {
pipelines: vec![read_mirror_pipeline_reg("smudge")],
evaluator: || Box::new(SmudgeEvaluator),
lifecycle: crate::brush::node::Lifecycle::SeedScratchFromPreStroke,
+ scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
@@ -91,7 +93,10 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: true,
supports_erase: false,
- preview_fallback_icon: Some("mdi:gesture-swipe"),
+ preview_staging: Some(PreviewStaging {
+ icon: "mdi:gesture-swipe",
+ backdrop: PreviewBackdrop::Stripes,
+ }),
},
}
}
diff --git a/crates/darkly/src/brush/nodes/split_color.rs b/crates/darkly/src/brush/nodes/split_color.rs
index 0cca670d..eb232bbc 100644
--- a/crates/darkly/src/brush/nodes/split_color.rs
+++ b/crates/darkly/src/brush/nodes/split_color.rs
@@ -47,7 +47,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
|| Box::new(SplitColorEvaluator),
)
diff --git a/crates/darkly/src/brush/nodes/stamp.rs b/crates/darkly/src/brush/nodes/stamp.rs
index 41284849..dd0daf09 100644
--- a/crates/darkly/src/brush/nodes/stamp.rs
+++ b/crates/darkly/src/brush/nodes/stamp.rs
@@ -23,6 +23,7 @@ pub fn register() -> BrushNodeRegistration {
pipelines: vec![],
evaluator: || Box::new(StampEvaluator),
lifecycle: crate::brush::node::Lifecycle::None,
+ scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
category: "shape",
@@ -40,7 +41,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
}
}
diff --git a/crates/darkly/src/brush/nodes/switch.rs b/crates/darkly/src/brush/nodes/switch.rs
index c5274988..cf8ab14b 100644
--- a/crates/darkly/src/brush/nodes/switch.rs
+++ b/crates/darkly/src/brush/nodes/switch.rs
@@ -90,7 +90,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
|| Box::new(SwitchEvaluator),
)
diff --git a/crates/darkly/src/brush/nodes/watercolor.rs b/crates/darkly/src/brush/nodes/watercolor.rs
index 79ff559a..ad772063 100644
--- a/crates/darkly/src/brush/nodes/watercolor.rs
+++ b/crates/darkly/src/brush/nodes/watercolor.rs
@@ -580,6 +580,7 @@ pub fn register() -> BrushNodeRegistration {
pipelines: vec![watercolor_pipeline_reg()],
evaluator: || Box::new(WatercolorEvaluator),
lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent,
+ scratch_format: crate::brush::node::COLOR_SCRATCH_FORMAT,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
@@ -652,7 +653,7 @@ pub fn register() -> BrushNodeRegistration {
is_gpu: true,
is_terminal: true,
supports_erase: false,
- preview_fallback_icon: None,
+ preview_staging: None,
},
}
}
diff --git a/crates/darkly/src/brush/pipeline.rs b/crates/darkly/src/brush/pipeline.rs
index 2797ea84..6cc3fd7e 100644
--- a/crates/darkly/src/brush/pipeline.rs
+++ b/crates/darkly/src/brush/pipeline.rs
@@ -222,7 +222,10 @@ pub struct BrushPipelineRegistration {
/// dropping its registration into this list; the harvest loop picks
/// it up automatically.
pub fn plumbing_registrations() -> Vec {
- vec![crate::brush::composite_pipeline::composite_pipeline_registration()]
+ vec![
+ crate::brush::composite_pipeline::composite_pipeline_registration(),
+ crate::brush::warp_field::warp_field_resolve_registration(),
+ ]
}
// ── BrushPipelines: shared infra + plumbing + per-node registry ──────────
@@ -250,9 +253,14 @@ pub struct BrushPipelines {
uniform_bgl: wgpu::BindGroupLayout,
selection_bgl: wgpu::BindGroupLayout,
canvas_copy_bgl: wgpu::BindGroupLayout,
+ /// Non-filtering twin of `canvas_copy_bgl`, for scratches holding a
+ /// float32 warp field rather than colour.
+ canvas_copy_unfilterable_bgl: wgpu::BindGroupLayout,
// ── Shared samplers / default bind groups ────────────────────────
canvas_copy_sampler: wgpu::Sampler,
+ /// Nearest sampler paired with `canvas_copy_unfilterable_bgl`.
+ canvas_copy_nearest_sampler: wgpu::Sampler,
/// 1×1 white selection (= fully selected). Bound when no selection
/// is active. `pub` because hot-path call sites take its address
/// directly via `unwrap_or(&self.brush_pipelines.default_selection_bind_group)`.
@@ -351,6 +359,35 @@ impl BrushPipelines {
],
});
+ // Same shape, non-filtering. Warp terminals put a float32
+ // displacement field in the scratch, and `Rg32Float` is not
+ // filterable in core WebGPU (only with the optional
+ // `float32-filterable` feature). Their shaders fetch it with
+ // `textureLoad` and interpolate by hand, so a non-filtering
+ // layout costs them nothing.
+ let canvas_copy_unfilterable_bgl =
+ device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("brush-canvas-copy-unfilterable-bgl"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Float { filterable: false },
+ view_dimension: wgpu::TextureViewDimension::D2,
+ multisampled: false,
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
+ count: None,
+ },
+ ],
+ });
+
// ── Default selection (1×1 white = fully selected) ─────────
let sel_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("brush-default-selection"),
@@ -417,6 +454,17 @@ impl BrushPipelines {
..Default::default()
});
+ // Nearest counterpart, for the non-filtering layout above. Warp
+ // terminals never sample through it — it exists because the
+ // layout declares a sampler slot — but a `Filtering` sampler is
+ // illegal against a `NonFiltering` entry, so it must be nearest.
+ let canvas_copy_nearest_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("brush-canvas-copy-nearest-sampler"),
+ mag_filter: wgpu::FilterMode::Nearest,
+ min_filter: wgpu::FilterMode::Nearest,
+ ..Default::default()
+ });
+
// ── Plumbing pipelines (no owning node) ────────────────────
// Blit: stretch a UV sub-rect of the source across the target viewport.
@@ -591,7 +639,9 @@ impl BrushPipelines {
uniform_bgl,
selection_bgl,
canvas_copy_bgl,
+ canvas_copy_unfilterable_bgl,
canvas_copy_sampler,
+ canvas_copy_nearest_sampler,
default_selection_bind_group,
blit_pipeline,
blit_uniform_ring,
@@ -713,6 +763,30 @@ impl BrushPipelines {
&self.canvas_copy_sampler
}
+ /// The canvas-copy BGL + sampler a `Scratch` of `format` must be built
+ /// against. Color scratches get the filtering pair; float32 warp
+ /// fields get the non-filtering pair, because `Rg32Float` is not
+ /// filterable without the optional `float32-filterable` feature.
+ /// Callers pass the result straight to `Scratch::new` — this is the
+ /// single place the pairing is decided.
+ pub fn canvas_copy_layout_for(
+ &self,
+ format: wgpu::TextureFormat,
+ ) -> (&wgpu::BindGroupLayout, &wgpu::Sampler) {
+ if format
+ .guaranteed_format_features(wgpu::Features::empty())
+ .flags
+ .contains(wgpu::TextureFormatFeatureFlags::FILTERABLE)
+ {
+ (&self.canvas_copy_bgl, &self.canvas_copy_sampler)
+ } else {
+ (
+ &self.canvas_copy_unfilterable_bgl,
+ &self.canvas_copy_nearest_sampler,
+ )
+ }
+ }
+
/// The 1×1 white selection bind group — bound when no selection is
/// active. Exposed for out-of-crate tests that construct a
/// `BrushGpuContext` manually and need a default selection mask.
diff --git a/crates/darkly/src/brush/portable.rs b/crates/darkly/src/brush/portable.rs
index 70e58d8d..1b191067 100644
--- a/crates/darkly/src/brush/portable.rs
+++ b/crates/darkly/src/brush/portable.rs
@@ -93,6 +93,18 @@ pub struct PortableNode {
/// a compact diff.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub inputs: BTreeMap,
+ /// Per-input slider-bound overrides, keyed by input name, as
+ /// `[min, max]`. Diffed against the registration exactly like `inputs`,
+ /// so only genuinely re-ranged ports serialize.
+ ///
+ /// Where `inputs` authors *where the knob sits*, this authors *how far it
+ /// travels* — the escape hatch for a port whose registration range is a
+ /// poor fit for one brush. A math node declaring `0..1` can be given a
+ /// bipolar `[-1.0, 1.0]` control, or a port whose useful band is a sliver
+ /// of its declared range can be narrowed onto it, without a helper node
+ /// in the graph doing the arithmetic.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub ranges: BTreeMap,
}
/// A wire serialized as `". -> ."`.
@@ -208,12 +220,28 @@ impl PortableBrush {
}
}
+ // Slider bounds: the same diff-against-registration treatment,
+ // so a brush that never re-ranges anything emits no `ranges` key.
+ let mut ranges = BTreeMap::new();
+ for port in &node.ports {
+ if port.dir != PortDir::Input {
+ continue;
+ }
+ let Some(reg_port) = reg.ports.iter().find(|p| p.name == port.name) else {
+ continue;
+ };
+ if reg_port.min != port.min || reg_port.max != port.max {
+ ranges.insert(port.name.clone(), [port.min, port.max]);
+ }
+ }
+
nodes.insert(
id.0.clone(),
PortableNode {
type_id: node.type_id.clone(),
comment: node.comment.clone(),
inputs,
+ ranges,
},
);
}
@@ -316,6 +344,14 @@ impl PortableBrush {
.set_node_comment(&new_id, pn.comment.clone())
.expect("node just added by add_node must exist");
}
+ // Applied through the graph setter rather than onto the cloned
+ // ports above so the ascending-and-finite invariant is enforced
+ // in one place for every author — yaml, editor, and paste alike.
+ for (name, [min, max]) in &pn.ranges {
+ graph
+ .set_port_range(&new_id, name, *min, *max)
+ .map_err(|e| format!("range '{name}' on '{}': {e}", pn.type_id))?;
+ }
id_map.insert(yaml_id.clone(), new_id);
}
@@ -471,6 +507,69 @@ mod tests {
assert_eq!(meta.icon, "fa6-solid:circle-half-stroke");
}
+ /// A re-ranged port survives the yaml round trip, and a graph that
+ /// re-ranges nothing emits no `ranges` key at all — the diff stays a
+ /// diff, so untouched brushes' yaml doesn't churn.
+ #[test]
+ fn port_ranges_survive_and_stay_a_diff() {
+ let registry = registry();
+ let mut graph = crate::brush::default_graph();
+ let circle = graph
+ .nodes()
+ .iter()
+ .find(|(_, n)| n.type_id == "circle")
+ .map(|(id, _)| id.clone())
+ .expect("default has a circle node");
+
+ // Untouched graph: no node carries a `ranges` entry.
+ let clean = PortableBrush::from_graph_only(&graph, registry).unwrap();
+ assert!(
+ clean.nodes.values().all(|n| n.ranges.is_empty()),
+ "unmodified graph must not serialize any ranges"
+ );
+ assert!(!serde_yaml_ng::to_string(&clean).unwrap().contains("ranges"));
+
+ graph
+ .set_port_range(&circle, "softness", -1.0, 2.5)
+ .unwrap();
+
+ let portable = PortableBrush::from_graph_only(&graph, registry).unwrap();
+ let yaml = serde_yaml_ng::to_string(&portable).unwrap();
+ let restored = serde_yaml_ng::from_str::(&yaml)
+ .unwrap()
+ .into_graph(registry)
+ .unwrap();
+ let port = restored
+ .nodes()
+ .values()
+ .find(|n| n.type_id == "circle")
+ .expect("restored graph has a circle node")
+ .ports
+ .iter()
+ .find(|p| p.name == "softness")
+ .unwrap();
+ assert_eq!((port.min, port.max), (-1.0, 2.5));
+ }
+
+ /// A hand-edited yaml carrying a degenerate or inverted range is
+ /// rejected at import rather than producing a control whose normalize
+ /// and clamp arithmetic silently misbehaves.
+ #[test]
+ fn inverted_yaml_range_is_rejected_at_import() {
+ let registry = registry();
+ let graph = crate::brush::default_graph();
+ let mut portable = PortableBrush::from_graph_only(&graph, registry).unwrap();
+ let circle = portable
+ .nodes
+ .values_mut()
+ .find(|n| n.type_id == "circle")
+ .expect("default has a circle node");
+ circle.ranges.insert("softness".into(), [1.0, 0.0]);
+
+ let err = portable.into_graph(registry).unwrap_err();
+ assert!(err.contains("softness"), "unexpected error: {err}");
+ }
+
/// The unified model's headline guarantee: a non-wirable input (here
/// the circle node's `algorithm` Enum) is fully *exposable*, and both the
/// exposure and a non-default enum value survive a YAML round trip.
diff --git a/crates/darkly/src/brush/preview_renderer.rs b/crates/darkly/src/brush/preview_renderer.rs
index 839a8463..54b0ede9 100644
--- a/crates/darkly/src/brush/preview_renderer.rs
+++ b/crates/darkly/src/brush/preview_renderer.rs
@@ -22,19 +22,40 @@ use super::stabilizer::PassThrough;
use super::stroke_buffer::StrokeBuffer;
use super::stroke_engine::StrokeEngine;
use super::wire::BrushWireType;
+use crate::gpu::preview::PreviewBackdrop;
use crate::nodegraph::Graph;
+/// Stroke seed every preview render uses.
+///
+/// A preview is a picture of a brush, not of one stroke of it: five shipped
+/// brushes contain `random`/`noise` nodes, and seeding those from the clock
+/// would make a cached thumbnail differ from its own re-bake and a
+/// documentation asset differ from its own rebuild. The value is arbitrary; that
+/// it never changes is the point.
+const PREVIEW_STROKE_SEED: u32 = 0x5EED_B00C;
+
/// Reusable GPU scratch + layer textures for preview rendering.
struct PreviewTarget {
width: u32,
height: u32,
+ /// Scratch format the cached `stroke_buffer` was built for. Part of
+ /// the cache key: this renderer is reused across brushes, and a warp
+ /// terminal's scratch holds a float field rather than colour, so a
+ /// buffer cached for one is unbindable by the other.
+ scratch_format: wgpu::TextureFormat,
layer_texture: wgpu::Texture,
layer_view: wgpu::TextureView,
stroke_buffer: StrokeBuffer,
}
impl PreviewTarget {
- fn new(device: &wgpu::Device, width: u32, height: u32, pipelines: &BrushPipelines) -> Self {
+ fn new(
+ device: &wgpu::Device,
+ width: u32,
+ height: u32,
+ pipelines: &BrushPipelines,
+ scratch_format: wgpu::TextureFormat,
+ ) -> Self {
let layer_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("brush-preview-layer"),
size: wgpu::Extent3d {
@@ -53,10 +74,11 @@ impl PreviewTarget {
view_formats: &[],
});
let layer_view = layer_texture.create_view(&wgpu::TextureViewDescriptor::default());
- let stroke_buffer = StrokeBuffer::new(device, width, height, pipelines);
+ let stroke_buffer = StrokeBuffer::new(device, width, height, pipelines, scratch_format);
Self {
width,
height,
+ scratch_format,
layer_texture,
layer_view,
stroke_buffer,
@@ -90,6 +112,7 @@ impl BrushStrokePreviewRenderer {
path: &[PaintInformation],
fg_color: [f32; 4],
bg_color: [f32; 4],
+ backdrop: PreviewBackdrop,
width: u32,
height: u32,
base_size_override: Option,
@@ -100,43 +123,46 @@ impl BrushStrokePreviewRenderer {
// Fresh compile so callers can edit the graph between renders.
let runner = super::compile_graph(graph).ok()?;
- // Ensure scratch + layer textures match the requested size.
+ // Ensure scratch + layer textures match the requested size *and*
+ // the brush's scratch format — the cached target is shared across
+ // brushes, so previewing a warp terminal after a colour one must
+ // reallocate rather than bind a colour scratch to a field pipeline.
+ let scratch_format = runner.scratch_format();
let target_changed = match &self.target {
- Some(t) => t.width != width || t.height != height,
+ Some(t) => t.width != width || t.height != height || t.scratch_format != scratch_format,
None => true,
};
if target_changed {
- self.target = Some(PreviewTarget::new(device, width, height, pipelines));
+ self.target = Some(PreviewTarget::new(
+ device,
+ width,
+ height,
+ pipelines,
+ scratch_format,
+ ));
}
let target = self.target.as_mut().unwrap();
- // Pre-fill the layer with the background color, then snapshot it as
- // the pre-stroke. `color_output::commit` composites the stroke
- // scratch onto this snapshot and writes the result back to the
- // layer — so seeding `bg` here is how the background gets shown.
+ // Pre-fill the layer with the backdrop, then snapshot it as the
+ // pre-stroke. `color_output::commit` composites the stroke scratch onto
+ // this snapshot and writes the result back to the layer — so painting
+ // the backdrop here is how it gets shown. It is also the only way one
+ // reaches a terminal that *transports* the destination: those sample
+ // `source_override.unwrap_or(pre_stroke_texture)` (`gpu_context.rs`),
+ // and the preview captures no source snapshot, so the pre-stroke is
+ // what they smear, warp, blur or clone.
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("brush-preview-pre-fill"),
});
- {
- let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
- label: Some("brush-preview-bg-clear"),
- color_attachments: &[Some(wgpu::RenderPassColorAttachment {
- view: &target.layer_view,
- resolve_target: None,
- depth_slice: None,
- ops: wgpu::Operations {
- load: wgpu::LoadOp::Clear(wgpu::Color {
- r: bg_color[0] as f64,
- g: bg_color[1] as f64,
- b: bg_color[2] as f64,
- a: bg_color[3] as f64,
- }),
- store: wgpu::StoreOp::Store,
- },
- })],
- ..Default::default()
- });
- }
+ backdrop.fill(
+ queue,
+ &mut encoder,
+ &target.layer_view,
+ &target.layer_texture,
+ (width, height),
+ fg_color,
+ bg_color,
+ );
let paint_target = crate::gpu::paint_target::GpuPaintTarget::from_canvas_texture(
&target.layer_texture,
&target.layer_view,
@@ -164,16 +190,34 @@ impl BrushStrokePreviewRenderer {
// to survive the crop-and-downscale to the thumbnail. The stroke
// preview passes `None` and keeps the graph-driven size.
let base_size = base_size_override.unwrap_or_else(|| brush_settings::base_size(graph));
- // No clone source in the editor preview — a clone brush renders
- // its synthetic preview stroke without a set-source anchor.
+
+ // A brush that transports pixels from elsewhere has nowhere to
+ // transport them from unless the preview says where. The offset comes
+ // from the backdrop — the only thing that knows what displacement
+ // escapes its own field — and the compiled graph says whether anything
+ // will use it, so no node authors a coordinate and any future
+ // source-sampling node gets a working preview for free.
+ let clone_source_anchor = runner.samples_source().then(|| {
+ let [du, dv] = backdrop.source_offset();
+ [
+ path[0].pos[0] + du * width as f32,
+ path[0].pos[1] + dv * height as f32,
+ ]
+ });
let mut engine = StrokeEngine::new(
runner,
fg_color,
spacing,
base_size,
Box::new(PassThrough::new()),
- None,
+ clone_source_anchor,
+ PREVIEW_STROKE_SEED,
);
+ if clone_source_anchor.is_some() {
+ // The snapshot being sampled is the pre-stroke, which covers the
+ // whole preview target.
+ engine.set_clone_source_frame(crate::coord::CanvasRect::from_xywh(0, 0, width, height));
+ }
// Pre-cooked points: pass them through a pass-through stabilizer so
// `render_from_stabilized_range_to` walks them verbatim. No
@@ -189,9 +233,9 @@ impl BrushStrokePreviewRenderer {
macro_rules! make_gpu_ctx {
($label:expr) => {{
// The preview stroke buffer never captures a source
- // snapshot — a source-sampling brush previews off its own
- // (blank) pre-stroke snapshot, so the thumbnail stays
- // neutral.
+ // snapshot, so a source-sampling brush previews off the
+ // pre-stroke snapshot — which is the backdrop, and is what
+ // gives it something to transport.
let (scratch, pre_stroke_texture, pre_stroke_bind_group, source_override) =
target.stroke_buffer.parts_for_brush_ctx();
BrushGpuContext {
diff --git a/crates/darkly/src/brush/read_mirror_terminal.rs b/crates/darkly/src/brush/read_mirror_terminal.rs
index 5c2c696c..029f98fb 100644
--- a/crates/darkly/src/brush/read_mirror_terminal.rs
+++ b/crates/darkly/src/brush/read_mirror_terminal.rs
@@ -141,7 +141,12 @@ struct PerBrushPipeline {
}
impl PerBrushPipeline {
- fn build(ctx: &BuildContext, compiled: &CompiledBrush, label: &str) -> Self {
+ fn build(
+ ctx: &BuildContext,
+ compiled: &CompiledBrush,
+ label: &str,
+ target_format: wgpu::TextureFormat,
+ ) -> Self {
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -181,9 +186,13 @@ impl PerBrushPipeline {
immediate_size: 0,
});
- // REPLACE blend — the fragment shader writes the final pixel;
+ // No blending — the fragment shader writes the final value;
// outside the disc it discards so LoadOp::Load preserves the
- // scratch.
+ // scratch. `blend: None` rather than `BlendState::REPLACE`
+ // because a warp terminal's target is `Rg32Float`, and wgpu gates
+ // *any* blend state on a format being BLENDABLE, which float32
+ // formats are not without the optional `FLOAT32_BLENDABLE`
+ // feature. A pure replace is what REPLACE meant anyway.
let pipeline = ctx
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -199,8 +208,8 @@ impl PerBrushPipeline {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
- format: wgpu::TextureFormat::Rgba8Unorm,
- blend: Some(wgpu::BlendState::REPLACE),
+ format: target_format,
+ blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
@@ -281,11 +290,17 @@ impl ReadMirrorPipeline {
}
}
- fn ensure_pipeline(&self, ctx: &BuildContext, compiled: &CompiledBrush, label: &str) {
+ fn ensure_pipeline(
+ &self,
+ ctx: &BuildContext,
+ compiled: &CompiledBrush,
+ label: &str,
+ target_format: wgpu::TextureFormat,
+ ) {
let mut cache = self.cache.borrow_mut();
cache
.entry(compiled.topology_hash)
- .or_insert_with(|| PerBrushPipeline::build(ctx, compiled, label));
+ .or_insert_with(|| PerBrushPipeline::build(ctx, compiled, label, target_format));
}
fn with_pipeline(&self, hash: u64, f: impl FnOnce(&PerBrushPipeline) -> R) -> R {
@@ -489,8 +504,16 @@ pub fn flush_dabs(gpu: &mut BrushGpuContext) {
gpu.perf
.record_dab_flush_workload(total_dabs, union_w, union_h);
+ // The pass renders into the scratch, so the pipeline's colour target
+ // and its `@group(3)` layout both follow the scratch's format — colour
+ // for smudge/blur, a float displacement field for liquify.
+ let target_format = gpu
+ .stroke
+ .as_ref()
+ .map(|s| s.scratch.format())
+ .unwrap_or(crate::brush::node::COLOR_SCRATCH_FORMAT);
let pipeline_ref = gpu.pipelines.get::(T::PIPELINE_ID);
- ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled, T::LABEL);
+ ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled, T::LABEL, target_format);
let stroke = gpu
.stroke
@@ -590,13 +613,40 @@ pub fn flush_dabs(gpu: &mut BrushGpuContext) {
gpu.perf.record_dab_flush(total_dabs);
}
-/// Direct blit scratch → layer. The scratch already holds the finished
-/// image; commit just copies it across. `gpu.blend_mode` is ignored —
-/// erase semantics aren't meaningful for these read-back transforms.
+/// Scratch → layer. `gpu.blend_mode` is ignored — erase semantics aren't
+/// meaningful for these read-back transforms.
+///
+/// Color terminals (smudge, blur) hold the finished image in the
+/// scratch, so commit is a direct blit. A warp terminal's scratch holds a
+/// displacement field instead, so commit is the single resample that
+/// turns it into pixels — sampling the pre-stroke snapshot (or a
+/// clone-style `source_override`) through the field across the layer's
+/// full extent. Which one runs is decided by the scratch's own format,
+/// not by a list of terminal names here.
pub fn commit(gpu: &mut BrushGpuContext) {
let Some(stroke) = gpu.stroke.as_ref() else {
return;
};
+
+ if stroke.scratch.format() == crate::brush::warp_field::FIELD_FORMAT {
+ let extent = stroke.paint_target.layer_extent();
+ let source_view = stroke.source_view();
+ gpu.pipelines
+ .get::(
+ crate::brush::warp_field::RESOLVE_PIPELINE_ID,
+ )
+ .resolve(
+ gpu.device,
+ &mut gpu.encoder,
+ stroke.scratch.write_view(),
+ &source_view,
+ stroke.paint_target.view(),
+ stroke.paint_target.format(),
+ (extent.width, extent.height),
+ );
+ return;
+ }
+
stroke.paint_target.commit_scratch_blit(
gpu.device,
&mut gpu.encoder,
@@ -657,22 +707,27 @@ fn ensure_per_brush_pipeline(
pipe: &ReadMirrorPipeline,
compiled: &CompiledBrush,
label: &str,
+ target_format: wgpu::TextureFormat,
) {
if pipe.cache.borrow().contains_key(&compiled.topology_hash) {
return;
}
+ // `@group(3)` binds the scratch's own read-mirror bind group, so the
+ // pipeline layout has to be the one that scratch was built against.
+ let (canvas_copy_bgl, canvas_copy_sampler) =
+ gpu.pipelines.canvas_copy_layout_for(target_format);
let ctx = BuildContext {
device: gpu.device,
queue: gpu.queue,
uniform_bgl: gpu.pipelines.uniform_bind_group_layout(),
selection_bgl: gpu.pipelines.selection_bind_group_layout(),
- canvas_copy_bgl: gpu.pipelines.canvas_copy_bind_group_layout(),
- canvas_copy_sampler: gpu.pipelines.canvas_copy_sampler(),
+ canvas_copy_bgl,
+ canvas_copy_sampler,
min_uniform_align: gpu.device.limits().min_uniform_buffer_offset_alignment,
texture_registry: gpu.pipelines.texture_registry(),
baked_sources: gpu.pipelines.baked_sources(),
};
- pipe.ensure_pipeline(&ctx, compiled, label);
+ pipe.ensure_pipeline(&ctx, compiled, label, target_format);
}
#[cfg(test)]
diff --git a/crates/darkly/src/brush/scalar_binary.rs b/crates/darkly/src/brush/scalar_binary.rs
index ceefc4d0..cfd3f5b8 100644
--- a/crates/darkly/src/brush/scalar_binary.rs
+++ b/crates/darkly/src/brush/scalar_binary.rs
@@ -77,7 +77,7 @@ impl ScalarBinaryNode {
is_gpu: false,
is_terminal: false,
supports_erase: true,
- preview_fallback_icon: None,
+ preview_staging: None,
},
self.evaluator,
)
diff --git a/crates/darkly/src/brush/scratch.rs b/crates/darkly/src/brush/scratch.rs
index 0f93e89c..39022ff6 100644
--- a/crates/darkly/src/brush/scratch.rs
+++ b/crates/darkly/src/brush/scratch.rs
@@ -88,6 +88,11 @@ pub struct Scratch {
/// Sampler for the write-side bind group. Nearest filter — no sub-
/// pixel reads in the consumers (commit blit is integer-aligned).
write_sampler: wgpu::Sampler,
+ /// Texel format of both sides. Color terminals use `Rgba8Unorm`;
+ /// warp terminals store a two-channel displacement field instead of
+ /// pixels (see [`crate::brush::warp_field`]) and declare their own
+ /// format on [`crate::brush::node::BrushNodeRegistration`].
+ format: wgpu::TextureFormat,
}
impl Scratch {
@@ -101,12 +106,19 @@ impl Scratch {
///
/// `canvas_copy_sampler` is shared across the canvas-copy BGL bind
/// groups. Linear filter (liquify needs sub-pixel sampling).
+ ///
+ /// `format` is the terminal's declared scratch format. For anything
+ /// other than `Rgba8Unorm` the caller must pass the matching
+ /// non-filtering BGL and sampler from
+ /// [`BrushPipelines::canvas_copy_layout_for`](crate::brush::pipeline::BrushPipelines::canvas_copy_layout_for)
+ /// — float32 formats are not filterable in core WebGPU.
pub fn new(
device: &wgpu::Device,
layer_w: u32,
layer_h: u32,
canvas_copy_bgl: &wgpu::BindGroupLayout,
canvas_copy_sampler: &wgpu::Sampler,
+ format: wgpu::TextureFormat,
) -> Self {
let write_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("scratch-write-sampler"),
@@ -116,12 +128,16 @@ impl Scratch {
});
let read_mirror_sampler = canvas_copy_sampler.clone();
- let (write_texture, write_view) = create_write_texture(device, layer_w, layer_h);
+ let (write_texture, write_view) = create_write_texture(device, layer_w, layer_h, format);
let write_bind_group =
build_write_bind_group(device, canvas_copy_bgl, &write_view, &write_sampler);
- let (read_mirror_texture, read_mirror_view) =
- create_read_mirror_texture(device, READ_MIRROR_INITIAL_DIM, READ_MIRROR_INITIAL_DIM);
+ let (read_mirror_texture, read_mirror_view) = create_read_mirror_texture(
+ device,
+ READ_MIRROR_INITIAL_DIM,
+ READ_MIRROR_INITIAL_DIM,
+ format,
+ );
let read_mirror_bind_group = build_read_mirror_bind_group(
device,
canvas_copy_bgl,
@@ -144,6 +160,7 @@ impl Scratch {
canvas_copy_bgl: canvas_copy_bgl.clone(),
read_mirror_sampler,
write_sampler,
+ format,
}
}
@@ -151,6 +168,11 @@ impl Scratch {
&self.write_texture
}
+ /// Texel format of both sides.
+ pub fn format(&self) -> wgpu::TextureFormat {
+ self.format
+ }
+
/// Stroke-prologue helper: clear the write side to fully transparent
/// in a single attachment-clear render pass. Used by terminals whose
/// composite accumulates from zero (paint, watercolor) — see
@@ -323,7 +345,7 @@ impl Scratch {
let target_w = new_w.max(self.write_w);
let target_h = new_h.max(self.write_h);
- let (new_texture, new_view) = create_write_texture(device, target_w, target_h);
+ let (new_texture, new_view) = create_write_texture(device, target_w, target_h, self.format);
// Copy existing scratch contents into the new texture at the
// canvas-anchored offset. Old regions outside the source rect
@@ -376,7 +398,7 @@ impl Scratch {
/// bind group that references it. Contents are not preserved; the
/// next `sync_read_mirror` call re-populates from the write side.
fn grow_read_mirror(&mut self, device: &wgpu::Device, new_w: u32, new_h: u32) {
- let (new_texture, new_view) = create_read_mirror_texture(device, new_w, new_h);
+ let (new_texture, new_view) = create_read_mirror_texture(device, new_w, new_h, self.format);
let new_read_bg = build_read_mirror_bind_group(
device,
@@ -398,6 +420,7 @@ fn create_write_texture(
device: &wgpu::Device,
width: u32,
height: u32,
+ format: wgpu::TextureFormat,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scratch-write"),
@@ -409,7 +432,7 @@ fn create_write_texture(
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
- format: wgpu::TextureFormat::Rgba8Unorm,
+ format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST
@@ -424,6 +447,7 @@ fn create_read_mirror_texture(
device: &wgpu::Device,
width: u32,
height: u32,
+ format: wgpu::TextureFormat,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scratch-read-mirror"),
@@ -435,7 +459,7 @@ fn create_read_mirror_texture(
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
- format: wgpu::TextureFormat::Rgba8Unorm,
+ format,
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
diff --git a/crates/darkly/src/brush/stabilizers/laplacian.rs b/crates/darkly/src/brush/stabilizers/laplacian.rs
index 91ac1509..8ffb81ce 100644
--- a/crates/darkly/src/brush/stabilizers/laplacian.rs
+++ b/crates/darkly/src/brush/stabilizers/laplacian.rs
@@ -16,12 +16,11 @@ use crate::brush::stabilizer::{
};
use crate::gpu::params::{ParamDef, ParamValue};
-const PARAMS: &[ParamDef] = &[ParamDef::Float {
- name: "strength",
- min: 0.0,
- max: 1.0,
- default: 0.5,
-}];
+const PARAMS: &[ParamDef] = &[ParamDef::float("strength", 0.0, 1.0, 0.5)
+ .with_label("Strength")
+ .with_description(
+ "How firmly the stroke is smoothed as you draw; higher lags further behind the cursor.",
+ )];
pub fn register() -> StabilizerRegistration {
StabilizerRegistration {
diff --git a/crates/darkly/src/brush/stroke_buffer.rs b/crates/darkly/src/brush/stroke_buffer.rs
index 587aa8bd..5d182656 100644
--- a/crates/darkly/src/brush/stroke_buffer.rs
+++ b/crates/darkly/src/brush/stroke_buffer.rs
@@ -66,13 +66,26 @@ impl StrokeBuffer {
///
/// `pipelines` provides the canvas-copy BGL/sampler that the embedded
/// `Scratch` needs for both its read-mirror and write bind groups.
- pub fn new(device: &wgpu::Device, width: u32, height: u32, pipelines: &BrushPipelines) -> Self {
+ /// `scratch_format` comes from the stroke's terminal
+ /// ([`BrushNodeRegistration::scratch_format`](crate::brush::node::BrushNodeRegistration::scratch_format)).
+ /// The pre-stroke snapshot stays `Rgba8Unorm` regardless — it holds the
+ /// layer's pixels, which a warp terminal resolves *through* its field.
+ pub fn new(
+ device: &wgpu::Device,
+ width: u32,
+ height: u32,
+ pipelines: &BrushPipelines,
+ scratch_format: wgpu::TextureFormat,
+ ) -> Self {
+ let (canvas_copy_bgl, canvas_copy_sampler) =
+ pipelines.canvas_copy_layout_for(scratch_format);
let scratch = Scratch::new(
device,
width,
height,
- pipelines.canvas_copy_bind_group_layout(),
- pipelines.canvas_copy_sampler(),
+ canvas_copy_bgl,
+ canvas_copy_sampler,
+ scratch_format,
);
let pre_stroke_texture = device.create_texture(&wgpu::TextureDescriptor {
diff --git a/crates/darkly/src/brush/stroke_engine.rs b/crates/darkly/src/brush/stroke_engine.rs
index 40a75e9f..326a9559 100644
--- a/crates/darkly/src/brush/stroke_engine.rs
+++ b/crates/darkly/src/brush/stroke_engine.rs
@@ -94,7 +94,10 @@ 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.
+ /// `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.
pub fn new(
mut runner: BrushGraphRunner,
color: [f32; 4],
@@ -102,6 +105,7 @@ impl StrokeEngine {
base_size: f32,
stabilizer: Box,
clone_source_anchor: Option<[f32; 2]>,
+ stroke_seed: u32,
) -> Self {
// Base brush size is stroke-constant, read out-of-band from
// `pen_input.size` at stroke start. Injected as ambient state so every
@@ -109,11 +113,6 @@ impl StrokeEngine {
// see one consistent value.
runner.set_base_size(base_size);
- let stroke_seed = web_time::SystemTime::now()
- .duration_since(web_time::SystemTime::UNIX_EPOCH)
- .map(|d| d.as_nanos() as u32)
- .unwrap_or(42);
-
let d = Self::default_diameter();
Self {
runner,
@@ -134,6 +133,26 @@ impl StrokeEngine {
}
}
+ /// A seed drawn from the wall clock, so two strokes of the same brush
+ /// scatter differently. What a stroke the painter is making wants — and
+ /// what a stroke rendered into a cached thumbnail or a documentation asset
+ /// must not have, which is why it is the caller's to choose.
+ /// Texel format the stroke scratch must be allocated in for this
+ /// stroke's brush — see
+ /// [`BrushGraphRunner::scratch_format`](crate::brush::eval::BrushGraphRunner::scratch_format).
+ /// The engine builds its `StrokeEngine` before its `StrokeBuffer`, so
+ /// this is available at allocation time.
+ pub fn scratch_format(&self) -> wgpu::TextureFormat {
+ self.runner.scratch_format()
+ }
+
+ pub fn random_seed() -> u32 {
+ web_time::SystemTime::now()
+ .duration_since(web_time::SystemTime::UNIX_EPOCH)
+ .map(|d| d.as_nanos() as u32)
+ .unwrap_or(42)
+ }
+
/// Set the clone source snapshot's plane-space frame for the current
/// stroke. Called by the engine every pen event, before rendering —
/// see the field doc for what the frame is.
diff --git a/crates/darkly/src/brush/warp_field.rs b/crates/darkly/src/brush/warp_field.rs
new file mode 100644
index 00000000..077bb9bd
--- /dev/null
+++ b/crates/darkly/src/brush/warp_field.rs
@@ -0,0 +1,338 @@
+//! Warp field — the stroke scratch as a displacement map, and the single
+//! resample that turns it back into pixels.
+//!
+//! A warp terminal (liquify, and any pinch/swirl/bloat sibling) does not
+//! paint. Its whole stroke is expressible as one coordinate map, so
+//! rasterising the intermediate states is not just wasteful, it is
+//! destructive: each dab would resample the picture, and a chain of
+//! bilinear filters is a low-pass cascade, not a bilinear filter. At
+//! liquify's 4 px dab spacing a pixel passes under dozens of dabs per
+//! swipe and the detail is gone.
+//!
+//! So a warp terminal's scratch holds the *map* instead of the picture.
+//! Per dab it advects and accumulates a two-channel displacement in plane
+//! pixels; at commit the pre-stroke snapshot is sampled **once** through
+//! the accumulated field. Detail is then independent of dab count.
+//!
+//! This is the architecture both reference implementations converged on:
+//!
+//! * GEGL's `gegl:warp` iterates a two-component float coordinate buffer
+//! (`operations/common-cxx/warp.cc:321`) and per stamp does
+//! `field'(p) = field(p + nv) + nv` (`:700-704`) — the exact update
+//! [`advect_wgsl`] emits. GIMP wires the accumulated buffer through
+//! `gegl:map-relative` to sample the drawable once
+//! (`app/tools/gimpwarptool.c:913,1059-1071`).
+//! * Krita's `KisLiquifyTransformWorker` keeps `originalPoints` /
+//! `transformedPoints` grids (`libs/image/kis_liquify_transform_worker.cpp:31-32`),
+//! moves only the grid per touch (`:251-253`), and rasterises once from
+//! the source device in `run()` (`:414-440`).
+//!
+//! ## Displacement is relative, and that is load-bearing
+//!
+//! The field stores `source − target`, not an absolute source coordinate.
+//! `Scratch::grow_write` rebases the scratch when the layer grows
+//! mid-stroke; a relative delta survives that untouched, and the
+//! zero-filled new region is exactly the right identity. Absolute
+//! coordinates would all have to be rewritten.
+//!
+//! ## Why `Rg32Float`
+//!
+//! At full strength liquify locks pixels to the cursor, so the field is
+//! the *cumulative* drag — a 1200 px drag stores values near 1200, where
+//! half-float ULP is a whole pixel (measured f16-vs-f32 error over such a
+//! drag: p90 3.6 px, p99 15.7 px). GEGL and GIMP both use float32 here.
+//! `Rg32Float` is renderable in core WebGPU but not *filterable*, so both
+//! the per-dab advect and the resolve fetch it with `textureLoad` and
+//! interpolate in [`FIELD_HELPERS_WGSL`]. That also makes the resolve
+//! provably exact where the field is zero: at zero displacement the
+//! interpolation weights are exactly 0 and `mix` returns the source texel
+//! bit-for-bit, on every backend. Since the resolve rewrites the whole
+//! layer on every pen event, anything less would re-introduce the
+//! softening this module exists to remove.
+
+use std::any::Any;
+
+use crate::brush::pipeline::{BrushPipelineEntry, BrushPipelineRegistration, BuildContext};
+
+/// Texel format of a warp terminal's scratch. Two channels of `f32`:
+/// the displacement from target pixel to source pixel, in plane pixels.
+pub const FIELD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg32Float;
+
+/// Registry id of the shared resolve pipeline.
+pub const RESOLVE_PIPELINE_ID: &str = "warp_field_resolve";
+
+/// Manual bilinear fetch of a two-channel field, by `textureLoad`.
+///
+/// `p` is in texels from the texture's origin, integer `p` naming a texel
+/// *corner* — the same convention the read-mirror UV math uses, where
+/// `copy_origin` is floored to integers and `target_pos` interpolates to
+/// fragment centres. Hence the `-0.5` before `floor`.
+///
+/// Edge-clamped: a dab clipped at the layer edge addresses outside the
+/// mirror's valid region, and clamping there reads the nearest valid
+/// displacement rather than a stale texel.
+pub const FIELD_HELPERS_WGSL: &str = "\
+fn warp_field_texel(t: texture_2d, c: vec2) -> vec2 {
+ let dims = vec2(textureDimensions(t));
+ let cc = clamp(c, vec2(0, 0), dims - vec2(1, 1));
+ return textureLoad(t, cc, 0).xy;
+}
+fn warp_field_bilinear(t: texture_2d, p: vec2) -> vec2 {
+ let q = p - vec2(0.5, 0.5);
+ let base = floor(q);
+ let frac = q - base;
+ let b = vec2(base);
+ let top = mix(warp_field_texel(t, b),
+ warp_field_texel(t, b + vec2(1, 0)), frac.x);
+ let bot = mix(warp_field_texel(t, b + vec2(0, 1)),
+ warp_field_texel(t, b + vec2(1, 1)), frac.x);
+ return mix(top, bot, frac.y);
+}
+";
+
+/// The per-dab fragment tail every warp terminal shares: advect the
+/// accumulated field by this dab's offset, add the offset, write it back.
+///
+/// `offset_expr` must evaluate to a `vec2` — the displacement this
+/// dab contributes at the current fragment, already shaped by whatever
+/// falloff, selection and mask attenuation the terminal wants. Pointing
+/// it *backward* along travel makes content move *forward* with the
+/// cursor (GEGL does the same: `motion_x = priv->last_x - x`,
+/// `warp.cc:412`).
+///
+/// A new warp behaviour is therefore one file supplying one expression —
+/// `scale`/`rotate` offsets are functions of `local`, which the framework
+/// wrapper already provides.
+pub fn advect_wgsl(offset_expr: &str, copy_origin_field: &str) -> String {
+ format!(
+ " let nv = {offset_expr};\n\
+ \x20 let prev = warp_field_bilinear(\n\
+ \x20 scratch_mirror_tex, target_pos + nv - d.{copy_origin_field});\n\
+ \x20 return vec4(prev + nv, 0.0, 0.0);\n"
+ )
+}
+
+// ── Resolve pipeline ────────────────────────────────────────────────────
+
+const RESOLVE_WGSL: &str = r#"
+@group(0) @binding(0) var field_tex: texture_2d;
+@group(0) @binding(1) var source_tex: texture_2d;
+
+struct VsOut {
+ @builtin(position) clip_pos: vec4,
+};
+
+@vertex
+fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
+ // Oversized triangle covering the viewport.
+ var xy = array, 3>(
+ vec2(-1.0, -1.0),
+ vec2( 3.0, -1.0),
+ vec2(-1.0, 3.0),
+ );
+ var out: VsOut;
+ out.clip_pos = vec4(xy[vi], 0.0, 1.0);
+ return out;
+}
+
+fn source_texel(c: vec2) -> vec4 {
+ let dims = vec2(textureDimensions(source_tex));
+ let cc = clamp(c, vec2(0, 0), dims - vec2(1, 1));
+ return textureLoad(source_tex, cc, 0);
+}
+
+@fragment
+fn fs_main(in: VsOut) -> @location(0) vec4 {
+ // `clip_pos.xy` is the fragment centre in layer-local pixels.
+ let p = in.clip_pos.xy;
+ let disp = warp_field_bilinear(field_tex, p);
+ let src = p + disp;
+
+ // Manual bilinear rather than a sampler: where `disp` is exactly
+ // zero the weights are exactly zero and this returns the source
+ // texel bit-for-bit. The resolve rewrites the entire layer every
+ // pen event, so an off-by-half-texel here would soften everything
+ // the stroke did not touch.
+ let q = src - vec2(0.5, 0.5);
+ let base = floor(q);
+ let frac = q - base;
+ let b = vec2(base);
+ let top = mix(source_texel(b),
+ source_texel(b + vec2(1, 0)), frac.x);
+ let bot = mix(source_texel(b + vec2(0, 1)),
+ source_texel(b + vec2(1, 1)), frac.x);
+ return mix(top, bot, frac.y);
+}
+"#;
+
+/// Resolves an accumulated warp field against a source snapshot, straight
+/// onto the paint target.
+///
+/// Two pipelines, one per destination format — raster layers are
+/// `Rgba8Unorm`, mask layers `R8Unorm`. Per the type-owned-dispatch
+/// principle the branch lives in [`WarpFieldResolve::pipeline`], not at
+/// the call site, mirroring `CompositePipeline`.
+pub struct WarpFieldResolve {
+ pipeline_rgba: wgpu::RenderPipeline,
+ pipeline_r8: wgpu::RenderPipeline,
+ bgl: wgpu::BindGroupLayout,
+}
+
+/// Harvested by `BrushPipelines::new` alongside the other plumbing
+/// pipelines — the resolve belongs to no single node, since every warp
+/// terminal shares it.
+pub fn warp_field_resolve_registration() -> BrushPipelineRegistration {
+ BrushPipelineRegistration {
+ id: RESOLVE_PIPELINE_ID,
+ build: |ctx| Box::new(WarpFieldResolve::build(ctx)),
+ }
+}
+
+impl WarpFieldResolve {
+ fn build(ctx: &BuildContext) -> Self {
+ let shader = ctx
+ .device
+ .create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("warp-field-resolve"),
+ source: wgpu::ShaderSource::Wgsl(
+ format!("{FIELD_HELPERS_WGSL}\n{RESOLVE_WGSL}").into(),
+ ),
+ });
+
+ // Both textures are fetched with `textureLoad`, so neither needs a
+ // sampler and the field's non-filterability is irrelevant here.
+ let texture_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
+ binding,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Float { filterable: false },
+ view_dimension: wgpu::TextureViewDimension::D2,
+ multisampled: false,
+ },
+ count: None,
+ };
+ let bgl = ctx
+ .device
+ .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("warp-field-resolve-bgl"),
+ entries: &[texture_entry(0), texture_entry(1)],
+ });
+
+ let layout = ctx
+ .device
+ .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("warp-field-resolve-layout"),
+ bind_group_layouts: &[Some(&bgl)],
+ immediate_size: 0,
+ });
+
+ let make = |format: wgpu::TextureFormat, label: &str| {
+ ctx.device
+ .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some(label),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("vs_main"),
+ buffers: &[],
+ compilation_options: Default::default(),
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("fs_main"),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ compilation_options: Default::default(),
+ }),
+ primitive: wgpu::PrimitiveState::default(),
+ depth_stencil: None,
+ multisample: wgpu::MultisampleState::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
+
+ Self {
+ pipeline_rgba: make(wgpu::TextureFormat::Rgba8Unorm, "warp-field-resolve-rgba"),
+ pipeline_r8: make(wgpu::TextureFormat::R8Unorm, "warp-field-resolve-r8"),
+ bgl,
+ }
+ }
+
+ fn pipeline(&self, format: wgpu::TextureFormat) -> &wgpu::RenderPipeline {
+ if format == wgpu::TextureFormat::R8Unorm {
+ &self.pipeline_r8
+ } else {
+ &self.pipeline_rgba
+ }
+ }
+
+ /// Sample `source` through `field` and write the result across
+ /// `dest`'s full extent.
+ ///
+ /// Full extent, not a damage rect: the whole point is that the output
+ /// is a pure function of the pre-stroke snapshot and the current
+ /// field, so it stays correct when the stabiliser rewinds and discards
+ /// dabs — a tracked rect would leave a stale warped fringe behind the
+ /// truncation. It also costs no more than the full-extent scratch copy
+ /// a colour terminal's commit already does.
+ #[allow(clippy::too_many_arguments)]
+ pub fn resolve(
+ &self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ field_view: &wgpu::TextureView,
+ source_view: &wgpu::TextureView,
+ dest_view: &wgpu::TextureView,
+ dest_format: wgpu::TextureFormat,
+ dest_size: (u32, u32),
+ ) {
+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("warp-field-resolve-bg"),
+ layout: &self.bgl,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(field_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(source_view),
+ },
+ ],
+ });
+
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("warp-field-resolve"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: dest_view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_viewport(0.0, 0.0, dest_size.0 as f32, dest_size.1 as f32, 0.0, 1.0);
+ pass.set_pipeline(self.pipeline(dest_format));
+ pass.set_bind_group(0, &bind_group, &[]);
+ pass.draw(0..3, 0..1);
+ }
+}
+
+impl BrushPipelineEntry for WarpFieldResolve {
+ fn as_any(&self) -> &dyn Any {
+ self
+ }
+ fn ring(&self) -> Option<&crate::brush::pipeline::DynamicUniformRing> {
+ None
+ }
+ fn rings(&self) -> Vec<&crate::brush::pipeline::DynamicUniformRing> {
+ Vec::new()
+ }
+}
diff --git a/crates/darkly/src/catalog.rs b/crates/darkly/src/catalog.rs
new file mode 100644
index 00000000..973d6352
--- /dev/null
+++ b/crates/darkly/src/catalog.rs
@@ -0,0 +1,437 @@
+//! The one shape every registry of typed variants projects into.
+//!
+//! Filters, veils, voids, blend modes, tools, layer kinds and layer filters all
+//! answer the same questions — what is this variant called, what does it look
+//! like, what does it do, what can you set on it — and each used to answer them
+//! through its own flat `*TypeInfo` struct and its own engine query. This module
+//! replaces all of them with [`Catalog`] and [`CatalogEntry`].
+//!
+//! Each registration type builds its own [`CatalogEntry`] in its own file, so
+//! nothing here branches on which registry it is looking at and adding a veil
+//! touches nothing outside `gpu/veils/`. The enumeration of catalog-producing
+//! 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;
+
+/// One browsable entry in a registry of typed variants — the single shape the
+/// UI pickers, the settings surface and the metadata export all consume.
+#[derive(serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
+pub struct CatalogEntry {
+ #[serde(rename = "type")]
+ pub type_id: &'static str,
+ pub display_name: &'static str,
+ /// Iconify name, or `None` when the variant deliberately declares no icon
+ /// (veils render a live preview; raster layers always show a thumbnail).
+ pub icon: Option<&'static str>,
+ pub description: Option<&'static str>,
+ /// Grouping label within the catalog, for variants that group.
+ pub category: Option<&'static str>,
+ /// Action id this variant is bound to, for variants a hotkey can select.
+ pub hotkey_action: Option<&'static str>,
+ pub params: Vec,
+ /// Whether this variant declares a
+ /// [`PreviewAnim`](crate::gpu::preview::PreviewAnim) — the one fact behind
+ /// "a rendered preview of it exists". False for the registries whose entries
+ /// are affordances rather than images.
+ ///
+ /// It does **not** promise a *picker* preview. A blend mode declares one and
+ /// has a documentation asset, but is a relation between two images rather
+ /// than an effect over one, so its catalog exports no preview mechanism and
+ /// `start_preview` no-ops for it exactly as it does for an unknown type.
+ /// Whether a catalog can be driven live is
+ /// [`preview_mechanisms`](crate::catalog::preview_mechanisms)' answer, not
+ /// this field's.
+ pub supports_preview: bool,
+ /// How the browser captures this variant's external frames; voids only.
+ pub capture_kind: Option,
+}
+
+impl CatalogEntry {
+ /// An entry carrying only the fields every registry has. Registries layer
+ /// their own metadata on with the `with_*` setters, so a registry that has
+ /// no icon or no parameters says so by staying silent rather than by
+ /// spelling out a row of `None`s.
+ pub fn new(type_id: &'static str, display_name: &'static str) -> Self {
+ CatalogEntry {
+ type_id,
+ display_name,
+ icon: None,
+ description: None,
+ category: None,
+ hotkey_action: None,
+ params: Vec::new(),
+ supports_preview: false,
+ capture_kind: None,
+ }
+ }
+
+ /// Set the Iconify name, treating `""` as "declares no icon". Registries
+ /// that store a non-optional `&'static str` use the empty string for that,
+ /// and this is the one place that convention is translated.
+ pub fn with_icon(mut self, icon: &'static str) -> Self {
+ self.icon = (!icon.is_empty()).then_some(icon);
+ self
+ }
+
+ pub fn with_description(mut self, description: &'static str) -> Self {
+ self.description = (!description.is_empty()).then_some(description);
+ self
+ }
+
+ pub fn with_category(mut self, category: &'static str) -> Self {
+ self.category = (!category.is_empty()).then_some(category);
+ self
+ }
+
+ pub fn with_hotkey_action(mut self, action: &'static str) -> Self {
+ self.hotkey_action = (!action.is_empty()).then_some(action);
+ self
+ }
+
+ pub fn with_params(mut self, params: &'static [crate::gpu::params::ParamDef]) -> Self {
+ self.params = params
+ .iter()
+ .map(|d| ParamInfo::from_def(d, None))
+ .collect();
+ self
+ }
+
+ pub fn with_supports_preview(mut self, supports_preview: bool) -> Self {
+ self.supports_preview = supports_preview;
+ self
+ }
+
+ pub fn with_capture_kind(mut self, capture_kind: Option) -> Self {
+ self.capture_kind = capture_kind;
+ self
+ }
+}
+
+/// A named group of entries — "filters", "veils", "settings.canvas", …
+#[derive(serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
+pub struct Catalog {
+ pub id: &'static str,
+ pub title: &'static str,
+ pub description: Option<&'static str>,
+ pub icon: Option<&'static str>,
+ /// Presentation order, for catalogs that declare one. Registry catalogs do
+ /// not; settings sections do.
+ pub order: Option,
+ pub entries: Vec,
+ /// Whether an entry's icon identifies it within the catalog.
+ ///
+ /// True for the registries whose entries are picker variants, where the
+ /// glyph is most of what the user has to tell two of them apart and a
+ /// duplicate is a copy-pasted `register()` that kept the donor's. Actions
+ /// declare it false: an action's glyph depicts the *operation*, so the same
+ /// operation at another scope wants the same glyph (flipping the canvas and
+ /// flipping a layer are both `arrows-left-right`), and an action is never
+ /// shown without its label.
+ ///
+ /// Says how to read the icons rather than what they are, so it stays out of
+ /// the exported artifact.
+ #[serde(skip)]
+ #[cfg_attr(feature = "ts-export", ts(skip))]
+ pub icons_identify_entries: bool,
+}
+
+impl Catalog {
+ pub fn new(id: &'static str, title: &'static str, entries: Vec) -> Self {
+ Catalog {
+ id,
+ title,
+ description: None,
+ icon: None,
+ order: None,
+ entries,
+ icons_identify_entries: true,
+ }
+ }
+
+ /// Declare that entries in this catalog may share a glyph — see
+ /// [`Catalog::icons_identify_entries`].
+ pub fn with_shared_icons(mut self) -> Self {
+ self.icons_identify_entries = false;
+ self
+ }
+
+ pub fn with_description(mut self, description: &'static str) -> Self {
+ self.description = (!description.is_empty()).then_some(description);
+ self
+ }
+
+ pub fn with_icon(mut self, icon: &'static str) -> Self {
+ self.icon = (!icon.is_empty()).then_some(icon);
+ self
+ }
+
+ pub fn with_order(mut self, order: i32) -> Self {
+ self.order = Some(order);
+ self
+ }
+}
+
+include!(concat!(env!("OUT_DIR"), "/catalog_sources_gen.rs"));
+
+/// Every settings section, projected into the same shape as a registry.
+///
+/// One catalog per section, one entry per section, one [`ParamInfo`] per pref —
+/// so a consumer that can render a filter's parameter table can render the
+/// 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.
+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 entry = CatalogEntry {
+ type_id: section.id,
+ display_name: section.display_name,
+ icon: section.icon,
+ description: section.description,
+ category: None,
+ hotkey_action: None,
+ params,
+ supports_preview: false,
+ capture_kind: None,
+ };
+ let mut catalog = Catalog::new(
+ // `id` must be `'static`; sections are `'static` data, so lean
+ // on the same leak-free trick the registries do — the section's
+ // own id with a compile-time-known prefix, built once.
+ settings_catalog_id(section.id),
+ section.display_name,
+ vec![entry],
+ )
+ .with_order(section.order);
+ if let Some(d) = section.description {
+ catalog = catalog.with_description(d);
+ }
+ if let Some(i) = section.icon {
+ catalog = catalog.with_icon(i);
+ }
+ (section.order, section.id, catalog)
+ })
+ .collect();
+ out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
+ out.into_iter().map(|(_, _, c)| c).collect()
+}
+
+/// `"settings."` as a `&'static str`. Sections are a fixed, compile-time
+/// set discovered by `build.rs`, so the ids are interned once for the process
+/// rather than rebuilt per call.
+fn settings_catalog_id(section_id: &'static str) -> &'static str {
+ use std::collections::HashMap;
+ use std::sync::{Mutex, OnceLock};
+ static IDS: OnceLock>> = OnceLock::new();
+ let mut map = IDS
+ .get_or_init(|| Mutex::new(HashMap::new()))
+ .lock()
+ .expect("settings id cache poisoned");
+ map.entry(section_id)
+ .or_insert_with(|| Box::leak(format!("settings.{section_id}").into_boxed_str()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Nothing ships undocumented: every catalog names and describes itself,
+ /// and so does every entry in it.
+ ///
+ /// `icon`, `category` and `hotkey_action` are deliberately *not* asserted.
+ /// `None` on those is a declared value rather than a missing one — veils
+ /// render a live preview instead of an icon, raster layers always show a
+ /// thumbnail, and only tools bind a hotkey — which is the whole reason they
+ /// are `Option` rather than `&'static str`. Demanding them back would be a
+ /// rule the data does not have. Malformed icons are caught by
+ /// [`icons_are_wellformed_and_unique_within_a_catalog`] instead.
+ #[test]
+ fn every_catalog_entry_is_documented() {
+ for cat in catalogs() {
+ assert!(!cat.id.is_empty(), "catalog has an empty id");
+ assert!(!cat.title.is_empty(), "catalog `{}` has no title", cat.id);
+ assert!(
+ cat.description.is_some_and(|d| !d.is_empty()),
+ "catalog `{}` has no description",
+ cat.id
+ );
+ assert!(
+ !cat.entries.is_empty(),
+ "catalog `{}` has no entries",
+ cat.id
+ );
+
+ for e in &cat.entries {
+ assert!(
+ !e.type_id.is_empty(),
+ "an entry in `{}` has an empty type id",
+ cat.id
+ );
+ assert!(
+ !e.display_name.is_empty(),
+ "`{}/{}` has no display name",
+ cat.id,
+ e.type_id
+ );
+ assert!(
+ e.description.is_some_and(|d| !d.is_empty()),
+ "`{}/{}` has no description",
+ cat.id,
+ e.type_id
+ );
+ }
+ }
+ }
+
+ /// Generalizes the per-registry uniqueness check `gpu/filter.rs` used to
+ /// carry: a copy-pasted `register()` that kept the donor's glyph shows up
+ /// as two entries in one catalog claiming the same icon. Catalogs whose
+ /// glyphs are not identifying opt out of the uniqueness half — see
+ /// [`Catalog::icons_identify_entries`] — but not the wellformedness half.
+ #[test]
+ fn icons_are_wellformed_and_unique_within_a_catalog() {
+ for cat in catalogs() {
+ let mut seen: Vec<(&str, &str)> = Vec::new();
+ for e in &cat.entries {
+ let Some(icon) = e.icon else { continue };
+ assert!(
+ icon.contains(':'),
+ "`{}/{}` icon `{icon}` is not a `collection:name` Iconify id",
+ cat.id,
+ e.type_id
+ );
+ assert!(
+ !icon.ends_with(':') && !icon.starts_with(':'),
+ "`{}/{}` icon `{icon}` has an empty collection or name",
+ cat.id,
+ e.type_id
+ );
+ if !cat.icons_identify_entries {
+ continue;
+ }
+ if let Some((owner, _)) = seen.iter().find(|(_, i)| *i == icon) {
+ panic!(
+ "`{}/{}` and `{}/{}` both use icon `{icon}`",
+ cat.id, owner, cat.id, e.type_id
+ );
+ }
+ seen.push((e.type_id, icon));
+ }
+ }
+ }
+
+ /// 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.
+ #[test]
+ fn settings_project_as_catalogs_without_hidden_prefs() {
+ let cats = settings_catalogs();
+ assert!(!cats.is_empty(), "no settings sections found");
+
+ let declared: usize = crate::config::sections::registrations()
+ .iter()
+ .map(|s| s.prefs.len())
+ .sum();
+ let hidden: usize = crate::config::sections::registrations()
+ .iter()
+ .flat_map(|s| s.prefs.iter())
+ .filter(|p| matches!(p.widget, WidgetHint::Hidden))
+ .count();
+ let exported: usize = cats
+ .iter()
+ .flat_map(|c| &c.entries)
+ .map(|e| e.params.len())
+ .sum();
+ assert!(
+ hidden > 0,
+ "expected some hidden prefs to exercise the filter"
+ );
+ assert_eq!(
+ exported,
+ declared - hidden,
+ "settings export must carry every declared pref except the hidden ones"
+ );
+
+ for c in &cats {
+ assert!(
+ c.id.starts_with("settings."),
+ "`{}` is not settings-prefixed",
+ c.id
+ );
+ assert!(
+ c.order.is_some(),
+ "settings catalog `{}` declares no order",
+ c.id
+ );
+ assert_eq!(
+ c.entries.len(),
+ 1,
+ "`{}` 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.
+ for r in catalogs() {
+ assert!(
+ !r.id.contains('.'),
+ "registry catalog `{}` contains a dot",
+ r.id
+ );
+ }
+ }
+
+ #[test]
+ fn catalog_ids_and_entry_ids_are_unique_and_stable() {
+ let cats = catalogs();
+ let mut ids: Vec<&str> = cats.iter().map(|c| c.id).collect();
+ ids.sort_unstable();
+ let before = ids.len();
+ ids.dedup();
+ assert_eq!(before, ids.len(), "two catalogs claim the same id");
+
+ for cat in &cats {
+ let mut entry_ids: Vec<&str> = cat.entries.iter().map(|e| e.type_id).collect();
+ entry_ids.sort_unstable();
+ let before = entry_ids.len();
+ entry_ids.dedup();
+ assert_eq!(
+ before,
+ entry_ids.len(),
+ "two entries in catalog `{}` claim the same type id",
+ cat.id
+ );
+ }
+
+ // Entry order is part of the contract — a caller that renders a catalog
+ // twice must get the same table both times.
+ for cat in &cats {
+ let rebuilt = catalogs();
+ let same = rebuilt.iter().find(|c| c.id == cat.id).unwrap();
+ let a: Vec<&str> = cat.entries.iter().map(|e| e.type_id).collect();
+ let b: Vec<&str> = same.entries.iter().map(|e| e.type_id).collect();
+ assert_eq!(a, b, "catalog `{}` is not order-stable", cat.id);
+ }
+ }
+}
diff --git a/crates/darkly/src/config/chord.rs b/crates/darkly/src/config/chord.rs
new file mode 100644
index 00000000..2a1db276
--- /dev/null
+++ b/crates/darkly/src/config/chord.rs
@@ -0,0 +1,210 @@
+//! Binding grammar and chord rendering.
+//!
+//! A binding in `presets/*.yaml` is an optional `site@scope@brush:` prefix
+//! followed by a chord. The prefix says *where* the binding applies; the chord
+//! says which keys or mouse gesture triggers it. Rust owns both halves because
+//! the metadata export ships chords already rendered, and a second
+//! implementation of this table on the consumer side is exactly what the
+//! artifact exists to avoid.
+
+/// Which modifier vocabulary a chord renders with. Documentation carries both,
+/// because a static document is read on both.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Platform {
+ Mac,
+ Other,
+}
+
+/// The parsed halves of a binding: its optional prefix parts and the chord.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct ParsedBinding {
+ /// Binding-site name (`"layerPanel"`, `"canvas"`), or `None` for any.
+ pub site: Option,
+ /// Active-tool group (`"paint"`, `"select"`), or `None` for any tool.
+ pub scope: Option,
+ /// Brush kind (`"clone"`), or `None` for any brush.
+ pub brush: Option,
+ /// Everything after the first `:`, verbatim.
+ pub chord: String,
+}
+
+/// Split a binding into its prefix parts and its chord.
+///
+/// The colon is the chord separator; the part before it splits on `@` into
+/// `site@scope@brush`, each optional. Anything after the *first* colon is the
+/// chord verbatim, so a `@` inside a chord stays put.
+///
+/// ```text
+/// "Delete" → site None, scope None, brush None, chord "Delete"
+/// "layerPanel:Delete" → site layerPanel chord "Delete"
+/// "canvas@paint:shift+drag" → site canvas, scope paint, chord "shift+drag"
+/// "@paint:KeyB" → scope paint, chord "KeyB"
+/// "canvas@paint@clone:$mod+drag" → site canvas, scope paint, brush clone, chord "$mod+drag"
+/// ```
+pub fn parse_binding(raw: &str) -> ParsedBinding {
+ let Some(colon) = raw.find(':') else {
+ return ParsedBinding {
+ chord: raw.to_string(),
+ ..Default::default()
+ };
+ };
+ let (prefix, rest) = raw.split_at(colon);
+ let chord = rest[1..].to_string();
+
+ let some = |s: &str| (!s.is_empty()).then(|| s.to_string());
+
+ match prefix.find('@') {
+ None => ParsedBinding {
+ site: some(prefix),
+ scope: None,
+ brush: None,
+ chord,
+ },
+ Some(at) => {
+ let (site, tail) = prefix.split_at(at);
+ let mut parts = tail[1..].split('@');
+ ParsedBinding {
+ site: some(site),
+ scope: parts.next().and_then(some),
+ brush: parts.next().and_then(some),
+ chord,
+ }
+ }
+ }
+}
+
+/// Render a chord for one platform's modifier vocabulary.
+///
+/// Handles both the keyboard vocabulary (`Shift`/`Alt` capitalized, key codes
+/// like `KeyA` / `Comma`) and the mouse vocabulary (`shift`/`alt`/`ctrl`/`meta`
+/// lowercase, verbs like `click` / `drag`). A part it does not recognize passes
+/// through unchanged, which is what keeps a new key code from rendering blank.
+pub fn format_chord(chord: &str, platform: Platform) -> String {
+ let mac = platform == Platform::Mac;
+ chord
+ .split('+')
+ .map(|part| match part {
+ "$mod" => if mac { "⌘" } else { "Ctrl" }.to_string(),
+ "Shift" | "shift" => if mac { "⇧" } else { "Shift" }.to_string(),
+ "Alt" | "alt" => if mac { "⌥" } else { "Alt" }.to_string(),
+ "ctrl" => if mac { "⌃" } else { "Ctrl" }.to_string(),
+ "meta" => if mac { "⌘" } else { "Win" }.to_string(),
+ "click" => "click".to_string(),
+ "doubleClick" => "double-click".to_string(),
+ "middleClick" => "middle-click".to_string(),
+ "drag" => "drag".to_string(),
+ "middleDrag" => "middle-drag".to_string(),
+ "rightDrag" => "right-drag".to_string(),
+ "Delete" => "Del".to_string(),
+ "Comma" => ",".to_string(),
+ "Period" => ".".to_string(),
+ "Semicolon" => ";".to_string(),
+ "Quote" => "'".to_string(),
+ "BracketLeft" => "[".to_string(),
+ "BracketRight" => "]".to_string(),
+ "Backslash" => "\\".to_string(),
+ "Minus" => "-".to_string(),
+ "Equal" => "=".to_string(),
+ "Slash" => "/".to_string(),
+ "Backquote" => "`".to_string(),
+ other => match other.strip_prefix("Key") {
+ Some(k) => k.to_string(),
+ None => other.to_string(),
+ },
+ })
+ .collect::>()
+ .join("+")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn parsed(
+ site: Option<&str>,
+ scope: Option<&str>,
+ brush: Option<&str>,
+ chord: &str,
+ ) -> ParsedBinding {
+ ParsedBinding {
+ site: site.map(str::to_string),
+ scope: scope.map(str::to_string),
+ brush: brush.map(str::to_string),
+ chord: chord.to_string(),
+ }
+ }
+
+ /// The prefix is a three-part grammar, not one opaque string. Collapsing it
+ /// to a `(scope, chord)` pair would report `layerThumb` — a *site* — as a
+ /// scope and drop `brush` entirely.
+ #[test]
+ fn parse_binding_splits_site_scope_brush() {
+ let cases: &[(&str, ParsedBinding)] = &[
+ ("KeyI", parsed(None, None, None, "KeyI")),
+ ("Delete", parsed(None, None, None, "Delete")),
+ (
+ "layerThumb:alt+click",
+ parsed(Some("layerThumb"), None, None, "alt+click"),
+ ),
+ (
+ "layerPanel:Delete",
+ parsed(Some("layerPanel"), None, None, "Delete"),
+ ),
+ (
+ "canvas@paint:shift+drag",
+ parsed(Some("canvas"), Some("paint"), None, "shift+drag"),
+ ),
+ ("@paint:KeyB", parsed(None, Some("paint"), None, "KeyB")),
+ (
+ "canvas@paint@clone:$mod+drag",
+ parsed(Some("canvas"), Some("paint"), Some("clone"), "$mod+drag"),
+ ),
+ // Only the FIRST colon separates; an `@` after it belongs to the chord.
+ ("canvas:a@b", parsed(Some("canvas"), None, None, "a@b")),
+ ];
+ for (raw, want) in cases {
+ assert_eq!(&parse_binding(raw), want, "parsing `{raw}`");
+ }
+ }
+
+ #[test]
+ fn format_chord_renders_both_platforms() {
+ let cases: &[(&str, &str, &str)] = &[
+ ("$mod+KeyZ", "⌘+Z", "Ctrl+Z"),
+ ("$mod+Shift+KeyP", "⌘+⇧+P", "Ctrl+Shift+P"),
+ ("Alt+KeyA", "⌥+A", "Alt+A"),
+ ("alt+click", "⌥+click", "Alt+click"),
+ ("ctrl+drag", "⌃+drag", "Ctrl+drag"),
+ ("meta+click", "⌘+click", "Win+click"),
+ ("$mod+drag", "⌘+drag", "Ctrl+drag"),
+ ("shift+doubleClick", "⇧+double-click", "Shift+double-click"),
+ ("middleClick", "middle-click", "middle-click"),
+ ("middleDrag", "middle-drag", "middle-drag"),
+ ("rightDrag", "right-drag", "right-drag"),
+ // The twelve key codes.
+ ("Delete", "Del", "Del"),
+ ("Comma", ",", ","),
+ ("Period", ".", "."),
+ ("Semicolon", ";", ";"),
+ ("Quote", "'", "'"),
+ ("BracketLeft", "[", "["),
+ ("BracketRight", "]", "]"),
+ ("Backslash", "\\", "\\"),
+ ("Minus", "-", "-"),
+ ("Equal", "=", "="),
+ ("Slash", "/", "/"),
+ ("Backquote", "`", "`"),
+ // Unrecognized parts pass through rather than rendering blank.
+ ("Space", "Space", "Space"),
+ ("F5", "F5", "F5"),
+ ];
+ for (chord, mac, other) in cases {
+ assert_eq!(&format_chord(chord, Platform::Mac), mac, "mac `{chord}`");
+ assert_eq!(
+ &format_chord(chord, Platform::Other),
+ other,
+ "other `{chord}`"
+ );
+ }
+ }
+}
diff --git a/crates/darkly/src/config/mod.rs b/crates/darkly/src/config/mod.rs
index cdcb7d55..04d0c770 100644
--- a/crates/darkly/src/config/mod.rs
+++ b/crates/darkly/src/config/mod.rs
@@ -1,3 +1,4 @@
+pub mod chord;
pub mod schema;
pub mod sections;
@@ -9,7 +10,7 @@ mod presets_gen {
pub use presets_gen::{BASE_SETTINGS_OPTIONS, DEFAULTS_YAML, OVERLAYS};
use std::cell::RefCell;
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
/// On-disk schema version for `user_settings.json`. Bump whenever a change
/// to the schema or YAML layers cannot be auto-cleaned by
@@ -74,12 +75,25 @@ impl Config {
}
}
- /// Resolve a key down the layer stack.
- fn get(&self, key: &str) -> Option<&ConfigValue> {
- if let Some(v) = self.user.get(key) {
- return Some(v);
+ /// The one walk down the layer stack: named `overlay` above `defaults`,
+ /// with the user layer consulted only when `include_user` is set.
+ ///
+ /// Every resolution goes through here so the layer order exists in exactly
+ /// one place. `get` and `base_value` differ only in whether the user layer
+ /// participates; the exporter differs only in naming the overlay outright
+ /// rather than reading it from `app.baseSettings`.
+ fn resolve(
+ &self,
+ overlay: Option<&str>,
+ key: &str,
+ include_user: bool,
+ ) -> Option<&ConfigValue> {
+ if include_user {
+ if let Some(v) = self.user.get(key) {
+ return Some(v);
+ }
}
- if let Some(ConfigValue::Str(name)) = self.user.get("app.baseSettings") {
+ if let Some(name) = overlay {
if let Some(v) = self.overlays.get(name).and_then(|m| m.get(key)) {
return Some(v);
}
@@ -87,16 +101,24 @@ impl Config {
self.defaults.get(key)
}
+ /// The overlay the user has selected, if any.
+ fn active_overlay(&self) -> Option<&str> {
+ match self.user.get("app.baseSettings") {
+ Some(ConfigValue::Str(name)) => Some(name.as_str()),
+ _ => None,
+ }
+ }
+
+ /// Resolve a key down the layer stack.
+ fn get(&self, key: &str) -> Option<&ConfigValue> {
+ self.resolve(self.active_overlay(), key, true)
+ }
+
/// What "Reset override on this key" would reveal — the layer below
/// the user layer. Drives the Settings UI's "displayed default" and
/// the Reset-affordance disabled state.
fn base_value(&self, key: &str) -> Option<&ConfigValue> {
- if let Some(ConfigValue::Str(name)) = self.user.get("app.baseSettings") {
- if let Some(v) = self.overlays.get(name).and_then(|m| m.get(key)) {
- return Some(v);
- }
- }
- self.defaults.get(key)
+ self.resolve(self.active_overlay(), key, false)
}
}
@@ -322,15 +344,140 @@ pub fn kind_is_int(key: &str) -> bool {
/// Return the full schema as a serializable view. Used by the WASM bridge to
/// feed the Settings UI.
-pub fn schema_info() -> Vec {
- let mut out: Vec<_> = sections::registrations()
- .iter()
- .map(schema::SectionInfo::from_section)
- .collect();
- out.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.id.cmp(b.id)));
+/// The editor-agnostic baseline value for a key — the bottom layer alone, with
+/// no user override and no editor overlay.
+///
+/// Reads `defaults.yaml` directly rather than the process-global config, so a
+/// caller that only wants to describe the schema (the metadata exporter, the
+/// settings projection) needs no initialization and cannot be perturbed by
+/// whatever the running editor has chosen.
+/// One effective binding: the raw preset string, its parsed prefix, and the
+/// chord rendered for both platform conventions.
+#[derive(Clone, Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Binding {
+ /// The binding exactly as the preset YAML declares it.
+ pub raw: String,
+ /// Prefix parts, each absent unless the binding declares it.
+ pub site: Option,
+ pub scope: Option,
+ pub brush: Option,
+ /// The chord rendered with Apple modifier glyphs.
+ pub mac: String,
+ /// The chord rendered with the Windows/Linux modifier names.
+ pub other: String,
+}
+
+impl Binding {
+ fn parse(raw: &str) -> Self {
+ let p = chord::parse_binding(raw);
+ Binding {
+ raw: raw.to_string(),
+ site: p.site,
+ scope: p.scope,
+ brush: p.brush,
+ mac: chord::format_chord(&p.chord, chord::Platform::Mac),
+ other: chord::format_chord(&p.chord, chord::Platform::Other),
+ }
+ }
+}
+
+/// The action id a config key binds, or `None` when the key binds no action.
+///
+/// Bindings live under `hotkeys.` / `mouseclicks.`, with one exception: the
+/// canvas-navigation held modifiers (`hotkeys.nav.trigger` and friends) are
+/// prefs declared by [`sections`], sharing the prefix because that is where a
+/// user looks for them. Nothing dispatches an action from a pref, so asking the
+/// schema is what separates the two — no rule about dots in ids.
+pub fn bound_action_id(key: &str) -> Option<&str> {
+ use std::collections::HashSet;
+ use std::sync::OnceLock;
+ static PREF_KEYS: OnceLock> = OnceLock::new();
+ let prefs = PREF_KEYS.get_or_init(|| {
+ sections::registrations()
+ .iter()
+ .flat_map(|s| s.prefs.iter())
+ .map(|p| p.key)
+ .collect()
+ });
+ let id = key
+ .strip_prefix("hotkeys.")
+ .or_else(|| key.strip_prefix("mouseclicks."))?;
+ (!prefs.contains(key)).then_some(id)
+}
+
+/// Every hotkey and mouse binding a named preset resolves to, with no user
+/// layer. `None` resolves the editor-agnostic baseline alone.
+///
+/// Keys are action ids; every value holds at least one [`Binding`]. An action
+/// the preset binds nothing to is **absent** — the map is already resolved, so
+/// absence is a complete statement rather than an instruction to look in a
+/// lower layer. No empty vector is ever emitted.
+///
+/// Builds from the generated presets directly rather than the process-global
+/// config, so a caller needs no initialization and cannot be perturbed by
+/// whatever the running editor has selected.
+pub fn preset_bindings(overlay: Option<&str>) -> BTreeMap> {
+ let defaults = parse_yaml_preset(presets_gen::DEFAULTS_YAML)
+ .unwrap_or_else(|e| panic!("failed to parse defaults.yaml: {e}"));
+ let mut overlays = HashMap::new();
+ for (name, yaml) in presets_gen::OVERLAYS {
+ let map = parse_yaml_preset(yaml)
+ .unwrap_or_else(|e| panic!("failed to parse overlay {name}: {e}"));
+ overlays.insert((*name).to_string(), map);
+ }
+ let config = Config {
+ defaults,
+ overlays,
+ user: HashMap::new(),
+ };
+
+ // The union of keys the agnostic layer and this overlay declare — every key
+ // that could resolve to anything under this preset. Deduped: a key both
+ // layers declare is one key that resolves once, not two.
+ let mut keys: Vec<&String> = config.defaults.keys().collect();
+ if let Some(name) = overlay {
+ if let Some(m) = config.overlays.get(name) {
+ keys.extend(m.keys());
+ }
+ }
+ keys.sort();
+ keys.dedup();
+
+ let mut out: BTreeMap> = BTreeMap::new();
+ for key in keys {
+ let Some(action) = bound_action_id(key) else {
+ continue;
+ };
+ let Some(ConfigValue::Str(raw)) = config.resolve(overlay, key, false) else {
+ continue;
+ };
+ // `collect_string_facet` joins a YAML list with `|`, so one id can
+ // carry several chords. Shipping the joined string would push a
+ // splitting rule onto every consumer.
+ let bindings: Vec = raw
+ .split('|')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(Binding::parse)
+ .collect();
+ if bindings.is_empty() {
+ continue;
+ }
+ out.entry(action.to_string()).or_default().extend(bindings);
+ }
out
}
+pub fn agnostic_default(key: &str) -> Option {
+ thread_local! {
+ static DEFAULTS: HashMap =
+ parse_yaml_preset(presets_gen::DEFAULTS_YAML)
+ .unwrap_or_else(|e| panic!("failed to parse defaults.yaml: {e}"));
+ }
+ DEFAULTS.with(|d| d.get(key).cloned())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -431,4 +578,194 @@ mod tests {
// Unknown key → false (defensive).
assert!(!kind_is_int("bogus.key"));
}
+
+ /// Resolution never returns a short map: every id the agnostic layer or
+ /// this overlay declares comes back, because both layers are walked. An
+ /// inheritance bug shows up here as a missing key.
+ #[test]
+ fn preset_bindings_covers_every_key_in_every_preset() {
+ let binding_ids = |yaml: &str| -> Vec {
+ parse_yaml_preset(yaml)
+ .unwrap()
+ .into_iter()
+ .filter_map(|(k, v)| {
+ // A key whose value is an empty string binds nothing.
+ match v {
+ ConfigValue::Str(s) if !s.trim().is_empty() => Some(k),
+ _ => None,
+ }
+ })
+ .filter_map(|k| bound_action_id(&k).map(str::to_string))
+ .collect()
+ };
+
+ for (name, yaml) in presets_gen::OVERLAYS {
+ let mut expected: Vec = binding_ids(presets_gen::DEFAULTS_YAML);
+ expected.extend(binding_ids(yaml));
+ expected.sort();
+ expected.dedup();
+
+ let got = preset_bindings(Some(name));
+ let mut got_ids: Vec = got.keys().cloned().collect();
+ got_ids.sort();
+ assert_eq!(
+ got_ids, expected,
+ "preset `{name}` resolves a different id set than its layers declare"
+ );
+ }
+
+ let mut expected = binding_ids(presets_gen::DEFAULTS_YAML);
+ expected.sort();
+ expected.dedup();
+ let mut got_ids: Vec = preset_bindings(None).keys().cloned().collect();
+ got_ids.sort();
+ assert_eq!(
+ got_ids, expected,
+ "the agnostic baseline resolves a different id set"
+ );
+ }
+
+ /// An overlay sits *above* the baseline rather than replacing it: every id
+ /// the agnostic layer binds is still bound under every overlay, with the
+ /// overlay's chords where it overrides and the baseline's otherwise.
+ #[test]
+ fn preset_bindings_inherits_the_agnostic_layer() {
+ let base = preset_bindings(None);
+ for (name, yaml) in presets_gen::OVERLAYS {
+ let overlay_map = parse_yaml_preset(yaml).unwrap();
+ let resolved = preset_bindings(Some(name));
+ for (id, base_bindings) in &base {
+ let got = resolved.get(id).unwrap_or_else(|| {
+ panic!("preset `{name}` dropped `{id}`, which the baseline binds")
+ });
+ let overridden = overlay_map.contains_key(&format!("hotkeys.{id}"))
+ || overlay_map.contains_key(&format!("mouseclicks.{id}"));
+ if !overridden {
+ let a: Vec<&String> = base_bindings.iter().map(|b| &b.raw).collect();
+ let b: Vec<&String> = got.iter().map(|b| &b.raw).collect();
+ assert_eq!(a, b, "preset `{name}` changed `{id}` without overriding it");
+ }
+ }
+ }
+ }
+
+ /// Absence means "binds nothing"; there is no empty vector to misread as
+ /// "explicitly unbound". No preset mechanism produces one today, and this
+ /// keeps the two-valued design from creeping back without one.
+ #[test]
+ fn preset_bindings_never_emits_an_empty_vec() {
+ let mut presets: Vec