From 6b0df8bda99e3de2dd8b92a18bd59d7c21efd3fb Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 25 Sep 2026 22:47:02 -0400 Subject: [PATCH 01/11] ci: release-plz with binary builds; fix red main - release-plz.yml: release PR on every push to main; on merge it tags v, creates the GitHub release, then calls release.yml (6-target binaries + .deb + SHA256SUMS) and crates.yml from the same run, since a GITHUB_TOKEN tag cannot trigger the tag/release workflows. - cliff.toml: conventional-commit changelog, internal ticket ids scrubbed. - clippy (rust 1.97): sort_by_key, checked_div, collapsed match guard. - fix: `pxl install` treated every path dependency as stale when the project path contained a symlink (macOS /var); compare canonical paths. - browser-tests: drop the npm cache keyed on a lockfile that does not exist. - python: macos-13 runners are retired; use macos-15-intel. - README: crates badge pointed at an unrelated `pxl` crate; dead pixelsrc.dev link; Homebrew tap instruction pointed at a repo that does not exist; add `cargo install pixelsrc`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- .github/workflows/browser-tests.yml | 3 -- .github/workflows/crates.yml | 9 ++++ .github/workflows/python.yml | 2 +- .github/workflows/release-plz.yml | 59 +++++++++++++++++++++++ .github/workflows/release.yml | 16 ++++++- README.md | 14 ++---- cliff.toml | 72 +++++++++++++++++++++++++++++ release-plz.toml | 18 ++++++++ src/analyze/dimensions.rs | 2 +- src/analyze/families.rs | 2 +- src/analyze/tokens.rs | 4 +- src/atlas.rs | 2 +- src/composition/render.rs | 4 +- src/install.rs | 4 ++ src/lsp/server.rs | 10 ++-- 15 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/release-plz.yml create mode 100644 cliff.toml create mode 100644 release-plz.toml diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index be860870..9abe8e08 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -31,9 +31,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - cache-dependency-path: | - wasm/package-lock.json - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/crates.yml b/.github/workflows/crates.yml index f7d5d93b..3326aa1e 100644 --- a/.github/workflows/crates.yml +++ b/.github/workflows/crates.yml @@ -3,6 +3,13 @@ name: Publish to crates.io on: release: types: [published] + # Called by release-plz.yml after it tags a release (a GITHUB_TOKEN release + # cannot trigger the `release` arm above). + workflow_call: + inputs: + tag: + required: true + type: string workflow_dispatch: inputs: dry_run: @@ -23,6 +30,8 @@ jobs: id-token: write # Required for OIDC trusted publishing steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9fb5edc7..3e16edf3 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -41,7 +41,7 @@ jobs: fail-fast: false matrix: include: - - runner: macos-13 + - runner: macos-15-intel target: x86_64-apple-darwin - runner: macos-14 target: aarch64-apple-darwin diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 00000000..fb731d22 --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,59 @@ +name: Release-plz + +# On every push to main: open or update the release PR (version bump + +# CHANGELOG). When that PR merges: tag v, create the GitHub release, +# then build the binaries and publish to crates.io from this same run. +on: + push: + branches: [main] + +concurrency: + group: release-plz + cancel-in-progress: false + +jobs: + release-plz: + name: Release-plz + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + releases_created: ${{ steps.release.outputs.releases_created }} + tag: ${{ steps.tag.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - uses: MarcoIeni/release-plz-action@v0.5 + id: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve the new tag + id: tag + if: steps.release.outputs.releases_created == 'true' + env: + RELEASES: ${{ steps.release.outputs.releases }} + run: echo "tag=$(echo "$RELEASES" | jq -r '.[0].tag')" >> "$GITHUB_OUTPUT" + + binaries: + name: Binaries + needs: release-plz + if: needs.release-plz.outputs.releases_created == 'true' + permissions: + contents: write + uses: ./.github/workflows/release.yml + with: + tag: ${{ needs.release-plz.outputs.tag }} + + crates: + name: crates.io + needs: release-plz + if: needs.release-plz.outputs.releases_created == 'true' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/crates.yml + with: + tag: ${{ needs.release-plz.outputs.tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b47eaedc..cf0dd804 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,13 @@ on: push: tags: - 'v*' + # Called by release-plz.yml after it tags a release (a GITHUB_TOKEN tag push + # cannot trigger the `tags` arm above). + workflow_call: + inputs: + tag: + required: true + type: string workflow_dispatch: inputs: version: @@ -13,7 +20,7 @@ on: env: CARGO_TERM_COLOR: always - VERSION: ${{ inputs.version || github.ref_name }} + VERSION: ${{ inputs.tag || inputs.version || github.ref_name }} jobs: build: @@ -49,6 +56,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ env.VERSION }} - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -97,6 +106,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ env.VERSION }} - name: Download Linux x86_64 binary uses: actions/download-artifact@v4 @@ -194,5 +205,6 @@ jobs: with: tag_name: ${{ env.VERSION }} files: artifacts/* - generate_release_notes: true + # release-plz already wrote the notes from the changelog; keep them. + generate_release_notes: ${{ inputs.tag == '' }} fail_on_unmatched_files: true diff --git a/README.md b/README.md index 229de4ed..2cff7e22 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ [![CI](https://github.com/scbrown/pixelsrc/actions/workflows/ci.yml/badge.svg)](https://github.com/scbrown/pixelsrc/actions/workflows/ci.yml) [![Release](https://github.com/scbrown/pixelsrc/actions/workflows/release.yml/badge.svg)](https://github.com/scbrown/pixelsrc/actions/workflows/release.yml) [![WASM](https://github.com/scbrown/pixelsrc/actions/workflows/wasm.yml/badge.svg)](https://github.com/scbrown/pixelsrc/actions/workflows/wasm.yml) -[![Version](https://img.shields.io/crates/v/pxl)](https://crates.io/crates/pxl) +[![Version](https://img.shields.io/crates/v/pixelsrc)](https://crates.io/crates/pixelsrc) [![npm](https://img.shields.io/npm/v/@stiwi/pixelsrc-wasm)](https://www.npmjs.com/package/@stiwi/pixelsrc-wasm) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Made with pixelsrc](https://img.shields.io/badge/made%20with-pixelsrc-BD93F9)](https://pixelsrc.dev) +[![Made with pixelsrc](https://img.shields.io/badge/made%20with-pixelsrc-BD93F9)](https://scbrown.github.io/pixelsrc/) **The first pixel art format designed for GenAI.** @@ -51,15 +51,11 @@ pixelsrc is a semantic, human-readable text format for defining pixel art. Unlik ## Installation -### Homebrew (macOS/Linux) - -```bash -brew install scbrown/tap/pixelsrc -``` - -### Cargo (from source) +### Cargo ```bash +cargo install pixelsrc +# or the latest main: cargo install --git https://github.com/scbrown/pixelsrc ``` diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..35fd6ffb --- /dev/null +++ b/cliff.toml @@ -0,0 +1,72 @@ +[changelog] +header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n" +footer = "" +body = """ +{%- macro remote_url() -%} + https://github.com/scbrown/pixelsrc +{%- endmacro -%} + +{% if version -%} +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} +## [Unreleased] +{% endif -%} + +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ +{% if commit.breaking %}[**breaking**] {% endif %}\ +{{ commit.message | upper_first }}\ +([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ +{% endfor %} +{% endfor %} +""" +# Preserve the body's trailing newline so adjacent release sections keep a +# blank line between the final list item and the next version heading. +trim = false + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9].*" +sort_commits = "oldest" + +# Strip internal ticket IDs from PUBLISHED changelog entries. Commit messages +# keep the refs (internal git history wants them); the public CHANGELOG must +# not carry them — same enforcement split as the pre-push guard, and the same +# preprocessor block quipu's cliff.toml carries. Without this, an entry like +# "... pre-edit guard ()" ships verbatim (it was about to). +commit_preprocessors = [ + # Keep a leading tracker-style subject as a release entry. + { pattern = '^(?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*:[ \t]*', replace = "other: " }, + # "(ID, rest)" -> "(rest)" e.g. (proj-1a2b, #36/#37) -> (#36/#37) + { pattern = '\((?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*,[ \t]*', replace = "(" }, + # "(rest, ID)" -> "(rest)" + { pattern = ',[ \t]*(?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*\)', replace = ")" }, + # " (ID)" standalone paren -> removed e.g. ... shape (proj-1a2b) -> ... shape + { pattern = '[ \t]*\((?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*\)', replace = "" }, + # a RUN of bracketed refs -> removed e.g. ... dirs [proj-1a2b] [proj-3c4d] + { pattern = '(?:[ \t]*\[(?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*\])+', replace = "" }, + # "[ID, rest]" -> "[rest]" and "[rest, ID]" -> "[rest]", mirroring the parens + { pattern = '\[(?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*,[ \t]*', replace = "[" }, + { pattern = ',[ \t]*(?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*\]', replace = "]" }, + # any bare leftover " ID" + { pattern = '[ \t]*\b(?:aegis|hq|gassy|qp|TTP)-[a-z0-9]{3,6}(?:\.[0-9]+)*\b', replace = "" }, +] + +commit_parsers = [ + { message = "^other", group = "Other" }, + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^refactor", group = "Changed" }, + { message = "^doc", group = "Documentation" }, + { message = "^test", group = "Testing" }, + { message = "^chore", group = "Miscellaneous" }, + { message = "^ci", group = "CI/CD" }, + { body = ".*security", group = "Security" }, + { message = "^revert", group = "Reverted" }, +] diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 00000000..66648aef --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,18 @@ +[workspace] +changelog_config = "cliff.toml" +# release-plz only cuts the release PR, the tag and the GitHub release. The +# binaries and the crates.io publish run as jobs in release-plz.yml, because a +# tag or release created with GITHUB_TOKEN does not trigger `on: push: tags` +# or `on: release` workflows. Left to those triggers, a release would ship +# with no binaries. +publish = false +# Use the tags as the baseline instead of asking crates.io; with publish off, +# the registry is not the source of truth. +git_only = true +git_release_enable = true +git_tag_enable = true +# The default template is `{{ package }}-v{{ version }}`; every tag here is v*. +git_tag_name = "v{{ version }}" + +[[package]] +name = "pixelsrc" diff --git a/src/analyze/dimensions.rs b/src/analyze/dimensions.rs index 447d1585..0ba1e45e 100644 --- a/src/analyze/dimensions.rs +++ b/src/analyze/dimensions.rs @@ -23,7 +23,7 @@ impl DimensionStats { /// Get dimension counts sorted by frequency. pub fn sorted_by_frequency(&self) -> Vec<((u32, u32), usize)> { let mut items: Vec<_> = self.dimensions.iter().map(|(k, v)| (*k, *v)).collect(); - items.sort_by(|a, b| b.1.cmp(&a.1)); + items.sort_by_key(|b| std::cmp::Reverse(b.1)); items } diff --git a/src/analyze/families.rs b/src/analyze/families.rs index 26af50a0..05bcf783 100644 --- a/src/analyze/families.rs +++ b/src/analyze/families.rs @@ -57,7 +57,7 @@ impl TokenFamilyDetector { .collect(); // Sort by total count descending - families.sort_by(|a, b| b.total_count.cmp(&a.total_count)); + families.sort_by_key(|f| std::cmp::Reverse(f.total_count)); families } diff --git a/src/analyze/tokens.rs b/src/analyze/tokens.rs index 2956e84c..3e84376d 100644 --- a/src/analyze/tokens.rs +++ b/src/analyze/tokens.rs @@ -111,7 +111,7 @@ impl CoOccurrenceMatrix { /// Get top N token pairs by co-occurrence count. pub fn top_n(&self, n: usize) -> Vec<((&String, &String), usize)> { let mut items: Vec<_> = self.pairs.iter().map(|((a, b), count)| ((a, b), *count)).collect(); - items.sort_by(|a, b| b.1.cmp(&a.1)); + items.sort_by_key(|b| std::cmp::Reverse(b.1)); items.truncate(n); items } @@ -131,7 +131,7 @@ impl CoOccurrenceMatrix { } }) .collect(); - results.sort_by(|a, b| b.1.cmp(&a.1)); + results.sort_by_key(|b| std::cmp::Reverse(b.1)); results } diff --git a/src/atlas.rs b/src/atlas.rs index cd20d616..c1ea00ed 100644 --- a/src/atlas.rs +++ b/src/atlas.rs @@ -129,7 +129,7 @@ pub fn pack_atlas( // Sort sprites by height descending (better packing) let mut sorted_sprites: Vec<(usize, &SpriteInput)> = sprites.iter().enumerate().collect(); - sorted_sprites.sort_by(|a, b| b.1.image.height().cmp(&a.1.image.height())); + sorted_sprites.sort_by_key(|s| std::cmp::Reverse(s.1.image.height())); let mut atlases: Vec<(RgbaImage, AtlasMetadata, Vec)> = vec![]; let mut sprite_to_atlas: HashMap = HashMap::new(); diff --git a/src/composition/render.rs b/src/composition/render.rs index 1f696bb8..f82223b7 100644 --- a/src/composition/render.rs +++ b/src/composition/render.rs @@ -147,8 +147,8 @@ pub fn render_composition( } // Calculate expected grid dimensions for map validation - let expected_cols = if cell_size[0] > 0 { width / cell_size[0] } else { width }; - let expected_rows = if cell_size[1] > 0 { height / cell_size[1] } else { height }; + let expected_cols = width.checked_div(cell_size[0]).unwrap_or(width); + let expected_rows = height.checked_div(cell_size[1]).unwrap_or(height); // Create canvas (transparent by default) let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); diff --git a/src/install.rs b/src/install.rs index 60bc9c83..937c8d84 100644 --- a/src/install.rs +++ b/src/install.rs @@ -111,6 +111,10 @@ impl LockFile { } else { project_root.join(declared_path) }; + // install_path_dep records the canonical path, so compare against that: + // otherwise any symlink in the project path (macOS /var -> /private/var) + // makes every path dep look stale and reinstall on each run. + let resolved = resolved.canonicalize().unwrap_or(resolved); let resolved_str = resolved.to_string_lossy().to_string(); locked.dep_type == "path" && locked.source == resolved_str } else if dep.is_git() { diff --git a/src/lsp/server.rs b/src/lsp/server.rs index fd192490..ccfeadb6 100644 --- a/src/lsp/server.rs +++ b/src/lsp/server.rs @@ -189,10 +189,12 @@ impl PixelsrcLanguageServer { // Likely inside a region definition return CompletionContext::RegionDef; } - Some("palette") if line.contains(':') => { - if content.contains("\"roles\"") && !line.contains("\"colors\"") { - return CompletionContext::Roles; - } + Some("palette") + if line.contains(':') + && content.contains("\"roles\"") + && !line.contains("\"colors\"") => + { + return CompletionContext::Roles; } _ => {} } From d134c9d08b85c772d179e727cb86981077ff3eb2 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 25 Sep 2026 22:51:17 -0400 Subject: [PATCH 02/11] ci: clippy late-init lint; drop native x86_64 linker override that broke wheel builds .cargo/config.toml forced x86_64-linux-gnu-gcc for the native Linux target, which does not exist in the manylinux container maturin builds in (and is just gcc on a plain x86_64 host). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- .cargo/config.toml | 4 ---- src/build/pipeline.rs | 23 ++++++----------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 963acbaf..193fd7e2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,9 +1,5 @@ # Cross-compilation configuration for pixelsrc/pxl -# Linux x86_64 (GNU) -[target.x86_64-unknown-linux-gnu] -linker = "x86_64-linux-gnu-gcc" - # Linux ARM64 (GNU) [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" diff --git a/src/build/pipeline.rs b/src/build/pipeline.rs index 5762b1e6..b0b5da0c 100644 --- a/src/build/pipeline.rs +++ b/src/build/pipeline.rs @@ -593,10 +593,7 @@ impl BuildPipeline { // Select registries with import support let has_file_imports = !file_imports.is_empty(); - let effective_palette_reg; - let effective_sprite_reg; - - if has_file_imports { + let (effective_palette_reg, effective_sprite_reg) = if has_file_imports { if let Some(pr) = project_registry { for palette_name in pr.palette_names() { if let Some(loc) = pr.palette_location(palette_name) { @@ -613,20 +610,12 @@ impl BuildPipeline { local_sprite_registry.register_sprite(sprite.clone()); } } - effective_palette_reg = local_palette_registry; - effective_sprite_reg = local_sprite_registry; + (local_palette_registry, local_sprite_registry) + } else if let Some(pr) = project_registry { + (pr.palettes.clone(), pr.sprites.clone()) } else { - effective_palette_reg = if let Some(pr) = project_registry { - pr.palettes.clone() - } else { - local_palette_registry - }; - effective_sprite_reg = if let Some(pr) = project_registry { - pr.sprites.clone() - } else { - local_sprite_registry - }; - } + (local_palette_registry, local_sprite_registry) + }; let palette_registry = &effective_palette_reg; let sprite_registry = &effective_sprite_reg; From 701470e5659e32d9045cb48d6ba736bff35c5881 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 25 Sep 2026 23:03:00 -0400 Subject: [PATCH 03/11] ci: unbreak the Python wheel and browser-test lanes - drop .cargo/config.toml: its linker overrides (x86_64-linux-gnu-gcc, aarch64-linux-gnu-gcc) do not exist in maturin's manylinux containers, and cross sets its own linker anyway; the macOS entries were defaults. - wheels: pyo3 0.23 rejects the 3.14 interpreter that --find-interpreter now finds; build it against the stable ABI. - browser tests: npx serve was still downloading when the 3s wait ended (ERR_EMPTY_RESPONSE); serve the book with python3 -m http.server. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- .cargo/config.toml | 17 ----------------- .github/workflows/browser-tests.yml | 5 ++--- .github/workflows/python.yml | 3 +++ 3 files changed, 5 insertions(+), 20 deletions(-) delete mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 193fd7e2..00000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,17 +0,0 @@ -# Cross-compilation configuration for pixelsrc/pxl - -# Linux ARM64 (GNU) -[target.aarch64-unknown-linux-gnu] -linker = "aarch64-linux-gnu-gcc" - -# macOS x86_64 -[target.x86_64-apple-darwin] -linker = "clang" - -# macOS ARM64 (Apple Silicon) -[target.aarch64-apple-darwin] -linker = "clang" - -# Windows x86_64 (MSVC) -[target.x86_64-pc-windows-msvc] -# Uses default MSVC linker when available diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index 9abe8e08..142f17c9 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -74,9 +74,8 @@ jobs: test.beforeAll(async () => { // Start a simple HTTP server - server = spawn('npx', ['serve', '-p', PORT, 'docs/book/book'], { - stdio: 'pipe', - shell: true + server = spawn('python3', ['-m', 'http.server', String(PORT), '--directory', 'docs/book/book'], { + stdio: 'pipe' }); // Wait for server to start await new Promise(resolve => setTimeout(resolve, 3000)); diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3e16edf3..ce8d25a4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -10,6 +10,9 @@ on: env: CARGO_TERM_COLOR: always + # pyo3 0.23 tops out at Python 3.13; runners now carry 3.14, which + # --find-interpreter picks up. Build those against the stable ABI. + PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" jobs: linux: From e168b994565f0679b6b249a5336d371284495ccd Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 25 Sep 2026 23:09:21 -0400 Subject: [PATCH 04/11] ci: publish the Homebrew formula to the scbrown/homebrew-pixelsrc tap The formula lived in homebrew-pixelsrc/ inside this repo, which brew cannot tap, and the README pointed at scbrown/tap, which never existed. The tap is now its own repo (seeded with 0.2.0, verified with brew install + brew test). homebrew.yml checks it out with a write deploy key and pushes the regenerated formula; release-plz.yml calls it after the binaries are attached. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- .github/workflows/homebrew.yml | 36 ++++++++++++++----- .github/workflows/release-plz.yml | 12 ++++++- README.md | 6 ++++ homebrew-pixelsrc/Formula/pxl-src.rb | 23 ------------ homebrew-pixelsrc/Formula/pxl.rb | 41 ---------------------- homebrew-pixelsrc/README.md | 52 ---------------------------- 6 files changed, 45 insertions(+), 125 deletions(-) delete mode 100644 homebrew-pixelsrc/Formula/pxl-src.rb delete mode 100644 homebrew-pixelsrc/Formula/pxl.rb delete mode 100644 homebrew-pixelsrc/README.md diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index 7b389c8d..e19008b5 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -3,6 +3,16 @@ name: Update Homebrew Formula on: release: types: [published] + # Called by release-plz.yml once the binaries are attached (a GITHUB_TOKEN + # release cannot trigger the `release` arm above). + workflow_call: + inputs: + tag: + required: true + type: string + secrets: + HOMEBREW_TAP_DEPLOY_KEY: + required: true workflow_dispatch: inputs: version: @@ -10,25 +20,34 @@ on: required: true type: string +# The formula lives in the tap repo, scbrown/homebrew-pixelsrc, so that +# `brew install scbrown/pixelsrc/pxl` works. CI writes to it with a deploy key +# (HOMEBREW_TAP_DEPLOY_KEY) scoped to that one repo. env: - FORMULA_FILE: homebrew-pixelsrc/Formula/pxl.rb + FORMULA_FILE: tap/Formula/pxl.rb + # the working directory is not a pixelsrc checkout, so tell gh which repo + GH_REPO: ${{ github.repository }} jobs: update-formula: name: Update Homebrew formula runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - - name: Checkout + - name: Checkout the tap uses: actions/checkout@v4 with: - ref: main + repository: scbrown/homebrew-pixelsrc + ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + path: tap - name: Get version id: version run: | - if [ -n "${{ inputs.version }}" ]; then + if [ -n "${{ inputs.tag }}" ]; then + VERSION="${{ inputs.tag }}" + elif [ -n "${{ inputs.version }}" ]; then VERSION="${{ inputs.version }}" else VERSION="${{ github.event.release.tag_name }}" @@ -88,7 +107,7 @@ jobs: # frozen_string_literal: true # Homebrew formula for pxl - Pixelsrc CLI - # Install: brew tap scbrown/pixelsrc && brew install pxl + # Install: brew install scbrown/pixelsrc/pxl class Pxl < Formula desc "GenAI-native pixel art format and compiler" homepage "https://github.com/scbrown/pixelsrc" @@ -131,9 +150,10 @@ jobs: cat $FORMULA_FILE - name: Commit and push + working-directory: tap run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add $FORMULA_FILE - git diff --staged --quiet || git commit -m "Update Homebrew formula for ${{ steps.version.outputs.tag }}" + git add Formula/pxl.rb + git diff --staged --quiet || git commit -m "pxl ${{ steps.version.outputs.number }}" git push diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index fb731d22..fae2a395 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -2,7 +2,8 @@ name: Release-plz # On every push to main: open or update the release PR (version bump + # CHANGELOG). When that PR merges: tag v, create the GitHub release, -# then build the binaries and publish to crates.io from this same run. +# then build the binaries, update the Homebrew tap and publish to crates.io +# from this same run. on: push: branches: [main] @@ -47,6 +48,15 @@ jobs: with: tag: ${{ needs.release-plz.outputs.tag }} + homebrew: + name: Homebrew tap + needs: [release-plz, binaries] + uses: ./.github/workflows/homebrew.yml + with: + tag: ${{ needs.release-plz.outputs.tag }} + secrets: + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + crates: name: crates.io needs: release-plz diff --git a/README.md b/README.md index 2cff7e22..02d450e9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ pixelsrc is a semantic, human-readable text format for defining pixel art. Unlik ## Installation +### Homebrew (macOS/Linux) + +```bash +brew install scbrown/pixelsrc/pxl +``` + ### Cargo ```bash diff --git a/homebrew-pixelsrc/Formula/pxl-src.rb b/homebrew-pixelsrc/Formula/pxl-src.rb deleted file mode 100644 index a0de66f6..00000000 --- a/homebrew-pixelsrc/Formula/pxl-src.rb +++ /dev/null @@ -1,23 +0,0 @@ -# typed: false -# frozen_string_literal: true - -# Homebrew formula for pxl - build from source -# Install: brew tap scbrown/pixelsrc && brew install pxl-src -class PxlSrc < Formula - desc "GenAI-native pixel art format and compiler (build from source)" - homepage "https://github.com/scbrown/pixelsrc" - url "https://github.com/scbrown/pixelsrc/archive/refs/tags/v0.1.0.tar.gz" - sha256 "PLACEHOLDER_SHA256_SOURCE" - license "MIT" - head "https://github.com/scbrown/pixelsrc.git", branch: "main" - - depends_on "rust" => :build - - def install - system "cargo", "install", *std_cargo_args - end - - test do - assert_match version.to_s, shell_output("#{bin}/pxl --version") - end -end diff --git a/homebrew-pixelsrc/Formula/pxl.rb b/homebrew-pixelsrc/Formula/pxl.rb deleted file mode 100644 index 725e1826..00000000 --- a/homebrew-pixelsrc/Formula/pxl.rb +++ /dev/null @@ -1,41 +0,0 @@ -# typed: false -# frozen_string_literal: true - -# Homebrew formula for pxl - Pixelsrc CLI -# Install: brew tap scbrown/pixelsrc && brew install pxl -class Pxl < Formula - desc "GenAI-native pixel art format and compiler" - homepage "https://github.com/scbrown/pixelsrc" - version "0.2.0" - license "MIT" - - on_macos do - on_arm do - url "https://github.com/scbrown/pixelsrc/releases/download/v0.2.0/pxl-v0.2.0-aarch64-apple-darwin.tar.gz" - sha256 "243cb8a3fddb10ae73c76e9a223ee91fab350d1c76e2e1a6a53d73dd8c5e153b" - end - on_intel do - url "https://github.com/scbrown/pixelsrc/releases/download/v0.2.0/pxl-v0.2.0-x86_64-apple-darwin.tar.gz" - sha256 "d6f6e559717337b11585a6193f28b2d12afdae46a377f4d0e0385314a053f880" - end - end - - on_linux do - on_arm do - url "https://github.com/scbrown/pixelsrc/releases/download/v0.2.0/pxl-v0.2.0-aarch64-unknown-linux-gnu.tar.gz" - sha256 "102572e116fae5e5fcdb2bb75c964eda1d46a66e8304bf34a45d4c3b412cc4d2" - end - on_intel do - url "https://github.com/scbrown/pixelsrc/releases/download/v0.2.0/pxl-v0.2.0-x86_64-unknown-linux-gnu.tar.gz" - sha256 "d376a081598379b105d11a36ef531eaedbb788c447311a643f4a1c190e032893" - end - end - - def install - bin.install "pxl" - end - - test do - assert_match version.to_s, shell_output("#{bin}/pxl --version") - end -end diff --git a/homebrew-pixelsrc/README.md b/homebrew-pixelsrc/README.md deleted file mode 100644 index 0b7fe4df..00000000 --- a/homebrew-pixelsrc/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Homebrew Tap for Pixelsrc - -This tap contains the Homebrew formula for `pxl`, the Pixelsrc CLI. - -## Installation - -```bash -# Add the tap -brew tap stiwi/pixelsrc - -# Install pxl -brew install pxl -``` - -## Available Formulas - -### pxl (binary) - -Pre-built binary for fast installation: - -```bash -brew install pxl -``` - -Supports: -- macOS (Intel and Apple Silicon) -- Linux (x86_64 and ARM64) - -### pxl-src (build from source) - -Build from source (requires Rust): - -```bash -brew install pxl-src -``` - -## Updating - -```bash -brew update -brew upgrade pxl -``` - -## Verification - -```bash -pxl --version -``` - -## About Pixelsrc - -Pixelsrc is a GenAI-native pixel art format and compiler. See the main repository for documentation. From 52826c768186c73a224f1d348fb802002fb2eed6 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 25 Sep 2026 23:28:44 -0400 Subject: [PATCH 05/11] ci: browser tests serve mdBook's html/ output; Linux wheels find interpreters With [output.linkcheck] configured, mdBook writes the site to book/html/, so the tests were loading a bare directory listing. The aarch64 manylinux-cross container has no default interpreter, so maturin needs --find-interpreter like the macOS and Windows jobs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- .github/workflows/browser-tests.yml | 2 +- .github/workflows/python.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index 142f17c9..f2cbbbe2 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -74,7 +74,7 @@ jobs: test.beforeAll(async () => { // Start a simple HTTP server - server = spawn('python3', ['-m', 'http.server', String(PORT), '--directory', 'docs/book/book'], { + server = spawn('python3', ['-m', 'http.server', String(PORT), '--directory', 'docs/book/book/html'], { stdio: 'pipe' }); // Wait for server to start diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index ce8d25a4..c953d66f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -28,7 +28,7 @@ jobs: - uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} - args: --release --out dist + args: --release --out dist --find-interpreter manylinux: auto working-directory: python From 34259d06c288acc75c969a6e057e813a43796835 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 25 Sep 2026 23:59:02 -0400 Subject: [PATCH 06/11] fix(docs): interactive demos never loaded; wheel and browser-test lanes green - wasm-demo.js probed dynamic import with `typeof import`, a SyntaxError that aborted the whole script, so window.pixelsrcDemo was never defined and every interactive example in the book was dead. - browser test: open mdBook search before asserting the bar is visible. - Linux wheels: the pyo3 forward-compat env var does not reach the manylinux container; list the supported interpreters explicitly. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- .github/workflows/browser-tests.yml | 2 ++ .github/workflows/python.yml | 4 +++- docs/book/custom/js/wasm-demo.js | 6 ++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index f2cbbbe2..024d01dd 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -102,6 +102,8 @@ jobs: test('search functionality works', async ({ page }) => { await page.goto(BASE_URL); // Check search is available + // mdBook keeps the search bar hidden until search is opened + await page.locator('#search-toggle').click(); const searchInput = page.locator('#searchbar'); await expect(searchInput).toBeVisible(); }); diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c953d66f..3a6b113d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -28,7 +28,9 @@ jobs: - uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} - args: --release --out dist --find-interpreter + # PYO3_USE_ABI3_FORWARD_COMPATIBILITY does not reach the manylinux + # container, so name the interpreters pyo3 0.23 supports. + args: --release --out dist -i python3.9 python3.10 python3.11 python3.12 python3.13 manylinux: auto working-directory: python diff --git a/docs/book/custom/js/wasm-demo.js b/docs/book/custom/js/wasm-demo.js index 48ec628d..238acb01 100644 --- a/docs/book/custom/js/wasm-demo.js +++ b/docs/book/custom/js/wasm-demo.js @@ -18,8 +18,10 @@ wasm: typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function', dynamicImport: (function() { try { - // Check if dynamic import is supported - return typeof import === 'function' || true; // Modern browsers + // Check if dynamic import is supported. `typeof import` is a + // SyntaxError that aborts this whole script, so probe via Function. + new Function('return import("data:text/javascript,")'); + return true; } catch (e) { return false; } From 2d112aa626e1cfa114f94c3819537ab6e6f9f172 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 26 Sep 2026 00:04:33 -0400 Subject: [PATCH 07/11] Apply sprite and layer transforms when rendering compositions --- src/cli/render.rs | 80 +++++-------- src/composition/error.rs | 3 + src/composition/render.rs | 50 +++++++- src/transforms/mod.rs | 32 +++++ tests/cli_composition_transforms.rs | 59 ++++++++++ tests/composition_layer_transforms.rs | 111 ++++++++++++++++++ .../fixtures/valid/composition_transforms.pxl | 9 ++ 7 files changed, 289 insertions(+), 55 deletions(-) create mode 100644 tests/cli_composition_transforms.rs create mode 100644 tests/composition_layer_transforms.rs create mode 100644 tests/fixtures/valid/composition_transforms.pxl diff --git a/src/cli/render.rs b/src/cli/render.rs index d3222261..a946311c 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -432,55 +432,20 @@ pub fn run_render( // Render the resolved sprite let (mut image, render_warnings) = render_resolved(&render_sprite_data); - // Apply transforms from sprite.transform if present - if let Some(ref transform_specs) = sprite.transform { - use crate::models::TransformSpec; - use crate::transforms::{apply_image_transform, parse_transform_str}; - - for spec in transform_specs { - let transform_result = match spec { - TransformSpec::String(s) => parse_transform_str(s), - TransformSpec::Object { op, params } => { - // Convert object to JSON and parse - let mut obj = serde_json::Map::new(); - obj.insert("op".to_string(), serde_json::Value::String(op.clone())); - for (k, v) in params { - obj.insert(k.clone(), v.clone()); - } - crate::transforms::parse_transform_value(&serde_json::Value::Object( - obj, - )) - } - }; - - match transform_result { - Ok(transform) => { - // Skip animation transforms (they don't apply to images) - if crate::transforms::is_animation_transform(&transform) { - continue; - } - match apply_image_transform(&image, &transform, Some(&final_palette)) { - Ok(transformed) => image = transformed, - Err(e) => { - let msg = - format!("sprite '{}': transform error: {}", sprite.name, e); - if strict { - eprintln!("Error: {}", msg); - return ExitCode::from(EXIT_ERROR); - } - all_warnings.push(msg); - } - } - } - Err(e) => { - let msg = format!("sprite '{}': invalid transform: {}", sprite.name, e); - if strict { - eprintln!("Error: {}", msg); - return ExitCode::from(EXIT_ERROR); - } - all_warnings.push(msg); - } + if let Some(ref specs) = sprite.transform { + let (transformed, warnings) = crate::transforms::apply_image_transform_specs( + image, + specs, + Some(&final_palette), + ); + image = transformed; + for warning in warnings { + let msg = format!("sprite '{}': {}", sprite.name, warning); + if strict { + eprintln!("Error: {}", msg); + return ExitCode::from(EXIT_ERROR); } + all_warnings.push(msg); } } @@ -661,7 +626,7 @@ fn run_composition_render( } /// Render a composition to an image buffer -/// TRF-9: Now uses SpriteRegistry to resolve sprites with transforms applied +/// Resolve sprite geometry and apply image transforms before caching composition inputs. #[allow(clippy::too_many_arguments)] fn render_composition_to_image( comp: &Composition, @@ -695,7 +660,7 @@ fn render_composition_to_image( // First check if sprite exists in the raw sprites map (for @include: handling) let original_sprite = sprites.get(sprite_name); - // Use sprite registry to resolve the sprite with transforms applied + // Resolve inherited sprite geometry; image transforms are applied below. let resolved_sprite = match sprite_registry.resolve(sprite_name, palette_registry, strict) { Ok(resolved) => { // Collect any sprite warnings @@ -764,8 +729,19 @@ fn render_composition_to_image( regions: resolved_sprite.regions.clone(), }; - // Render the resolved sprite (transforms already applied) - let (image, render_warnings) = render_resolved(&render_sprite_data); + // Registry resolution inherits regions and size, not transformed pixels. + let (mut image, render_warnings) = render_resolved(&render_sprite_data); + if let Some(specs) = original_sprite.and_then(|sprite| sprite.transform.as_deref()) { + let (transformed, warnings) = crate::transforms::apply_image_transform_specs( + image, + specs, + Some(&render_sprite_data.palette), + ); + image = transformed; + for warning in warnings { + all_warnings.push(format!("sprite '{}': {}", sprite_name, warning)); + } + } // Collect render warnings for warning in render_warnings { diff --git a/src/composition/error.rs b/src/composition/error.rs index 5fc3dc22..762a9460 100644 --- a/src/composition/error.rs +++ b/src/composition/error.rs @@ -18,6 +18,9 @@ impl Warning { #[derive(Debug, Clone, PartialEq, Error)] #[non_exhaustive] pub enum CompositionError { + /// A layer transform could not be parsed or applied in strict mode. + #[error("Layer transform in composition '{composition_name}': {message}")] + LayerTransform { composition_name: String, message: String }, /// Sprite dimensions exceed cell size #[error("Sprite '{sprite_name}' ({sprite_w}x{sprite_h}) exceeds cell size ({cell_w}x{cell_h}) in composition '{composition_name}'", sprite_w = sprite_size.0, sprite_h = sprite_size.1, cell_w = cell_size.0, cell_h = cell_size.1)] SizeMismatch { diff --git a/src/composition/render.rs b/src/composition/render.rs index 1f696bb8..8aaef902 100644 --- a/src/composition/render.rs +++ b/src/composition/render.rs @@ -3,7 +3,7 @@ use image::{Rgba, RgbaImage}; use std::collections::HashMap; -use crate::models::Composition; +use crate::models::{Composition, CompositionLayer}; use crate::registry::CompositionRegistry; use crate::variables::VariableRegistry; @@ -12,6 +12,34 @@ use super::context::RenderContext; use super::error::{CompositionError, Warning}; use super::resolve::{resolve_blend_mode, resolve_opacity}; +/// Transform a layer's input before placement, without mutating cached sprites +/// or nested compositions reused by other layers. +fn transform_layer_sprite<'a>( + image: &'a RgbaImage, + layer: &CompositionLayer, + composition_name: &str, + strict: bool, + warnings: &mut Vec, +) -> Result, CompositionError> { + let Some(specs) = layer.transform.as_deref().filter(|specs| !specs.is_empty()) else { + return Ok(std::borrow::Cow::Borrowed(image)); + }; + let (image, diagnostics) = + crate::transforms::apply_image_transform_specs(image.clone(), specs, None); + for diagnostic in diagnostics { + let message = + format!("layer '{}': {}", layer.name.as_deref().unwrap_or("unnamed"), diagnostic); + if strict { + return Err(CompositionError::LayerTransform { + composition_name: composition_name.to_string(), + message, + }); + } + warnings.push(Warning::new(message)); + } + Ok(std::borrow::Cow::Owned(image)) +} + /// Render a composition to an RGBA image buffer. /// /// Takes a composition and a map of sprite name -> rendered image. @@ -228,7 +256,15 @@ pub fn render_composition( } }; - // Check for size mismatch (Task 2.5) + let sprite_image = transform_layer_sprite( + sprite_image, + layer, + &comp.name, + strict, + &mut warnings, + )?; + + // Check the transformed dimensions for size mismatch (Task 2.5) let sprite_width = sprite_image.width(); let sprite_height = sprite_image.height(); if sprite_width > cell_size[0] || sprite_height > cell_size[1] { @@ -252,7 +288,7 @@ pub fn render_composition( let y = (row_idx as u32) * cell_size[1]; // Blit sprite onto canvas with blend mode and opacity (ATF-10) - blit_sprite_blended(&mut canvas, sprite_image, x, y, blend_mode, opacity); + blit_sprite_blended(&mut canvas, &sprite_image, x, y, blend_mode, opacity); } } } @@ -437,6 +473,14 @@ fn render_composition_inner( continue; }; + let sprite_image = transform_layer_sprite( + &sprite_image, + layer, + &comp.name, + strict, + &mut warnings, + )?; + let x = (col_idx as u32) * cell_size[0]; let y = (row_idx as u32) * cell_size[1]; diff --git a/src/transforms/mod.rs b/src/transforms/mod.rs index c6c99db0..e1d0b7d9 100644 --- a/src/transforms/mod.rs +++ b/src/transforms/mod.rs @@ -37,3 +37,35 @@ pub use types::{explain_transform, Transform, TransformError}; /// Result type alias for transform operations. pub type Result = std::result::Result; + +/// Apply image transform specifications in order, retaining valid operations in +/// lenient rendering. Callers turn the returned diagnostics into strict errors. +pub(crate) fn apply_image_transform_specs( + mut image: image::RgbaImage, + specs: &[crate::models::TransformSpec], + palette: Option<&std::collections::HashMap>, +) -> (image::RgbaImage, Vec) { + use crate::models::TransformSpec; + + let mut warnings = Vec::new(); + for spec in specs { + let parsed = match spec { + TransformSpec::String(s) => parse_transform_str(s), + TransformSpec::Object { op, params } => { + let mut object: serde_json::Map = + params.clone().into_iter().collect(); + object.insert("op".to_string(), serde_json::Value::String(op.clone())); + parse_transform_value(&serde_json::Value::Object(object)) + } + }; + match parsed { + Ok(transform) if is_animation_transform(&transform) => continue, + Ok(transform) => match apply_image_transform(&image, &transform, palette) { + Ok(transformed) => image = transformed, + Err(error) => warnings.push(format!("transform error: {}", error)), + }, + Err(error) => warnings.push(format!("invalid transform: {}", error)), + } + } + (image, warnings) +} diff --git a/tests/cli_composition_transforms.rs b/tests/cli_composition_transforms.rs new file mode 100644 index 00000000..03b66540 --- /dev/null +++ b/tests/cli_composition_transforms.rs @@ -0,0 +1,59 @@ +//! Composition transforms must agree with direct sprite rendering in PNG and GIF. + +use image::AnimationDecoder; +use std::fs::File; +use std::io::BufReader; +use std::path::Path; +use std::process::Command; + +const INPUT: &str = "tests/fixtures/valid/composition_transforms.pxl"; + +fn render(output: &Path, options: &[&str]) { + let result = Command::new(env!("CARGO_BIN_EXE_pxl")) + .args(["render", INPUT, "--strict"]) + .args(options) + .arg("-o") + .arg(output) + .output() + .unwrap(); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); +} + +fn expected(x: u32, y: u32) -> image::RgbaImage { + let mut image = image::RgbaImage::new(8, 8); + for dy in 0..2 { + for dx in 0..3 { + image.put_pixel(x + dx, y + dy, image::Rgba([227, 218, 201, 255])); + } + } + image +} + +#[test] +fn png_compositions_match_direct_transformed_sprites() { + let dir = tempfile::tempdir().unwrap(); + render(dir.path(), &[]); + let shifted = image::open(dir.path().join("jaw_shift.png")).unwrap().to_rgba8(); + assert_eq!(shifted, expected(2, 4)); + for name in ["layer_shift", "source_shift", "base_shift"] { + let actual = image::open(dir.path().join(format!("{name}.png"))).unwrap().to_rgba8(); + assert_eq!(actual, shifted, "{name}"); + } + let combined = image::open(dir.path().join("combined.png")).unwrap().to_rgba8(); + assert_eq!(combined, expected(3, 4)); + assert_eq!(combined, image::open(dir.path().join("jaw_combined.png")).unwrap().to_rgba8()); +} + +#[test] +fn gif_composition_frames_apply_source_then_layer_transforms() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("poses.gif"); + render(&path, &["--gif", "--animation", "poses"]); + let decoder = + image::codecs::gif::GifDecoder::new(BufReader::new(File::open(path).unwrap())).unwrap(); + let frames = decoder.into_frames().collect_frames().unwrap(); + assert_eq!(frames.len(), 3); + for (frame, x) in frames.iter().zip([2, 2, 3]) { + assert_eq!(frame.buffer(), &expected(x, 4)); + } +} diff --git a/tests/composition_layer_transforms.rs b/tests/composition_layer_transforms.rs new file mode 100644 index 00000000..aef3b9bc --- /dev/null +++ b/tests/composition_layer_transforms.rs @@ -0,0 +1,111 @@ +//! Layer transforms apply before placement and never alter shared cached inputs. + +use image::{Rgba, RgbaImage}; +use pixelsrc::composition::{render_composition, render_composition_nested, RenderContext}; +use pixelsrc::models::Composition; +use pixelsrc::registry::CompositionRegistry; +use serde_json::json; +use std::collections::HashMap; + +fn sprites() -> HashMap { + let mut image = RgbaImage::new(4, 1); + image.put_pixel(0, 0, Rgba([255, 0, 0, 255])); + HashMap::from([("dot".into(), image)]) +} + +#[test] +fn transforms_run_in_order_before_each_cell_placement() { + let comp: Composition = serde_json::from_value(json!({ + "name": "pair", "size": [8, 1], "cell_size": [4, 1], + "sprites": {"A": "dot"}, + "layers": [{"map": ["AA"], "transform": ["shift:1,0", "mirror-h"]}] + })) + .unwrap(); + let sprites = sprites(); + let (image, warnings) = render_composition(&comp, &sprites, true, None).unwrap(); + assert!(warnings.is_empty()); + for x in 0..8 { + assert_eq!(image.get_pixel(x, 0)[3] > 0, x == 2 || x == 6); + } + assert_eq!(sprites["dot"].get_pixel(0, 0)[3], 255); +} + +#[test] +fn nested_layer_transforms_leave_cached_compositions_unchanged() { + let inner: Composition = serde_json::from_value(json!({ + "name": "inner", "size": [4, 1], "cell_size": [4, 1], + "sprites": {"A": "dot"}, "layers": [{"map": ["A"]}] + })) + .unwrap(); + let outer: Composition = serde_json::from_value(json!({ + "name": "outer", "size": [8, 1], "cell_size": [4, 1], + "sprites": {"N": "inner"}, + "layers": [ + {"map": ["NN"], "transform": ["shift:1,0", "mirror-h"]}, + {"map": ["N"]} + ] + })) + .unwrap(); + let mut registry = CompositionRegistry::new(); + registry.register(inner); + let sprites = sprites(); + let mut context = RenderContext::new(); + let (image, warnings) = + render_composition_nested(&outer, &sprites, Some(®istry), &mut context, true, None) + .unwrap(); + assert!(warnings.is_empty()); + for x in 0..8 { + assert_eq!(image.get_pixel(x, 0)[3] > 0, [0, 2, 6].contains(&x)); + } + assert_eq!(context.get_cached("inner").unwrap(), &sprites["dot"]); +} + +#[test] +fn invalid_layer_transforms_warn_or_fail_in_both_renderers() { + let comp: Composition = serde_json::from_value(json!({ + "name": "bad", "size": [4, 1], "cell_size": [4, 1], + "sprites": {"A": "dot"}, + "layers": [{"name": "pose", "map": ["A"], + "transform": ["unknown-operation", {"op": "shift", "x": 1, "y": 0}]}] + })) + .unwrap(); + for nested in [false, true] { + for strict in [false, true] { + let sprites = sprites(); + let result = if nested { + render_composition_nested( + &comp, + &sprites, + None, + &mut RenderContext::new(), + strict, + None, + ) + } else { + render_composition(&comp, &sprites, strict, None) + }; + if strict { + let error = result.unwrap_err().to_string(); + assert!(error.contains("pose") && error.contains("invalid transform")); + } else { + let (image, warnings) = result.unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].message.contains("invalid transform")); + assert_eq!(image.get_pixel(0, 0)[3], 0); + assert_eq!(image.get_pixel(1, 0)[3], 255); + } + } + } +} + +#[test] +fn transformed_sprite_dimensions_are_validated() { + let comp: Composition = serde_json::from_value(json!({ + "name": "large", "size": [4, 1], "cell_size": [4, 1], + "sprites": {"A": "dot"}, + "layers": [{"map": ["A"], "transform": ["pad:1"]}] + })) + .unwrap(); + let error = render_composition(&comp, &sprites(), true, None).unwrap_err(); + assert!(error.to_string().contains("exceeds cell size")); +} diff --git a/tests/fixtures/valid/composition_transforms.pxl b/tests/fixtures/valid/composition_transforms.pxl new file mode 100644 index 00000000..c4f776f0 --- /dev/null +++ b/tests/fixtures/valid/composition_transforms.pxl @@ -0,0 +1,9 @@ +{"type":"palette","name":"p","colors":{"_":"transparent","bone":"#e3dac9"}} +{"type":"sprite","name":"jaw","size":[8,8],"palette":"p","regions":{"bone":{"rect":[2,1,3,2]}}} +{"type":"sprite","name":"jaw_shift","source":"jaw","palette":"p","transform":["shift:0,3"]} +{"type":"sprite","name":"jaw_combined","source":"jaw","palette":"p","transform":["shift:0,3",{"op":"shift","x":1,"y":0}]} +{"type":"composition","name":"layer_shift","size":[8,8],"cell_size":[8,8],"sprites":{"J":"jaw"},"layers":[{"map":["J"],"transform":["shift:0,3"]}]} +{"type":"composition","name":"source_shift","size":[8,8],"cell_size":[8,8],"sprites":{"J":"jaw_shift"},"layers":[{"map":["J"]}]} +{"type":"composition","name":"combined","size":[8,8],"cell_size":[8,8],"sprites":{"J":"jaw_shift"},"layers":[{"map":["J"],"transform":[{"op":"shift","x":1,"y":0}]}]} +{"type":"composition","name":"base_shift","base":"jaw_shift","layers":[],"sprites":{}} +{"type":"animation","name":"poses","frames":["layer_shift","source_shift","combined"],"duration":100} From 57f125873b451588579372938e3e3c4bedba633a Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 26 Sep 2026 00:13:25 -0400 Subject: [PATCH 08/11] Resolve current stable Clippy diagnostics without changing behavior --- src/analyze/dimensions.rs | 2 +- src/analyze/families.rs | 2 +- src/analyze/tokens.rs | 4 ++-- src/atlas.rs | 2 +- src/build/pipeline.rs | 31 +++++++++++++++---------------- src/composition/render.rs | 4 ++-- src/lsp/server.rs | 10 ++++++---- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/analyze/dimensions.rs b/src/analyze/dimensions.rs index 447d1585..1f70ff53 100644 --- a/src/analyze/dimensions.rs +++ b/src/analyze/dimensions.rs @@ -23,7 +23,7 @@ impl DimensionStats { /// Get dimension counts sorted by frequency. pub fn sorted_by_frequency(&self) -> Vec<((u32, u32), usize)> { let mut items: Vec<_> = self.dimensions.iter().map(|(k, v)| (*k, *v)).collect(); - items.sort_by(|a, b| b.1.cmp(&a.1)); + items.sort_by_key(|a| std::cmp::Reverse(a.1)); items } diff --git a/src/analyze/families.rs b/src/analyze/families.rs index 26af50a0..6ab04192 100644 --- a/src/analyze/families.rs +++ b/src/analyze/families.rs @@ -57,7 +57,7 @@ impl TokenFamilyDetector { .collect(); // Sort by total count descending - families.sort_by(|a, b| b.total_count.cmp(&a.total_count)); + families.sort_by_key(|a| std::cmp::Reverse(a.total_count)); families } diff --git a/src/analyze/tokens.rs b/src/analyze/tokens.rs index 2956e84c..85f0c102 100644 --- a/src/analyze/tokens.rs +++ b/src/analyze/tokens.rs @@ -111,7 +111,7 @@ impl CoOccurrenceMatrix { /// Get top N token pairs by co-occurrence count. pub fn top_n(&self, n: usize) -> Vec<((&String, &String), usize)> { let mut items: Vec<_> = self.pairs.iter().map(|((a, b), count)| ((a, b), *count)).collect(); - items.sort_by(|a, b| b.1.cmp(&a.1)); + items.sort_by_key(|a| std::cmp::Reverse(a.1)); items.truncate(n); items } @@ -131,7 +131,7 @@ impl CoOccurrenceMatrix { } }) .collect(); - results.sort_by(|a, b| b.1.cmp(&a.1)); + results.sort_by_key(|a| std::cmp::Reverse(a.1)); results } diff --git a/src/atlas.rs b/src/atlas.rs index cd20d616..1f469cc5 100644 --- a/src/atlas.rs +++ b/src/atlas.rs @@ -129,7 +129,7 @@ pub fn pack_atlas( // Sort sprites by height descending (better packing) let mut sorted_sprites: Vec<(usize, &SpriteInput)> = sprites.iter().enumerate().collect(); - sorted_sprites.sort_by(|a, b| b.1.image.height().cmp(&a.1.image.height())); + sorted_sprites.sort_by_key(|a| std::cmp::Reverse(a.1.image.height())); let mut atlases: Vec<(RgbaImage, AtlasMetadata, Vec)> = vec![]; let mut sprite_to_atlas: HashMap = HashMap::new(); diff --git a/src/build/pipeline.rs b/src/build/pipeline.rs index 5762b1e6..bac28b82 100644 --- a/src/build/pipeline.rs +++ b/src/build/pipeline.rs @@ -593,10 +593,8 @@ impl BuildPipeline { // Select registries with import support let has_file_imports = !file_imports.is_empty(); - let effective_palette_reg; - let effective_sprite_reg; - if has_file_imports { + let (effective_palette_reg, effective_sprite_reg) = if has_file_imports { if let Some(pr) = project_registry { for palette_name in pr.palette_names() { if let Some(loc) = pr.palette_location(palette_name) { @@ -613,20 +611,21 @@ impl BuildPipeline { local_sprite_registry.register_sprite(sprite.clone()); } } - effective_palette_reg = local_palette_registry; - effective_sprite_reg = local_sprite_registry; + (local_palette_registry, local_sprite_registry) } else { - effective_palette_reg = if let Some(pr) = project_registry { - pr.palettes.clone() - } else { - local_palette_registry - }; - effective_sprite_reg = if let Some(pr) = project_registry { - pr.sprites.clone() - } else { - local_sprite_registry - }; - } + ( + if let Some(pr) = project_registry { + pr.palettes.clone() + } else { + local_palette_registry + }, + if let Some(pr) = project_registry { + pr.sprites.clone() + } else { + local_sprite_registry + }, + ) + }; let palette_registry = &effective_palette_reg; let sprite_registry = &effective_sprite_reg; diff --git a/src/composition/render.rs b/src/composition/render.rs index 8aaef902..11855991 100644 --- a/src/composition/render.rs +++ b/src/composition/render.rs @@ -175,8 +175,8 @@ pub fn render_composition( } // Calculate expected grid dimensions for map validation - let expected_cols = if cell_size[0] > 0 { width / cell_size[0] } else { width }; - let expected_rows = if cell_size[1] > 0 { height / cell_size[1] } else { height }; + let expected_cols = width.checked_div(cell_size[0]).unwrap_or(width); + let expected_rows = height.checked_div(cell_size[1]).unwrap_or(height); // Create canvas (transparent by default) let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); diff --git a/src/lsp/server.rs b/src/lsp/server.rs index fd192490..ccfeadb6 100644 --- a/src/lsp/server.rs +++ b/src/lsp/server.rs @@ -189,10 +189,12 @@ impl PixelsrcLanguageServer { // Likely inside a region definition return CompletionContext::RegionDef; } - Some("palette") if line.contains(':') => { - if content.contains("\"roles\"") && !line.contains("\"colors\"") { - return CompletionContext::Roles; - } + Some("palette") + if line.contains(':') + && content.contains("\"roles\"") + && !line.contains("\"colors\"") => + { + return CompletionContext::Roles; } _ => {} } From c8d0153402765acf12987548f35c7e2d2696e939 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 26 Sep 2026 00:13:40 -0400 Subject: [PATCH 09/11] Keep wheel builds within supported Python versions and macOS runners --- .github/workflows/python.yml | 2 +- python/pyproject.toml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9fb5edc7..3e16edf3 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -41,7 +41,7 @@ jobs: fail-fast: false matrix: include: - - runner: macos-13 + - runner: macos-15-intel target: x86_64-apple-darwin - runner: macos-14 target: aarch64-apple-darwin diff --git a/python/pyproject.toml b/python/pyproject.toml index 8581f367..d7edd3fc 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -5,7 +5,8 @@ build-backend = "maturin" [project] name = "pixelsrc" version = "0.2.0" -requires-python = ">=3.9" +# PyO3 0.23 supports CPython through 3.13; keep interpreter discovery in range. +requires-python = ">=3.9,<3.14" classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", From a93fecd8fa3a5b1201ab25e307f44b2a0bb8c90e Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 26 Sep 2026 00:15:31 -0400 Subject: [PATCH 10/11] Revert "Keep wheel builds within supported Python versions and macOS runners" This reverts commit c8d0153402765acf12987548f35c7e2d2696e939. --- .github/workflows/python.yml | 2 +- python/pyproject.toml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3e16edf3..9fb5edc7 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -41,7 +41,7 @@ jobs: fail-fast: false matrix: include: - - runner: macos-15-intel + - runner: macos-13 target: x86_64-apple-darwin - runner: macos-14 target: aarch64-apple-darwin diff --git a/python/pyproject.toml b/python/pyproject.toml index d7edd3fc..8581f367 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -5,8 +5,7 @@ build-backend = "maturin" [project] name = "pixelsrc" version = "0.2.0" -# PyO3 0.23 supports CPython through 3.13; keep interpreter discovery in range. -requires-python = ">=3.9,<3.14" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", From 7c1a37b99042933d814f0f3e228200ba017f4c6a Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sat, 26 Sep 2026 00:15:31 -0400 Subject: [PATCH 11/11] Revert "Resolve current stable Clippy diagnostics without changing behavior" This reverts commit 57f125873b451588579372938e3e3c4bedba633a. --- src/analyze/dimensions.rs | 2 +- src/analyze/families.rs | 2 +- src/analyze/tokens.rs | 4 ++-- src/atlas.rs | 2 +- src/build/pipeline.rs | 31 ++++++++++++++++--------------- src/composition/render.rs | 4 ++-- src/lsp/server.rs | 10 ++++------ 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/analyze/dimensions.rs b/src/analyze/dimensions.rs index 1f70ff53..447d1585 100644 --- a/src/analyze/dimensions.rs +++ b/src/analyze/dimensions.rs @@ -23,7 +23,7 @@ impl DimensionStats { /// Get dimension counts sorted by frequency. pub fn sorted_by_frequency(&self) -> Vec<((u32, u32), usize)> { let mut items: Vec<_> = self.dimensions.iter().map(|(k, v)| (*k, *v)).collect(); - items.sort_by_key(|a| std::cmp::Reverse(a.1)); + items.sort_by(|a, b| b.1.cmp(&a.1)); items } diff --git a/src/analyze/families.rs b/src/analyze/families.rs index 6ab04192..26af50a0 100644 --- a/src/analyze/families.rs +++ b/src/analyze/families.rs @@ -57,7 +57,7 @@ impl TokenFamilyDetector { .collect(); // Sort by total count descending - families.sort_by_key(|a| std::cmp::Reverse(a.total_count)); + families.sort_by(|a, b| b.total_count.cmp(&a.total_count)); families } diff --git a/src/analyze/tokens.rs b/src/analyze/tokens.rs index 85f0c102..2956e84c 100644 --- a/src/analyze/tokens.rs +++ b/src/analyze/tokens.rs @@ -111,7 +111,7 @@ impl CoOccurrenceMatrix { /// Get top N token pairs by co-occurrence count. pub fn top_n(&self, n: usize) -> Vec<((&String, &String), usize)> { let mut items: Vec<_> = self.pairs.iter().map(|((a, b), count)| ((a, b), *count)).collect(); - items.sort_by_key(|a| std::cmp::Reverse(a.1)); + items.sort_by(|a, b| b.1.cmp(&a.1)); items.truncate(n); items } @@ -131,7 +131,7 @@ impl CoOccurrenceMatrix { } }) .collect(); - results.sort_by_key(|a| std::cmp::Reverse(a.1)); + results.sort_by(|a, b| b.1.cmp(&a.1)); results } diff --git a/src/atlas.rs b/src/atlas.rs index 1f469cc5..cd20d616 100644 --- a/src/atlas.rs +++ b/src/atlas.rs @@ -129,7 +129,7 @@ pub fn pack_atlas( // Sort sprites by height descending (better packing) let mut sorted_sprites: Vec<(usize, &SpriteInput)> = sprites.iter().enumerate().collect(); - sorted_sprites.sort_by_key(|a| std::cmp::Reverse(a.1.image.height())); + sorted_sprites.sort_by(|a, b| b.1.image.height().cmp(&a.1.image.height())); let mut atlases: Vec<(RgbaImage, AtlasMetadata, Vec)> = vec![]; let mut sprite_to_atlas: HashMap = HashMap::new(); diff --git a/src/build/pipeline.rs b/src/build/pipeline.rs index bac28b82..5762b1e6 100644 --- a/src/build/pipeline.rs +++ b/src/build/pipeline.rs @@ -593,8 +593,10 @@ impl BuildPipeline { // Select registries with import support let has_file_imports = !file_imports.is_empty(); + let effective_palette_reg; + let effective_sprite_reg; - let (effective_palette_reg, effective_sprite_reg) = if has_file_imports { + if has_file_imports { if let Some(pr) = project_registry { for palette_name in pr.palette_names() { if let Some(loc) = pr.palette_location(palette_name) { @@ -611,21 +613,20 @@ impl BuildPipeline { local_sprite_registry.register_sprite(sprite.clone()); } } - (local_palette_registry, local_sprite_registry) + effective_palette_reg = local_palette_registry; + effective_sprite_reg = local_sprite_registry; } else { - ( - if let Some(pr) = project_registry { - pr.palettes.clone() - } else { - local_palette_registry - }, - if let Some(pr) = project_registry { - pr.sprites.clone() - } else { - local_sprite_registry - }, - ) - }; + effective_palette_reg = if let Some(pr) = project_registry { + pr.palettes.clone() + } else { + local_palette_registry + }; + effective_sprite_reg = if let Some(pr) = project_registry { + pr.sprites.clone() + } else { + local_sprite_registry + }; + } let palette_registry = &effective_palette_reg; let sprite_registry = &effective_sprite_reg; diff --git a/src/composition/render.rs b/src/composition/render.rs index 11855991..8aaef902 100644 --- a/src/composition/render.rs +++ b/src/composition/render.rs @@ -175,8 +175,8 @@ pub fn render_composition( } // Calculate expected grid dimensions for map validation - let expected_cols = width.checked_div(cell_size[0]).unwrap_or(width); - let expected_rows = height.checked_div(cell_size[1]).unwrap_or(height); + let expected_cols = if cell_size[0] > 0 { width / cell_size[0] } else { width }; + let expected_rows = if cell_size[1] > 0 { height / cell_size[1] } else { height }; // Create canvas (transparent by default) let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); diff --git a/src/lsp/server.rs b/src/lsp/server.rs index ccfeadb6..fd192490 100644 --- a/src/lsp/server.rs +++ b/src/lsp/server.rs @@ -189,12 +189,10 @@ impl PixelsrcLanguageServer { // Likely inside a region definition return CompletionContext::RegionDef; } - Some("palette") - if line.contains(':') - && content.contains("\"roles\"") - && !line.contains("\"colors\"") => - { - return CompletionContext::Roles; + Some("palette") if line.contains(':') => { + if content.contains("\"roles\"") && !line.contains("\"colors\"") { + return CompletionContext::Roles; + } } _ => {} }