2 Commits

Author SHA1 Message Date
a50059cf19 Feat: UI / UX fixes
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 9m42s
Deploy Website / deploy (push) Failing after 32s
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
2026-02-26 21:17:53 +01:00
6cdb4d9d2c WIP: multi-platform builds pipeline 2026-02-26 18:54:01 +01:00
33 changed files with 1292 additions and 343 deletions

5
.cargo/config.toml Normal file
View File

@@ -0,0 +1,5 @@
[alias]
xtask = "run --package xtask --release --"
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "link-args=-lstdc++ -lws2_32 -liphlpapi -lwinmm"]

View File

@@ -1,9 +1,8 @@
name: CI name: CI
on: on:
workflow_dispatch:
push: push:
tags: ['v*'] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
@@ -15,26 +14,20 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build: check:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
artifact: cagire-linux-x86_64
- os: macos-15-intel
target: x86_64-apple-darwin
artifact: cagire-macos-x86_64
- os: macos-14 - os: macos-14
target: aarch64-apple-darwin target: aarch64-apple-darwin
artifact: cagire-macos-aarch64
- os: windows-latest - os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
artifact: cagire-windows-x86_64
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 30 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -45,6 +38,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
components: clippy
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@@ -57,13 +51,10 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \ sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev
cargo install cargo-bundle
- name: Install dependencies (macOS) - name: Install dependencies (macOS)
if: runner.os == 'macOS' if: runner.os == 'macOS'
run: | run: brew list cmake &>/dev/null || brew install cmake
brew list cmake &>/dev/null || brew install cmake
cargo install cargo-bundle
- name: Install dependencies (Windows) - name: Install dependencies (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -77,229 +68,8 @@ jobs:
- name: Build desktop - name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }} run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Bundle desktop app - name: Test
if: runner.os != 'Windows' run: cargo test --target ${{ matrix.target }}
run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Bundle CLAP plugin - name: Clippy
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }} run: cargo clippy --target ${{ matrix.target }} -- -D warnings
- name: Zip macOS app bundle
if: runner.os == 'macOS'
run: |
cd target/${{ matrix.target }}/release/bundle/osx
zip -r Cagire.app.zip Cagire.app
- name: Upload artifact (Unix)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire
- name: Upload artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire.exe
- name: Upload desktop artifact (Linux deb)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/deb/*.deb
- name: Upload desktop artifact (macOS app bundle)
if: runner.os == 'macOS'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
- name: Upload desktop artifact (Windows exe)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop.exe
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-clap
path: target/bundled/cagire-plugins.clap
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-vst3
path: target/bundled/cagire-plugins.vst3
universal-macos:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: cagire-macos-*
path: artifacts
- name: Create universal CLI binary
run: |
lipo -create \
artifacts/cagire-macos-x86_64/cagire \
artifacts/cagire-macos-aarch64/cagire \
-output cagire
chmod +x cagire
lipo -info cagire
- name: Create universal app bundle
run: |
cd artifacts/cagire-macos-aarch64-desktop
unzip Cagire.app.zip
cd ../cagire-macos-x86_64-desktop
unzip Cagire.app.zip
cd ../..
cp -R artifacts/cagire-macos-aarch64-desktop/Cagire.app Cagire.app
lipo -create \
artifacts/cagire-macos-x86_64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
artifacts/cagire-macos-aarch64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
-output Cagire.app/Contents/MacOS/cagire-desktop
lipo -info Cagire.app/Contents/MacOS/cagire-desktop
zip -r Cagire.app.zip Cagire.app
- name: Create universal CLAP plugin
run: |
mkdir -p cagire-plugins.clap/Contents/MacOS
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/Info.plist \
cagire-plugins.clap/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/PkgInfo \
cagire-plugins.clap/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
-output cagire-plugins.clap/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.clap/Contents/MacOS/cagire-plugins
- name: Create universal VST3 plugin
run: |
mkdir -p cagire-plugins.vst3/Contents/MacOS
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Info.plist \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/PkgInfo \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Resources \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
-output cagire-plugins.vst3/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.vst3/Contents/MacOS/cagire-plugins
- name: Build .pkg installer
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p pkg-root/Applications pkg-root/usr/local/bin
cp -R Cagire.app pkg-root/Applications/
cp cagire pkg-root/usr/local/bin/
pkgbuild --analyze --root pkg-root component.plist
plutil -replace BundleIsRelocatable -bool NO component.plist
pkgbuild --root pkg-root --identifier com.sova.cagire \
--version "$VERSION" --install-location / \
--component-plist component.plist \
"Cagire-${VERSION}-universal.pkg"
- name: Upload universal CLI
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal
path: cagire
- name: Upload universal app bundle
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-desktop
path: Cagire.app.zip
- name: Upload universal CLAP plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-clap
path: cagire-plugins.clap
- name: Upload universal VST3 plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-vst3
path: cagire-plugins.vst3
- name: Upload .pkg installer
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-pkg
path: Cagire-*-universal.pkg
release:
needs: [build, universal-macos]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
if [[ "$name" == "cagire-macos-universal-pkg" ]]; then
cp "$dir"/*.pkg release/
elif [[ "$name" == "cagire-macos-universal-desktop" ]]; then
cp "$dir/Cagire.app.zip" "release/cagire-macos-universal-desktop.app.zip"
elif [[ "$name" == "cagire-macos-universal" ]]; then
cp "$dir/cagire" "release/cagire-macos-universal"
elif [[ "$name" == "cagire-macos-universal-clap" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == "cagire-macos-universal-vst3" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-clap ]]; then
base="${name%-clap}"
cd "$dir" && zip -r "../../release/${base}-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == *-vst3 ]]; then
base="${name%-vst3}"
cd "$dir" && zip -r "../../release/${base}-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-desktop ]]; then
base="${name%-desktop}"
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
cp "$dir"/*.deb "release/${base}-desktop.deb"
elif [ -f "$dir/Cagire.app.zip" ]; then
cp "$dir/Cagire.app.zip" "release/${base}-desktop.app.zip"
elif [ -f "$dir/cagire-desktop.exe" ]; then
cp "$dir/cagire-desktop.exe" "release/${base}-desktop.exe"
fi
else
if [ -f "$dir/cagire.exe" ]; then
cp "$dir/cagire.exe" "release/${name}.exe"
elif [ -f "$dir/cagire" ]; then
cp "$dir/cagire" "release/${name}"
fi
fi
done
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/*
generate_release_notes: true

368
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,368 @@
name: Release
on:
workflow_dispatch:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: cagire-linux-x86_64
- os: macos-15-intel
target: x86_64-apple-darwin
artifact: cagire-macos-x86_64
- os: macos-14
target: aarch64-apple-darwin
artifact: cagire-macos-aarch64
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: cagire-windows-x86_64
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install cargo-binstall
uses: cargo-bins/cargo-binstall@main
- name: Install dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev
cargo binstall -y cargo-bundle
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
run: |
brew list cmake &>/dev/null || brew install cmake
cargo binstall -y cargo-bundle
- name: Install dependencies (Windows)
if: runner.os == 'Windows'
run: |
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
echo "C:\Program Files\CMake\bin" >> $env:GITHUB_PATH
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Bundle desktop app
if: runner.os != 'Windows'
run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Build AppImages (Linux)
if: runner.os == 'Linux'
run: |
mkdir -p target/releases
scripts/make-appimage.sh target/${{ matrix.target }}/release/cagire x86_64 target/releases
scripts/make-appimage.sh target/${{ matrix.target }}/release/cagire-desktop x86_64 target/releases
- name: Upload AppImage artifacts (Linux)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-appimage
path: target/releases/*.AppImage
- name: Bundle CLAP plugin
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }}
- name: Zip macOS app bundle
if: runner.os == 'macOS'
run: |
cd target/${{ matrix.target }}/release/bundle/osx
zip -r Cagire.app.zip Cagire.app
- name: Upload artifact (Unix)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire
- name: Upload artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire.exe
- name: Upload desktop artifact (Linux deb)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/deb/*.deb
- name: Upload desktop artifact (macOS app bundle)
if: runner.os == 'macOS'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
- name: Upload desktop artifact (Windows exe)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop.exe
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-clap
path: target/bundled/cagire-plugins.clap
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-vst3
path: target/bundled/cagire-plugins.vst3
build-cross:
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-unknown-linux-gnu
artifact: cagire-linux-aarch64
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build
run: cross build --release --target ${{ matrix.target }}
- name: Build desktop
run: cross build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire
- name: Upload desktop artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop
universal-macos:
needs: build
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: cagire-macos-*
path: artifacts
- name: Create universal CLI binary
run: |
lipo -create \
artifacts/cagire-macos-x86_64/cagire \
artifacts/cagire-macos-aarch64/cagire \
-output cagire
chmod +x cagire
lipo -info cagire
- name: Create universal app bundle
run: |
cd artifacts/cagire-macos-aarch64-desktop
unzip Cagire.app.zip
cd ../cagire-macos-x86_64-desktop
unzip Cagire.app.zip
cd ../..
cp -R artifacts/cagire-macos-aarch64-desktop/Cagire.app Cagire.app
lipo -create \
artifacts/cagire-macos-x86_64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
artifacts/cagire-macos-aarch64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
-output Cagire.app/Contents/MacOS/cagire-desktop
lipo -info Cagire.app/Contents/MacOS/cagire-desktop
zip -r Cagire.app.zip Cagire.app
- name: Create universal CLAP plugin
run: |
mkdir -p cagire-plugins.clap/Contents/MacOS
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/Info.plist \
cagire-plugins.clap/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/PkgInfo \
cagire-plugins.clap/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
-output cagire-plugins.clap/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.clap/Contents/MacOS/cagire-plugins
- name: Create universal VST3 plugin
run: |
mkdir -p cagire-plugins.vst3/Contents/MacOS
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Info.plist \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/PkgInfo \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Resources \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
-output cagire-plugins.vst3/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.vst3/Contents/MacOS/cagire-plugins
- name: Build .pkg installer
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p pkg-root/Applications pkg-root/usr/local/bin
cp -R Cagire.app pkg-root/Applications/
cp cagire pkg-root/usr/local/bin/
pkgbuild --analyze --root pkg-root component.plist
plutil -replace BundleIsRelocatable -bool NO component.plist
pkgbuild --root pkg-root --identifier com.sova.cagire \
--version "$VERSION" --install-location / \
--component-plist component.plist \
"Cagire-${VERSION}-universal.pkg"
- name: Upload universal CLI
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal
path: cagire
- name: Upload universal app bundle
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-desktop
path: Cagire.app.zip
- name: Upload universal CLAP plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-clap
path: cagire-plugins.clap
- name: Upload universal VST3 plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-vst3
path: cagire-plugins.vst3
- name: Upload .pkg installer
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-pkg
path: Cagire-*-universal.pkg
release:
needs: [build, build-cross, universal-macos]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
if [[ "$name" == "cagire-macos-universal-pkg" ]]; then
cp "$dir"/*.pkg release/
elif [[ "$name" == "cagire-macos-universal-desktop" ]]; then
cp "$dir/Cagire.app.zip" "release/cagire-macos-universal-desktop.app.zip"
elif [[ "$name" == "cagire-macos-universal" ]]; then
cp "$dir/cagire" "release/cagire-macos-universal"
elif [[ "$name" == "cagire-macos-universal-clap" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == "cagire-macos-universal-vst3" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-clap ]]; then
base="${name%-clap}"
cd "$dir" && zip -r "../../release/${base}-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == *-vst3 ]]; then
base="${name%-vst3}"
cd "$dir" && zip -r "../../release/${base}-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-appimage ]]; then
cp "$dir"/*.AppImage release/
elif [[ "$name" == *-desktop ]]; then
base="${name%-desktop}"
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
cp "$dir"/*.deb "release/${base}-desktop.deb"
elif [ -f "$dir/Cagire.app.zip" ]; then
cp "$dir/Cagire.app.zip" "release/${base}-desktop.app.zip"
elif [ -f "$dir/cagire-desktop.exe" ]; then
cp "$dir/cagire-desktop.exe" "release/${base}-desktop.exe"
fi
else
if [ -f "$dir/cagire.exe" ]; then
cp "$dir/cagire.exe" "release/${name}.exe"
elif [ -f "$dir/cagire" ]; then
cp "$dir/cagire" "release/${name}"
fi
fi
done
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/*
generate_release_notes: true

5
.gitignore vendored
View File

@@ -1,10 +1,11 @@
/target /target
/.cache
Cargo.lock Cargo.lock
*.prof *.prof
.DS_Store .DS_Store
# Cargo config # Local cargo overrides (doux path patch)
.cargo/config.toml .cargo/config.local.toml
# Claude # Claude
.claude/ .claude/

View File

@@ -1,5 +1,15 @@
# Building Cagire # Building Cagire
## Quick Start
```bash
git clone --recursive https://github.com/Bubobubobubobubo/cagire
cd cagire
cargo build --release
```
The `doux` audio engine is fetched automatically from git. No local path setup needed.
## Prerequisites ## Prerequisites
**Rust** (stable toolchain): https://rustup.rs **Rust** (stable toolchain): https://rustup.rs
@@ -68,6 +78,14 @@ Desktop (egui window):
cargo build --release --features desktop --bin cagire-desktop cargo build --release --features desktop --bin cagire-desktop
``` ```
Plugins (CLAP/VST3):
```bash
cargo xtask bundle cagire-plugins --release
```
The xtask alias is defined in `.cargo/config.toml` (committed). Plugin bundles are output to `target/bundled/`.
## Run ## Run
Terminal (default): Terminal (default):
@@ -89,3 +107,81 @@ cargo run --release --features desktop --bin cagire-desktop
| `-i, --input <device>` | Input audio device | | `-i, --input <device>` | Input audio device |
| `-c, --channels <n>` | Output channel count | | `-c, --channels <n>` | Output channel count |
| `-b, --buffer <size>` | Audio buffer size | | `-b, --buffer <size>` | Audio buffer size |
## Cross-Compilation
[cross](https://github.com/cross-rs/cross) uses Docker to build for other platforms without installing their toolchains locally. It works on any OS that runs Docker.
### Targets
| Target | Method | Binaries |
|--------|--------|----------|
| aarch64-apple-darwin | Native (macOS ARM only) | `cagire`, `cagire-desktop` |
| x86_64-apple-darwin | Native (macOS only) | `cagire`, `cagire-desktop` |
| x86_64-unknown-linux-gnu | `cross build` | `cagire`, `cagire-desktop` |
| aarch64-unknown-linux-gnu (RPi 64-bit) | `cross build` | `cagire`, `cagire-desktop` |
| x86_64-pc-windows-gnu | `cross build` | `cagire`, `cagire-desktop` |
macOS targets can only be built on macOS — Apple does not support cross-compilation to macOS from other platforms. Linux and Windows targets can be cross-compiled from any OS. The aarch64-unknown-linux-gnu target covers Raspberry Pi (64-bit OS).
### Windows ABI
CI produces `x86_64-pc-windows-msvc` binaries (native Windows build, better compatibility). Local cross-compilation from non-Windows hosts produces `x86_64-pc-windows-gnu` binaries (MinGW via Docker). Both work; MSVC is preferred for releases.
### Prerequisites
1. **Docker**: https://docs.docker.com/get-docker/
2. **cross**: `cargo install cross --git https://github.com/cross-rs/cross`
3. On macOS, add the Intel target: `rustup target add x86_64-apple-darwin`
Docker must be running before invoking `cross` or `scripts/build-all.sh`.
### Building Individual Targets
```bash
# Linux x86_64
cross build --release --target x86_64-unknown-linux-gnu
cross build --release --features desktop --bin cagire-desktop --target x86_64-unknown-linux-gnu
# Linux aarch64
cross build --release --target aarch64-unknown-linux-gnu
cross build --release --features desktop --bin cagire-desktop --target aarch64-unknown-linux-gnu
# Windows x86_64
cross build --release --target x86_64-pc-windows-gnu
cross build --release --features desktop --bin cagire-desktop --target x86_64-pc-windows-gnu
```
### Building All Targets (macOS only)
```bash
# Interactive (prompts for platform/target selection):
scripts/build-all.sh
# Non-interactive:
scripts/build-all.sh --platforms macos-arm64,linux-x86_64 --targets cli,desktop --yes
scripts/build-all.sh --all --yes
```
Builds selected targets, producing binaries in `target/releases/`.
Platform aliases: `macos-arm64`, `macos-x86_64`, `linux-x86_64`, `linux-aarch64`, `windows-x86_64`.
Target aliases: `cli`, `desktop`, `plugins`.
### Linux AppImage Packaging
Linux releases ship as AppImages — self-contained executables that bundle all shared library dependencies (ALSA, JACK, X11, OpenGL). No runtime dependencies required.
After building a Linux target, produce an AppImage with:
```bash
scripts/make-appimage.sh target/x86_64-unknown-linux-gnu/release/cagire x86_64 target/releases
```
`scripts/build-all.sh` does this automatically for every Linux target selected. The CI pipeline produces AppImages for the x86_64 Linux build. Cross-arch AppImage building (e.g. aarch64 on x86_64) is not supported — run on a matching host or in CI.
### Notes
- Custom Dockerfiles in `cross/` install the native libraries Cagire depends on (ALSA, JACK, X11, cmake, libclang, etc.). `Cross.toml` maps each target to its Dockerfile.
- The first build per target downloads Docker base images and installs packages. Subsequent builds use cached layers.
- Cross-architecture Docker builds (e.g. aarch64 on x86_64 or vice versa) run under QEMU emulation and are significantly slower.

View File

@@ -51,7 +51,7 @@ cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" } cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" } cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" } cagire-ratatui = { path = "crates/ratatui" }
doux = { path = "/Users/bubo/doux", features = ["native", "soundfont"] } doux = { git = "https://github.com/sova-org/doux", features = ["native", "soundfont"] }
rusty_link = "0.4" rusty_link = "0.4"
ratatui = "0.30" ratatui = "0.30"
crossterm = "0.29" crossterm = "0.29"

View File

@@ -1,5 +1,8 @@
[build]
volumes = ["/Users/bubo/doux:/Users/bubo/doux"]
[target.aarch64-unknown-linux-gnu] [target.aarch64-unknown-linux-gnu]
dockerfile = "./cross/aarch64-linux.Dockerfile" dockerfile = "./cross/aarch64-linux.Dockerfile"
[target.x86_64-unknown-linux-gnu]
dockerfile = "./cross/x86_64-linux.Dockerfile"
[target.x86_64-pc-windows-gnu]
dockerfile = "./cross/x86_64-windows.Dockerfile"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 72 KiB

7
assets/cagire.desktop Normal file
View File

@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Cagire
Comment=Forth-based music sequencer
Exec=cagire
Icon=cagire
Categories=Audio;Music;AudioVideo;

View File

@@ -1,6 +1,15 @@
//! Build script — embeds Windows application resources (icon, metadata). //! Build script — embeds Windows application resources (icon, metadata).
fn main() { fn main() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
// rusty_link's build.rs only links stdc++ on linux — add it for windows too
println!("cargo:rustc-link-lib=stdc++");
println!("cargo:rustc-link-lib=ws2_32");
println!("cargo:rustc-link-lib=iphlpapi");
}
#[cfg(windows)] #[cfg(windows)]
{ {
let mut res = winres::WindowsResource::new(); let mut res = winres::WindowsResource::new();

View File

@@ -11,4 +11,4 @@ description = "TUI components for cagire sequencer"
rand = "0.8" rand = "0.8"
ratatui = "0.30" ratatui = "0.30"
regex = "1" regex = "1"
tui-textarea = { git = "https://github.com/phsym/tui-textarea", branch = "main", features = ["search"] } tui-textarea = { git = "https://github.com/phsym/tui-textarea", rev = "e2ec4d3", features = ["search"] }

View File

@@ -7,6 +7,8 @@ RUN dpkg --add-architecture arm64 && \
libclang-dev \ libclang-dev \
libasound2-dev:arm64 \ libasound2-dev:arm64 \
libjack-dev:arm64 \ libjack-dev:arm64 \
libx11-dev:arm64 \
libx11-xcb-dev:arm64 \
libxcb-render0-dev:arm64 \ libxcb-render0-dev:arm64 \
libxcb-shape0-dev:arm64 \ libxcb-shape0-dev:arm64 \
libxcb-xfixes0-dev:arm64 \ libxcb-xfixes0-dev:arm64 \

View File

@@ -0,0 +1,16 @@
FROM ghcr.io/cross-rs/x86_64-unknown-linux-gnu:main
RUN apt-get update && \
apt-get install -y --no-install-recommends \
cmake \
libclang-dev \
libasound2-dev \
libjack-dev \
libx11-dev \
libx11-xcb-dev \
libxcb-render0-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxkbcommon-dev \
libgl1-mesa-dev \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -0,0 +1,13 @@
FROM ghcr.io/cross-rs/x86_64-pc-windows-gnu:main
RUN apt-get update && \
apt-get install -y --no-install-recommends \
cmake \
clang \
libclang-dev \
mingw-w64-tools \
mingw-w64-x86-64-dev \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf windows.h /usr/x86_64-w64-mingw32/include/Windows.h \
&& ln -sf winsock2.h /usr/x86_64-w64-mingw32/include/WinSock2.h \
&& ln -sf ws2tcpip.h /usr/x86_64-w64-mingw32/include/WS2tcpip.h

View File

@@ -14,7 +14,7 @@ cagire = { path = "../..", default-features = false, features = ["block-renderer
cagire-forth = { path = "../../crates/forth" } cagire-forth = { path = "../../crates/forth" }
cagire-project = { path = "../../crates/project" } cagire-project = { path = "../../crates/project" }
cagire-ratatui = { path = "../../crates/ratatui" } cagire-ratatui = { path = "../../crates/ratatui" }
doux = { path = "/Users/bubo/doux", features = ["native", "soundfont"] } doux = { git = "https://github.com/sova-org/doux", features = ["native", "soundfont"] }
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] } nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" }
egui_ratatui = "2.1" egui_ratatui = "2.1"

428
scripts/build-all.sh Executable file
View File

@@ -0,0 +1,428 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
PLUGIN_NAME="cagire-plugins"
LIB_NAME="cagire_plugins" # cargo converts hyphens to underscores
OUT="target/releases"
PLATFORMS=(
"aarch64-apple-darwin"
"x86_64-apple-darwin"
"x86_64-unknown-linux-gnu"
"aarch64-unknown-linux-gnu"
"x86_64-pc-windows-gnu"
)
PLATFORM_LABELS=(
"macOS aarch64 (native)"
"macOS x86_64 (native)"
"Linux x86_64 (cross)"
"Linux aarch64 (cross)"
"Windows x86_64 (cross)"
)
PLATFORM_ALIASES=(
"macos-arm64"
"macos-x86_64"
"linux-x86_64"
"linux-aarch64"
"windows-x86_64"
)
# --- CLI argument parsing ---
cli_platforms=""
cli_targets=""
cli_yes=false
cli_all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--platforms) cli_platforms="$2"; shift 2 ;;
--targets) cli_targets="$2"; shift 2 ;;
--yes) cli_yes=true; shift ;;
--all) cli_all=true; shift ;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --platforms <list> Comma-separated: macos-arm64,macos-x86_64,linux-x86_64,linux-aarch64,windows-x86_64"
echo " --targets <list> Comma-separated: cli,desktop,plugins"
echo " --all Build all platforms and targets"
echo " --yes Skip confirmation prompt"
echo ""
echo "Without options, runs interactively."
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
resolve_platform_alias() {
local alias="$1"
for i in "${!PLATFORM_ALIASES[@]}"; do
if [[ "${PLATFORM_ALIASES[$i]}" == "$alias" ]]; then
echo "$i"
return
fi
done
echo "Unknown platform: $alias" >&2
exit 1
}
# --- Helpers ---
prompt_platforms() {
echo "Select platform (0=all, comma-separated):"
echo " 0) All"
for i in "${!PLATFORMS[@]}"; do
echo " $((i+1))) ${PLATFORM_LABELS[$i]}"
done
read -rp "> " choice
if [[ "$choice" == "0" || -z "$choice" ]]; then
selected_platforms=("${PLATFORMS[@]}")
selected_labels=("${PLATFORM_LABELS[@]}")
else
IFS=',' read -ra indices <<< "$choice"
selected_platforms=()
selected_labels=()
for idx in "${indices[@]}"; do
idx="${idx// /}"
idx=$((idx - 1))
if (( idx < 0 || idx >= ${#PLATFORMS[@]} )); then
echo "Invalid platform index: $((idx+1))"
exit 1
fi
selected_platforms+=("${PLATFORMS[$idx]}")
selected_labels+=("${PLATFORM_LABELS[$idx]}")
done
fi
}
prompt_targets() {
echo ""
echo "Select targets (0=all, comma-separated):"
echo " 0) All"
echo " 1) cagire"
echo " 2) cagire-desktop"
echo " 3) cagire-plugins (CLAP/VST3)"
read -rp "> " choice
build_cagire=false
build_desktop=false
build_plugins=false
if [[ "$choice" == "0" || -z "$choice" ]]; then
build_cagire=true
build_desktop=true
build_plugins=true
else
IFS=',' read -ra targets <<< "$choice"
for t in "${targets[@]}"; do
t="${t// /}"
case "$t" in
1) build_cagire=true ;;
2) build_desktop=true ;;
3) build_plugins=true ;;
*) echo "Invalid target: $t"; exit 1 ;;
esac
done
fi
}
confirm_summary() {
echo ""
echo "=== Build Summary ==="
echo ""
echo "Platforms:"
for label in "${selected_labels[@]}"; do
echo " - $label"
done
echo ""
echo "Targets:"
$build_cagire && echo " - cagire"
$build_desktop && echo " - cagire-desktop"
$build_plugins && echo " - cagire-plugins (CLAP/VST3)"
echo ""
read -rp "Proceed? [Y/n] " yn
case "${yn,,}" in
n|no) echo "Aborted."; exit 0 ;;
esac
}
platform_os() {
case "$1" in
*windows*) echo "windows" ;;
*linux*) echo "linux" ;;
*apple*) echo "macos" ;;
esac
}
platform_arch() {
case "$1" in
aarch64*) echo "aarch64" ;;
x86_64*) echo "x86_64" ;;
esac
}
platform_suffix() {
case "$1" in
*windows*) echo ".exe" ;;
*) echo "" ;;
esac
}
is_cross_target() {
case "$1" in
*linux*|*windows*) return 0 ;;
*) return 1 ;;
esac
}
native_target() {
[[ "$1" == "aarch64-apple-darwin" ]]
}
release_dir() {
if native_target "$1"; then
echo "target/release"
else
echo "target/$1/release"
fi
}
target_flag() {
if native_target "$1"; then
echo ""
else
echo "--target $1"
fi
}
builder_for() {
if is_cross_target "$1"; then
echo "cross"
else
echo "cargo"
fi
}
build_binary() {
local platform="$1"
shift
local builder
builder=$(builder_for "$platform")
local tf
tf=$(target_flag "$platform")
# shellcheck disable=SC2086
$builder build --release $tf "$@"
}
bundle_plugins_native() {
local platform="$1"
local tf
tf=$(target_flag "$platform")
# shellcheck disable=SC2086
cargo xtask bundle "$PLUGIN_NAME" --release $tf
}
bundle_plugins_cross() {
local platform="$1"
local rd
rd=$(release_dir "$platform")
local os
os=$(platform_os "$platform")
local arch
arch=$(platform_arch "$platform")
# Build the cdylib with cross
# shellcheck disable=SC2046
build_binary "$platform" -p "$PLUGIN_NAME"
# Determine source library file
local src_lib
case "$os" in
linux) src_lib="$rd/lib${LIB_NAME}.so" ;;
windows) src_lib="$rd/${LIB_NAME}.dll" ;;
esac
if [[ ! -f "$src_lib" ]]; then
echo " ERROR: Expected library not found: $src_lib"
return 1
fi
# Assemble CLAP bundle (flat file)
local clap_out="$OUT/${PLUGIN_NAME}-${os}-${arch}.clap"
cp "$src_lib" "$clap_out"
echo " CLAP -> $clap_out"
# Assemble VST3 bundle (directory tree)
local vst3_dir="$OUT/${PLUGIN_NAME}-${os}-${arch}.vst3"
local vst3_contents
case "$os" in
linux)
vst3_contents="$vst3_dir/Contents/${arch}-linux"
mkdir -p "$vst3_contents"
cp "$src_lib" "$vst3_contents/${PLUGIN_NAME}.so"
;;
windows)
vst3_contents="$vst3_dir/Contents/${arch}-win"
mkdir -p "$vst3_contents"
cp "$src_lib" "$vst3_contents/${PLUGIN_NAME}.vst3"
;;
esac
echo " VST3 -> $vst3_dir/"
}
copy_artifacts() {
local platform="$1"
local rd
rd=$(release_dir "$platform")
local os
os=$(platform_os "$platform")
local arch
arch=$(platform_arch "$platform")
local suffix
suffix=$(platform_suffix "$platform")
if $build_cagire; then
local src="$rd/cagire${suffix}"
local dst="$OUT/cagire-${os}-${arch}${suffix}"
cp "$src" "$dst"
echo " cagire -> $dst"
fi
if $build_desktop; then
local src="$rd/cagire-desktop${suffix}"
local dst="$OUT/cagire-desktop-${os}-${arch}${suffix}"
cp "$src" "$dst"
echo " cagire-desktop -> $dst"
fi
# AppImage for Linux targets
if [[ "$os" == "linux" ]]; then
if $build_cagire; then
scripts/make-appimage.sh "$rd/cagire" "$arch" "$OUT"
fi
if $build_desktop; then
scripts/make-appimage.sh "$rd/cagire-desktop" "$arch" "$OUT"
fi
fi
# Plugin artifacts for native targets (cross handled in bundle_plugins_cross)
if $build_plugins && ! is_cross_target "$platform"; then
local bundle_dir="target/bundled"
# CLAP
local clap_src="$bundle_dir/${PLUGIN_NAME}.clap"
if [[ -e "$clap_src" ]]; then
local clap_dst="$OUT/${PLUGIN_NAME}-${os}-${arch}.clap"
cp -r "$clap_src" "$clap_dst"
echo " CLAP -> $clap_dst"
fi
# VST3
local vst3_src="$bundle_dir/${PLUGIN_NAME}.vst3"
if [[ -d "$vst3_src" ]]; then
local vst3_dst="$OUT/${PLUGIN_NAME}-${os}-${arch}.vst3"
rm -rf "$vst3_dst"
cp -r "$vst3_src" "$vst3_dst"
echo " VST3 -> $vst3_dst/"
fi
fi
}
# --- Main ---
if $cli_all; then
selected_platforms=("${PLATFORMS[@]}")
selected_labels=("${PLATFORM_LABELS[@]}")
build_cagire=true
build_desktop=true
build_plugins=true
elif [[ -n "$cli_platforms" || -n "$cli_targets" ]]; then
# Resolve platforms from CLI
if [[ -n "$cli_platforms" ]]; then
selected_platforms=()
selected_labels=()
IFS=',' read -ra aliases <<< "$cli_platforms"
for alias in "${aliases[@]}"; do
alias="${alias// /}"
idx=$(resolve_platform_alias "$alias")
selected_platforms+=("${PLATFORMS[$idx]}")
selected_labels+=("${PLATFORM_LABELS[$idx]}")
done
else
selected_platforms=("${PLATFORMS[@]}")
selected_labels=("${PLATFORM_LABELS[@]}")
fi
# Resolve targets from CLI
build_cagire=false
build_desktop=false
build_plugins=false
if [[ -n "$cli_targets" ]]; then
IFS=',' read -ra tgts <<< "$cli_targets"
for t in "${tgts[@]}"; do
t="${t// /}"
case "$t" in
cli) build_cagire=true ;;
desktop) build_desktop=true ;;
plugins) build_plugins=true ;;
*) echo "Unknown target: $t (expected: cli, desktop, plugins)"; exit 1 ;;
esac
done
else
build_cagire=true
build_desktop=true
build_plugins=true
fi
else
prompt_platforms
prompt_targets
fi
if ! $cli_yes && [[ -z "$cli_platforms" ]] && ! $cli_all; then
confirm_summary
fi
mkdir -p "$OUT"
step=0
total=${#selected_platforms[@]}
for platform in "${selected_platforms[@]}"; do
step=$((step + 1))
echo ""
echo "=== [$step/$total] $platform ==="
if $build_cagire; then
echo " -> cagire"
build_binary "$platform"
fi
if $build_desktop; then
echo " -> cagire-desktop"
build_binary "$platform" --features desktop --bin cagire-desktop
fi
if $build_plugins; then
echo " -> cagire-plugins"
if is_cross_target "$platform"; then
bundle_plugins_cross "$platform"
else
bundle_plugins_native "$platform"
fi
fi
echo " Copying artifacts..."
copy_artifacts "$platform"
done
echo ""
echo "=== Done ==="
echo ""
ls -lhR "$OUT/"

141
scripts/make-appimage.sh Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: scripts/make-appimage.sh <binary-path> <arch> <output-dir>
# Produces an AppImage from a Linux binary.
# On native Linux with matching arch: uses linuxdeploy.
# Otherwise (cross-compilation): builds AppImage via mksquashfs in Docker.
if [[ $# -ne 3 ]]; then
echo "Usage: $0 <binary-path> <arch> <output-dir>"
exit 1
fi
BINARY="$1"
ARCH="$2"
OUTDIR="$3"
REPO_ROOT="$(git rev-parse --show-toplevel)"
CACHE_DIR="$REPO_ROOT/.cache"
APP_NAME="$(basename "$BINARY")"
RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-${ARCH}"
RUNTIME="$CACHE_DIR/runtime-${ARCH}"
build_appdir() {
local appdir="$1"
mkdir -p "$appdir/usr/bin"
cp "$BINARY" "$appdir/usr/bin/cagire"
chmod +x "$appdir/usr/bin/cagire"
mkdir -p "$appdir/usr/share/icons/hicolor/512x512/apps"
cp "$REPO_ROOT/assets/Cagire.png" "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png"
cp "$REPO_ROOT/assets/cagire.desktop" "$appdir/cagire.desktop"
# AppRun entry point
cat > "$appdir/AppRun" <<'APPRUN'
#!/bin/sh
SELF="$(readlink -f "$0")"
HERE="$(dirname "$SELF")"
exec "$HERE/usr/bin/cagire" "$@"
APPRUN
chmod +x "$appdir/AppRun"
# Symlink icon at root for AppImage spec
ln -sf usr/share/icons/hicolor/512x512/apps/cagire.png "$appdir/cagire.png"
ln -sf cagire.desktop "$appdir/.DirIcon" 2>/dev/null || true
}
download_runtime() {
mkdir -p "$CACHE_DIR"
if [[ ! -f "$RUNTIME" ]]; then
echo " Downloading AppImage runtime for $ARCH..."
curl -fSL "$RUNTIME_URL" -o "$RUNTIME"
fi
}
run_native() {
local linuxdeploy_url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
local linuxdeploy="$CACHE_DIR/linuxdeploy-${ARCH}.AppImage"
mkdir -p "$CACHE_DIR"
if [[ ! -f "$linuxdeploy" ]]; then
echo " Downloading linuxdeploy for $ARCH..."
curl -fSL "$linuxdeploy_url" -o "$linuxdeploy"
chmod +x "$linuxdeploy"
fi
local appdir
appdir="$(mktemp -d)/AppDir"
build_appdir "$appdir"
export ARCH
export LDAI_RUNTIME_FILE="$RUNTIME"
"$linuxdeploy" \
--appimage-extract-and-run \
--appdir "$appdir" \
--desktop-file "$appdir/cagire.desktop" \
--icon-file "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png" \
--output appimage
local appimage
appimage=$(ls -1t ./*.AppImage 2>/dev/null | head -1 || true)
if [[ -z "$appimage" ]]; then
echo " ERROR: No AppImage produced"
exit 1
fi
mkdir -p "$OUTDIR"
mv "$appimage" "$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
echo " AppImage -> $OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
}
run_docker() {
local platform
case "$ARCH" in
x86_64) platform="linux/amd64" ;;
aarch64) platform="linux/arm64" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
local appdir
appdir="$(mktemp -d)/AppDir"
build_appdir "$appdir"
local image_tag="cagire-appimage-${ARCH}"
echo " Building Docker image $image_tag ($platform)..."
docker build --platform "$platform" -q -t "$image_tag" - <<'DOCKERFILE'
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
squashfs-tools \
&& rm -rf /var/lib/apt/lists/*
DOCKERFILE
echo " Creating squashfs via Docker ($image_tag)..."
docker run --rm --platform "$platform" \
-v "$appdir:/appdir:ro" \
-v "$CACHE_DIR:/cache" \
"$image_tag" \
mksquashfs /appdir /cache/appimage-${ARCH}.squashfs \
-root-owned -noappend -comp gzip -no-progress
mkdir -p "$OUTDIR"
local final="$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
cat "$RUNTIME" "$CACHE_DIR/appimage-${ARCH}.squashfs" > "$final"
chmod +x "$final"
rm -f "$CACHE_DIR/appimage-${ARCH}.squashfs"
echo " AppImage -> $final"
}
HOST_ARCH="$(uname -m)"
download_runtime
echo " Building AppImage for ${APP_NAME} ($ARCH)..."
if [[ "$HOST_ARCH" == "$ARCH" ]] && [[ "$(uname -s)" == "Linux" ]]; then
run_native
else
run_docker
fi

View File

@@ -175,7 +175,7 @@ impl App {
match model::share::export(pattern_data) { match model::share::export(pattern_data) {
Ok(encoded) => { Ok(encoded) => {
let len = encoded.len(); let len = encoded.len();
if let Some(clip) = &mut self.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
let _ = clip.set_text(encoded); let _ = clip.set_text(encoded);
} }
if len > 2000 { if len > 2000 {
@@ -201,7 +201,7 @@ impl App {
match model::share::export_bank(bank_data) { match model::share::export_bank(bank_data) {
Ok(encoded) => { Ok(encoded) => {
let len = encoded.len(); let len = encoded.len();
if let Some(clip) = &mut self.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
let _ = clip.set_text(encoded); let _ = clip.set_text(encoded);
} }
if len > 2000 { if len > 2000 {
@@ -223,7 +223,7 @@ impl App {
} }
pub fn import_bank(&mut self, bank: usize) { pub fn import_bank(&mut self, bank: usize) {
let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) { let text = match arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()) {
Some(t) => t, Some(t) => t,
None => { None => {
self.ui.flash("Clipboard empty", 150, FlashKind::Error); self.ui.flash("Clipboard empty", 150, FlashKind::Error);
@@ -250,7 +250,7 @@ impl App {
} }
pub fn import_pattern(&mut self, bank: usize, pattern: usize) { pub fn import_pattern(&mut self, bank: usize, pattern: usize) {
let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) { let text = match arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()) {
Some(t) => t, Some(t) => t,
None => { None => {
self.ui self.ui
@@ -305,7 +305,7 @@ impl App {
&indices, &indices,
); );
let count = copied.steps.len(); let count = copied.steps.len();
if let Some(clip) = &mut self.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
let text: String = copied.steps.iter().map(|s| s.script.as_str()).collect::<Vec<_>>().join("\n"); let text: String = copied.steps.iter().map(|s| s.script.as_str()).collect::<Vec<_>>().join("\n");
let _ = clip.set_text(text); let _ = clip.set_text(text);
} }

View File

@@ -49,6 +49,10 @@ impl App {
AppCommand::PrevStep => self.prev_step(), AppCommand::PrevStep => self.prev_step(),
AppCommand::StepUp => self.step_up(), AppCommand::StepUp => self.step_up(),
AppCommand::StepDown => self.step_down(), AppCommand::StepDown => self.step_down(),
AppCommand::NextPattern => self.navigate_pattern(1),
AppCommand::PrevPattern => self.navigate_pattern(-1),
AppCommand::NextBank => self.navigate_bank(1),
AppCommand::PrevBank => self.navigate_bank(-1),
// Pattern editing // Pattern editing
AppCommand::ToggleSteps => self.toggle_steps(), AppCommand::ToggleSteps => self.toggle_steps(),

View File

@@ -60,7 +60,6 @@ pub struct App {
// Held to keep the Arc alive (shared with ScriptEngine). // Held to keep the Arc alive (shared with ScriptEngine).
pub _rng: Rng, pub _rng: Rng,
pub live_keys: Arc<LiveKeyState>, pub live_keys: Arc<LiveKeyState>,
pub clipboard: Option<arboard::Clipboard>,
pub copied_patterns: Option<Vec<Pattern>>, pub copied_patterns: Option<Vec<Pattern>>,
pub copied_banks: Option<Vec<Bank>>, pub copied_banks: Option<Vec<Bank>>,
@@ -115,7 +114,6 @@ impl App {
_rng: rng, _rng: rng,
live_keys, live_keys,
script_engine, script_engine,
clipboard: arboard::Clipboard::new().ok(),
copied_patterns: None, copied_patterns: None,
copied_banks: None, copied_banks: None,

View File

@@ -1,5 +1,8 @@
//! Step and bank/pattern cursor navigation. //! Step and bank/pattern cursor navigation.
use cagire_project::{MAX_BANKS, MAX_PATTERNS};
use tachyonfx::Motion;
use super::App; use super::App;
impl App { impl App {
@@ -55,4 +58,22 @@ impl App {
self.editor_ctx.step = 0; self.editor_ctx.step = 0;
self.load_step_to_editor(); self.load_step_to_editor();
} }
pub fn navigate_pattern(&mut self, delta: i32) {
let cur = self.editor_ctx.pattern as i32;
self.editor_ctx.pattern = (cur + delta).rem_euclid(MAX_PATTERNS as i32) as usize;
self.editor_ctx.step = 0;
self.load_step_to_editor();
let direction = if delta > 0 { Motion::UpToDown } else { Motion::DownToUp };
self.ui.show_nav_indicator(500, direction);
}
pub fn navigate_bank(&mut self, delta: i32) {
let cur = self.editor_ctx.bank as i32;
self.editor_ctx.bank = (cur + delta).rem_euclid(MAX_BANKS as i32) as usize;
self.editor_ctx.step = 0;
self.load_step_to_editor();
let direction = if delta > 0 { Motion::LeftToRight } else { Motion::RightToLeft };
self.ui.show_nav_indicator(500, direction);
}
} }

View File

@@ -21,6 +21,10 @@ pub enum AppCommand {
PrevStep, PrevStep,
StepUp, StepUp,
StepDown, StepDown,
NextPattern,
PrevPattern,
NextBank,
PrevBank,
// Pattern editing // Pattern editing
ToggleSteps, ToggleSteps,

View File

@@ -131,6 +131,7 @@ pub enum SeqCommand {
length: usize, length: usize,
}, },
StopAll, StopAll,
RestartAll,
ResetScriptState, ResetScriptState,
Shutdown, Shutdown,
} }
@@ -712,6 +713,23 @@ impl SequencerState {
self.runs_counter.counts.clear(); self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true; self.audio_state.flush_midi_notes = true;
} }
SeqCommand::RestartAll => {
for active in self.audio_state.active_patterns.values_mut() {
active.step_index = 0;
active.iter = 0;
}
self.audio_state.prev_beat = -1.0;
self.script_frontier = -1.0;
self.script_step = 0;
self.script_trace = None;
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
self.speed_overrides.clear();
self.script_engine.clear_global_params();
self.runs_counter.counts.clear();
Arc::make_mut(&mut self.step_traces).clear();
self.audio_state.flush_midi_notes = true;
}
SeqCommand::ResetScriptState => { SeqCommand::ResetScriptState => {
// Clear shared state instead of replacing - preserves sharing with app // Clear shared state instead of replacing - preserves sharing with app
self.variables.store(Arc::new(HashMap::new())); self.variables.store(Arc::new(HashMap::new()));

View File

@@ -120,7 +120,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease),
KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)),
KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)),
KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenModal(Modal::Preview)), KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenPreludeEditor),
KeyCode::Delete | KeyCode::Backspace => { KeyCode::Delete | KeyCode::Backspace => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
if let Some(range) = ctx.app.editor_ctx.selection_range() { if let Some(range) = ctx.app.editor_ctx.selection_range() {
@@ -231,9 +231,6 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
ctx.app.send_mute_state(ctx.seq_cmd_tx); ctx.app.send_mute_state(ctx.seq_cmd_tx);
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
ctx.dispatch(AppCommand::OpenPreludeEditor);
}
KeyCode::Char('D') => {
ctx.dispatch(AppCommand::EvaluatePrelude); ctx.dispatch(AppCommand::EvaluatePrelude);
} }
KeyCode::Char('g') => { KeyCode::Char('g') => {

View File

@@ -14,7 +14,7 @@ use arc_swap::ArcSwap;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use std::sync::atomic::{AtomicBool, AtomicI64}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -87,7 +87,7 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
match (key.code, key.kind) { match (key.code, key.kind) {
_ if !matches!(ctx.app.ui.modal, Modal::None) => false, _ if !matches!(ctx.app.ui.modal, Modal::None) => false,
_ if ctx.app.page == Page::Script && ctx.app.script_editor.focused => false, _ if ctx.app.page == Page::Script && ctx.app.script_editor.focused => false,
(KeyCode::Char('f'), KeyEventKind::Press) => { (KeyCode::Char('f'), KeyEventKind::Press) if !key.modifiers.contains(KeyModifiers::ALT) => {
ctx.dispatch(AppCommand::ToggleLiveKeysFill); ctx.dispatch(AppCommand::ToggleLiveKeysFill);
true true
} }
@@ -97,11 +97,42 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
if key.code == KeyCode::F(12) && !ctx.app.plugin_mode {
if !ctx.app.playback.playing {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing.store(true, Ordering::Relaxed);
}
let _ = ctx.seq_cmd_tx.send(SeqCommand::RestartAll);
return InputResult::Continue;
}
if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side { if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side {
return panel::handle_panel_input(ctx, key); return panel::handle_panel_input(ctx, key);
} }
if alt {
match key.code {
KeyCode::Up => {
ctx.dispatch(AppCommand::PrevPattern);
return InputResult::Continue;
}
KeyCode::Down => {
ctx.dispatch(AppCommand::NextPattern);
return InputResult::Continue;
}
KeyCode::Left | KeyCode::Char('b') => {
ctx.dispatch(AppCommand::PrevBank);
return InputResult::Continue;
}
KeyCode::Right | KeyCode::Char('f') => {
ctx.dispatch(AppCommand::NextBank);
return InputResult::Continue;
}
_ => {}
}
}
if ctrl { if ctrl {
let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250));
match key.code { match key.code {

View File

@@ -383,19 +383,19 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Char('c') if ctrl => { KeyCode::Char('c') if ctrl => {
ctx.app.editor_ctx.editor.copy(); ctx.app.editor_ctx.editor.copy();
let text = ctx.app.editor_ctx.editor.yank_text(); let text = ctx.app.editor_ctx.editor.yank_text();
if let Some(clip) = &mut ctx.app.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
let _ = clip.set_text(text); let _ = clip.set_text(text);
} }
} }
KeyCode::Char('x') if ctrl => { KeyCode::Char('x') if ctrl => {
ctx.app.editor_ctx.editor.cut(); ctx.app.editor_ctx.editor.cut();
let text = ctx.app.editor_ctx.editor.yank_text(); let text = ctx.app.editor_ctx.editor.yank_text();
if let Some(clip) = &mut ctx.app.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
let _ = clip.set_text(text); let _ = clip.set_text(text);
} }
} }
KeyCode::Char('v') if ctrl => { KeyCode::Char('v') if ctrl => {
if let Some(clip) = &mut ctx.app.clipboard { if let Ok(mut clip) = arboard::Clipboard::new() {
if let Ok(text) = clip.get_text() { if let Ok(text) = clip.get_text() {
ctx.app.editor_ctx.editor.set_yank_text(text); ctx.app.editor_ctx.editor.set_yank_text(text);
} }
@@ -417,14 +417,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
crate::services::stack_preview::update_cache(&ctx.app.editor_ctx); crate::services::stack_preview::update_cache(&ctx.app.editor_ctx);
} }
} }
Modal::Preview => match key.code {
KeyCode::Esc | KeyCode::Char('p') => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Left => ctx.dispatch(AppCommand::PrevStep),
KeyCode::Right => ctx.dispatch(AppCommand::NextStep),
KeyCode::Up => ctx.dispatch(AppCommand::StepUp),
KeyCode::Down => ctx.dispatch(AppCommand::StepDown),
_ => {}
},
Modal::PatternProps { Modal::PatternProps {
bank, bank,
pattern, pattern,

View File

@@ -130,7 +130,7 @@ fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
ctx.dispatch(AppCommand::ClearStatus); ctx.dispatch(AppCommand::ClearStatus);
// If a modal is active, clicks outside dismiss it (except Editor/Preview) // If a modal is active, clicks outside dismiss it (except Editor)
if !matches!(ctx.app.ui.modal, Modal::None) { if !matches!(ctx.app.ui.modal, Modal::None) {
handle_modal_click(ctx, col, row, term); handle_modal_click(ctx, col, row, term);
return; return;
@@ -893,9 +893,6 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
Modal::Editor => { Modal::Editor => {
handle_editor_mouse(ctx, col, row, term, false); handle_editor_mouse(ctx, col, row, term, false);
} }
Modal::Preview => {
// Don't dismiss preview on click
}
Modal::Confirm { .. } => { Modal::Confirm { .. } => {
handle_confirm_click(ctx, col, row, term); handle_confirm_click(ctx, col, row, term);
} }

View File

@@ -338,7 +338,8 @@ fn main() -> io::Result<()> {
let effects_active = app.ui.effects.borrow().is_running() let effects_active = app.ui.effects.borrow().is_running()
|| app.ui.modal_fx.borrow().is_some() || app.ui.modal_fx.borrow().is_some()
|| app.ui.title_fx.borrow().is_some(); || app.ui.title_fx.borrow().is_some()
|| app.ui.nav_fx.borrow().is_some();
if app.playback.playing || had_event || app.ui.show_title || effects_active { if app.playback.playing || had_event || app.ui.show_title || effects_active {
if app.ui.show_title { if app.ui.show_title {
app.ui.sparkles.tick(terminal.get_frame().area()); app.ui.sparkles.tick(terminal.get_frame().area());

View File

@@ -9,6 +9,7 @@ use crate::theme;
pub enum FxId { pub enum FxId {
#[default] #[default]
PageTransition, PageTransition,
NavSwitch,
} }
pub fn tick_effects(ui: &mut UiState, page: Page) { pub fn tick_effects(ui: &mut UiState, page: Page) {
@@ -39,6 +40,14 @@ pub fn tick_effects(ui: &mut UiState, page: Page) {
} }
} }
pub fn nav_sweep(ui: &UiState, direction: Motion) {
let bg = theme::get().ui.bg;
ui.effects.borrow_mut().add_unique_effect(
FxId::NavSwitch,
fx::sweep_in(direction, 10, 0, bg, (300, Interpolation::QuadOut)),
);
}
fn page_direction(from: Page, to: Page) -> Motion { fn page_direction(from: Page, to: Page) -> Motion {
let (fc, fr) = from.grid_pos(); let (fc, fr) = from.grid_pos();
let (tc, tr) = to.grid_pos(); let (tc, tr) = to.grid_pos();

View File

@@ -77,7 +77,6 @@ pub enum Modal {
JumpToStep(String), JumpToStep(String),
AddSamplePath(Box<FileBrowserState>), AddSamplePath(Box<FileBrowserState>),
Editor, Editor,
Preview,
PatternProps { PatternProps {
bank: usize, bank: usize,
pattern: usize, pattern: usize,

View File

@@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
use cagire_markdown::ParsedMarkdown; use cagire_markdown::ParsedMarkdown;
use cagire_ratatui::Sparkles; use cagire_ratatui::Sparkles;
use tachyonfx::{fx, Effect, EffectManager, Interpolation}; use tachyonfx::{fx, Effect, EffectManager, Interpolation, Motion};
use crate::page::Page; use crate::page::Page;
use crate::state::effects::FxId; use crate::state::effects::FxId;
@@ -83,6 +83,8 @@ pub struct UiState {
pub window_height: u32, pub window_height: u32,
pub load_demo_on_startup: bool, pub load_demo_on_startup: bool,
pub demo_index: usize, pub demo_index: usize,
pub nav_indicator_until: Option<Instant>,
pub nav_fx: RefCell<Option<Effect>>,
} }
impl Default for UiState { impl Default for UiState {
@@ -135,6 +137,8 @@ impl Default for UiState {
window_height: 800, window_height: 800,
load_demo_on_startup: true, load_demo_on_startup: true,
demo_index: 0, demo_index: 0,
nav_indicator_until: None,
nav_fx: RefCell::new(None),
} }
} }
} }
@@ -196,6 +200,19 @@ impl UiState {
self.minimap = MinimapMode::Hidden; self.minimap = MinimapMode::Hidden;
} }
pub fn show_nav_indicator(&mut self, duration_ms: u64, direction: Motion) {
self.nav_indicator_until = Some(Instant::now() + Duration::from_millis(duration_ms));
let bg = crate::theme::get().ui.bg;
*self.nav_fx.borrow_mut() = Some(fx::fade_from_fg(bg, (150, Interpolation::QuadOut)));
crate::state::effects::nav_sweep(self, direction);
}
pub fn nav_indicator_visible(&self) -> bool {
self.nav_indicator_until
.map(|t| Instant::now() < t)
.unwrap_or(false)
}
pub fn invalidate_help_cache(&self) { pub fn invalidate_help_cache(&self) {
self.help_parsed self.help_parsed
.borrow_mut() .borrow_mut()

View File

@@ -12,6 +12,7 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
("s", "Save", "Save project"), ("s", "Save", "Save project"),
("l", "Load", "Load project"), ("l", "Load", "Load project"),
("?", "Keybindings", "Show this help"), ("?", "Keybindings", "Show this help"),
("F12", "Restart", "Full restart from step 0"),
]); ]);
// Page-specific bindings // Page-specific bindings
@@ -20,12 +21,14 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
if !plugin_mode { if !plugin_mode {
bindings.push(("Space", "Play/Stop", "Toggle playback")); bindings.push(("Space", "Play/Stop", "Toggle playback"));
} }
bindings.push(("Alt+↑↓", "Pattern", "Previous/next pattern"));
bindings.push(("Alt+←→", "Bank", "Previous/next bank"));
bindings.push(("←→↑↓", "Navigate", "Move cursor between steps")); bindings.push(("←→↑↓", "Navigate", "Move cursor between steps"));
bindings.push(("Shift+←→↑↓", "Select", "Extend selection")); bindings.push(("Shift+←→↑↓", "Select", "Extend selection"));
bindings.push(("Esc", "Clear", "Clear selection")); bindings.push(("Esc", "Clear", "Clear selection"));
bindings.push(("Enter", "Edit", "Open step editor")); bindings.push(("Enter", "Edit", "Open step editor"));
bindings.push(("t", "Toggle", "Toggle selected steps")); bindings.push(("t", "Toggle", "Toggle selected steps"));
bindings.push(("p", "Preview", "Preview step script")); bindings.push(("p", "Prelude", "Edit prelude script"));
bindings.push(("Tab", "Samples", "Toggle sample browser")); bindings.push(("Tab", "Samples", "Toggle sample browser"));
bindings.push(("Ctrl+C", "Copy", "Copy selected steps")); bindings.push(("Ctrl+C", "Copy", "Copy selected steps"));
bindings.push(("Ctrl+V", "Paste", "Paste steps")); bindings.push(("Ctrl+V", "Paste", "Paste steps"));
@@ -50,8 +53,7 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
bindings.push(("x", "Solo", "Stage solo for current pattern")); bindings.push(("x", "Solo", "Stage solo for current pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes")); bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos")); bindings.push(("X", "Clear solos", "Clear all solos"));
bindings.push(("d", "Prelude", "Edit prelude script")); bindings.push(("d", "Eval prelude", "Re-evaluate prelude without editing"));
bindings.push(("D", "Eval prelude", "Re-evaluate prelude without editing"));
bindings.push(("g", "Share", "Export pattern to clipboard")); bindings.push(("g", "Share", "Export pattern to clipboard"));
bindings.push(("G", "Import", "Import pattern from clipboard")); bindings.push(("G", "Import", "Import pattern from clipboard"));
} }

View File

@@ -201,6 +201,17 @@ pub fn render(
} }
let modal_area = render_modal(frame, app, snapshot, term); let modal_area = render_modal(frame, app, snapshot, term);
if app.ui.nav_indicator_visible() {
let nav_area = render_nav_indicator(frame, app, term);
let mut fx = app.ui.nav_fx.borrow_mut();
if let Some(effect) = fx.as_mut() {
effect.process(elapsed, frame.buffer_mut(), nav_area);
if !effect.running() {
*fx = None;
}
}
}
if app.ui.show_minimap() { if app.ui.show_minimap() {
let tiles: Vec<NavTile> = Page::ALL let tiles: Vec<NavTile> = Page::ALL
.iter() .iter()
@@ -234,6 +245,57 @@ pub fn render(
} }
} }
fn render_nav_indicator(frame: &mut Frame, app: &App, term: Rect) -> Rect {
let theme = theme::get();
let bank = &app.project_state.project.banks[app.editor_ctx.bank];
let pattern = &bank.patterns[app.editor_ctx.pattern];
let bank_num = format!("{:02}", app.editor_ctx.bank + 1);
let pattern_num = format!("{:02}", app.editor_ctx.pattern + 1);
let bank_name = bank.name.as_deref().unwrap_or("");
let pattern_name = pattern.name.as_deref().unwrap_or("");
let inner = ModalFrame::new("")
.width(34)
.height(5)
.border_color(theme.modal.border_accent)
.render_centered(frame, term);
let bank_style = Style::new().fg(theme.header.bank_fg).bold();
let pattern_style = Style::new().fg(theme.header.pattern_fg).bold();
let dim = Style::new().fg(theme.ui.text_dim);
let divider = Style::new().fg(theme.ui.border);
let line1 = Line::from(vec![
Span::styled(" BANK ", bank_style),
Span::styled("", divider),
Span::styled(" PATTERN ", pattern_style),
]);
let line2 = Line::from(vec![
Span::styled(format!(" {bank_num} "), bank_style),
Span::styled(format!("{bank_name:<10}"), dim),
Span::styled("", divider),
Span::styled(format!(" {pattern_num} "), pattern_style),
Span::styled(format!("{pattern_name:<11}"), dim),
]);
frame.render_widget(
Paragraph::new(line1),
Rect::new(inner.x, inner.y, inner.width, 1),
);
frame.render_widget(
Paragraph::new(line2),
Rect::new(inner.x, inner.y + 1, inner.width, 1),
);
Rect::new(
inner.x.saturating_sub(1),
inner.y.saturating_sub(1),
inner.width + 2,
inner.height + 2,
)
}
fn header_height(_width: u16) -> u16 { fn header_height(_width: u16) -> u16 {
3 3
} }
@@ -660,10 +722,6 @@ fn render_modal(
.height(18) .height(18)
.render_centered(frame, term) .render_centered(frame, term)
} }
Modal::Preview => {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
render_modal_preview(frame, app, snapshot, &user_words, term)
}
Modal::Editor => { Modal::Editor => {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect(); let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
render_modal_editor(frame, app, snapshot, &user_words, term) render_modal_editor(frame, app, snapshot, &user_words, term)
@@ -878,64 +936,6 @@ fn render_modal(
)) ))
} }
fn render_modal_preview(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
user_words: &HashSet<String>,
term: Rect,
) -> Rect {
let theme = theme::get();
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 80 / 100).max(10);
let pattern = app.current_edit_pattern();
let step_idx = app.editor_ctx.step;
let step = pattern.step(step_idx);
let source_idx = step.and_then(|s| s.source);
let step_name = step.and_then(|s| s.name.as_ref());
let title = match (source_idx, step_name) {
(Some(src), Some(name)) => {
format!("Step {:02}: {}{:02}", step_idx + 1, name, src + 1)
}
(None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name),
(Some(src), None) => format!("Step {:02}{:02}", step_idx + 1, src + 1),
(None, None) => format!("Step {:02}", step_idx + 1),
};
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(theme.modal.preview)
.render_centered(frame, term);
let script = pattern.resolve_script(step_idx).unwrap_or("");
if script.is_empty() {
let empty = Paragraph::new("(empty)")
.alignment(Alignment::Center)
.style(Style::new().fg(theme.ui.text_dim));
let centered_area = Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
};
frame.render_widget(empty, centered_area);
} else {
let trace = if app.ui.runtime_highlight && app.playback.playing {
let source = pattern.resolve_source(step_idx);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let lines = highlight_script_lines(script, trace, user_words, usize::MAX);
frame.render_widget(Paragraph::new(lines), inner);
}
inner
}
fn render_modal_editor( fn render_modal_editor(
frame: &mut Frame, frame: &mut Frame,
app: &App, app: &App,