19 Commits

Author SHA1 Message Date
2c8a6794a3 Feat: UI/UX fixes
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-05 00:28:30 +01:00
60fb62829f Feat: UI/UX fixes + removing clones from places 2026-03-05 00:15:51 +01:00
35370a6f2c Feat: better user feedback on patterns page
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-04 23:41:11 +01:00
4e1c04f9c7 trigger deploy 2026-03-04 08:44:34 +01:00
80a3d91f76 Feat: update download matrix on cagire website
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-03 22:26:03 +01:00
f130c9b54a Feat: adjust workflows again
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-03 21:05:53 +01:00
bdd2f9210e chore: Release
Some checks failed
Deploy Website / deploy (push) Has been skipped
CI / linux (push) Failing after 10s
CI / macos (push) Failing after 20s
CI / windows (push) Failing after 46s
Release / linux (push) Has been skipped
Release / macos (push) Has been skipped
Release / assemble-macos (push) Has been skipped
Release / windows (push) Has been skipped
Release / cross (push) Has been skipped
Release / release (push) Has been skipped
2026-03-03 20:02:20 +01:00
1fb599f574 Feat: Update CHANGELOG in preparation for 0.1.1 release 2026-03-03 20:01:39 +01:00
e8cf8c506b Feat: integrating workshop fixes
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-03 19:46:50 +01:00
16d6d76422 Feat: crash bugfixes
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-03 13:10:22 +01:00
cf1d2be140 Feat: separate workflows for plugins
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-02 23:55:51 +01:00
cc89021cc0 Feat: update CLAP / VST CI
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-02 23:39:43 +01:00
470f62df89 Feat: update website with download matrix
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-01 23:31:05 +01:00
88cb43a760 Fix: modularize CI workflows
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-01 23:18:08 +01:00
eeefb7d54d Fix: GitHub CI windows again
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-01 22:44:00 +01:00
cfd7d31d3d Fix: Github CI fix again (windows msi with wix) && autonomous msi workflow
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-01 22:09:59 +01:00
e9f5d8bb6d Fix: GitHub CI 2026-03-01 21:27:50 +01:00
17643b3332 Fix: GitHub CI again 2026-03-01 21:15:02 +01:00
95879c852d Fix: update Github CI
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-01 21:05:44 +01:00
59 changed files with 3229 additions and 9169 deletions

135
.github/workflows/assemble-macos.yml vendored Normal file
View File

@@ -0,0 +1,135 @@
name: Assemble macOS Universal
on:
workflow_call:
jobs:
assemble:
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
- uses: actions/checkout@v4
with:
sparse-checkout: |
assets/DMG-README.txt
scripts/make-dmg.sh
clean: false
- name: Create DMG
run: |
chmod +x scripts/make-dmg.sh
scripts/make-dmg.sh Cagire.app .
- 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: Prepare universal plugin staging
run: |
mkdir -p staging/clap staging/vst3
cp -R cagire-plugins.clap staging/clap/
cp -R cagire-plugins.vst3 staging/vst3/
- name: Upload universal CLAP plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-clap
path: staging/clap/
- name: Upload universal VST3 plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-vst3
path: staging/vst3/
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-dmg
path: Cagire-*.dmg
- name: Upload .pkg installer
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-pkg
path: Cagire-*-universal.pkg

49
.github/workflows/build-cross.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Build Cross (Linux ARM64)
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: aarch64-unknown-linux-gnu
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build
run: cross build --release --target aarch64-unknown-linux-gnu
- name: Build desktop
run: cross build --release --features desktop --bin cagire-desktop --target aarch64-unknown-linux-gnu
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: cagire-linux-aarch64
path: target/aarch64-unknown-linux-gnu/release/cagire
- name: Upload desktop artifact
uses: actions/upload-artifact@v4
with:
name: cagire-linux-aarch64-desktop
path: target/aarch64-unknown-linux-gnu/release/cagire-desktop

131
.github/workflows/build-linux.yml vendored Normal file
View File

@@ -0,0 +1,131 @@
name: Build Linux
on:
workflow_call:
inputs:
run-tests:
type: boolean
default: false
run-clippy:
type: boolean
default: false
build-packages:
type: boolean
default: false
workflow_dispatch:
inputs:
run-tests:
type: boolean
default: true
run-clippy:
type: boolean
default: true
build-packages:
type: boolean
default: false
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-unknown-linux-gnu
- name: Install dependencies
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 \
libx11-dev libx11-xcb-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev
- name: Build
run: cargo build --release --target x86_64-unknown-linux-gnu
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target x86_64-unknown-linux-gnu
- name: Test
if: inputs.run-tests
run: cargo test --target x86_64-unknown-linux-gnu
- name: Clippy
if: inputs.run-clippy
run: cargo clippy --target x86_64-unknown-linux-gnu -- -D warnings
- name: Install cargo-bundle
if: inputs.build-packages
run: cargo install cargo-bundle
- name: Bundle desktop app
if: inputs.build-packages
run: cargo bundle --release --features desktop --bin cagire-desktop --target x86_64-unknown-linux-gnu
- name: Build AppImages
if: inputs.build-packages
run: |
mkdir -p target/releases
scripts/make-appimage.sh target/x86_64-unknown-linux-gnu/release/cagire x86_64 target/releases
scripts/make-appimage.sh target/x86_64-unknown-linux-gnu/release/cagire-desktop x86_64 target/releases
- name: Bundle CLAP plugin
if: inputs.build-packages
run: cargo xtask bundle cagire-plugins --release --target x86_64-unknown-linux-gnu
- name: Upload CLI artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64
path: target/x86_64-unknown-linux-gnu/release/cagire
- name: Upload desktop artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-desktop
path: target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
- name: Upload AppImage artifacts
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-appimage
path: target/releases/*.AppImage
- name: Prepare plugin artifacts
if: inputs.build-packages
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-vst3
path: staging/vst3/

127
.github/workflows/build-macos.yml vendored Normal file
View File

@@ -0,0 +1,127 @@
name: Build macOS
on:
workflow_call:
inputs:
run-tests:
type: boolean
default: false
run-clippy:
type: boolean
default: false
build-packages:
type: boolean
default: false
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"cagire-macos-aarch64"}]'
workflow_dispatch:
inputs:
run-tests:
type: boolean
default: true
run-clippy:
type: boolean
default: true
build-packages:
type: boolean
default: false
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"cagire-macos-aarch64"}]'
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "12.0"
jobs:
build:
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs.matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install dependencies
run: brew list cmake &>/dev/null || brew install cmake
- 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: Test
if: inputs.run-tests
run: cargo test --target ${{ matrix.target }}
- name: Clippy
if: inputs.run-clippy
run: cargo clippy --target ${{ matrix.target }} -- -D warnings
- name: Bundle desktop app
if: inputs.build-packages
run: scripts/make-app-bundle.sh ${{ matrix.target }}
- name: Bundle CLAP plugin
if: inputs.build-packages
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }}
- name: Zip macOS app bundle
if: inputs.build-packages
run: |
cd target/${{ matrix.target }}/release/bundle/osx
zip -r Cagire.app.zip Cagire.app
- name: Upload CLI artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire
- name: Upload desktop artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
- name: Prepare plugin artifacts
if: inputs.build-packages
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-clap
path: staging/clap/
- name: Upload VST3 artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-vst3
path: staging/vst3/

View File

@@ -0,0 +1,56 @@
name: Build Plugins Linux
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-unknown-linux-gnu-plugins
- name: Install dependencies
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 \
libx11-dev libx11-xcb-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev
- name: Build plugins
run: cargo xtask bundle cagire-plugins --release --target x86_64-unknown-linux-gnu
- name: Prepare plugin artifacts
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-x86_64-vst3
path: staging/vst3/

View File

@@ -0,0 +1,66 @@
name: Build Plugins macOS
on:
workflow_call:
inputs:
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"plugins-macos-aarch64"},{"os":"macos-15-intel","target":"x86_64-apple-darwin","artifact":"plugins-macos-x86_64"}]'
workflow_dispatch:
inputs:
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"plugins-macos-aarch64"}]'
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "12.0"
jobs:
build:
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs.matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
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 }}-plugins
- name: Install dependencies
run: brew list cmake &>/dev/null || brew install cmake
- name: Build plugins
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }}
- name: Prepare plugin artifacts
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-vst3
path: staging/vst3/

59
.github/workflows/build-plugins-rpi.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Build Plugins RPi
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: aarch64-unknown-linux-gnu-plugins
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build plugins
run: cross build --release -p cagire-plugins --target aarch64-unknown-linux-gnu
- name: Prepare plugin artifacts
run: |
mkdir -p target/bundled
# CLAP: single .so renamed to .clap
cp target/aarch64-unknown-linux-gnu/release/libcagire_plugins.so target/bundled/cagire-plugins.clap
# VST3: correct directory structure
mkdir -p "target/bundled/cagire-plugins.vst3/Contents/aarch64-linux"
cp target/aarch64-unknown-linux-gnu/release/libcagire_plugins.so "target/bundled/cagire-plugins.vst3/Contents/aarch64-linux/cagire-plugins.so"
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-aarch64-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-aarch64-vst3
path: staging/vst3/

View File

@@ -0,0 +1,59 @@
name: Build Plugins Windows
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: windows-latest
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-pc-windows-msvc-plugins
- name: Install dependencies
shell: pwsh
run: |
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
echo "C:\Program Files\CMake\bin" >> $env:GITHUB_PATH
- name: Build plugins
run: cargo xtask bundle cagire-plugins --release --target x86_64-pc-windows-msvc
- name: Prepare plugin artifacts
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: plugins-windows-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: plugins-windows-x86_64-vst3
path: staging/vst3/

18
.github/workflows/build-plugins.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: Build Plugins
on:
workflow_call:
workflow_dispatch:
jobs:
linux:
uses: ./.github/workflows/build-plugins-linux.yml
macos:
uses: ./.github/workflows/build-plugins-macos.yml
windows:
uses: ./.github/workflows/build-plugins-windows.yml
rpi:
uses: ./.github/workflows/build-plugins-rpi.yml

122
.github/workflows/build-windows.yml vendored Normal file
View File

@@ -0,0 +1,122 @@
name: Build Windows
on:
workflow_call:
inputs:
run-tests:
type: boolean
default: false
run-clippy:
type: boolean
default: false
build-packages:
type: boolean
default: false
workflow_dispatch:
inputs:
run-tests:
type: boolean
default: true
run-clippy:
type: boolean
default: true
build-packages:
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-pc-windows-msvc
- name: Install dependencies
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 x86_64-pc-windows-msvc
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target x86_64-pc-windows-msvc
- name: Test
if: inputs.run-tests
run: cargo test --target x86_64-pc-windows-msvc
- name: Clippy
if: inputs.run-clippy
run: cargo clippy --target x86_64-pc-windows-msvc -- -D warnings
- name: Bundle CLAP plugin
if: inputs.build-packages
run: cargo xtask bundle cagire-plugins --release --target x86_64-pc-windows-msvc
- name: Install cargo-wix
if: inputs.build-packages
run: cargo install cargo-wix
- name: Build MSI installer
if: inputs.build-packages
run: cargo wix --no-build --nocapture --package cagire -C -arch -C x64
- name: Upload CLI artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64
path: target/x86_64-pc-windows-msvc/release/cagire.exe
- name: Upload desktop artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-desktop
path: target/x86_64-pc-windows-msvc/release/cagire-desktop.exe
- name: Upload MSI artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-msi
path: target/wix/*.msi
- name: Prepare plugin artifacts
if: inputs.build-packages
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-vst3
path: staging/vst3/

View File

@@ -4,70 +4,25 @@ on:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
linux:
uses: ./.github/workflows/build-linux.yml
with:
run-tests: true
run-clippy: true
runs-on: ${{ matrix.os }}
timeout-minutes: 20
macos:
uses: ./.github/workflows/build-macos.yml
with:
run-tests: true
run-clippy: true
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- 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
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
run: brew list cmake &>/dev/null || brew install cmake
- 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: Test
run: cargo test --target ${{ matrix.target }}
- name: Clippy
run: cargo clippy --target ${{ matrix.target }} -- -D warnings
windows:
uses: ./.github/workflows/build-windows.yml
with:
run-tests: true
run-clippy: true

View File

@@ -5,346 +5,44 @@ on:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "12.0"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
linux:
if: github.server_url == 'https://github.com'
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
uses: ./.github/workflows/build-linux.yml
with:
build-packages: true
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: Install cargo-wix (Windows)
if: runner.os == 'Windows'
run: cargo install cargo-wix
- name: Build MSI installer (Windows)
if: runner.os == 'Windows'
run: cargo wix --no-build --nocapture -C -p -C x64
- name: Upload MSI installer (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-msi
path: target/wix/*.msi
- 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:
macos:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 45
uses: ./.github/workflows/build-macos.yml
with:
build-packages: true
matrix: >-
[
{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"cagire-macos-aarch64"},
{"os":"macos-15-intel","target":"x86_64-apple-darwin","artifact":"cagire-macos-x86_64"}
]
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:
windows:
if: github.server_url == 'https://github.com'
needs: build
runs-on: macos-14
timeout-minutes: 10
uses: ./.github/workflows/build-windows.yml
with:
build-packages: true
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: cagire-macos-*
path: artifacts
cross:
if: github.server_url == 'https://github.com'
uses: ./.github/workflows/build-cross.yml
- 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
- uses: actions/checkout@v4
with:
sparse-checkout: |
assets/DMG-README.txt
scripts/make-dmg.sh
clean: false
- name: Create DMG
run: |
chmod +x scripts/make-dmg.sh
scripts/make-dmg.sh Cagire.app .
- 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 DMG
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-dmg
path: Cagire-*.dmg
- name: Upload .pkg installer
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-pkg
path: Cagire-*-universal.pkg
assemble-macos:
needs: macos
uses: ./.github/workflows/assemble-macos.yml
release:
needs: [build, build-cross, universal-macos]
needs: [linux, macos, windows, cross, assemble-macos]
if: startsWith(github.ref, 'refs/tags/v') && github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 10

View File

@@ -2,6 +2,28 @@
All notable changes to this project will be documented in this file.
## [0.1.1]
### Forth Language
- `map` word: apply a quotation to each stack element (`1 2 3 ( 10 * ) map => 10 20 30`).
- `loop` fix: now operates in steps instead of beats, uses `step_duration()` for correct timing.
### Fixed
- Crash on missing sample directories: sample path scanning now validates directories exist before scanning.
- Audio channel minimum enforced to 2, preventing crash on devices reporting fewer channels.
- Audio device disconnect: automatic stream restart when device is lost (terminal and desktop).
- Live keys (e.g. `f` for fill) no longer trigger while searching in dictionary or help views.
- Side panel always uses horizontal layout (removed broken vertical fallback for narrow terminals).
### Changed
- Runtime highlight enabled by default.
### Packaging
- Modular CI: split monolithic release workflow into per-platform builds (Linux, macOS, Windows, cross-compilation).
- Separate CI workflows for CLAP/VST plugin builds (Linux, macOS, Windows, Raspberry Pi).
- Windows MSI installer workflow fixes.
- Website download matrix updated.
## [0.1.0]
### Breaking

16
Cargo.lock generated
View File

@@ -846,7 +846,7 @@ checksum = "981520c98f422fcc584dc1a95c334e6953900b9106bc47a9839b81790009eb21"
[[package]]
name = "cagire"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"arboard",
"arc-swap",
@@ -885,7 +885,7 @@ dependencies = [
[[package]]
name = "cagire-forth"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"arc-swap",
"parking_lot",
@@ -894,7 +894,7 @@ dependencies = [
[[package]]
name = "cagire-markdown"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"minimad",
"ratatui",
@@ -902,7 +902,7 @@ dependencies = [
[[package]]
name = "cagire-plugins"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"arc-swap",
"cagire",
@@ -926,7 +926,7 @@ dependencies = [
[[package]]
name = "cagire-project"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"base64",
"brotli",
@@ -938,7 +938,7 @@ dependencies = [
[[package]]
name = "cagire-ratatui"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"rand",
"ratatui",
@@ -1809,8 +1809,8 @@ dependencies = [
[[package]]
name = "doux"
version = "0.0.5"
source = "git+https://github.com/sova-org/doux#886702b4fe937d26ed681a2f6d7626d26d6890d0"
version = "0.0.6"
source = "git+https://github.com/sova-org/doux#14ccf68c0ac626718664f32ede20f13087df8de6"
dependencies = [
"arc-swap",
"clap",

View File

@@ -2,7 +2,7 @@
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "plugins/cagire-plugins", "plugins/baseview", "plugins/egui-baseview", "plugins/nih-plug-egui", "xtask"]
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0"

View File

@@ -118,6 +118,7 @@ pub enum Op {
Euclid,
EuclidRot,
Times,
Map,
Chord(&'static [i64]),
Transpose,
Invert,

View File

@@ -112,7 +112,7 @@ impl Forth {
let vars_snapshot = self.vars.load_full();
let mut var_writes: HashMap<String, Value> = HashMap::new();
cmd.set_global(self.global_params.lock().clone());
cmd.set_global(std::mem::take(&mut *self.global_params.lock()));
self.execute_ops(
ops,
@@ -459,7 +459,7 @@ impl Forth {
if b.as_float().map_or(true, |v| v == 0.0) {
return Err("division by zero".into());
}
stack.push(lift_binary(a, b, |x, y| x / y)?);
stack.push(lift_binary(&a, &b, |x, y| x / y)?);
}
Op::Mod => {
let b = pop(stack)?;
@@ -467,47 +467,47 @@ impl Forth {
if b.as_float().map_or(true, |v| v == 0.0) {
return Err("modulo by zero".into());
}
let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?;
let result = lift_binary(&a, &b, |x, y| (x as i64 % y as i64) as f64)?;
stack.push(result);
}
Op::Neg => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| -x)?);
stack.push(lift_unary(&v, |x| -x)?);
}
Op::Abs => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.abs())?);
stack.push(lift_unary(&v, |x| x.abs())?);
}
Op::Floor => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.floor())?);
stack.push(lift_unary(&v, |x| x.floor())?);
}
Op::Ceil => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.ceil())?);
stack.push(lift_unary(&v, |x| x.ceil())?);
}
Op::Round => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.round())?);
stack.push(lift_unary(&v, |x| x.round())?);
}
Op::Min => binary_op(stack, |a, b| a.min(b))?,
Op::Max => binary_op(stack, |a, b| a.max(b))?,
Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
Op::Sqrt => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.sqrt())?);
stack.push(lift_unary(&v, |x| x.sqrt())?);
}
Op::Sin => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.sin())?);
stack.push(lift_unary(&v, |x| x.sin())?);
}
Op::Cos => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.cos())?);
stack.push(lift_unary(&v, |x| x.cos())?);
}
Op::Log => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.ln())?);
stack.push(lift_unary(&v, |x| x.ln())?);
}
Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?,
@@ -1055,7 +1055,7 @@ impl Forth {
let key = read_key(&var_writes_cell, vars_snapshot);
let values = std::mem::take(stack);
for val in values {
let result = lift_unary_int(val, |degree| {
let result = lift_unary_int(&val, |degree| {
let octave_offset = degree.div_euclid(len);
let idx = degree.rem_euclid(len) as usize;
key + octave_offset * 12 + pattern[idx]
@@ -1155,7 +1155,7 @@ impl Forth {
Op::Oct => {
let shift = pop(stack)?;
let note = pop(stack)?;
let result = lift_binary(note, shift, |n, s| n + s * 12.0)?;
let result = lift_binary(&note, &shift, |n, s| n + s * 12.0)?;
stack.push(result);
}
@@ -1180,11 +1180,11 @@ impl Forth {
}
Op::Loop => {
let beats = pop_float(stack)?;
let steps = pop_float(stack)?;
if ctx.tempo == 0.0 || ctx.speed == 0.0 {
return Err("tempo and speed must be non-zero".into());
}
let dur = beats * 60.0 / ctx.tempo / ctx.speed;
let dur = steps * ctx.step_duration();
cmd.set_param("fit", Value::Float(dur, None));
cmd.set_param("dur", Value::Float(dur, None));
}
@@ -1374,6 +1374,15 @@ impl Forth {
}
}
Op::Map => {
let quot = pop(stack)?;
let items = std::mem::take(stack);
for item in items {
stack.push(item);
run_quotation(quot.clone(), stack, outputs, cmd)?;
}
}
Op::GeomRange => {
let count = pop_int(stack)?;
let ratio = pop_float(stack)?;
@@ -1912,65 +1921,65 @@ fn float_to_value(result: f64) -> Value {
}
}
fn lift_unary<F>(val: Value, f: F) -> Result<Value, String>
fn lift_unary<F>(val: &Value, f: F) -> Result<Value, String>
where
F: Fn(f64) -> f64 + Copy,
{
match val {
Value::ArpList(items) => {
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect();
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect();
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
v => Ok(float_to_value(f(v.as_float()?))),
}
}
fn lift_unary_int<F>(val: Value, f: F) -> Result<Value, String>
fn lift_unary_int<F>(val: &Value, f: F) -> Result<Value, String>
where
F: Fn(i64) -> i64 + Copy,
{
match val {
Value::ArpList(items) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_unary_int(x.clone(), f)).collect();
items.iter().map(|x| lift_unary_int(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_unary_int(x.clone(), f)).collect();
items.iter().map(|x| lift_unary_int(x, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
v => Ok(Value::Int(f(v.as_int()?), None)),
}
}
fn lift_binary<F>(a: Value, b: Value, f: F) -> Result<Value, String>
fn lift_binary<F>(a: &Value, b: &Value, f: F) -> Result<Value, String>
where
F: Fn(f64, f64) -> f64 + Copy,
{
match (a, b) {
(Value::ArpList(items), b) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect();
items.iter().map(|x| lift_binary(x, b, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(a, Value::ArpList(items)) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect();
items.iter().map(|x| lift_binary(a, x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(Value::CycleList(items), b) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect();
items.iter().map(|x| lift_binary(x, b, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
(a, Value::CycleList(items)) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect();
items.iter().map(|x| lift_binary(a, x, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
(a, b) => Ok(float_to_value(f(a.as_float()?, b.as_float()?))),
@@ -1983,7 +1992,7 @@ where
{
let b = pop(stack)?;
let a = pop(stack)?;
stack.push(lift_binary(a, b, f)?);
stack.push(lift_binary(&a, &b, f)?);
Ok(())
}

View File

@@ -110,6 +110,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"euclid" => Op::Euclid,
"euclidrot" => Op::EuclidRot,
"times" => Op::Times,
"map" => Op::Map,
"m." => Op::MidiEmit,
"ccval" => Op::GetMidiCC,
"mclock" => Op::MidiClock,

View File

@@ -567,6 +567,16 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
Word {
name: "map",
aliases: &[],
category: "Control",
stack: "(..vals quot -- ..results)",
desc: "Apply quotation to each stack element",
example: "1 2 3 ( 10 * ) map => 10 20 30",
compile: Simple,
varargs: false,
},
// Variables
Word {
name: "@<var>",

View File

@@ -280,8 +280,8 @@ pub(super) const WORDS: &[Word] = &[
aliases: &[],
category: "Time",
stack: "(n --)",
desc: "Fit sample to n beats",
example: "\"break\" s 4 loop @",
desc: "Fit sample to n steps",
example: "\"break\" s 16 loop @",
compile: Simple,
varargs: false,
},

View File

@@ -170,6 +170,17 @@ impl LaunchQuantization {
}
}
pub fn short_label(&self) -> &'static str {
match self {
Self::Immediate => "Imm",
Self::Beat => "Bt",
Self::Bar => "1B",
Self::Bars2 => "2B",
Self::Bars4 => "4B",
Self::Bars8 => "8B",
}
}
/// Cycle to the next longer quantization, clamped at `Bars8`.
pub fn next(&self) -> Self {
match self {
@@ -212,6 +223,13 @@ impl SyncMode {
}
}
pub fn short_label(&self) -> &'static str {
match self {
Self::Reset => "Rst",
Self::PhaseLock => "Plk",
}
}
/// Toggle between Reset and PhaseLock.
pub fn toggle(&self) -> Self {
match self {

View File

@@ -1,6 +1,7 @@
//! Script editor widget with completion, search, and sample finder popups.
use std::cell::Cell;
use std::sync::Arc;
use crate::theme;
use ratatui::{
@@ -25,7 +26,7 @@ pub struct CompletionCandidate {
}
struct CompletionState {
candidates: Vec<CompletionCandidate>,
candidates: Arc<[CompletionCandidate]>,
matches: Vec<usize>,
cursor: usize,
prefix: String,
@@ -37,7 +38,7 @@ struct CompletionState {
impl CompletionState {
fn new() -> Self {
Self {
candidates: Vec::new(),
candidates: Arc::from([]),
matches: Vec::new(),
cursor: 0,
prefix: String::new(),
@@ -171,7 +172,7 @@ impl Editor {
self.scroll_offset.set(0);
}
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
pub fn set_candidates(&mut self, candidates: Arc<[CompletionCandidate]>) {
self.completion.candidates = candidates;
}

View File

@@ -14,11 +14,14 @@ pub struct FileBrowserModal<'a> {
title: &'a str,
input: &'a str,
entries: &'a [(String, bool, bool)],
audio_counts: &'a [Option<usize>],
selected: usize,
scroll_offset: usize,
border_color: Option<Color>,
width: u16,
height: u16,
hints: Option<Line<'a>>,
color_path: bool,
}
impl<'a> FileBrowserModal<'a> {
@@ -27,11 +30,14 @@ impl<'a> FileBrowserModal<'a> {
title,
input,
entries,
audio_counts: &[],
selected: 0,
scroll_offset: 0,
border_color: None,
width: 60,
height: 16,
hints: None,
color_path: false,
}
}
@@ -60,6 +66,21 @@ impl<'a> FileBrowserModal<'a> {
self
}
pub fn hints(mut self, hints: Line<'a>) -> Self {
self.hints = Some(hints);
self
}
pub fn audio_counts(mut self, counts: &'a [Option<usize>]) -> Self {
self.audio_counts = counts;
self
}
pub fn color_path(mut self) -> Self {
self.color_path = true;
self
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
let colors = theme::get();
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
@@ -70,37 +91,61 @@ impl<'a> FileBrowserModal<'a> {
.border_color(border_color)
.render_centered(frame, term);
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
let has_hints = self.hints.is_some();
let constraints = if has_hints {
vec![
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
]
} else {
vec![Constraint::Length(1), Constraint::Min(1)]
};
let rows = Layout::vertical(constraints).split(inner);
// Input line
frame.render_widget(
Paragraph::new(Line::from(vec![
let input_spans = if self.color_path {
let (path_part, filter_part) = match self.input.rfind('/') {
Some(pos) => (&self.input[..=pos], &self.input[pos + 1..]),
None => ("", self.input),
};
vec![
Span::raw("> "),
Span::styled(path_part.to_string(), Style::new().fg(colors.browser.directory)),
Span::styled(filter_part.to_string(), Style::new().fg(colors.input.text)),
Span::styled("", Style::new().fg(colors.input.cursor)),
]
} else {
vec![
Span::raw("> "),
Span::styled(self.input, Style::new().fg(colors.input.text)),
Span::styled("", Style::new().fg(colors.input.cursor)),
])),
rows[0],
);
]
};
frame.render_widget(Paragraph::new(Line::from(input_spans)), rows[0]);
// Hints bar
if let Some(hints) = self.hints {
let hint_row = rows[2];
frame.render_widget(
Paragraph::new(hints).alignment(ratatui::layout::Alignment::Right),
hint_row,
);
}
// Entries list
let visible_height = rows[1].height as usize;
let visible_entries = self
.entries
.iter()
.enumerate()
.skip(self.scroll_offset)
.take(visible_height);
let lines: Vec<Line> = visible_entries
.enumerate()
.map(|(i, (name, is_dir, is_cagire))| {
let abs_idx = i + self.scroll_offset;
.map(|(abs_idx, (name, is_dir, is_cagire))| {
let is_selected = abs_idx == self.selected;
let prefix = if is_selected { "> " } else { " " };
let display = if *is_dir {
format!("{prefix}{name}/")
} else {
format!("{prefix}{name}")
};
let color = if is_selected {
colors.browser.selected
} else if *is_dir {
@@ -110,7 +155,21 @@ impl<'a> FileBrowserModal<'a> {
} else {
colors.browser.file
};
Line::from(Span::styled(display, Style::new().fg(color)))
let display = if *is_dir {
format!("{prefix}{name}/")
} else {
format!("{prefix}{name}")
};
let mut spans = vec![Span::styled(display, Style::new().fg(color))];
if *is_dir && name != ".." {
if let Some(Some(count)) = self.audio_counts.get(abs_idx) {
spans.push(Span::styled(
format!(" ({count})"),
Style::new().fg(colors.browser.file),
));
}
}
Line::from(spans)
})
.collect();

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ Each pattern is an independent sequence of steps with its own properties:
| Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` |
| Follow Up | What happens when the pattern finishes an iteration | `Loop` |
Press `e` in the patterns view to edit these settings. After editing properties, you will have to hit the `c` key to _commit_ these changes. More about that later!
Press `e` in the patterns view to edit these settings. After editing properties, you will have to hit the `c` key to _launch_ these changes. More about that later!
### Follow Up
@@ -46,12 +46,12 @@ The follow-up action determines what happens when a pattern reaches the end of i
Access the patterns view with `F2` (or `Ctrl+Up` from the sequencer). The view shows all banks and patterns in a grid. Indicators show pattern state:
- `>` Currently playing
- `+` Staged to play
- `-` Staged to stop
- `+` Armed to play
- `-` Armed to stop
- `M` Muted
- `S` Soloed
It is quite essential for you to understand the stage / commit system in order to use patterns. Please read the next section carefully!
It is quite essential for you to understand the arm / launch system in order to use patterns. Please read the next section carefully!
### Keybindings
@@ -59,13 +59,13 @@ It is quite essential for you to understand the stage / commit system in order t
|-----|--------|
| `Arrows` | Navigate banks and patterns |
| `Enter` | Select and return to sequencer |
| `p` | Stage pattern to play/stop |
| `c` | Commit staged changes |
| `m` / `x` | Stage mute / solo toggle |
| `p` | Arm pattern to play/stop |
| `c` | Launch armed changes |
| `m` / `x` | Arm mute / solo toggle |
| `e` | Edit pattern properties |
| `r` | Rename bank or pattern |
| `Ctrl+c` / `Ctrl+v` | Copy / Paste |
| `Delete` | Reset to empty pattern |
| `Esc` | Cancel staged changes |
| `Esc` | Cancel armed changes |

View File

@@ -1,35 +1,35 @@
# Stage / Commit
# Arm / Launch
In Cagire, changes to playback happen in two steps. First you **stage**: you mark what you want to happen. Then you **commit**: you apply all staged changes at once. Nothing changes until you commit. It is simpler than it sounds.
In Cagire, changes to playback happen in two steps. First you **arm**: you mark what you want to happen. Then you **launch**: you apply all armed changes at once. Nothing changes until you launch. It is simpler than it sounds.
Say you want patterns `04` and `05` to start playing together. You stage both (`p` on each), then commit (`c`). Both start at the same time. Want to stop them later? Stage them again, commit again. That's it.
Say you want patterns `04` and `05` to start playing together. You arm both (`p` on each), then launch (`c`). Both start at the same time. Want to stop them later? Arm them again, launch again. That's it.
This two-step process exists for good reasons:
- **Multiple changes at once**: queue several patterns to start/stop, commit them together.
- **Multiple changes at once**: queue several patterns to start/stop, launch them together.
- **Clean timing**: all changes land on beat or bar boundaries, never mid-step.
- **Safe preparation**: set up the next section while the current one keeps playing.
## Push changes, then apply
## Arm changes, then launch
Staging is an essential feature to understand to be effective when doing live performances:
Arming is an essential feature to understand to be effective when doing live performances:
1. Open the **Patterns** view (`F2` or `Ctrl+Up` from sequencer)
2. Navigate to a pattern you wish to change/play
3. Press `p` to stage it. The pending change is going to be displayed:
- `+` (staged to play)
- `-` (staged to stop)
- `m` (staged to mute)
- `s` (staged to solo)
3. Press `p` to arm it. The pending change is going to be displayed:
- `+` (armed to play)
- `-` (armed to stop)
- `m` (armed to mute)
- `s` (armed to solo)
- etc.
4. Repeat for other patterns you want to change
5. Press `c` to commit all changes
5. Press `c` to launch all changes
6. Or press `Esc` to cancel
You can also stage mute/solo changes:
You can also arm mute/solo changes:
- Press `m` to stage a mute toggle
- Press `x` to stage a solo toggle
- Press `m` to arm a mute toggle
- Press `x` to arm a solo toggle
- Press `Shift+m` to clear all mutes
- Press `Shift+x` to clear all solos
@@ -41,16 +41,18 @@ It might wait for the next beat/bar boundary.
| Indicator | Meaning |
|-----------|---------|
| `>` | Currently playing |
| `+` | Staged to play |
| `-` | Staged to stop |
| `+` | Armed to play |
| `-` | Armed to stop |
| `M` | Muted |
| `S` | Soloed |
A pattern can show combined indicators, e.g. `>` (playing) and `-` (staged to stop), or `>M` (playing and muted).
A pattern can show combined indicators, e.g. `>` (playing) and `-` (armed to stop), or `>M` (playing and muted).
Armed patterns blink to make pending changes impossible to miss.
## Quantization
Committed changes don't execute immediately. They wait for a quantization boundary:
Launched changes don't execute immediately. They wait for a quantization boundary:
| Setting | Behavior |
|---------|----------|

View File

@@ -330,7 +330,7 @@ copy_artifacts() {
# MSI installer for Windows targets
if [[ "$os" == "windows" ]] && command -v cargo-wix &>/dev/null; then
echo " Building MSI installer..."
cargo wix --no-build --nocapture -C -p -C x64
cargo wix --no-build --nocapture --package cagire -C -arch -C x64
cp target/wix/*.msi "$OUT/" 2>/dev/null && echo " MSI -> $OUT/" || true
fi

View File

@@ -14,4 +14,8 @@ RUN dpkg --add-architecture arm64 && \
libxcb-xfixes0-dev:arm64 \
libxkbcommon-dev:arm64 \
libgl1-mesa-dev:arm64 \
libxcursor-dev:arm64 \
libxrandr-dev:arm64 \
libxi-dev:arm64 \
libwayland-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -13,4 +13,8 @@ RUN apt-get update && \
libxcb-xfixes0-dev \
libxkbcommon-dev \
libgl1-mesa-dev \
libxcursor-dev \
libxrandr-dev \
libxi-dev \
libwayland-dev \
&& rm -rf /var/lib/apt/lists/*

66
scripts/make-app-bundle.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: scripts/make-app-bundle.sh <target>
# Creates a macOS .app bundle at target/<target>/release/bundle/osx/Cagire.app
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <target>"
exit 1
fi
TARGET="$1"
REPO_ROOT="$(git rev-parse --show-toplevel)"
BINARY="$REPO_ROOT/target/$TARGET/release/cagire-desktop"
ICON="$REPO_ROOT/assets/Cagire.icns"
VERSION="0.1.0"
if [[ ! -f "$BINARY" ]]; then
echo "ERROR: binary not found at $BINARY"
exit 1
fi
APP_DIR="$REPO_ROOT/target/$TARGET/release/bundle/osx/Cagire.app"
CONTENTS="$APP_DIR/Contents"
rm -rf "$APP_DIR"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
cp "$BINARY" "$CONTENTS/MacOS/cagire-desktop"
[[ -f "$ICON" ]] && cp "$ICON" "$CONTENTS/Resources/Cagire.icns"
cat > "$CONTENTS/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Cagire</string>
<key>CFBundleDisplayName</key>
<string>Cagire</string>
<key>CFBundleIdentifier</key>
<string>com.sova.cagire</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleExecutable</key>
<string>cagire-desktop</string>
<key>CFBundleIconFile</key>
<string>Cagire.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) 2025 Raphaël Forment</string>
<key>NSMicrophoneUsageDescription</key>
<string>Cagire needs microphone access for audio input.</string>
</dict>
</plist>
PLIST
echo " APP -> $APP_DIR"

View File

@@ -215,7 +215,7 @@ impl App {
},
);
self.ui
.set_status(format!("{} props staged", bp_label(bank, pattern)));
.set_status(format!("{} props armed", bp_label(bank, pattern)));
}
// Page navigation

View File

@@ -29,7 +29,7 @@ use crate::state::{
ProjectState, ScriptEditorState, UiState,
};
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
static COMPLETION_CANDIDATES: LazyLock<Arc<[CompletionCandidate]>> = LazyLock::new(|| {
model::WORDS
.iter()
.map(|w| CompletionCandidate {

View File

@@ -1,5 +1,7 @@
//! Forth script compilation, evaluation, and editor ↔ step synchronization.
use std::sync::Arc;
use crossbeam_channel::Sender;
use crate::engine::LinkState;
@@ -55,7 +57,7 @@ impl App {
script.lines().map(String::from).collect()
};
self.editor_ctx.editor.set_content(lines);
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
self.editor_ctx
.editor
.set_completion_enabled(self.ui.show_completion);
@@ -87,7 +89,7 @@ impl App {
prelude.lines().map(String::from).collect()
};
self.editor_ctx.editor.set_content(lines);
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
self.editor_ctx
.editor
.set_completion_enabled(self.ui.show_completion);
@@ -190,7 +192,7 @@ impl App {
script.lines().map(String::from).collect()
};
self.script_editor.editor.set_content(lines);
self.script_editor.editor.set_candidates(COMPLETION_CANDIDATES.clone());
self.script_editor.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
self.script_editor
.editor
.set_completion_enabled(self.ui.show_completion);

View File

@@ -24,7 +24,7 @@ impl App {
if let Some(idx) = existing {
self.playback.staged_changes.remove(idx);
self.ui
.set_status(format!("{} unstaged", bp_label(bank, pattern)));
.set_status(format!("{} disarmed", bp_label(bank, pattern)));
} else if is_playing {
self.playback.staged_changes.push(StagedChange {
change: PatternChange::Stop { bank, pattern },
@@ -32,7 +32,7 @@ impl App {
sync_mode: pattern_data.sync_mode,
});
self.ui
.set_status(format!("{} staged to stop", bp_label(bank, pattern)));
.set_status(format!("{} armed to stop", bp_label(bank, pattern)));
} else {
self.playback.staged_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
@@ -40,7 +40,7 @@ impl App {
sync_mode: pattern_data.sync_mode,
});
self.ui
.set_status(format!("{} staged to play", bp_label(bank, pattern)));
.set_status(format!("{} armed to play", bp_label(bank, pattern)));
}
}
@@ -52,7 +52,7 @@ impl App {
let prop_count = self.playback.staged_prop_changes.len();
if pattern_count == 0 && mute_count == 0 && prop_count == 0 {
self.ui.set_status("No changes to commit".to_string());
self.ui.set_status("No changes to launch".to_string());
return false;
}
@@ -90,7 +90,7 @@ impl App {
}
let total = pattern_count + mute_count + prop_count;
let status = format!("Committed {total} changes");
let status = format!("Launched {total} changes");
self.ui.set_status(status);
mute_changed
@@ -110,7 +110,7 @@ impl App {
self.playback.staged_prop_changes.clear();
let total = pattern_count + mute_count + prop_count;
let status = format!("Cleared {total} staged changes");
let status = format!("Cleared {total} armed changes");
self.ui.set_status(status);
}
}

View File

@@ -161,6 +161,7 @@ struct CagireDesktop {
_input_stream: Option<cpal::Stream>,
_analysis_handle: Option<AnalysisHandle>,
midi_rx: Receiver<MidiCommand>,
device_lost: Arc<AtomicBool>,
stream_error_rx: crossbeam_channel::Receiver<String>,
current_font: FontChoice,
zoom_factor: f32,
@@ -207,6 +208,7 @@ impl CagireDesktop {
_input_stream: b.input_stream,
_analysis_handle: b.analysis_handle,
midi_rx: b.midi_rx,
device_lost: b.device_lost,
stream_error_rx: b.stream_error_rx,
current_font,
zoom_factor,
@@ -251,9 +253,13 @@ impl CagireDesktop {
let mut restart_samples = Vec::new();
self.app.audio.config.sample_counts.clear();
for path in &self.app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
self.app.audio.config.sample_counts.push(index.len());
restart_samples.extend(index);
if path.is_dir() {
let index = doux::sampling::scan_samples_dir(path);
self.app.audio.config.sample_counts.push(index.len());
restart_samples.extend(index);
} else {
self.app.audio.config.sample_counts.push(0);
}
}
self.audio_sample_pos.store(0, Ordering::Release);
@@ -273,6 +279,7 @@ impl CagireDesktop {
Arc::clone(&self.audio_sample_pos),
new_error_tx,
&self.app.audio.config.sample_paths,
Arc::clone(&self.device_lost),
) {
Ok((new_stream, new_input, info, new_analysis, registry)) => {
self._stream = Some(new_stream);
@@ -369,6 +376,11 @@ impl eframe::App for CagireDesktop {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.handle_audio_restart();
if self.device_lost.load(Ordering::Acquire) {
self.device_lost.store(false, Ordering::Release);
self.app.audio.restart_pending = true;
}
while let Ok(err) = self.stream_error_rx.try_recv() {
self.app.ui.flash(&err, 3000, cagire::state::FlashKind::Error);
}

View File

@@ -309,6 +309,7 @@ pub fn build_stream(
audio_sample_pos: Arc<AtomicU64>,
error_tx: Sender<String>,
sample_paths: &[std::path::PathBuf],
device_lost: Arc<AtomicBool>,
) -> Result<BuildStreamResult, String> {
let device = match &config.output_device {
Some(name) => doux::audio::find_output_device(name)
@@ -320,7 +321,7 @@ pub fn build_stream(
let sample_rate = default_config.sample_rate() as f32;
let max_channels = doux::audio::max_output_channels(&device);
let channels = config.channels.min(max_channels);
let channels = config.channels.min(max_channels).max(2);
let host_name = doux::audio::preferred_host().id().name().to_string();
let is_jack = host_name.to_lowercase().contains("jack");
@@ -410,7 +411,19 @@ pub fn build_stream(
drop(b.drain(..excess));
}
},
|err| eprintln!("input stream error: {err}"),
{
let device_lost = Arc::clone(&device_lost);
move |err: cpal::StreamError| {
eprintln!("input stream error: {err}");
match err {
cpal::StreamError::DeviceNotAvailable
| cpal::StreamError::StreamInvalidated => {
device_lost.store(true, Ordering::Release);
}
_ => {}
}
}
},
None,
)
.ok()?;
@@ -521,7 +534,16 @@ pub fn build_stream(
let _ = fft_producer.try_push(mono);
}
},
move |err| { let _ = error_tx.try_send(format!("stream error: {err}")); },
move |err: cpal::StreamError| {
let _ = error_tx.try_send(format!("stream error: {err}"));
match err {
cpal::StreamError::DeviceNotAvailable
| cpal::StreamError::StreamInvalidated => {
device_lost.store(true, Ordering::Release);
}
_ => {}
}
},
None,
)
.map_err(|e| format!("Failed to build stream: {e}"))?;

View File

@@ -555,7 +555,7 @@ pub struct SequencerState {
pattern_cache: PatternCache,
pending_updates: HashMap<(usize, usize), PatternSnapshot>,
runs_counter: RunsCounter,
step_traces: StepTracesMap,
step_traces: Arc<StepTracesMap>,
event_count: usize,
script_engine: ScriptEngine,
variables: Variables,
@@ -593,7 +593,7 @@ impl SequencerState {
pattern_cache: PatternCache::new(),
pending_updates: HashMap::new(),
runs_counter: RunsCounter::new(),
step_traces: HashMap::new(),
step_traces: Arc::new(HashMap::new()),
event_count: 0,
script_engine,
variables,
@@ -713,7 +713,7 @@ impl SequencerState {
self.audio_state.active_patterns.clear();
self.audio_state.pending_starts.clear();
self.audio_state.pending_stops.clear();
self.step_traces.clear();
self.step_traces = Arc::new(HashMap::new());
self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true;
}
@@ -731,7 +731,7 @@ impl SequencerState {
self.speed_overrides.clear();
self.script_engine.clear_global_params();
self.runs_counter.counts.clear();
self.step_traces.clear();
self.step_traces = Arc::new(HashMap::new());
self.audio_state.flush_midi_notes = true;
}
SeqCommand::ResetScriptState => {
@@ -811,7 +811,7 @@ impl SequencerState {
fn tick_paused(&mut self) -> TickOutput {
for pending in self.audio_state.pending_stops.drain(..) {
self.audio_state.active_patterns.remove(&pending.id);
self.step_traces.retain(|&(bank, pattern, _), _| {
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
bank != pending.id.bank || pattern != pending.id.pattern
});
let key = (pending.id.bank, pending.id.pattern);
@@ -894,7 +894,7 @@ impl SequencerState {
for pending in &self.audio_state.pending_stops {
if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) {
self.audio_state.active_patterns.remove(&pending.id);
self.step_traces.retain(|&(bank, pattern, _), _| {
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
bank != pending.id.bank || pattern != pending.id.pattern
});
// Flush pending update so cache stays current for future launches
@@ -1015,7 +1015,7 @@ impl SequencerState {
.script_engine
.evaluate_with_trace(script, &ctx, &mut trace)
{
self.step_traces.insert(
Arc::make_mut(&mut self.step_traces).insert(
(active.bank, active.pattern, source_idx),
std::mem::take(&mut trace),
);
@@ -1229,7 +1229,7 @@ impl SequencerState {
last_step_beat: a.last_step_beat,
})
.collect(),
step_traces: Arc::new(self.step_traces.clone()),
step_traces: Arc::clone(&self.step_traces),
event_count: self.event_count,
tempo: self.last_tempo,
beat: self.last_beat,

View File

@@ -43,6 +43,7 @@ pub struct Init {
pub input_stream: Option<cpal::Stream>,
pub analysis_handle: Option<AnalysisHandle>,
pub midi_rx: Receiver<MidiCommand>,
pub device_lost: Arc<AtomicBool>,
pub stream_error_rx: crossbeam_channel::Receiver<String>,
#[cfg(feature = "desktop")]
pub settings: Settings,
@@ -158,9 +159,14 @@ pub fn init(args: InitArgs) -> Init {
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_counts.push(index.len());
initial_samples.extend(index);
if path.is_dir() {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_counts.push(index.len());
initial_samples.extend(index);
} else {
eprintln!("Sample path not found: {}", path.display());
app.audio.config.sample_counts.push(0);
}
}
let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples
.iter()
@@ -197,6 +203,7 @@ pub fn init(args: InitArgs) -> Init {
seq_config,
);
let device_lost = Arc::new(AtomicBool::new(false));
let (stream_error_tx, stream_error_rx) = crossbeam_channel::bounded(16);
let stream_config = AudioStreamConfig {
@@ -217,6 +224,7 @@ pub fn init(args: InitArgs) -> Init {
Arc::clone(&audio_sample_pos),
stream_error_tx,
&app.audio.config.sample_paths,
Arc::clone(&device_lost),
) {
Ok((s, input, info, analysis, registry)) => {
app.audio.config.sample_rate = info.sample_rate;
@@ -262,6 +270,7 @@ pub fn init(args: InitArgs) -> Init {
input_stream,
analysis_handle,
midi_rx,
device_lost,
stream_error_rx,
#[cfg(feature = "desktop")]
settings,

View File

@@ -267,7 +267,8 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
}
KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => {
use crate::state::file_browser::FileBrowserState;
let state = FileBrowserState::new_load(String::new());
let mut state = FileBrowserState::new_load(String::new());
state.compute_audio_counts();
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
}
KeyCode::Char('D') => {

View File

@@ -79,9 +79,6 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
let current = format!("{:.1}", ctx.link.tempo());
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
}
KeyCode::Char(':') => {
ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(String::new())));
}
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),

View File

@@ -85,6 +85,7 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
match (key.code, key.kind) {
_ if !matches!(ctx.app.ui.modal, Modal::None) => false,
_ if ctx.app.page == Page::Script && ctx.app.script_editor.focused => false,
_ if ctx.app.ui.dict_search_active || ctx.app.ui.help_search_active => false,
(KeyCode::Char('f'), KeyEventKind::Press) if !key.modifiers.contains(KeyModifiers::ALT) => {
ctx.dispatch(AppCommand::ToggleLiveKeysFill);
true
@@ -178,6 +179,19 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
return InputResult::Continue;
}
if key.code == KeyCode::Char(':') {
let in_search = ctx.app.ui.dict_search_active || ctx.app.ui.help_search_active;
let in_script = ctx.app.page == Page::Script && ctx.app.script_editor.focused;
if !in_search && !in_script {
ctx.dispatch(AppCommand::OpenModal(Modal::CommandPalette {
input: String::new(),
cursor: 0,
scroll: 0,
}));
return InputResult::Continue;
}
}
match ctx.app.page {
Page::Main => main_page::handle_main_page(ctx, key, ctrl),
Page::Patterns => patterns_page::handle_patterns_page(ctx, key),

View File

@@ -10,6 +10,49 @@ use crate::state::{
};
pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
// Handle CommandPalette before the main match to avoid borrow conflicts
// (Enter needs &App for palette_entries while the match borrows &mut modal)
if let Modal::CommandPalette { input, cursor, scroll } = &mut ctx.app.ui.modal {
match key.code {
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Char(c) => {
input.push(c);
*cursor = 0;
*scroll = 0;
}
KeyCode::Backspace => {
input.pop();
*cursor = 0;
*scroll = 0;
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
*cursor = cursor.saturating_sub(5);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
*cursor += 5;
}
KeyCode::Up => {
*cursor = cursor.saturating_sub(1);
}
KeyCode::Down => {
*cursor += 1;
}
KeyCode::PageUp => {
*cursor = cursor.saturating_sub(10);
}
KeyCode::PageDown => {
*cursor += 10;
}
KeyCode::Enter => {
let query = input.clone();
let cursor_val = *cursor;
handle_palette_enter(ctx, &query, cursor_val);
}
_ => {}
}
return InputResult::Continue;
}
match &mut ctx.app.ui.modal {
Modal::Confirm { action, selected } => {
let confirmed = *selected;
@@ -179,22 +222,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Char(c) => input.push(c),
_ => {}
},
Modal::JumpToStep(input) => match key.code {
KeyCode::Enter => {
if let Ok(step) = input.parse::<usize>() {
if step > 0 {
ctx.dispatch(AppCommand::GoToStep(step - 1));
}
}
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => input.push(c),
_ => {}
},
Modal::SetTempo(input) => match key.code {
KeyCode::Enter => {
if let Ok(tempo) = input.parse::<f64>() {
@@ -222,8 +249,12 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
Some(state.current_dir().join(&entry.name))
} else if entry.is_dir {
state.enter_selected();
state.compute_audio_counts();
None
} else {
ctx.dispatch(AppCommand::SetStatus(
"Select a directory, not a file".into(),
));
None
}
} else {
@@ -261,15 +292,16 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
}
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Tab => state.autocomplete(),
KeyCode::Left => state.go_up(),
KeyCode::Right => state.enter_selected(),
KeyCode::Up => state.select_prev(14),
KeyCode::Down => state.select_next(14),
KeyCode::Backspace => state.backspace(),
KeyCode::Tab => { state.autocomplete(); state.compute_audio_counts(); }
KeyCode::Left => { state.go_up(); state.compute_audio_counts(); }
KeyCode::Right => { state.enter_selected(); state.compute_audio_counts(); }
KeyCode::Up => state.select_prev(16),
KeyCode::Down => state.select_next(16),
KeyCode::Backspace => { state.backspace(); state.compute_audio_counts(); }
KeyCode::Char(c) => {
state.input.push(c);
state.refresh_entries();
state.compute_audio_counts();
}
_ => {}
},
@@ -514,7 +546,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
}
}
Modal::KeybindingsHelp { scroll } => {
let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page, ctx.app.plugin_mode).len();
let bindings_count = crate::model::palette::bindings_for(ctx.app.page, ctx.app.plugin_mode).len();
match key.code {
KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Up | KeyCode::Char('k') => {
@@ -636,6 +668,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
_ => ctx.dispatch(AppCommand::CloseModal),
}
}
Modal::CommandPalette { .. } => unreachable!(),
Modal::None => unreachable!(),
}
InputResult::Continue
@@ -670,6 +703,97 @@ fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResul
InputResult::Continue
}
fn handle_palette_enter(ctx: &mut InputContext, query: &str, cursor: usize) {
let page = ctx.app.page;
let plugin_mode = ctx.app.plugin_mode;
// Numeric input on Main page → jump to step
if page == crate::page::Page::Main
&& !query.is_empty()
&& query.chars().all(|c| c.is_ascii_digit())
{
if let Ok(step) = query.parse::<usize>() {
if step > 0 {
ctx.dispatch(AppCommand::GoToStep(step - 1));
}
}
ctx.dispatch(AppCommand::CloseModal);
return;
}
let entries = crate::model::palette::palette_entries(query, plugin_mode, ctx.app);
if let Some(entry) = entries.get(cursor) {
ctx.dispatch(AppCommand::CloseModal);
execute_palette_entry(ctx, entry);
} else {
ctx.dispatch(AppCommand::CloseModal);
}
}
fn execute_palette_entry(
ctx: &mut InputContext,
entry: &crate::model::palette::CommandEntry,
) {
use crate::model::palette::PaletteAction;
use std::sync::atomic::Ordering;
match &entry.action {
Some(PaletteAction::Resolve(f)) => {
if let Some(cmd) = f(ctx.app) {
ctx.dispatch(cmd);
}
}
Some(PaletteAction::Save) => super::open_save(ctx),
Some(PaletteAction::Load) => super::open_load(ctx),
Some(PaletteAction::TogglePlaying) => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
Some(PaletteAction::MuteToggle) => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.app.playback.toggle_mute(bank, pattern);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::SoloToggle) => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.app.playback.toggle_solo(bank, pattern);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::ClearMutes) => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::ClearSolos) => {
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::Hush) => {
let _ = ctx
.audio_tx
.load()
.send(crate::engine::AudioCommand::Hush);
}
Some(PaletteAction::Panic) => {
let _ = ctx
.audio_tx
.load()
.send(crate::engine::AudioCommand::Panic);
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
}
Some(PaletteAction::TestTone) => {
let _ = ctx
.audio_tx
.load()
.send(crate::engine::AudioCommand::Evaluate {
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
time: None,
});
}
None => {}
}
}
fn rename_command(target: &RenameTarget, name: Option<String>) -> AppCommand {
match target {
RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name },

View File

@@ -983,10 +983,12 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
Modal::PatternProps { .. } => (50, 18),
Modal::EuclideanDistribution { .. } => (50, 11),
Modal::Onboarding { .. } => (57, 20),
Modal::FileBrowser(_) | Modal::AddSamplePath(_) => (60, 18),
Modal::FileBrowser(_) => (60, 18),
Modal::AddSamplePath(_) => (70, 20),
Modal::Rename { .. } => (40, 5),
Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5),
Modal::SetTempo(_) | Modal::JumpToStep(_) => (30, 5),
Modal::SetTempo(_) => (30, 5),
Modal::CommandPalette { .. } => (55, 20),
_ => return,
};
let modal_area = centered_rect(term, w, h);

View File

@@ -103,6 +103,7 @@ fn main() -> io::Result<()> {
let mut _input_stream = b.input_stream;
let mut _analysis_handle = b.analysis_handle;
let mut midi_rx = b.midi_rx;
let device_lost = b.device_lost;
let mut stream_error_rx = b.stream_error_rx;
enable_raw_mode()?;
@@ -141,9 +142,13 @@ fn main() -> io::Result<()> {
let mut restart_samples = Vec::new();
app.audio.config.sample_counts.clear();
for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_counts.push(index.len());
restart_samples.extend(index);
if path.is_dir() {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_counts.push(index.len());
restart_samples.extend(index);
} else {
app.audio.config.sample_counts.push(0);
}
}
audio_sample_pos.store(0, Ordering::Relaxed);
@@ -163,6 +168,7 @@ fn main() -> io::Result<()> {
Arc::clone(&audio_sample_pos),
new_error_tx,
&app.audio.config.sample_paths,
Arc::clone(&device_lost),
) {
Ok((new_stream, new_input, info, new_analysis, registry)) => {
_stream = Some(new_stream);
@@ -193,6 +199,11 @@ fn main() -> io::Result<()> {
}
}
if device_lost.load(Ordering::Acquire) {
device_lost.store(false, Ordering::Release);
app.audio.restart_pending = true;
}
while let Ok(err) = stream_error_rx.try_recv() {
app.ui.flash(&err, 3000, state::FlashKind::Error);
}
@@ -344,10 +355,16 @@ fn main() -> io::Result<()> {
let elapsed = last_frame.elapsed();
last_frame = Instant::now();
let has_armed = app.playback.has_armed();
if has_armed {
let rate = std::f32::consts::TAU; // 1 Hz full cycle
app.ui.pulse_phase = (app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU;
}
let effects_active = app.ui.effects.borrow().is_running()
|| app.ui.modal_fx.borrow().is_some()
|| app.ui.title_fx.borrow().is_some()
|| app.ui.nav_fx.borrow().is_some();
|| app.ui.nav_fx.borrow().is_some()
|| has_armed;
let cursor_pulse = app.page == page::Page::Main && !app.ui.performance_mode && !app.playback.playing;
let audio_cooldown = !app.playback.playing && last_stop_time.elapsed() < Duration::from_secs(1);
if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() || cursor_pulse || audio_cooldown {

View File

@@ -2,6 +2,7 @@ pub mod categories;
pub mod demos;
pub mod docs;
pub mod onboarding;
pub mod palette;
mod script;
pub use cagire_forth::{

View File

@@ -30,12 +30,12 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
],
Page::Patterns => &[
(
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. The bottom strip previews steps and pattern properties. Stage patterns to play or stop, then commit to apply all changes at once.",
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. The bottom strip previews steps and pattern properties. Arm patterns to play or stop, then launch to apply all changes at once.",
&[
("Arrows", "navigate"),
("Enter", "open in sequencer"),
("Space", "stage play/stop"),
("c", "commit changes"),
("p", "arm play/stop"),
("c", "launch changes"),
("r", "rename"),
("e", "properties"),
("?", "all keys"),
@@ -44,8 +44,8 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
(
"Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.",
&[
("m", "stage mute"),
("s", "stage solo"),
("m", "arm mute"),
("s", "arm solo"),
("E", "euclidean"),
("Shift+↑↓", "select range"),
("y", "copy"),

1320
src/model/palette.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -112,7 +112,7 @@ impl Default for DisplaySettings {
fn default() -> Self {
Self {
fps: 60,
runtime_highlight: false,
runtime_highlight: true,
show_scope: true,
show_spectrum: true,
show_lissajous: true,

View File

@@ -27,6 +27,7 @@ pub struct FileBrowserState {
pub entries: Vec<DirEntry>,
pub selected: usize,
pub scroll_offset: usize,
pub audio_counts: Vec<Option<usize>>,
}
impl FileBrowserState {
@@ -37,6 +38,7 @@ impl FileBrowserState {
entries: Vec::new(),
selected: 0,
scroll_offset: 0,
audio_counts: Vec::new(),
};
state.refresh_entries();
state
@@ -49,6 +51,7 @@ impl FileBrowserState {
entries: Vec::new(),
selected: 0,
scroll_offset: 0,
audio_counts: Vec::new(),
};
state.refresh_entries();
state
@@ -119,10 +122,27 @@ impl FileBrowserState {
});
self.entries = entries;
self.audio_counts = Vec::new();
self.selected = 0;
self.scroll_offset = 0;
}
pub fn compute_audio_counts(&mut self) {
let dir = self.current_dir();
self.audio_counts = self
.entries
.iter()
.map(|entry| {
if !entry.is_dir || entry.name == ".." {
return None;
}
let path = dir.join(&entry.name);
let count = count_audio_files(&path);
if count > 0 { Some(count) } else { None }
})
.collect();
}
pub fn autocomplete(&mut self) {
let real_entries: Vec<&DirEntry> =
self.entries.iter().filter(|e| e.name != "..").collect();
@@ -249,6 +269,23 @@ fn ensure_parent_dirs(path: &Path) {
}
}
fn count_audio_files(path: &Path) -> usize {
let Ok(read_dir) = fs::read_dir(path) else {
return 0;
};
read_dir
.flatten()
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
matches!(
name.rsplit('.').next().map(|ext| ext.to_lowercase()).as_deref(),
Some("wav" | "flac" | "ogg" | "aiff" | "aif" | "mp3")
)
})
.count()
}
fn longest_common_prefix(entries: &[&DirEntry]) -> String {
if entries.is_empty() {
return String::new();

View File

@@ -74,7 +74,6 @@ pub enum Modal {
input: String,
},
SetTempo(String),
JumpToStep(String),
AddSamplePath(Box<FileBrowserState>),
Editor,
PatternProps {
@@ -102,4 +101,9 @@ pub enum Modal {
rotation: String,
},
Onboarding { page: usize },
CommandPalette {
input: String,
cursor: usize,
scroll: usize,
},
}

View File

@@ -130,6 +130,34 @@ impl PlaybackState {
self.soloed.contains(&(bank, pattern))
}
pub fn has_armed(&self) -> bool {
!self.staged_changes.is_empty()
|| !self.staged_mute_changes.is_empty()
|| !self.staged_prop_changes.is_empty()
}
pub fn armed_summary(&self) -> Option<String> {
let play = self.staged_changes.iter().filter(|c| matches!(c.change, PatternChange::Start { .. })).count();
let stop = self.staged_changes.iter().filter(|c| matches!(c.change, PatternChange::Stop { .. })).count();
let mute = self.staged_mute_changes.iter().filter(|c| matches!(c, StagedMuteChange::ToggleMute { .. })).count();
let solo = self.staged_mute_changes.iter().filter(|c| matches!(c, StagedMuteChange::ToggleSolo { .. })).count();
let props = self.staged_prop_changes.len();
let parts: Vec<String> = [
(play, "play"),
(stop, "stop"),
(mute, "mute"),
(solo, "solo"),
(props, "props"),
]
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, label)| format!("{n} {label}"))
.collect();
if parts.is_empty() { None } else { Some(parts.join(", ")) }
}
pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
if self.muted.contains(&(bank, pattern)) {
return true;

View File

@@ -86,6 +86,7 @@ pub struct UiState {
pub demo_index: usize,
pub nav_indicator_until: Option<Instant>,
pub nav_fx: RefCell<Option<Effect>>,
pub pulse_phase: f32,
pub last_click: Option<(Instant, u16, u16)>,
}
@@ -142,6 +143,7 @@ impl Default for UiState {
demo_index: 0,
nav_indicator_until: None,
nav_fx: RefCell::new(None),
pulse_phase: 0.0,
last_click: None,
}
}

View File

@@ -1,144 +0,0 @@
use crate::page::Page;
pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'static str, &'static str)> {
let mut bindings = vec![
("F1F7", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine/Script"),
("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
];
if !plugin_mode {
bindings.push(("q", "Quit", "Quit application"));
}
bindings.extend([
("s", "Save", "Save project"),
("l", "Load", "Load project"),
("?", "Keybindings", "Show this help"),
("F12", "Restart", "Full restart from step 0"),
]);
// Page-specific bindings
match page {
Page::Main => {
if !plugin_mode {
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(("Shift+←→↑↓", "Select", "Extend selection"));
bindings.push(("Esc", "Clear", "Clear selection"));
bindings.push(("Enter", "Edit", "Open step editor"));
bindings.push(("t", "Toggle", "Toggle selected steps"));
bindings.push(("p", "Prelude", "Edit prelude script"));
bindings.push(("Tab", "Samples", "Toggle sample browser"));
bindings.push(("Ctrl+C", "Copy", "Copy selected steps"));
bindings.push(("Ctrl+V", "Paste", "Paste steps"));
bindings.push(("Ctrl+B", "Link", "Paste as linked steps"));
bindings.push(("Ctrl+D", "Duplicate", "Duplicate selection"));
bindings.push(("Ctrl+H", "Harden", "Convert links to copies"));
bindings.push(("Del", "Delete", "Delete step(s)"));
bindings.push(("< >", "Length", "Decrease/increase pattern length"));
bindings.push(("[ ]", "Speed", "Decrease/increase pattern speed"));
if !plugin_mode {
bindings.push(("+ -", "Tempo", "Increase/decrease tempo"));
bindings.push(("T", "Set tempo", "Open tempo input"));
}
bindings.push(("L", "Set length", "Open length input"));
bindings.push(("S", "Set speed", "Open speed input"));
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
bindings.push(("r", "Rename", "Rename current step"));
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
bindings.push((":", "Jump", "Jump to step number"));
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
bindings.push(("m", "Mute", "Stage mute for current pattern"));
bindings.push(("x", "Solo", "Stage solo for current pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos"));
bindings.push(("d", "Eval prelude", "Re-evaluate prelude without editing"));
bindings.push(("g", "Share", "Export pattern to clipboard"));
bindings.push(("G", "Import", "Import pattern from clipboard"));
}
Page::Patterns => {
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
bindings.push(("Shift+↑↓", "Select", "Extend selection"));
bindings.push(("Alt+↑↓", "Shift", "Move patterns up/down"));
bindings.push(("Enter", "Select", "Select pattern for editing"));
if !plugin_mode {
bindings.push(("Space", "Play", "Toggle pattern playback"));
}
bindings.push(("Esc", "Back", "Clear staged or go back"));
bindings.push(("c", "Commit", "Commit staged changes"));
bindings.push(("p", "Stage play", "Stage pattern play toggle"));
bindings.push(("r", "Rename", "Rename bank/pattern"));
bindings.push(("d", "Describe", "Add description to pattern"));
bindings.push(("e", "Properties", "Edit pattern properties"));
bindings.push(("m", "Mute", "Stage mute for pattern"));
bindings.push(("x", "Solo", "Stage solo for pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos"));
bindings.push(("g", "Share", "Export bank or pattern to clipboard"));
bindings.push(("G", "Import", "Import bank or pattern from clipboard"));
bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern"));
bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern"));
bindings.push(("Del", "Reset", "Reset bank/pattern"));
bindings.push(("Ctrl+Z", "Undo", "Undo last action"));
bindings.push(("Ctrl+Shift+Z", "Redo", "Redo last action"));
}
Page::Engine => {
bindings.push(("Tab", "Section", "Next section"));
bindings.push(("Shift+Tab", "Section", "Previous section"));
bindings.push(("←→", "Switch", "Switch device type or adjust setting"));
bindings.push(("↑↓", "Navigate", "Navigate list items"));
bindings.push(("PgUp/Dn", "Page", "Page through device list"));
bindings.push(("Enter", "Select", "Select device"));
if !plugin_mode {
bindings.push(("R", "Restart", "Restart audio engine"));
}
bindings.push(("A", "Add path", "Add sample path"));
bindings.push(("D", "Refresh/Del", "Refresh devices or delete path"));
bindings.push(("h", "Hush", "Stop all sounds gracefully"));
bindings.push(("p", "Panic", "Stop all sounds immediately"));
bindings.push(("r", "Reset", "Reset peak voice counter"));
if !plugin_mode {
bindings.push(("t", "Test", "Play test tone"));
}
}
Page::Options => {
bindings.push(("Tab", "Next", "Move to next option"));
bindings.push(("Shift+Tab", "Previous", "Move to previous option"));
bindings.push(("↑↓", "Navigate", "Navigate options"));
bindings.push(("←→", "Toggle", "Toggle or adjust option"));
if !plugin_mode {
bindings.push(("Space", "Play/Stop", "Toggle playback"));
}
}
Page::Help => {
bindings.push(("↑↓ j/k", "Scroll", "Scroll content"));
bindings.push(("Tab", "Topic", "Next topic"));
bindings.push(("Shift+Tab", "Topic", "Previous topic"));
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
bindings.push(("n", "Next code", "Jump to next code block"));
bindings.push(("p", "Prev code", "Jump to previous code block"));
bindings.push(("Enter", "Run code", "Execute focused code block"));
bindings.push(("/", "Search", "Activate search"));
bindings.push(("Esc", "Clear", "Clear search / deselect block"));
}
Page::Dict => {
bindings.push(("Tab", "Focus", "Toggle category/words focus"));
bindings.push(("↑↓ j/k", "Navigate", "Navigate items"));
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
bindings.push(("/", "Search", "Activate search"));
bindings.push(("Ctrl+F", "Search", "Activate search"));
bindings.push(("Esc", "Clear", "Clear search"));
}
Page::Script => {
bindings.push(("Enter", "Focus", "Focus editor for typing"));
bindings.push(("Esc", "Unfocus", "Unfocus editor to use page keybindings"));
bindings.push(("Ctrl+E", "Evaluate", "Compile and check for errors (focused)"));
bindings.push(("S", "Set Speed", "Set script speed via text input (unfocused)"));
bindings.push(("L", "Set Length", "Set script length via text input (unfocused)"));
bindings.push(("Ctrl+S", "Stack", "Toggle stack preview (focused)"));
}
}
bindings
}

View File

@@ -2,7 +2,6 @@ pub mod dict_view;
pub mod engine_view;
pub mod help_view;
pub mod highlight;
pub mod keybindings;
pub mod main_view;
pub mod options_view;
pub mod patterns_view;

View File

@@ -1,7 +1,7 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::App;
@@ -13,6 +13,20 @@ use crate::widgets::{render_scroll_indicators, IndicatorAlign};
const MIN_ROW_HEIGHT: u16 = 1;
fn pulse_value(phase: f32) -> f32 {
phase.sin() * 0.5 + 0.5
}
fn pulse_color(from: Color, to: Color, t: f32) -> Color {
match (from, to) {
(Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t) as u8;
Color::Rgb(l(r1, r2), l(g1, g2), l(b1, b2))
}
_ => from,
}
}
/// Replaces the background color of spans beyond `filled_cols` with `unfilled_bg`.
fn apply_progress_bg(spans: Vec<Span<'_>>, filled_cols: usize, unfilled_bg: Color) -> Vec<Span<'_>> {
let mut result = Vec::with_capacity(spans.len() + 1);
@@ -54,7 +68,32 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]).areas(bottom_area);
render_banks(frame, app, snapshot, banks_area);
render_patterns(frame, app, snapshot, patterns_area);
let armed_summary = app.playback.armed_summary();
let (patterns_main, launch_bar_area) = if armed_summary.is_some() {
let [main, bar] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(patterns_area);
(main, Some(bar))
} else {
(patterns_area, None)
};
render_patterns(frame, app, snapshot, patterns_main);
if let (Some(bar_area), Some(summary)) = (launch_bar_area, armed_summary) {
let pulse = pulse_value(app.ui.pulse_phase);
let pulsed_fg = pulse_color(theme.list.staged_play_fg, theme.list.staged_play_bg, pulse * 0.6);
let text = format!("\u{25b6} {summary} \u{2014} c to launch");
let bar = Paragraph::new(text)
.alignment(Alignment::Center)
.style(
Style::new()
.fg(pulsed_fg)
.bg(theme.list.staged_play_bg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(bar, bar_area);
}
let bank = app.patterns_nav.bank_cursor;
let pattern_idx = app.patterns_nav.pattern_cursor;
@@ -82,6 +121,7 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let theme = theme::get();
let pulse = pulse_value(app.ui.pulse_phase);
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
let border_color = if is_focused { theme.ui.header } else { theme.ui.border };
@@ -215,6 +255,12 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
} else {
style
};
let style = if (is_staged || has_staged_mute_solo) && !is_cursor && !is_in_range {
let pulsed = pulse_color(fg, bg, pulse * 0.6);
style.fg(pulsed)
} else {
style
};
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
@@ -247,6 +293,7 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
use crate::model::PatternSpeed;
let pulse = pulse_value(app.ui.pulse_phase);
let theme = theme::get();
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
@@ -312,8 +359,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let cursor = app.patterns_nav.pattern_cursor;
let available = inner.height as usize;
// Cursor row takes 2 lines (main + detail); account for 1 extra
let max_visible = available.saturating_sub(1).max(1);
let max_visible = available.max(1);
let scroll_offset = if MAX_PATTERNS <= max_visible {
0
@@ -328,8 +374,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let mut y = inner.y;
for visible_idx in 0..visible_count {
let idx = scroll_offset + visible_idx;
let is_expanded = idx == cursor;
let row_h = if is_expanded { 2u16 } else { 1u16 };
if y >= inner.y + inner.height {
break;
}
@@ -338,7 +382,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
x: inner.x,
y,
width: inner.width,
height: row_h.min(inner.y + inner.height - y),
height: 1u16.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
@@ -424,21 +468,9 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let base_style = Style::new().bg(bg).fg(fg);
let bold_style = base_style.add_modifier(Modifier::BOLD);
let content_area = if is_expanded {
let border_color = if is_focused { theme.selection.cursor } else { theme.ui.unfocused };
let block = Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::new().fg(border_color).bg(bg))
.style(Style::new().bg(bg));
let content = block.inner(row_area);
frame.render_widget(block, row_area);
content
} else {
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
row_area
};
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let content_area = row_area;
let text_area = Rect {
x: content_area.x,
@@ -454,6 +486,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
};
let dim_style = base_style.remove_modifier(Modifier::BOLD);
let is_armed = is_staged_play || is_staged_stop || has_staged_mute || has_staged_solo || has_staged_props;
let (name_style, dim_style) = if is_armed && !is_cursor && !is_in_range {
let pulsed = pulse_color(fg, bg, pulse * 0.6);
(name_style.fg(pulsed), dim_style.fg(pulsed))
} else {
(name_style, dim_style)
};
let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)];
if !name.is_empty() {
spans.push(Span::styled(format!(" {name}"), name_style));
@@ -466,16 +506,38 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
String::new()
};
let props_indicator = if has_staged_props { "~" } else { "" };
let right_info = if content_count > 0 {
format!("{props_indicator}{content_count}/{length}{speed_str}")
let quant_sync = if is_selected {
format!(
"{}:{} ",
pattern.quantization.short_label(),
pattern.sync_mode.short_label()
)
} else {
format!("{props_indicator} {length}{speed_str}")
String::new()
};
let right_info = if content_count > 0 {
format!("{quant_sync}{props_indicator}{content_count}/{length}{speed_str}")
} else {
format!("{quant_sync}{props_indicator} {length}{speed_str}")
};
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let right_width = right_info.chars().count();
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
let gap = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
spans.push(Span::styled(" ".repeat(padding), dim_style));
if let Some(desc) = pattern.description.as_deref().filter(|d| !d.is_empty() && gap > 4) {
let budget = gap - 2;
let char_count = desc.chars().count();
if char_count <= budget {
spans.push(Span::styled(format!(" {desc}"), dim_style));
spans.push(Span::styled(" ".repeat(gap - char_count - 1), dim_style));
} else {
let truncated: String = desc.chars().take(budget - 1).collect();
spans.push(Span::styled(format!(" {truncated}\u{2026}"), dim_style));
spans.push(Span::styled(" ", dim_style));
}
} else {
spans.push(Span::styled(" ".repeat(gap), dim_style));
}
spans.push(Span::styled(right_info, dim_style));
let spans = if is_playing && !is_cursor && !is_in_range {
@@ -488,52 +550,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
if is_expanded && content_area.height >= 2 {
let detail_area = Rect {
x: content_area.x,
y: content_area.y + 1,
width: content_area.width,
height: 1,
};
let right_label = format!(
"{} · {}",
pattern.quantization.label(),
pattern.sync_mode.label()
);
let w = detail_area.width as usize;
let label = if let Some(desc) = &pattern.description {
let right_len = right_label.chars().count();
let max_desc = w.saturating_sub(right_len + 1);
let truncated: String = desc.chars().take(max_desc).collect();
let pad = w.saturating_sub(truncated.chars().count() + right_len);
format!("{truncated}{}{right_label}", " ".repeat(pad))
} else {
format!("{right_label:>w$}")
};
let padded_label = label;
let filled_width = if is_playing {
let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0);
(ratio * detail_area.width as f64).min(detail_area.width as f64) as usize
} else {
0
};
let dim_fg = theme.ui.text_muted;
let progress_bg = theme.list.playing_bg;
let byte_offset = padded_label
.char_indices()
.nth(filled_width)
.map_or(padded_label.len(), |(i, _)| i);
let (left, right) = padded_label.split_at(byte_offset);
let detail_spans = vec![
Span::styled(left.to_string(), Style::new().bg(progress_bg).fg(dim_fg)),
Span::styled(right.to_string(), Style::new().bg(theme.ui.bg).fg(dim_fg)),
];
frame.render_widget(Paragraph::new(Line::from(detail_spans)), detail_area);
}
y += row_area.height;
}

View File

@@ -165,19 +165,11 @@ pub fn render(
}
let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
if body_area.width >= 120 {
let panel_width = body_area.width * 35 / 100;
let [main, side] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
.areas(body_area);
(main, Some(side))
} else {
let panel_height = body_area.height * 40 / 100;
let [main, side] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
.areas(body_area);
(main, Some(side))
}
let panel_width = body_area.width * 35 / 100;
let [main, side] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
.areas(body_area);
(main, Some(side))
} else {
(body_area, None)
};
@@ -560,7 +552,7 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
Page::Patterns => vec![
("Enter", "Select"),
("Space", "Play"),
("c", "Commit"),
("c", "Launch"),
("r", "Rename"),
("?", "Keys"),
],
@@ -715,15 +707,6 @@ fn render_modal(
.border_color(theme.modal.confirm)
.render_centered(frame, term)
}
Modal::JumpToStep(input) => {
let pattern_len = app.current_edit_pattern().length;
let title = format!("Jump to Step (1-{})", pattern_len);
TextInputModal::new(&title, input)
.hint("Enter step number")
.width(30)
.border_color(theme.modal.confirm)
.render_centered(frame, term)
}
Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
.hint("Enter BPM")
.width(30)
@@ -736,12 +719,21 @@ fn render_modal(
.iter()
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
.collect();
FileBrowserModal::new("Add Sample Path", &state.input, &entries)
let hints = hint_line(&[
("\u{2190}", "parent"),
("\u{2192}", "enter"),
("Enter", "add"),
("Esc", "cancel"),
]);
FileBrowserModal::new("Browse Samples", &state.input, &entries)
.selected(state.selected)
.scroll_offset(state.scroll_offset)
.border_color(theme.modal.rename)
.width(60)
.height(18)
.audio_counts(&state.audio_counts)
.hints(hints)
.color_path()
.width(70)
.height(20)
.render_centered(frame, term)
}
Modal::Editor => {
@@ -891,6 +883,9 @@ fn render_modal(
inner
}
Modal::CommandPalette { input, cursor, scroll } => {
render_command_palette(frame, app, input, *cursor, *scroll, term)
}
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
Modal::EuclideanDistribution {
source_step,
@@ -1094,6 +1089,247 @@ fn render_modal_editor(
inner
}
fn render_command_palette(
frame: &mut Frame,
app: &App,
query: &str,
cursor: usize,
scroll: usize,
term: Rect,
) -> Rect {
use crate::model::palette::{palette_entries, CommandEntry};
let theme = theme::get();
let entries = palette_entries(query, app.plugin_mode, app);
// On Main page, numeric input prepends a synthetic "Jump to Step" entry
let jump_step: Option<usize> = if app.page == Page::Main
&& !query.is_empty()
&& query.chars().all(|c| c.is_ascii_digit())
{
query.parse().ok()
} else {
None
};
// Build display rows: each is either a separator header or a command entry
struct DisplayRow<'a> {
entry: Option<&'a CommandEntry>,
separator: Option<&'static str>,
is_jump: bool,
jump_label: String,
}
let mut rows: Vec<DisplayRow> = Vec::new();
if let Some(n) = jump_step {
rows.push(DisplayRow {
entry: None,
separator: None,
is_jump: true,
jump_label: format!("Jump to Step {n}"),
});
}
if query.is_empty() {
// Grouped by category with separators
let mut last_category = "";
for e in &entries {
if e.category != last_category {
rows.push(DisplayRow {
entry: None,
separator: Some(e.category),
is_jump: false,
jump_label: String::new(),
});
last_category = e.category;
}
rows.push(DisplayRow {
entry: Some(e),
separator: None,
is_jump: false,
jump_label: String::new(),
});
}
} else {
for e in &entries {
rows.push(DisplayRow {
entry: Some(e),
separator: None,
is_jump: false,
jump_label: String::new(),
});
}
}
// Count selectable items (non-separator)
let selectable_count = rows.iter().filter(|r| r.separator.is_none()).count();
let cursor = cursor.min(selectable_count.saturating_sub(1));
let width: u16 = 55;
let max_height = (term.height as usize * 60 / 100).max(8);
let content_height = rows.len() + 4; // input + gap + hint + border padding
let height = content_height.min(max_height) as u16;
let inner = ModalFrame::new(": Command Palette")
.width(width)
.height(height)
.border_color(theme.modal.confirm)
.render_centered(frame, term);
let mut y = inner.y;
let content_width = inner.width;
// Input line
let input_line = Line::from(vec![
Span::styled("> ", Style::default().fg(theme.modal.confirm)),
Span::styled(query, Style::default().fg(theme.ui.text_primary)),
Span::styled("\u{2588}", Style::default().fg(theme.modal.confirm)),
]);
frame.render_widget(
Paragraph::new(input_line),
Rect::new(inner.x, y, content_width, 1),
);
y += 1;
// Visible area for entries
let visible_height = inner.height.saturating_sub(2) as usize; // minus input line and hint line
// Auto-scroll
let scroll = {
let mut s = scroll;
// Map cursor (selectable index) to row index for scrolling
let mut selectable_idx = 0;
let mut cursor_row = 0;
for (i, row) in rows.iter().enumerate() {
if row.separator.is_some() {
continue;
}
if selectable_idx == cursor {
cursor_row = i;
break;
}
selectable_idx += 1;
}
if cursor_row >= s + visible_height {
s = cursor_row + 1 - visible_height;
}
if cursor_row < s {
s = cursor_row;
}
s
};
// Render visible rows
let mut selectable_idx = rows.iter().take(scroll).filter(|r| r.separator.is_none()).count();
for row in rows.iter().skip(scroll).take(visible_height) {
if y >= inner.y + inner.height - 1 {
break;
}
if let Some(cat) = row.separator {
// Category header
let pad = content_width.saturating_sub(cat.len() as u16 + 4) / 2;
let sep_left = "\u{2500}".repeat(pad as usize);
let sep_right =
"\u{2500}".repeat(content_width.saturating_sub(pad + cat.len() as u16 + 4) as usize);
let line = Line::from(vec![
Span::styled(
format!("{sep_left} "),
Style::default().fg(theme.ui.text_muted),
),
Span::styled(cat, Style::default().fg(theme.ui.text_dim)),
Span::styled(
format!(" {sep_right}"),
Style::default().fg(theme.ui.text_muted),
),
]);
frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1));
y += 1;
continue;
}
let is_selected = selectable_idx == cursor;
let (bg, fg) = if is_selected {
(theme.selection.cursor_bg, theme.selection.cursor_fg)
} else if selectable_idx.is_multiple_of(2) {
(theme.table.row_even, theme.ui.text_primary)
} else {
(theme.table.row_odd, theme.ui.text_primary)
};
let (name, keybinding) = if row.is_jump {
(row.jump_label.as_str(), "")
} else if let Some(e) = row.entry {
(e.name, e.keybinding)
} else {
selectable_idx += 1;
y += 1;
continue;
};
let key_len = keybinding.len() as u16;
let name_width = content_width.saturating_sub(key_len + 2);
let truncated_name: String = name.chars().take(name_width as usize).collect();
let padding = name_width.saturating_sub(truncated_name.len() as u16);
let key_fg = if is_selected {
theme.selection.cursor_fg
} else {
theme.ui.text_dim
};
let line = Line::from(vec![
Span::styled(format!(" {truncated_name}"), Style::default().bg(bg).fg(fg)),
Span::styled(
" ".repeat(padding as usize),
Style::default().bg(bg),
),
Span::styled(
format!("{keybinding} "),
Style::default().bg(bg).fg(key_fg),
),
]);
frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1));
selectable_idx += 1;
y += 1;
}
// Empty state
if selectable_count == 0 {
let msg = "No matching commands";
let empty_y = inner.y + inner.height / 2;
if empty_y < inner.y + inner.height - 1 {
frame.render_widget(
Paragraph::new(msg)
.style(Style::default().fg(theme.ui.text_muted))
.alignment(Alignment::Center),
Rect::new(inner.x, empty_y, content_width, 1),
);
}
}
// Hint bar
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let hints = if jump_step.is_some() && cursor == 0 {
hint_line(&[
("\u{2191}\u{2193}", "navigate"),
("Enter", "jump to step"),
("Esc", "close"),
])
} else {
hint_line(&[
("\u{2191}\u{2193}", "navigate"),
("Enter", "run"),
("Esc", "close"),
])
};
frame.render_widget(Paragraph::new(hints), hint_area);
inner
}
fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect {
let theme = theme::get();
let width = (term.width * 80 / 100).clamp(60, 100);
@@ -1106,7 +1342,7 @@ fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: R
.border_color(theme.modal.editor)
.render_centered(frame, term);
let bindings = super::keybindings::bindings_for(app.page, app.plugin_mode);
let bindings = crate::model::palette::bindings_for(app.page, app.plugin_mode);
let visible_rows = inner.height.saturating_sub(2) as usize;
let rows: Vec<Row> = bindings

View File

@@ -66,3 +66,6 @@ mod case_statement;
#[path = "forth/harmony.rs"]
mod harmony;
#[path = "forth/map.rs"]
mod map;

55
tests/forth/map.rs Normal file
View File

@@ -0,0 +1,55 @@
use crate::harness::{expect_error, expect_int, expect_stack, run};
use cagire::forth::Value;
#[test]
fn map_add() {
expect_stack(
"1 2 3 4 5 ( 2 + ) map",
&[
Value::Int(3, None),
Value::Int(4, None),
Value::Int(5, None),
Value::Int(6, None),
Value::Int(7, None),
],
);
}
#[test]
fn map_multiply() {
expect_stack(
"1 2 3 ( 10 * ) map",
&[
Value::Int(10, None),
Value::Int(20, None),
Value::Int(30, None),
],
);
}
#[test]
fn map_single_element() {
expect_int("42 ( 1 + ) map", 43);
}
#[test]
fn map_empty_stack() {
run("( 1 + ) map");
}
#[test]
fn map_identity() {
expect_stack(
"1 2 3 ( ) map",
&[
Value::Int(1, None),
Value::Int(2, None),
Value::Int(3, None),
],
);
}
#[test]
fn map_missing_quotation() {
expect_error("1 2 3 map", "expected quotation");
}

View File

@@ -62,33 +62,33 @@ const DL = 'https://dlcagire.raphaelforment.fr';
</tr>
<tr>
<td>macOS (ARM)</td>
<td><a href={`${DL}/cagire-macos-aarch64`}>binary</a></td>
<td><a href={`${DL}/Cagire-aarch64.dmg`}>.dmg</a></td>
<td><a href={`${DL}/cagire-plugins-macos-aarch64.clap`}>CLAP</a> · <a href={`${DL}/cagire-plugins-macos-aarch64.vst3`}>VST3</a></td>
<td><a href={`${DL}/cagire-macos-aarch64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-aarch64-desktop.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-aarch64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-macos-aarch64-vst3.zip`}>VST3</a></td>
</tr>
<tr>
<td>macOS (Intel)</td>
<td><a href={`${DL}/cagire-macos-x86_64`}>binary</a></td>
<td><a href={`${DL}/Cagire-x86_64.dmg`}>.dmg</a></td>
<td><a href={`${DL}/cagire-plugins-macos-x86_64.clap`}>CLAP</a> · <a href={`${DL}/cagire-plugins-macos-x86_64.vst3`}>VST3</a></td>
<td><a href={`${DL}/cagire-macos-x86_64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-x86_64-desktop.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-macos-x86_64-vst3.zip`}>VST3</a></td>
</tr>
<tr>
<td>Linux (x86_64)</td>
<td><a href={`${DL}/cagire-linux-x86_64.AppImage`}>AppImage</a></td>
<td><a href={`${DL}/cagire-desktop-linux-x86_64.AppImage`}>AppImage</a></td>
<td><a href={`${DL}/cagire-plugins-linux-x86_64.clap`}>CLAP</a> · <a href={`${DL}/cagire-plugins-linux-x86_64.vst3`}>VST3</a></td>
<td><a href={`${DL}/cagire-linux-x86_64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-linux-x86_64-desktop.zip`}>zip</a> · <a href={`${DL}/cagire-linux-x86_64-appimage.zip`}>AppImage</a></td>
<td><a href={`${DL}/cagire-linux-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-linux-x86_64-vst3.zip`}>VST3</a></td>
</tr>
<tr>
<td>Linux (ARM)</td>
<td><a href={`${DL}/cagire-linux-aarch64.AppImage`}>AppImage</a></td>
<td><a href={`${DL}/cagire-desktop-linux-aarch64.AppImage`}>AppImage</a></td>
<td><a href={`${DL}/cagire-plugins-linux-aarch64.clap`}>CLAP</a> · <a href={`${DL}/cagire-plugins-linux-aarch64.vst3`}>VST3</a></td>
<td><a href={`${DL}/cagire-linux-aarch64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-linux-aarch64-desktop.zip`}>zip</a></td>
<td><s>CLAP</s> · <s>VST3</s></td>
</tr>
<tr>
<td>Windows (x86_64)</td>
<td><a href={`${DL}/cagire-windows-x86_64.exe`}>.exe</a></td>
<td><a href={`${DL}/cagire-desktop-windows-x86_64.exe`}>.exe</a> · <a href={`${DL}/cagire-windows-x86_64.msi`}>.msi</a></td>
<td><a href={`${DL}/cagire-plugins-windows-x86_64.clap`}>CLAP</a> · <a href={`${DL}/cagire-plugins-windows-x86_64.vst3`}>VST3</a></td>
<td><a href={`${DL}/cagire-windows-x86_64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-desktop-windows-x86_64.zip`}>zip</a> · <s>.msi</s></td>
<td><a href={`${DL}/plugins-windows-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/plugins-windows-x86_64-vst3.zip`}>VST3</a></td>
</tr>
</table>
<p class="note">Source code and issue tracker on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>. You can also compile the software yourself from source!</p>