Initial release: Orca VM, TUI, MIDI/OSC/UDP output, and release pipeline
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Successful in 5m23s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 5m32s
Release / update-aur (push) Successful in 12s

This commit is contained in:
2026-07-07 17:40:00 +02:00
commit bf388576a3
19 changed files with 3009 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
name: Release
on:
push:
tags:
- 'v*'
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
include:
- arch: x86_64
target: x86_64-unknown-linux-gnu
runs-on: ubuntu-latest
- arch: aarch64
target: aarch64-unknown-linux-gnu
runs-on: aarch64
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
if: matrix.arch == 'x86_64'
run: |
sudo apt-get update
sudo apt-get install -y libasound2-dev pkg-config
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-${{ matrix.arch }}-cargo-${{ hashFiles('Cargo.lock') }}
- name: Test
run: cargo test --target ${{ matrix.target }}
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Package
run: |
TAG=${{ gitea.ref_name }}
TARBALL="orca-rs-${TAG}-${{ matrix.arch }}.tar.gz"
mkdir pkg
cp target/${{ matrix.target }}/release/orca-rs pkg/
cp target/${{ matrix.target }}/release/orca-cli pkg/
cp LICENSE pkg/
tar -czf "${TARBALL}" -C pkg .
sha256sum "${TARBALL}" > "${TARBALL}.sha256"
echo "TARBALL=${TARBALL}" >> $GITHUB_ENV
- name: Create release
run: |
curl -sX POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \
--fail-with-body || true
- name: Upload assets
run: |
RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \
| jq -r '.id')
for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do
# Remove any existing asset with the same name so re-runs stay clean
EXISTING=$(curl -s \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
| jq -r ".[] | select(.name == \"${FILE}\") | .id")
for AID in $EXISTING; do
curl -sX DELETE \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}"
done
curl -sX POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \
--data-binary "@${FILE}" --fail-with-body
done
update-aur:
needs: build
runs-on: aarch64
steps:
- uses: actions/checkout@v4
- name: Compute checksums and update PKGBUILD
run: |
TAG=${{ gitea.ref_name }}
BASE="${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/${TAG}"
SUM_X86=$(curl -sL "${BASE}/orca-rs-${TAG}-x86_64.tar.gz" | sha256sum | cut -d' ' -f1)
SUM_AARCH=$(curl -sL "${BASE}/orca-rs-${TAG}-aarch64.tar.gz" | sha256sum | cut -d' ' -f1)
sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD
sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD
- name: Push to AUR
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
run: |
install -dm700 ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
echo "Host aur.archlinux.org" >> ~/.ssh/config
echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config
echo " User aur" >> ~/.ssh/config
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
rm -rf /tmp/aur-orca-rs
git clone ssh://aur@aur.archlinux.org/orca-rs.git /tmp/aur-orca-rs
cp aur/PKGBUILD /tmp/aur-orca-rs/
cd /tmp/aur-orca-rs
makepkg --printsrcinfo > .SRCINFO
git config user.name "Bendik Aagaard Lynghaug"
git config user.email "bendik.lynghaug@gmail.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ gitea.ref_name }}"
git push origin master
+1
View File
@@ -0,0 +1 @@
/target
Generated
+621
View File
@@ -0,0 +1,621 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.13.0",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "coremidi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fa14fb8c3ca83d0d7f22f4afc9ecfe5f40947f01ce639a638a9377c2662dde"
dependencies = [
"block2",
"core-foundation",
"core-foundation-sys",
"coremidi-sys",
]
[[package]]
name = "coremidi-sys"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e"
dependencies = [
"core-foundation-sys",
]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.13.0",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "midir"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56542e359bb7e4bd1a77cb79042be32d4af0713a9ce58160355eaf72df9db87c"
dependencies = [
"alsa",
"bitflags 1.3.2",
"coremidi",
"js-sys",
"libc",
"parking_lot",
"wasm-bindgen",
"web-sys",
"windows",
]
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "orca-rs"
version = "0.1.0"
dependencies = [
"crossterm",
"midir",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "orca-rs"
version = "0.1.0"
edition = "2021"
description = "Orca live coding environment, ported to Rust"
license = "MIT"
repository = "https://prosjekt.klingenbergbygg.no/bl/orca-rs"
[dependencies]
crossterm = "0.28"
midir = "0.10"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bendik Aagaard Lynghaug
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+84
View File
@@ -0,0 +1,84 @@
# orca-rs
A Rust port of [Orca-c](https://github.com/hundredrabbits/Orca-c), the livecoding
environment for procedural sequencing.
## Install
On Arch Linux:
```sh
yay -S orca-rs
```
## Build
```sh
cargo build --release
```
Requires ALSA headers on Linux (`alsa-lib`) for MIDI support.
## Run
```sh
orca-rs [options] [file.orca]
```
With no file, edits save to `untitled.orca`. A named file that doesn't exist
yet is created on first save.
| Option | Effect |
|---|---|
| `--bpm <n>` | Tempo (default 120; one tick = one 16th note) |
| `--seed <n>` | Random seed for the `R` operator |
| `--udp-port <n>` / `--udp-host <h>` | Send `;` strings over UDP (host defaults to 127.0.0.1) |
| `--osc-port <n>` / `--osc-host <h>` | Send `=` messages over OSC |
| `--no-midi` | Disable MIDI output (on by default) |
| `--midi-name <s>` | Connect to an existing MIDI port whose name contains `<s>` |
| `--list-midi` | List MIDI output ports and exit |
MIDI is on by default: orca-rs creates a virtual ALSA sequencer port named
`orca-rs`, which PipeWire exposes through its Midi-Bridge — connect it to a
synth with qpwgraph/Helvum, or find it under `wpctl status`.
There is also a headless runner, equivalent to orca-c's `cli`:
```sh
orca-cli -t 60 file.orca # simulate 60 ticks, print the grid
```
## Keys
| Key | Action |
|---|---|
| Arrow keys | Move cursor (Alt+arrows: leap by 8) |
| Shift+arrows | Grow/shrink selection |
| Ctrl+arrows | Resize grid |
| `0-9 a-z A-Z ! : ; = # * . ?` | Place glyph (fills selection) |
| Backspace / Delete | Erase selection |
| Space | Play / pause |
| Ctrl+F | Step one frame (while paused) |
| Ctrl+R | Reset frame counter |
| `<` / `>` | BPM 1 / +1 |
| `(` / `)` | BPM 10 / +10 |
| Ctrl+Z or Ctrl+U | Undo |
| Ctrl+X / Ctrl+C / Ctrl+V | Cut / copy / paste selection |
| Ctrl+S | Save |
| Esc | Collapse selection |
| Ctrl+Q | Quit |
## Layout
- `src/sim/` — the VM: all operators, ported from orca-c's `sim.c`
- `src/tui.rs` — terminal UI (crossterm), counterpart of `tui_main.c`
- `src/io.rs` — UDP / OSC / MIDI event output
- `src/bin/orca-cli.rs` — headless runner, counterpart of `cli_main.c`
## Releasing
Releases are cut with [cargo-release](https://github.com/crate-ci/cargo-release)
(`cargo release patch|minor`), which bumps `Cargo.toml` and `aur/PKGBUILD` and
pushes a `v*` tag. The Gitea Actions workflow then builds x86_64 and aarch64
tarballs, attaches them to the Gitea release, and pushes the updated PKGBUILD
to the AUR. Required repo secrets: `GITEA_TOKEN`, `AUR_SSH_KEY`.
+19
View File
@@ -0,0 +1,19 @@
pkgbase = orca-rs
pkgdesc = Orca live coding environment, ported to Rust (terminal UI with MIDI/OSC/UDP output)
pkgver = 0.1.0
pkgrel = 1
url = https://prosjekt.klingenbergbygg.no/bl/orca-rs
arch = x86_64
arch = aarch64
license = MIT
depends = alsa-lib
optdepends = pipewire-alsa: expose the MIDI port through PipeWire
provides = orca-rs
conflicts = orca-rs-git
conflicts = orca-rs-bin
source_x86_64 = orca-rs-v0.1.0-x86_64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/orca-rs/releases/download/v0.1.0/orca-rs-v0.1.0-x86_64.tar.gz
sha256sums_x86_64 = SKIP
source_aarch64 = orca-rs-v0.1.0-aarch64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/orca-rs/releases/download/v0.1.0/orca-rs-v0.1.0-aarch64.tar.gz
sha256sums_aarch64 = SKIP
pkgname = orca-rs
+25
View File
@@ -0,0 +1,25 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=orca-rs
pkgver=0.1.0
pkgrel=1
pkgdesc="Orca live coding environment, ported to Rust (terminal UI with MIDI/OSC/UDP output)"
arch=('x86_64' 'aarch64')
url="https://prosjekt.klingenbergbygg.no/bl/orca-rs"
license=('MIT')
depends=('alsa-lib')
optdepends=(
'pipewire-alsa: expose the MIDI port through PipeWire'
)
provides=('orca-rs')
conflicts=('orca-rs-git' 'orca-rs-bin')
source_x86_64=("orca-rs-v${pkgver}-x86_64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/orca-rs/releases/download/v${pkgver}/orca-rs-v${pkgver}-x86_64.tar.gz")
source_aarch64=("orca-rs-v${pkgver}-aarch64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/orca-rs/releases/download/v${pkgver}/orca-rs-v${pkgver}-aarch64.tar.gz")
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
package() {
install -Dm755 orca-rs "$pkgdir/usr/bin/orca-rs"
install -Dm755 orca-cli "$pkgdir/usr/bin/orca-cli"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+12
View File
@@ -0,0 +1,12 @@
publish = false
allow-branch = ["main"]
[[pre-release-replacements]]
file = "aur/PKGBUILD"
search = "pkgver=.*"
replace = "pkgver={{version}}"
[[pre-release-replacements]]
file = "aur/PKGBUILD"
search = "pkgrel=.*"
replace = "pkgrel=1"
+75
View File
@@ -0,0 +1,75 @@
//! Headless runner, equivalent to orca-c's cli_main.c:
//! load a grid file, simulate N ticks, print the resulting grid to stdout.
use std::io::Write;
use std::process::exit;
use orca_rs::sim::{orca_run, Field, MarkBuffer, Oevent};
const USAGE: &str = "\
Usage: orca-cli [options] infile
Options:
-t <n> Number of timesteps to simulate (default: 1)
-h, --help Print this message and exit
";
fn die(msg: &str) -> ! {
eprintln!("{msg}");
exit(1);
}
fn main() {
let mut ticks: usize = 1;
let mut path: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"-t" => {
ticks = args
.next()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| die("-t requires a non-negative integer"));
}
"-h" | "--help" => {
print!("{USAGE}");
return;
}
_ if path.is_none() && !arg.starts_with('-') => path = Some(arg),
_ => die(USAGE),
}
}
let path = path.unwrap_or_else(|| die(USAGE));
let mut field = match Field::load_file(&path) {
Ok(f) => f,
Err(e) => die(&format!("File load error: {}", e.as_str())),
};
let height = field.height as usize;
let width = field.width as usize;
let mut mbuf = MarkBuffer::new();
mbuf.ensure_size(height, width);
let mut events: Vec<Oevent> = Vec::new();
for tick in 0..ticks {
mbuf.clear(height, width);
orca_run(
&mut field.buffer,
&mut mbuf.buffer[..height * width],
height,
width,
tick,
&mut events,
0,
);
events.clear();
}
let stdout = std::io::stdout();
let mut out = stdout.lock();
if field.write_to(&mut out).and_then(|_| out.flush()).is_err() {
exit(1);
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Event output backends: UDP strings (`;`), OSC integer messages (`=`),
//! and MIDI notes/CC/pitchbend (`:` `%` `!` `?`) — the counterpart of the
//! PortMidi/oosc code in orca-c's tui_main.c.
use std::io;
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use midir::{MidiOutput, MidiOutputConnection};
use crate::sim::Oevent;
fn resolve(host: &str, port: u16) -> io::Result<(UdpSocket, SocketAddr)> {
let dest = (host, port)
.to_socket_addrs()?
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot resolve host"))?;
let bind_addr = if dest.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" };
let socket = UdpSocket::bind(bind_addr)?;
Ok((socket, dest))
}
pub struct UdpOut {
socket: UdpSocket,
dest: SocketAddr,
}
impl UdpOut {
pub fn new(host: &str, port: u16) -> io::Result<Self> {
let (socket, dest) = resolve(host, port)?;
Ok(Self { socket, dest })
}
pub fn send(&self, payload: &[u8]) {
let _ = self.socket.send_to(payload, self.dest);
}
}
pub struct OscOut {
socket: UdpSocket,
dest: SocketAddr,
}
impl OscOut {
pub fn new(host: &str, port: u16) -> io::Result<Self> {
let (socket, dest) = resolve(host, port)?;
Ok(Self { socket, dest })
}
pub fn send_ints(&self, path: &str, ints: &[i32]) {
let mut msg = Vec::with_capacity(path.len() + ints.len() * 5 + 8);
msg.extend_from_slice(path.as_bytes());
msg.push(0);
while msg.len() % 4 != 0 {
msg.push(0);
}
msg.push(b',');
msg.extend(std::iter::repeat_n(b'i', ints.len()));
msg.push(0);
while msg.len() % 4 != 0 {
msg.push(0);
}
for i in ints {
msg.extend_from_slice(&i.to_be_bytes());
}
let _ = self.socket.send_to(&msg, self.dest);
}
}
// A sounding note; `remaining` counts down one per tick, note-off at zero.
struct SusNote {
channel: u8,
pitch: u8,
remaining: u8,
}
pub struct MidiOut {
conn: MidiOutputConnection,
sustained: Vec<SusNote>,
}
impl MidiOut {
pub fn list_ports() -> Vec<String> {
match MidiOutput::new("orca-rs") {
Ok(out) => out
.ports()
.iter()
.filter_map(|p| out.port_name(p).ok())
.collect(),
Err(_) => Vec::new(),
}
}
/// With a name hint, connect to the first real port whose name contains it
/// (case-insensitive). Without one, create a virtual ALSA port named
/// "orca-rs" that synths can connect to.
pub fn open(name_hint: Option<&str>) -> Result<Self, String> {
let out = MidiOutput::new("orca-rs").map_err(|e| e.to_string())?;
let conn = match name_hint {
Some(hint) => {
let needle = hint.to_lowercase();
let port = out
.ports()
.into_iter()
.find(|p| {
out.port_name(p)
.map(|n| n.to_lowercase().contains(&needle))
.unwrap_or(false)
})
.ok_or_else(|| format!("no MIDI output port matching '{hint}'"))?;
out.connect(&port, "orca-rs").map_err(|e| e.to_string())?
}
None => {
#[cfg(unix)]
{
use midir::os::unix::VirtualOutput;
out.create_virtual("orca-rs").map_err(|e| e.to_string())?
}
#[cfg(not(unix))]
{
let ports = out.ports();
let port = ports.first().ok_or("no MIDI output ports available")?;
out.connect(port, "orca-rs").map_err(|e| e.to_string())?
}
}
};
Ok(Self { conn, sustained: Vec::new() })
}
fn send(&mut self, msg: &[u8]) {
let _ = self.conn.send(msg);
}
/// Advance one tick: expire sustained notes and emit their note-offs.
pub fn advance_tick(&mut self) {
let mut offs: Vec<(u8, u8)> = Vec::new();
self.sustained.retain_mut(|n| {
n.remaining -= 1;
if n.remaining == 0 {
offs.push((n.channel, n.pitch));
false
} else {
true
}
});
for (channel, pitch) in offs {
self.send(&[0x80 | channel, pitch, 0]);
}
}
pub fn note_on(&mut self, channel: u8, pitch: u8, velocity: u8, duration: u8, mono: bool) {
// Retrigger rules from orca-c: mono kills the whole channel, and a
// repeated pitch is released before sounding again.
let mut offs: Vec<(u8, u8)> = Vec::new();
self.sustained.retain(|n| {
if n.channel == channel && (mono || n.pitch == pitch) {
offs.push((n.channel, n.pitch));
false
} else {
true
}
});
for (ch, p) in offs {
self.send(&[0x80 | ch, p, 0]);
}
self.send(&[0x90 | channel, pitch, velocity]);
self.sustained.push(SusNote {
channel,
pitch,
remaining: duration.max(1),
});
}
pub fn control_change(&mut self, channel: u8, control: u8, value: u8) {
self.send(&[0xB0 | channel, control, value]);
}
pub fn pitch_bend(&mut self, channel: u8, lsb: u8, msb: u8) {
self.send(&[0xE0 | channel, lsb, msb]);
}
/// Release everything immediately (used on pause and on exit).
pub fn all_notes_off(&mut self) {
let notes: Vec<(u8, u8)> = self
.sustained
.drain(..)
.map(|n| (n.channel, n.pitch))
.collect();
for (channel, pitch) in notes {
self.send(&[0x80 | channel, pitch, 0]);
}
}
}
#[derive(Default)]
pub struct EventOutputs {
pub udp: Option<UdpOut>,
pub osc: Option<OscOut>,
pub midi: Option<MidiOut>,
}
impl EventOutputs {
/// Call once at the start of every tick, before running the sim.
pub fn begin_tick(&mut self) {
if let Some(midi) = &mut self.midi {
midi.advance_tick();
}
}
/// Deliver the events produced by one sim tick.
pub fn dispatch(&mut self, events: &mut Vec<Oevent>) {
for ev in events.drain(..) {
match ev {
Oevent::MidiNote { channel, octave, note, velocity, duration, mono } => {
if let Some(midi) = &mut self.midi {
let pitch = (octave as u16 * 12 + note as u16).min(127) as u8;
midi.note_on(channel & 15, pitch, velocity.min(127), duration, mono);
}
}
Oevent::MidiCc { channel, control, value } => {
if let Some(midi) = &mut self.midi {
midi.control_change(channel & 15, control & 0x7f, value & 0x7f);
}
}
Oevent::MidiPb { channel, lsb, msb } => {
if let Some(midi) = &mut self.midi {
midi.pitch_bend(channel & 15, lsb & 0x7f, msb & 0x7f);
}
}
Oevent::OscInts { glyph, numbers } => {
if let Some(osc) = &self.osc {
let path = format!("/{}", glyph as char);
let ints: Vec<i32> = numbers.iter().map(|&n| n as i32).collect();
osc.send_ints(&path, &ints);
}
}
Oevent::UdpString { chars } => {
if let Some(udp) = &self.udp {
udp.send(&chars);
}
}
}
}
}
/// Silence all sustained MIDI notes (pause / quit).
pub fn silence(&mut self) {
if let Some(midi) = &mut self.midi {
midi.all_notes_off();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod io;
pub mod sim;
pub mod tui;
+102
View File
@@ -0,0 +1,102 @@
use std::process::exit;
use orca_rs::io::{EventOutputs, MidiOut, OscOut, UdpOut};
use orca_rs::tui::{self, TuiConfig};
const USAGE: &str = "\
Usage: orca-rs [options] [file.orca]
Open the Orca live-coding environment. If the file does not exist it will
be created on first save; with no file, edits save to untitled.orca.
Options:
--bpm <n> Tempo (default: 120)
--seed <n> Random seed for the R operator (default: 1)
--udp-port <n> Send ';' operator strings over UDP to this port
--udp-host <h> UDP destination host (default: 127.0.0.1)
--osc-port <n> Send '=' operator messages over OSC to this port
--osc-host <h> OSC destination host (default: 127.0.0.1)
--no-midi Disable MIDI output (on by default: creates a virtual
ALSA port named 'orca-rs', visible in PipeWire)
--midi-name <s> Connect to the MIDI output port whose name contains <s>
instead of creating a virtual port
--list-midi List available MIDI output ports and exit
-h, --help Print this message and exit
";
fn die(msg: &str) -> ! {
eprintln!("{msg}");
exit(1);
}
fn parse_num(args: &mut impl Iterator<Item = String>, flag: &str) -> usize {
args.next()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| die(&format!("{flag} requires a number")))
}
fn main() {
let mut path: Option<String> = None;
let mut bpm: usize = 120;
let mut seed: usize = 1;
let mut udp_host = "127.0.0.1".to_string();
let mut udp_port: Option<u16> = None;
let mut osc_host = "127.0.0.1".to_string();
let mut osc_port: Option<u16> = None;
let mut midi = true;
let mut midi_name: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--bpm" => bpm = parse_num(&mut args, "--bpm").clamp(10, 999),
"--seed" => seed = parse_num(&mut args, "--seed"),
"--udp-port" => udp_port = Some(parse_num(&mut args, "--udp-port") as u16),
"--udp-host" => udp_host = args.next().unwrap_or_else(|| die("--udp-host requires a value")),
"--osc-port" => osc_port = Some(parse_num(&mut args, "--osc-port") as u16),
"--osc-host" => osc_host = args.next().unwrap_or_else(|| die("--osc-host requires a value")),
"--midi" => midi = true,
"--no-midi" => midi = false,
"--midi-name" => {
midi = true;
midi_name = Some(args.next().unwrap_or_else(|| die("--midi-name requires a value")));
}
"--list-midi" => {
let ports = MidiOut::list_ports();
if ports.is_empty() {
println!("no MIDI output ports found");
} else {
for p in ports {
println!("{p}");
}
}
return;
}
"-h" | "--help" => {
print!("{USAGE}");
return;
}
_ if path.is_none() && !arg.starts_with('-') => path = Some(arg),
_ => die(USAGE),
}
}
let mut outputs = EventOutputs::default();
if let Some(port) = udp_port {
outputs.udp =
Some(UdpOut::new(&udp_host, port).unwrap_or_else(|e| die(&format!("UDP setup failed: {e}"))));
}
if let Some(port) = osc_port {
outputs.osc =
Some(OscOut::new(&osc_host, port).unwrap_or_else(|e| die(&format!("OSC setup failed: {e}"))));
}
if midi {
outputs.midi = Some(MidiOut::open(midi_name.as_deref()).unwrap_or_else(|e| {
die(&format!("MIDI setup failed: {e} (use --no-midi to run without MIDI)"))
}));
}
if let Err(e) = tui::run(TuiConfig { path, bpm, seed, outputs }) {
die(&e);
}
}
+145
View File
@@ -0,0 +1,145 @@
use std::io::{self, BufRead, Write};
use std::fs::File;
use super::{Glyph, Mark};
pub struct Field {
pub buffer: Vec<Glyph>,
pub width: u16,
pub height: u16,
}
pub struct MarkBuffer {
pub buffer: Vec<Mark>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldLoadError {
CantOpen,
TooManyColumns,
TooManyRows,
NoRowsRead,
NotARectangle,
}
impl FieldLoadError {
pub fn as_str(self) -> &'static str {
match self {
Self::CantOpen => "cannot open file",
Self::TooManyColumns => "too many columns",
Self::TooManyRows => "too many rows",
Self::NoRowsRead => "no rows read",
Self::NotARectangle => "not a rectangle",
}
}
}
impl Field {
pub fn new() -> Self {
Self { buffer: Vec::new(), width: 0, height: 0 }
}
pub fn with_fill(height: usize, width: usize, fill: Glyph) -> Self {
Self {
buffer: vec![fill; height * width],
width: width as u16,
height: height as u16,
}
}
pub fn resize(&mut self, height: usize, width: usize) {
let mut new_buffer = vec![b'.'; height * width];
super::copy_subrect(
&self.buffer,
&mut new_buffer,
self.height as usize,
self.width as usize,
height,
width,
0,
0,
0,
0,
(self.height as usize).min(height),
(self.width as usize).min(width),
);
self.buffer = new_buffer;
self.width = width as u16;
self.height = height as u16;
}
pub fn load_file(path: &str) -> Result<Self, FieldLoadError> {
let file = File::open(path).map_err(|_| FieldLoadError::CantOpen)?;
let reader = io::BufReader::new(file);
let mut rows: Vec<Vec<u8>> = Vec::new();
let mut row_width: Option<usize> = None;
for line in reader.lines() {
let line = line.map_err(|_| FieldLoadError::CantOpen)?;
let row: Vec<u8> = line.trim_end_matches('\r').bytes().collect();
if rows.len() >= u16::MAX as usize {
return Err(FieldLoadError::TooManyRows);
}
if row.len() > u16::MAX as usize {
return Err(FieldLoadError::TooManyColumns);
}
match row_width {
None => row_width = Some(row.len()),
Some(w) if w != row.len() => return Err(FieldLoadError::NotARectangle),
_ => {}
}
rows.push(row);
}
if rows.is_empty() {
return Err(FieldLoadError::NoRowsRead);
}
let width = row_width.unwrap_or(0);
let height = rows.len();
let buffer = rows.into_iter().flatten().collect();
Ok(Self { buffer, width: width as u16, height: height as u16 })
}
pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
let h = self.height as usize;
let wd = self.width as usize;
for iy in 0..h {
w.write_all(&self.buffer[iy * wd..(iy + 1) * wd])?;
writeln!(w)?;
}
Ok(())
}
}
impl Default for Field {
fn default() -> Self {
Self::new()
}
}
impl MarkBuffer {
pub fn new() -> Self {
Self { buffer: Vec::new() }
}
pub fn ensure_size(&mut self, height: usize, width: usize) {
let needed = height * width;
if self.buffer.len() < needed {
self.buffer.resize(needed, 0);
}
}
pub fn clear(&mut self, height: usize, width: usize) {
let n = height * width;
if self.buffer.len() >= n {
self.buffer[..n].fill(0);
}
}
}
impl Default for MarkBuffer {
fn default() -> Self {
Self::new()
}
}
+126
View File
@@ -0,0 +1,126 @@
// Coordinate-heavy signatures deliberately mirror the C gbuffer API.
#![allow(clippy::too_many_arguments)]
use super::{Glyph, Mark};
#[inline]
pub fn peek_rel(
gbuf: &[Glyph],
height: usize,
width: usize,
y: usize,
x: usize,
dy: isize,
dx: isize,
) -> Glyph {
let y0 = y as isize + dy;
let x0 = x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= height || x0 as usize >= width {
return b'.';
}
gbuf[y0 as usize * width + x0 as usize]
}
#[inline]
pub fn poke(gbuf: &mut [Glyph], height: usize, width: usize, y: usize, x: usize, g: Glyph) {
debug_assert!(y < height && x < width);
let _ = height;
gbuf[y * width + x] = g;
}
#[inline]
pub fn poke_rel(
gbuf: &mut [Glyph],
height: usize,
width: usize,
y: usize,
x: usize,
dy: isize,
dx: isize,
g: Glyph,
) {
let y0 = y as isize + dy;
let x0 = x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= height || x0 as usize >= width {
return;
}
gbuf[y0 as usize * width + x0 as usize] = g;
}
#[inline]
pub fn mbuf_poke_rel_or(
mbuf: &mut [Mark],
height: usize,
width: usize,
y: usize,
x: usize,
dy: isize,
dx: isize,
flags: Mark,
) {
let y0 = y as isize + dy;
let x0 = x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= height || x0 as usize >= width {
return;
}
mbuf[y0 as usize * width + x0 as usize] |= flags;
}
pub fn copy_subrect(
src: &[Glyph],
dest: &mut [Glyph],
src_h: usize,
src_w: usize,
dest_h: usize,
dest_w: usize,
src_y: usize,
src_x: usize,
dest_y: usize,
dest_x: usize,
height: usize,
width: usize,
) {
if width == 0 || height == 0 {
return;
}
for row in 0..height {
let sy = src_y + row;
let dy = dest_y + row;
if sy >= src_h || dy >= dest_h {
break;
}
for col in 0..width {
let sx = src_x + col;
let dx = dest_x + col;
if sx >= src_w || dx >= dest_w {
break;
}
dest[dy * dest_w + dx] = src[sy * src_w + sx];
}
}
}
pub fn fill_subrect(
gbuf: &mut [Glyph],
grid_h: usize,
grid_w: usize,
y: usize,
x: usize,
height: usize,
width: usize,
fill: Glyph,
) {
for row in 0..height {
let gy = y + row;
if gy >= grid_h {
break;
}
for col in 0..width {
let gx = x + col;
if gx >= grid_w {
break;
}
gbuf[gy * grid_w + gx] = fill;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
mod field;
mod gbuffer;
mod vmio;
#[allow(clippy::module_inception)]
mod sim;
pub use field::{Field, MarkBuffer, FieldLoadError};
pub use gbuffer::{copy_subrect, fill_subrect};
pub use vmio::Oevent;
pub use sim::orca_run;
pub type Glyph = u8;
pub type Mark = u8;
pub const MARK_INPUT: Mark = 1 << 0;
pub const MARK_OUTPUT: Mark = 1 << 1;
pub const MARK_HASTE: Mark = 1 << 2;
pub const MARK_LOCK: Mark = 1 << 3;
pub const MARK_SLEEP: Mark = 1 << 4;
+832
View File
@@ -0,0 +1,832 @@
use super::vmio::Oevent;
use super::{MARK_HASTE, MARK_INPUT, MARK_LOCK, MARK_OUTPUT, MARK_SLEEP};
use super::gbuffer::{mbuf_poke_rel_or, peek_rel, poke, poke_rel};
// Port flag aliases matching the C macros
const IN: u8 = MARK_INPUT;
const OUT: u8 = MARK_OUTPUT;
const PARAM: u8 = MARK_HASTE;
const NONLOCKING: u8 = MARK_LOCK;
// glyph_table[i] = the glyph for numeric index i (0-35)
const GLYPH_TABLE: [u8; 36] = *b"0123456789abcdefghijklmnopqrstuvwxyz";
// index_table[ascii] = numeric index of that glyph (0-35); 0 for invalid
#[rustfmt::skip]
const INDEX_TABLE: [u8; 128] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 32-47
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, // 48-63
0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, // 64-79
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 0, 0, 0, 0, 0, // 80-95
0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, // 96-111
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 0, 0, 0, 0, 0, // 112-127
];
#[inline]
fn index_of(g: u8) -> usize {
INDEX_TABLE[(g & 0x7f) as usize] as usize
}
#[inline]
fn glyph_of(idx: usize) -> u8 {
GLYPH_TABLE[idx % 36]
}
// Preserve the alphabetic case of `caser` in `g`; digits are returned unchanged.
// Mirrors the C bit-manipulation formula in sim.c.
#[inline]
fn glyph_with_case(g: u8, caser: u8) -> u8 {
const CASE_BIT: u8 = 1 << 5;
const ALPHA_BIT: u8 = 1 << 6;
(g & !CASE_BIT) | ((!g & ALPHA_BIT) >> 1) | (caser & CASE_BIT)
}
// Returns MIDI pitch class (0-11 + optional sharp) for letter glyphs A-Z/a-z.
// Uppercase = natural, lowercase = sharp. A=H, B=I (i.e. C is the base note).
fn midi_note_number_of(g: u8) -> Option<u8> {
let sharp = (g >> 5) & 1;
let g = g & !0x20u8; // make uppercase
if !g.is_ascii_uppercase() {
return None;
}
let deg = if g <= b'B' {
(b'G' - b'B' + g - b'A') as usize
} else {
(g - b'C') as usize
};
let semitones: [u8; 7] = [0, 2, 4, 5, 7, 9, 11];
Some(deg as u8 / 7 * 12 + semitones[deg % 7] + sharp)
}
// ── Operator context ─────────────────────────────────────────────────────────
struct Ctx<'a> {
gbuf: &'a mut [u8],
mbuf: &'a mut [u8],
height: usize,
width: usize,
y: usize,
x: usize,
tick: usize,
vars: &'a mut [u8; 36],
events: &'a mut Vec<Oevent>,
random_seed: usize,
this_char: u8,
}
impl<'a> Ctx<'a> {
#[inline]
fn peek(&self, dy: isize, dx: isize) -> u8 {
peek_rel(self.gbuf, self.height, self.width, self.y, self.x, dy, dx)
}
#[inline]
fn poke(&mut self, dy: isize, dx: isize, g: u8) {
poke_rel(self.gbuf, self.height, self.width, self.y, self.x, dy, dx, g);
}
#[inline]
fn stun(&mut self, dy: isize, dx: isize) {
mbuf_poke_rel_or(self.mbuf, self.height, self.width, self.y, self.x, dy, dx, MARK_SLEEP);
}
// Write glyph and immediately mark it as sleeping (won't execute this tick).
#[inline]
fn poke_stunned(&mut self, dy: isize, dx: isize, g: u8) {
let y0 = self.y as isize + dy;
let x0 = self.x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= self.height || x0 as usize >= self.width {
return;
}
let offs = y0 as usize * self.width + x0 as usize;
self.gbuf[offs] = g;
self.mbuf[offs] |= MARK_SLEEP;
}
// Mark a neighboring cell's port flags. XOR with LOCK matches the C PORT macro:
// INPUT/OUTPUT ports are automatically locked; NONLOCKING cancels that lock.
#[inline]
fn port(&mut self, dy: isize, dx: isize, flags: u8) {
mbuf_poke_rel_or(self.mbuf, self.height, self.width, self.y, self.x, dy, dx, flags ^ MARK_LOCK);
}
#[inline]
fn lock(&mut self, dy: isize, dx: isize) {
mbuf_poke_rel_or(self.mbuf, self.height, self.width, self.y, self.x, dy, dx, MARK_LOCK);
}
#[inline]
fn has_neighboring_bang(&self) -> bool {
let base = self.y * self.width + self.x;
let gbuf = &self.gbuf;
if self.x + 1 < self.width && gbuf[base + 1] == b'*' {
return true;
}
if self.x > 0 && gbuf[base - 1] == b'*' {
return true;
}
if self.y + 1 < self.height && gbuf[base + self.width] == b'*' {
return true;
}
if self.y > 0 && gbuf[base - self.width] == b'*' {
return true;
}
false
}
// Returns true when the operator should bail out early (lowercase but not banged).
#[inline]
fn lowercase_requires_bang(&self) -> bool {
(self.this_char & (1 << 5) != 0) && !self.has_neighboring_bang()
}
// Returns true when the operator should bail out (not banged at all).
#[inline]
fn stop_if_not_banged(&self) -> bool {
!self.has_neighboring_bang()
}
}
// ── Operator implementations ─────────────────────────────────────────────────
fn oper_movement(ctx: &mut Ctx) {
if (ctx.this_char & (1 << 5) != 0) && !ctx.has_neighboring_bang() {
return;
}
let (dy, dx): (isize, isize) = match ctx.this_char | 0x20 {
b'n' => (-1, 0),
b'e' => (0, 1),
b's' => (1, 0),
b'w' => (0, -1),
_ => (0, 0),
};
let y0 = ctx.y as isize + dy;
let x0 = ctx.x as isize + dx;
let cur = ctx.y * ctx.width + ctx.x;
if y0 < 0 || x0 < 0 || y0 as usize >= ctx.height || x0 as usize >= ctx.width {
ctx.gbuf[cur] = b'*';
return;
}
let dest = y0 as usize * ctx.width + x0 as usize;
if ctx.gbuf[dest] == b'.' {
ctx.gbuf[dest] = ctx.this_char;
ctx.gbuf[cur] = b'.';
ctx.mbuf[dest] |= MARK_SLEEP;
} else {
ctx.gbuf[cur] = b'*';
}
}
fn oper_midicc(ctx: &mut Ctx) {
ctx.port(0, 1, IN);
ctx.port(0, 2, IN);
ctx.port(0, 3, IN);
if ctx.stop_if_not_banged() { return; }
let channel_g = ctx.peek(0, 1);
let control_g = ctx.peek(0, 2);
let value_g = ctx.peek(0, 3);
if channel_g == b'.' || control_g == b'.' { return; }
let channel = index_of(channel_g);
if channel > 15 { return; }
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::MidiCc {
channel: channel as u8,
control: index_of(control_g) as u8,
value: (index_of(value_g) * 127 / 35) as u8,
});
}
fn oper_comment(ctx: &mut Ctx) {
let max_x = (ctx.x + 255).min(ctx.width);
for x0 in (ctx.x + 1)..max_x {
ctx.mbuf[ctx.y * ctx.width + x0] |= MARK_LOCK;
if ctx.gbuf[ctx.y * ctx.width + x0] == b'#' {
break;
}
}
}
fn oper_bang(ctx: &mut Ctx) {
poke(ctx.gbuf, ctx.height, ctx.width, ctx.y, ctx.x, b'.');
}
fn oper_midi(ctx: &mut Ctx) {
for i in 1isize..=5 {
ctx.port(0, i, IN);
}
if ctx.stop_if_not_banged() { return; }
let channel_g = ctx.peek(0, 1);
let octave_g = ctx.peek(0, 2);
let note_g = ctx.peek(0, 3);
let velocity_g = ctx.peek(0, 4);
let length_g = ctx.peek(0, 5);
if octave_g == b'.' { return; }
let mut octave_num = index_of(octave_g) as u8;
if octave_num > 9 { octave_num = 9; }
let note_num = match midi_note_number_of(note_g) {
Some(n) => n,
None => return,
};
let channel_num = {
let c = index_of(channel_g);
if c > 15 { 15 } else { c as u8 }
};
let vel_num: u8 = if velocity_g == b'.' {
127
} else {
let v = index_of(velocity_g);
if v == 0 { return; }
let v = v * 8 - 1;
if v > 127 { 127 } else { v as u8 }
};
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::MidiNote {
channel: channel_num,
octave: octave_num,
note: note_num,
velocity: vel_num,
duration: (index_of(length_g) & 0x7f) as u8,
mono: ctx.this_char == b'%',
});
}
fn oper_udp(ctx: &mut Ctx) {
let n = (ctx.width - ctx.x - 1).min(super::vmio::UDP_STR_COUNT);
let row_base = ctx.y * ctx.width + ctx.x + 1;
let mut chars: Vec<u8> = Vec::new();
for i in 0..n {
let g = ctx.gbuf[row_base + i];
if g == b'.' { break; }
chars.push(g);
ctx.mbuf[row_base + i] |= MARK_LOCK;
}
if ctx.stop_if_not_banged() { return; }
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::UdpString { chars });
}
fn oper_osc(ctx: &mut Ctx) {
ctx.port(0, 1, IN | PARAM);
ctx.port(0, 2, IN | PARAM);
let len = index_of(ctx.peek(0, 2)).min(super::vmio::OSC_INT_COUNT);
for i in 0..len {
ctx.port(0, i as isize + 3, IN);
}
if ctx.stop_if_not_banged() { return; }
let g = ctx.peek(0, 1);
if g == b'.' { return; }
ctx.port(0, 0, OUT);
let numbers: Vec<u8> = (0..len)
.map(|i| index_of(ctx.peek(0, i as isize + 3)) as u8)
.collect();
ctx.events.push(Oevent::OscInts { glyph: g, numbers });
}
fn oper_midipb(ctx: &mut Ctx) {
ctx.port(0, 1, IN);
ctx.port(0, 2, IN);
ctx.port(0, 3, IN);
if ctx.stop_if_not_banged() { return; }
let channel_g = ctx.peek(0, 1);
let msb_g = ctx.peek(0, 2);
let lsb_g = ctx.peek(0, 3);
if channel_g == b'.' { return; }
let channel = index_of(channel_g);
if channel > 15 { return; }
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::MidiPb {
channel: channel as u8,
msb: (index_of(msb_g) * 127 / 35) as u8,
lsb: (index_of(lsb_g) * 127 / 35) as u8,
});
}
fn oper_add(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let a = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let g = glyph_of((index_of(a) + index_of(b)) % 36);
ctx.poke(1, 0, glyph_with_case(g, b));
}
fn oper_subtract(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let a = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let val = (index_of(b) as isize - index_of(a) as isize).unsigned_abs();
ctx.poke(1, 0, glyph_with_case(glyph_of(val), b));
}
fn oper_clock(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let b = ctx.peek(0, 1);
let rate = {
let r = index_of(ctx.peek(0, -1));
if r == 0 { 1 } else { r }
};
let mod_num = {
let m = index_of(b);
if m == 0 { 8 } else { m }
};
let g = glyph_of(ctx.tick / rate % mod_num);
ctx.poke(1, 0, glyph_with_case(g, b));
}
fn oper_delay(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let rate = {
let r = index_of(ctx.peek(0, -1));
if r == 0 { 1 } else { r }
};
let mod_num = {
let m = index_of(ctx.peek(0, 1));
if m == 0 { 8 } else { m }
};
let g = if ctx.tick.is_multiple_of(rate * mod_num) { b'*' } else { b'.' };
ctx.poke(1, 0, g);
}
fn oper_if(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let g0 = ctx.peek(0, -1);
let g1 = ctx.peek(0, 1);
ctx.poke(1, 0, if g0 == g1 { b'*' } else { b'.' });
}
fn oper_generator(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let out_x = index_of(ctx.peek(0, -3)) as isize;
let out_y = index_of(ctx.peek(0, -2)) as isize + 1;
let len = index_of(ctx.peek(0, -1)) as isize;
ctx.port(0, -3, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
for i in 0..len {
ctx.port(0, i + 1, IN);
ctx.port(out_y, out_x + i, OUT | NONLOCKING);
let g = ctx.peek(0, i + 1);
ctx.poke_stunned(out_y, out_x + i, g);
}
}
fn oper_halt(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(1, 0, IN | PARAM);
}
fn oper_increment(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, IN | OUT);
let ga = ctx.peek(0, -1);
let gb = ctx.peek(0, 1);
let rate = if ga == b'.' || ga == b'*' { 1 } else { index_of(ga) };
let max = { let m = index_of(gb); if m == 0 { 36 } else { m } };
let val = (index_of(ctx.peek(1, 0)) + rate) % max;
ctx.poke(1, 0, glyph_with_case(glyph_of(val), gb));
}
fn oper_jump(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let g = ctx.peek(-1, 0);
if g == b'J' { return; }
ctx.port(-1, 0, IN);
for i in 1isize..=256 {
if ctx.peek(i, 0) != ctx.this_char {
ctx.port(i, 0, OUT);
ctx.poke(i, 0, g);
break;
}
ctx.stun(i, 0);
}
}
fn oper_konkat(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let len = {
let l = index_of(ctx.peek(0, -1)) as isize;
if l == 0 { 1 } else { l }
};
ctx.port(0, -1, IN | PARAM);
for i in 0..len {
ctx.port(0, i + 1, IN);
let var = ctx.peek(0, i + 1);
if var != b'.' {
let result = ctx.vars[index_of(var)];
ctx.port(1, i + 1, OUT);
ctx.poke(1, i + 1, result);
}
}
}
fn oper_lesser(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let ga = ctx.peek(0, -1);
let gb = ctx.peek(0, 1);
if ga == b'.' || gb == b'.' {
ctx.poke(1, 0, b'.');
} else {
let ia = index_of(ga);
let ib = index_of(gb);
let out = ia.min(ib);
ctx.poke(1, 0, glyph_with_case(glyph_of(out), gb));
}
}
fn oper_multiply(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let a = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let g = glyph_of((index_of(a) * index_of(b)) % 36);
ctx.poke(1, 0, glyph_with_case(g, b));
}
fn oper_offset(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let in_x = index_of(ctx.peek(0, -2)) as isize + 1;
let in_y = index_of(ctx.peek(0, -1)) as isize;
ctx.port(0, -1, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(in_y, in_x, IN);
ctx.port(1, 0, OUT);
let g = ctx.peek(in_y, in_x);
ctx.poke(1, 0, g);
}
fn oper_push(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let key = index_of(ctx.peek(0, -2));
let len = index_of(ctx.peek(0, -1));
ctx.port(0, -1, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(0, 1, IN);
if len == 0 { return; }
let out_x = (key % len) as isize;
for i in 0..len as isize {
ctx.lock(1, i);
}
ctx.port(1, out_x, OUT);
let g = ctx.peek(0, 1);
ctx.poke(1, out_x, g);
}
fn oper_query(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let in_x = index_of(ctx.peek(0, -3)) as isize + 1;
let in_y = index_of(ctx.peek(0, -2)) as isize;
let len = index_of(ctx.peek(0, -1)) as isize;
let out_x = 1 - len;
ctx.port(0, -3, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
for i in 0..len {
ctx.port(in_y, in_x + i, IN);
ctx.port(1, out_x + i, OUT);
let g = ctx.peek(in_y, in_x + i);
ctx.poke(1, out_x + i, g);
}
}
fn oper_random(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let gb = ctx.peek(0, 1);
let a = index_of(ctx.peek(0, -1));
let b = { let b = index_of(gb); if b == 0 { 36 } else { b } };
let (min, max) = if a == b {
ctx.poke(1, 0, glyph_of(a));
return;
} else if a < b {
(a, b)
} else {
(b, a)
};
// 32-bit shift-multiply hash — same algorithm as C sim.c
let mut key = (ctx.random_seed + ctx.y * ctx.width + ctx.x)
^ (ctx.tick << 16);
key = (key ^ 61) ^ (key >> 16);
key = key.wrapping_add(key << 3);
key ^= key >> 4;
key = key.wrapping_mul(0x27d4eb2d);
key ^= key >> 15;
let val = key % (max - min) + min;
ctx.poke(1, 0, glyph_with_case(glyph_of(val), gb));
}
fn oper_track(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let key = index_of(ctx.peek(0, -2));
let len = index_of(ctx.peek(0, -1));
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
if len == 0 { return; }
let read_x = (key % len) as isize + 1;
for i in 0..len as isize {
ctx.lock(0, i + 1);
}
ctx.port(0, read_x, IN);
ctx.port(1, 0, OUT);
let g = ctx.peek(0, read_x);
ctx.poke(1, 0, g);
}
fn oper_uclid(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let left = ctx.peek(0, -1);
let steps = if left == b'.' || left == b'*' { 1 } else { index_of(left) };
let max = { let m = index_of(ctx.peek(0, 1)); if m == 0 { 8 } else { m } };
let bucket = (steps * (ctx.tick + max - 1)) % max + steps;
ctx.poke(1, 0, if bucket >= max { b'*' } else { b'.' });
}
fn oper_variable(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
let left = ctx.peek(0, -1);
let right = ctx.peek(0, 1);
if left != b'.' {
ctx.vars[index_of(left)] = right;
} else if right != b'.' {
ctx.port(1, 0, OUT);
let result = ctx.vars[index_of(right)];
ctx.poke(1, 0, result);
}
}
fn oper_teleport(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let out_x = index_of(ctx.peek(0, -2)) as isize;
let out_y = index_of(ctx.peek(0, -1)) as isize + 1;
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(out_y, out_x, OUT | NONLOCKING);
let g = ctx.peek(0, 1);
ctx.poke_stunned(out_y, out_x, g);
}
fn oper_yump(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let g = ctx.peek(0, -1);
if g == b'Y' { return; }
ctx.port(0, -1, IN);
for i in 1isize..=256 {
if ctx.peek(0, i) != ctx.this_char {
ctx.port(0, i, OUT);
ctx.poke(0, i, g);
break;
}
ctx.stun(0, i);
}
}
fn oper_lerp(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, IN | OUT);
let g = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let rate: isize = if g == b'.' || g == b'*' { 1 } else { index_of(g) as isize };
let goal = index_of(b) as isize;
let val = index_of(ctx.peek(1, 0)) as isize;
let modv = if val <= goal - rate {
rate
} else if val >= goal + rate {
-rate
} else {
goal - val
};
ctx.poke(1, 0, glyph_with_case(glyph_of((val + modv) as usize), b));
}
// ── Dispatch ─────────────────────────────────────────────────────────────────
fn dispatch(ctx: &mut Ctx) {
match ctx.this_char {
b'!' => oper_midicc(ctx),
b'#' => oper_comment(ctx),
b'%' | b':' => oper_midi(ctx),
b'*' => oper_bang(ctx),
b';' => oper_udp(ctx),
b'=' => oper_osc(ctx),
b'?' => oper_midipb(ctx),
b'A' | b'a' => oper_add(ctx),
b'B' | b'b' => oper_subtract(ctx),
b'C' | b'c' => oper_clock(ctx),
b'D' | b'd' => oper_delay(ctx),
b'E' | b'e' => oper_movement(ctx),
b'F' | b'f' => oper_if(ctx),
b'G' | b'g' => oper_generator(ctx),
b'H' | b'h' => oper_halt(ctx),
b'I' | b'i' => oper_increment(ctx),
b'J' | b'j' => oper_jump(ctx),
b'K' | b'k' => oper_konkat(ctx),
b'L' | b'l' => oper_lesser(ctx),
b'M' | b'm' => oper_multiply(ctx),
b'N' | b'n' => oper_movement(ctx),
b'O' | b'o' => oper_offset(ctx),
b'P' | b'p' => oper_push(ctx),
b'Q' | b'q' => oper_query(ctx),
b'R' | b'r' => oper_random(ctx),
b'S' | b's' => oper_movement(ctx),
b'T' | b't' => oper_track(ctx),
b'U' | b'u' => oper_uclid(ctx),
b'V' | b'v' => oper_variable(ctx),
b'W' | b'w' => oper_movement(ctx),
b'X' | b'x' => oper_teleport(ctx),
b'Y' | b'y' => oper_yump(ctx),
b'Z' | b'z' => oper_lerp(ctx),
_ => {}
}
}
// ── Public entry point ────────────────────────────────────────────────────────
/// Run one tick of the Orca VM.
///
/// `gbuf` and `mbuf` must both have length `height * width`.
/// `mbuf` should be zeroed before each call (mark flags are per-tick).
/// Events generated this tick are appended to `events`.
pub fn orca_run(
gbuf: &mut [u8],
mbuf: &mut [u8],
height: usize,
width: usize,
tick: usize,
events: &mut Vec<Oevent>,
random_seed: usize,
) {
debug_assert_eq!(gbuf.len(), height * width);
debug_assert_eq!(mbuf.len(), height * width);
let mut vars = [b'.'; 36];
for iy in 0..height {
for ix in 0..width {
let glyph_char = gbuf[iy * width + ix];
if glyph_char == b'.' {
continue;
}
let cell_flags = mbuf[iy * width + ix] & (MARK_LOCK | MARK_SLEEP);
if cell_flags != 0 {
continue;
}
let mut ctx = Ctx {
gbuf: &mut *gbuf,
mbuf: &mut *mbuf,
height,
width,
y: iy,
x: ix,
tick,
vars: &mut vars,
events,
random_seed,
this_char: glyph_char,
};
dispatch(&mut ctx);
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn run_grid(src: &str, ticks: usize) -> (Vec<u8>, Vec<Oevent>) {
let lines: Vec<&str> = src.lines().collect();
let height = lines.len();
let width = lines.iter().map(|l| l.len()).max().unwrap_or(0);
let mut gbuf: Vec<u8> = vec![b'.'; height * width];
for (iy, line) in lines.iter().enumerate() {
for (ix, b) in line.bytes().enumerate() {
gbuf[iy * width + ix] = b;
}
}
let mut mbuf = vec![0u8; height * width];
let mut events = Vec::new();
for tick in 0..ticks {
mbuf.fill(0);
orca_run(&mut gbuf, &mut mbuf, height, width, tick, &mut events, 1);
}
(gbuf, events)
}
#[test]
fn bang_clears_itself() {
let (grid, _) = run_grid("*", 1);
assert_eq!(grid[0], b'.');
}
#[test]
fn movement_north_hits_wall() {
// N at top row should turn into *
let (grid, _) = run_grid("N..", 1);
assert_eq!(grid[0], b'*');
}
#[test]
fn movement_south_moves_down() {
let (grid, _) = run_grid("S\n.\n.", 1);
// 3×1 grid; S moves to row 1 (index 1)
assert_eq!(grid[0], b'.');
assert_eq!(grid[1], b'S');
}
#[test]
fn movement_east_moves_right() {
let (grid, _) = run_grid("E.", 1);
assert_eq!(grid[0], b'.');
assert_eq!(grid[1], b'E');
}
#[test]
fn clock_basic() {
// .2C. — rate=2, mod=default(8), tick=0 → output=glyph_of(0/2%8)=glyph_of(0)='0'
// C is at x=2, output at (1,2) so the grid needs 2 rows:
let (grid, _) = run_grid(".2C.\n....", 1);
// output cell is row1, col2 = index 1*4+2 = 6
assert_eq!(grid[6], b'0');
}
#[test]
fn add_operator() {
// .A3. where left param='.', right='3' → (0+3)%36 = '3', case of '3' = '3'
// 3A3. — left='3'(=3), right='3'(=3) → (3+3)%36=6='6'
let (grid, _) = run_grid("3A3.\n....", 1);
// output at row1,col1 = index 4+1 = 5
assert_eq!(grid[5], b'6');
}
#[test]
fn delay_fires_at_zero() {
// .D. with rate=1, mod=8 → fires at tick%8==0
// .1D8 — but we need output below
let (grid, _) = run_grid(".1D8\n....", 1);
// tick=0: 0 % (1*8) == 0 → output '*'
assert_eq!(grid[4 + 2], b'*');
}
#[test]
fn midi_note_event() {
// ':' at (0,0) with '*' directly below at (1,0). The '*' is processed
// after row 0, so ':' still sees it as a neighbor during its own tick.
// Params: channel='0'→0, octave='5'→5, note='C'→C natural, vel='.', len='.'
let (_, events) = run_grid(":05C..\n*.....", 1);
assert!(events.iter().any(|e| matches!(e, Oevent::MidiNote { channel: 0, octave: 5, note: 0, .. })));
}
#[test]
fn if_equal_outputs_bang() {
// aFa. — a==a → '*'
let (grid, _) = run_grid("aFa.\n....", 1);
assert_eq!(grid[4 + 1], b'*'); // output at row1,col1
}
#[test]
fn if_unequal_outputs_dot() {
// aFb. — a!=b → '.'
let (grid, _) = run_grid("aFb.\n....", 1);
assert_eq!(grid[4 + 1], b'.');
}
#[test]
fn increment_wraps() {
// .I. with value at row1='9'(=9), no max → max=36
// rate=1, so 9+1=10 → 'a'
let (grid, _) = run_grid(".I.\n.9.", 1);
// output/input at row1,col1 = index 4
assert_eq!(grid[4], b'a');
}
}
+31
View File
@@ -0,0 +1,31 @@
pub const OSC_INT_COUNT: usize = 35;
pub const UDP_STR_COUNT: usize = 16;
#[derive(Debug, Clone, PartialEq)]
pub enum Oevent {
MidiNote {
channel: u8,
octave: u8,
note: u8,
velocity: u8,
duration: u8,
mono: bool,
},
MidiCc {
channel: u8,
control: u8,
value: u8,
},
MidiPb {
channel: u8,
lsb: u8,
msb: u8,
},
OscInts {
glyph: u8,
numbers: Vec<u8>,
},
UdpString {
chars: Vec<u8>,
},
}
+494
View File
@@ -0,0 +1,494 @@
//! Interactive terminal UI — the crossterm counterpart of orca-c's
//! ncurses tui_main.c: grid editor, playback clock, selection, undo,
//! and event delivery to the configured outputs.
use std::fs::File;
use std::io::{self, Write};
use std::time::{Duration, Instant};
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::style::{Attribute, Print, SetAttribute};
use crossterm::terminal::{
self, disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType,
EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::{execute, queue};
use crate::io::EventOutputs;
use crate::sim::{
copy_subrect, fill_subrect, orca_run, Field, MarkBuffer, Oevent, MARK_HASTE, MARK_INPUT,
MARK_LOCK, MARK_OUTPUT, MARK_SLEEP,
};
const MAX_UNDO: usize = 256;
const MAX_DIM: usize = 512;
const HELP_LINE: &str =
"space play ^F step ^S save ^Z undo ^X/^C/^V clip shift+arrows select ^arrows resize < > ( ) bpm ^Q quit";
pub struct TuiConfig {
pub path: Option<String>,
pub bpm: usize,
pub seed: usize,
pub outputs: EventOutputs,
}
struct Clip {
data: Vec<u8>,
h: usize,
w: usize,
}
struct Snapshot {
data: Vec<u8>,
h: usize,
w: usize,
tick: usize,
}
struct App {
field: Field,
mbuf: MarkBuffer,
scratch: Vec<u8>,
tick: usize,
bpm: usize,
playing: bool,
cur_y: usize,
cur_x: usize,
sel_h: usize,
sel_w: usize,
clipboard: Option<Clip>,
undo: Vec<Snapshot>,
outputs: EventOutputs,
events: Vec<Oevent>,
seed: usize,
path: String,
msg: String,
needs_preview: bool,
}
fn is_valid_glyph(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '!' | '#' | '%' | '*' | '.' | ':' | ';' | '=' | '?')
}
fn tick_period(bpm: usize) -> Duration {
// One Orca frame is a 16th note: 60000 ms / bpm / 4.
Duration::from_millis((60_000 / bpm.max(1) / 4).max(1) as u64)
}
impl App {
fn dims(&self) -> (usize, usize) {
(self.field.height as usize, self.field.width as usize)
}
fn clamp_cursor(&mut self) {
let (h, w) = self.dims();
self.cur_y = self.cur_y.min(h - 1);
self.cur_x = self.cur_x.min(w - 1);
self.sel_h = self.sel_h.clamp(1, h - self.cur_y);
self.sel_w = self.sel_w.clamp(1, w - self.cur_x);
}
fn do_tick(&mut self) {
let (h, w) = self.dims();
self.mbuf.ensure_size(h, w);
self.mbuf.clear(h, w);
self.outputs.begin_tick();
orca_run(
&mut self.field.buffer,
&mut self.mbuf.buffer[..h * w],
h,
w,
self.tick,
&mut self.events,
self.seed,
);
self.outputs.dispatch(&mut self.events);
self.tick += 1;
self.needs_preview = false;
}
// Run the sim on a scratch copy so port highlighting stays live while
// paused, without mutating the grid or emitting events.
fn preview(&mut self) {
let (h, w) = self.dims();
self.mbuf.ensure_size(h, w);
self.mbuf.clear(h, w);
self.scratch.clear();
self.scratch.extend_from_slice(&self.field.buffer);
let mut discarded: Vec<Oevent> = Vec::new();
orca_run(
&mut self.scratch,
&mut self.mbuf.buffer[..h * w],
h,
w,
self.tick,
&mut discarded,
self.seed,
);
self.needs_preview = false;
}
fn push_undo(&mut self) {
if self.undo.len() >= MAX_UNDO {
self.undo.remove(0);
}
let (h, w) = self.dims();
self.undo.push(Snapshot {
data: self.field.buffer.clone(),
h,
w,
tick: self.tick,
});
}
fn undo(&mut self) {
if let Some(s) = self.undo.pop() {
self.field.buffer = s.data;
self.field.height = s.h as u16;
self.field.width = s.w as u16;
self.tick = s.tick;
self.clamp_cursor();
self.needs_preview = true;
self.msg = "undid".into();
} else {
self.msg = "nothing to undo".into();
}
}
fn fill_selection(&mut self, g: u8) {
self.push_undo();
let (h, w) = self.dims();
fill_subrect(
&mut self.field.buffer,
h,
w,
self.cur_y,
self.cur_x,
self.sel_h,
self.sel_w,
g,
);
self.needs_preview = true;
}
fn copy_selection(&mut self) {
let (h, w) = self.dims();
let mut data = vec![b'.'; self.sel_h * self.sel_w];
copy_subrect(
&self.field.buffer,
&mut data,
h,
w,
self.sel_h,
self.sel_w,
self.cur_y,
self.cur_x,
0,
0,
self.sel_h,
self.sel_w,
);
self.clipboard = Some(Clip {
data,
h: self.sel_h,
w: self.sel_w,
});
self.msg = "copied".into();
}
fn paste(&mut self) {
let Some(clip) = self.clipboard.take() else {
self.msg = "clipboard empty".into();
return;
};
self.push_undo();
let (h, w) = self.dims();
copy_subrect(
&clip.data,
&mut self.field.buffer,
clip.h,
clip.w,
h,
w,
0,
0,
self.cur_y,
self.cur_x,
clip.h,
clip.w,
);
self.msg = "pasted".into();
self.clipboard = Some(clip);
self.needs_preview = true;
}
fn resize_grid(&mut self, dy: isize, dx: isize) {
let (h, w) = self.dims();
let nh = (h as isize + dy).clamp(1, MAX_DIM as isize) as usize;
let nw = (w as isize + dx).clamp(1, MAX_DIM as isize) as usize;
if (nh, nw) == (h, w) {
return;
}
self.push_undo();
self.field.resize(nh, nw);
self.mbuf.ensure_size(nh, nw);
self.clamp_cursor();
self.needs_preview = true;
self.msg = format!("grid {nw}x{nh}");
}
fn save(&mut self) {
let result = File::create(&self.path).and_then(|mut f| self.field.write_to(&mut f));
self.msg = match result {
Ok(()) => format!("saved {}", self.path),
Err(e) => format!("save failed: {e}"),
};
}
}
// Restore the terminal even if we exit via error or panic.
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, Show);
}
}
pub fn run(cfg: TuiConfig) -> Result<(), String> {
let (path, field) = match cfg.path {
Some(p) => {
if std::path::Path::new(&p).exists() {
let f = Field::load_file(&p)
.map_err(|e| format!("cannot load {p}: {}", e.as_str()))?;
(p, f)
} else {
(p, default_field()?)
}
}
None => ("untitled.orca".to_string(), default_field()?),
};
let mut app = App {
mbuf: MarkBuffer::new(),
scratch: Vec::new(),
tick: 0,
bpm: cfg.bpm,
playing: false,
cur_y: 0,
cur_x: 0,
sel_h: 1,
sel_w: 1,
clipboard: None,
undo: Vec::new(),
outputs: cfg.outputs,
events: Vec::new(),
seed: cfg.seed,
path,
msg: String::new(),
needs_preview: true,
field,
};
enable_raw_mode().map_err(|e| e.to_string())?;
let _guard = TermGuard;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, Hide).map_err(|e| e.to_string())?;
main_loop(&mut app, &mut stdout).map_err(|e| e.to_string())?;
app.outputs.silence();
Ok(())
}
fn default_field() -> Result<Field, String> {
let (cols, rows) = terminal::size().unwrap_or((80, 25));
let w = (cols as usize).clamp(20, 200);
let h = (rows.saturating_sub(3) as usize).clamp(10, 200);
Ok(Field::with_fill(h, w, b'.'))
}
fn main_loop(app: &mut App, stdout: &mut io::Stdout) -> io::Result<()> {
let mut next_tick = Instant::now();
loop {
if app.needs_preview && !app.playing {
app.preview();
}
draw(stdout, app)?;
let timeout = if app.playing {
let now = Instant::now();
if now >= next_tick {
app.do_tick();
next_tick += tick_period(app.bpm);
if next_tick < now {
next_tick = now + tick_period(app.bpm);
}
continue;
}
next_tick - now
} else {
Duration::from_millis(200)
};
if event::poll(timeout)? {
match event::read()? {
Event::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !handle_key(app, key, &mut next_tick) =>
{
return Ok(());
}
_ => {}
}
}
}
}
// Returns false when the app should quit.
fn handle_key(app: &mut App, key: KeyEvent, next_tick: &mut Instant) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let (h, w) = app.dims();
match key.code {
KeyCode::Char('q') if ctrl => return false,
KeyCode::Char('s') if ctrl => app.save(),
KeyCode::Char('z') | KeyCode::Char('u') if ctrl => app.undo(),
KeyCode::Char('c') if ctrl => app.copy_selection(),
KeyCode::Char('x') if ctrl => {
app.copy_selection();
app.fill_selection(b'.');
app.msg = "cut".into();
}
KeyCode::Char('v') if ctrl => app.paste(),
KeyCode::Char('f') if ctrl
&& !app.playing => {
app.do_tick();
app.needs_preview = true;
}
KeyCode::Char('r') if ctrl => {
app.tick = 0;
app.needs_preview = true;
app.msg = "frame reset".into();
}
KeyCode::Char(' ') => {
app.playing = !app.playing;
if app.playing {
*next_tick = Instant::now();
} else {
app.outputs.silence();
app.needs_preview = true;
}
}
KeyCode::Char('<') => app.bpm = (app.bpm.saturating_sub(1)).max(10),
KeyCode::Char('>') => app.bpm = (app.bpm + 1).min(999),
KeyCode::Char('(') => app.bpm = (app.bpm.saturating_sub(10)).max(10),
KeyCode::Char(')') => app.bpm = (app.bpm + 10).min(999),
KeyCode::Esc => {
app.sel_h = 1;
app.sel_w = 1;
app.msg.clear();
}
KeyCode::Backspace | KeyCode::Delete => app.fill_selection(b'.'),
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
let (dy, dx): (isize, isize) = match key.code {
KeyCode::Up => (-1, 0),
KeyCode::Down => (1, 0),
KeyCode::Left => (0, -1),
_ => (0, 1),
};
if ctrl {
app.resize_grid(dy, dx);
} else if shift {
app.sel_h = (app.sel_h as isize + dy).clamp(1, (h - app.cur_y) as isize) as usize;
app.sel_w = (app.sel_w as isize + dx).clamp(1, (w - app.cur_x) as isize) as usize;
} else {
let step: isize = if alt { 8 } else { 1 };
app.cur_y = (app.cur_y as isize + dy * step).clamp(0, h as isize - 1) as usize;
app.cur_x = (app.cur_x as isize + dx * step).clamp(0, w as isize - 1) as usize;
app.clamp_cursor();
}
}
KeyCode::Char(c) if !ctrl && !alt && is_valid_glyph(c) => {
app.fill_selection(c as u8);
}
_ => {}
}
true
}
fn draw(out: &mut io::Stdout, app: &App) -> io::Result<()> {
let (tw, th) = terminal::size()?;
let (gh, gw) = app.dims();
let grid_rows = th.saturating_sub(2) as usize;
let vis_h = gh.min(grid_rows);
let vis_w = gw.min(tw as usize);
queue!(out, BeginSynchronizedUpdate, Hide, Clear(ClearType::All))?;
for y in 0..vis_h {
queue!(out, MoveTo(0, y as u16))?;
for x in 0..vis_w {
let g = app.field.buffer[y * gw + x];
let m = app.mbuf.buffer.get(y * gw + x).copied().unwrap_or(0);
let is_cursor = y == app.cur_y && x == app.cur_x;
let in_sel = y >= app.cur_y
&& y < app.cur_y + app.sel_h
&& x >= app.cur_x
&& x < app.cur_x + app.sel_w;
let ch = if g == b'.' {
if is_cursor {
'@'
} else if y % 8 == 0 && x % 8 == 0 {
'+'
} else {
'.'
}
} else {
g as char
};
queue!(out, SetAttribute(Attribute::Reset))?;
if is_cursor {
queue!(out, SetAttribute(Attribute::Reverse), SetAttribute(Attribute::Bold))?;
} else if in_sel {
queue!(out, SetAttribute(Attribute::Reverse))?;
} else if m & MARK_OUTPUT != 0 {
queue!(out, SetAttribute(Attribute::Reverse))?;
} else if m & (MARK_INPUT | MARK_HASTE) != 0 {
queue!(out, SetAttribute(Attribute::Bold))?;
} else if g == b'.' || m & (MARK_LOCK | MARK_SLEEP) != 0 {
queue!(out, SetAttribute(Attribute::Dim))?;
}
queue!(out, Print(ch))?;
}
}
let state = if app.playing { "playing" } else { "paused" };
let status = format!(
"{} {}f {}bpm {} {},{} {}x{} {}",
app.path, app.tick, app.bpm, state, app.cur_x, app.cur_y, gw, gh, app.msg
);
let mut line1: String = status.chars().take(tw as usize).collect();
line1 = format!("{:<width$}", line1, width = tw as usize);
let help: String = HELP_LINE.chars().take(tw as usize).collect();
queue!(
out,
SetAttribute(Attribute::Reset),
MoveTo(0, th.saturating_sub(2)),
SetAttribute(Attribute::Reverse),
Print(line1),
SetAttribute(Attribute::Reset),
MoveTo(0, th.saturating_sub(1)),
SetAttribute(Attribute::Dim),
Print(help),
SetAttribute(Attribute::Reset),
EndSynchronizedUpdate
)?;
out.flush()
}