commit bf388576a377d99476e61a2afe06d5004244aadd Author: Bendik Aagaard Lynghaug Date: Tue Jul 7 17:40:00 2026 +0200 Initial release: Orca VM, TUI, MIDI/OSC/UDP output, and release pipeline diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9065a88 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9caf68e --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..95b170e --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..add8a78 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..30f1f04 --- /dev/null +++ b/README.md @@ -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 ` | Tempo (default 120; one tick = one 16th note) | +| `--seed ` | Random seed for the `R` operator | +| `--udp-port ` / `--udp-host ` | Send `;` strings over UDP (host defaults to 127.0.0.1) | +| `--osc-port ` / `--osc-host ` | Send `=` messages over OSC | +| `--no-midi` | Disable MIDI output (on by default) | +| `--midi-name ` | Connect to an existing MIDI port whose name contains `` | +| `--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`. diff --git a/aur/.SRCINFO b/aur/.SRCINFO new file mode 100644 index 0000000..9742766 --- /dev/null +++ b/aur/.SRCINFO @@ -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 diff --git a/aur/PKGBUILD b/aur/PKGBUILD new file mode 100644 index 0000000..f573ef6 --- /dev/null +++ b/aur/PKGBUILD @@ -0,0 +1,25 @@ +# Maintainer: Bendik Aagaard Lynghaug +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" +} diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..1c30fae --- /dev/null +++ b/release.toml @@ -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" diff --git a/src/bin/orca-cli.rs b/src/bin/orca-cli.rs new file mode 100644 index 0000000..5df16f9 --- /dev/null +++ b/src/bin/orca-cli.rs @@ -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 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 = 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 = 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); + } +} diff --git a/src/io.rs b/src/io.rs new file mode 100644 index 0000000..7d4eb77 --- /dev/null +++ b/src/io.rs @@ -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 { + 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 { + 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, +} + +impl MidiOut { + pub fn list_ports() -> Vec { + 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 { + 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, + pub osc: Option, + pub midi: Option, +} + +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) { + 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 = 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(); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9392ab4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod io; +pub mod sim; +pub mod tui; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6e4aa46 --- /dev/null +++ b/src/main.rs @@ -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 Tempo (default: 120) + --seed Random seed for the R operator (default: 1) + --udp-port Send ';' operator strings over UDP to this port + --udp-host UDP destination host (default: 127.0.0.1) + --osc-port Send '=' operator messages over OSC to this port + --osc-host 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 Connect to the MIDI output port whose name contains + 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, 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 = 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 = None; + let mut osc_host = "127.0.0.1".to_string(); + let mut osc_port: Option = None; + let mut midi = true; + let mut midi_name: Option = 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); + } +} diff --git a/src/sim/field.rs b/src/sim/field.rs new file mode 100644 index 0000000..70a7685 --- /dev/null +++ b/src/sim/field.rs @@ -0,0 +1,145 @@ +use std::io::{self, BufRead, Write}; +use std::fs::File; +use super::{Glyph, Mark}; + +pub struct Field { + pub buffer: Vec, + pub width: u16, + pub height: u16, +} + +pub struct MarkBuffer { + pub buffer: Vec, +} + +#[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 { + let file = File::open(path).map_err(|_| FieldLoadError::CantOpen)?; + let reader = io::BufReader::new(file); + let mut rows: Vec> = Vec::new(); + let mut row_width: Option = None; + + for line in reader.lines() { + let line = line.map_err(|_| FieldLoadError::CantOpen)?; + let row: Vec = 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(&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() + } +} diff --git a/src/sim/gbuffer.rs b/src/sim/gbuffer.rs new file mode 100644 index 0000000..41aa14f --- /dev/null +++ b/src/sim/gbuffer.rs @@ -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; + } + } +} diff --git a/src/sim/mod.rs b/src/sim/mod.rs new file mode 100644 index 0000000..1832141 --- /dev/null +++ b/src/sim/mod.rs @@ -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; diff --git a/src/sim/sim.rs b/src/sim/sim.rs new file mode 100644 index 0000000..c2c54b8 --- /dev/null +++ b/src/sim/sim.rs @@ -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 { + 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, + 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 = 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 = (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, + 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, Vec) { + 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 = 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'); + } +} diff --git a/src/sim/vmio.rs b/src/sim/vmio.rs new file mode 100644 index 0000000..887bece --- /dev/null +++ b/src/sim/vmio.rs @@ -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, + }, + UdpString { + chars: Vec, + }, +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..bfb0a45 --- /dev/null +++ b/src/tui.rs @@ -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, + pub bpm: usize, + pub seed: usize, + pub outputs: EventOutputs, +} + +struct Clip { + data: Vec, + h: usize, + w: usize, +} + +struct Snapshot { + data: Vec, + h: usize, + w: usize, + tick: usize, +} + +struct App { + field: Field, + mbuf: MarkBuffer, + scratch: Vec, + tick: usize, + bpm: usize, + playing: bool, + cur_y: usize, + cur_x: usize, + sel_h: usize, + sel_w: usize, + clipboard: Option, + undo: Vec, + outputs: EventOutputs, + events: Vec, + 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 = 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 { + 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!("{: