From f1af4d2cdb5a15221b87b66e517e655c6a883f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Fri, 6 Feb 2026 00:37:08 +0100 Subject: [PATCH] Words and universal macOS installer --- .github/workflows/ci.yml | 75 ++++++++++++++++++++++++++- CHANGELOG.md | 3 ++ crates/forth/src/ops.rs | 2 + crates/forth/src/vm.rs | 55 ++++++++++++++++++++ crates/forth/src/words/compile.rs | 2 + crates/forth/src/words/sequencing.rs | 20 ++++++++ tests/forth/randomness.rs | 77 ++++++++++++++++++++++++++++ 7 files changed, 232 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a51907d..ee725c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,9 +122,74 @@ jobs: name: ${{ matrix.artifact }}-desktop path: target/${{ matrix.target }}/release/cagire-desktop.exe - release: + universal-macos: needs: build if: startsWith(github.ref, 'refs/tags/v') + runs-on: macos-14 + timeout-minutes: 10 + + steps: + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + pattern: cagire-macos-* + path: artifacts + + - name: Create universal CLI binary + run: | + lipo -create \ + artifacts/cagire-macos-x86_64/cagire \ + artifacts/cagire-macos-aarch64/cagire \ + -output cagire + chmod +x cagire + lipo -info cagire + + - name: Create universal app bundle + run: | + cd artifacts/cagire-macos-aarch64-desktop + unzip Cagire.app.zip + cd ../cagire-macos-x86_64-desktop + unzip Cagire.app.zip + cd ../.. + cp -R artifacts/cagire-macos-aarch64-desktop/Cagire.app Cagire.app + lipo -create \ + artifacts/cagire-macos-x86_64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \ + artifacts/cagire-macos-aarch64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \ + -output Cagire.app/Contents/MacOS/cagire-desktop + lipo -info Cagire.app/Contents/MacOS/cagire-desktop + zip -r Cagire.app.zip Cagire.app + + - name: Build .pkg installer + run: | + VERSION="${GITHUB_REF_NAME#v}" + mkdir -p pkg-root/Applications pkg-root/usr/local/bin + cp -R Cagire.app pkg-root/Applications/ + cp cagire pkg-root/usr/local/bin/ + pkgbuild --root pkg-root --identifier com.sova.cagire \ + --version "$VERSION" --install-location / \ + "Cagire-${VERSION}-universal.pkg" + + - name: Upload universal CLI + uses: actions/upload-artifact@v4 + with: + name: cagire-macos-universal + path: cagire + + - name: Upload universal app bundle + uses: actions/upload-artifact@v4 + with: + name: cagire-macos-universal-desktop + path: Cagire.app.zip + + - name: Upload .pkg installer + uses: actions/upload-artifact@v4 + with: + name: cagire-macos-universal-pkg + path: Cagire-*-universal.pkg + + release: + needs: [build, universal-macos] + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -141,7 +206,13 @@ jobs: mkdir -p release for dir in artifacts/*/; do name=$(basename "$dir") - if [[ "$name" == *-desktop ]]; then + if [[ "$name" == "cagire-macos-universal-pkg" ]]; then + cp "$dir"/*.pkg release/ + elif [[ "$name" == "cagire-macos-universal-desktop" ]]; then + cp "$dir/Cagire.app.zip" "release/cagire-macos-universal-desktop.app.zip" + elif [[ "$name" == "cagire-macos-universal" ]]; then + cp "$dir/cagire" "release/cagire-macos-universal" + elif [[ "$name" == *-desktop ]]; then base="${name%-desktop}" if ls "$dir"/*.deb 1>/dev/null 2>&1; then cp "$dir"/*.deb "release/${base}-desktop.deb" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edcfbe..a10e4f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,10 @@ All notable changes to this project will be documented in this file. ## [0.0.8] - 2026-06-05 ### Added +- Universal macOS `.pkg` installer in CI: combines Intel and Apple Silicon builds into fat binaries via `lipo`, then packages `Cagire.app` and CLI into a single `.pkg` installer. Releases now include `cagire-macos-universal`, `cagire-macos-universal-desktop.app.zip`, and `Cagire--universal.pkg`. - New themes: **Eden** (dark forest — black background with green-only palette, terminal aesthetic) and **Georges** (Commodore 64 palette on pure black background). +- `bounce` word: ping-pong cycle through n items by step runs (e.g., `60 64 67 72 4 bounce` → 60 64 67 72 67 64 60 64...). +- `wchoose` word: weighted random selection from n value/weight pairs (e.g., `60 0.6 64 0.3 67 0.1 3 wchoose`). Supports quotations. ### Improved - Sample library browser: search now shows folder names only (no files) while typing, sorted by fuzzy match score. After confirming search with Enter, folders can be expanded and collapsed normally. Esc clears the search filter before closing the panel. Left arrow on a file collapses the parent folder. Cursor and scroll position stay valid after expand/collapse operations. diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 1643c57..1f92a09 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -72,6 +72,8 @@ pub enum Op { Cycle, PCycle, Choose, + Bounce, + WChoose, ChanceExec, ProbExec, Coin, diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index f343999..a30b668 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -711,6 +711,61 @@ impl Forth { drain_select_run(count, idx, stack, outputs, cmd)?; } + Op::Bounce => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("bounce count must be > 0".into()); + } + let idx = if count == 1 { + 0 + } else { + let period = 2 * (count - 1); + let raw = ctx.runs % period; + if raw < count { raw } else { period - raw } + }; + drain_select_run(count, idx, stack, outputs, cmd)?; + } + + Op::WChoose => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("wchoose count must be > 0".into()); + } + let pairs_needed = count * 2; + if stack.len() < pairs_needed { + return Err("stack underflow".into()); + } + let start = stack.len() - pairs_needed; + let mut values = Vec::with_capacity(count); + let mut weights = Vec::with_capacity(count); + for i in 0..count { + let val = stack[start + i * 2].clone(); + let w = stack[start + i * 2 + 1].as_float()?; + if w < 0.0 { + return Err("wchoose: negative weight".into()); + } + values.push(val); + weights.push(w); + } + stack.truncate(start); + let total: f64 = weights.iter().sum(); + if total <= 0.0 { + return Err("wchoose: total weight must be > 0".into()); + } + let threshold: f64 = self.rng.lock().gen::() * total; + let mut cumulative = 0.0; + let mut selected_idx = count - 1; + for (i, &w) in weights.iter().enumerate() { + cumulative += w; + if threshold < cumulative { + selected_idx = i; + break; + } + } + let selected = values.swap_remove(selected_idx); + select_and_run(selected, stack, outputs, cmd)?; + } + Op::ChanceExec | Op::ProbExec => { let threshold = stack.pop().ok_or("stack underflow")?.as_float()?; let quot = stack.pop().ok_or("stack underflow")?; diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index 5030871..0cfcd32 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -66,6 +66,8 @@ pub(super) fn simple_op(name: &str) -> Option { "cycle" => Op::Cycle, "pcycle" => Op::PCycle, "choose" => Op::Choose, + "bounce" => Op::Bounce, + "wchoose" => Op::WChoose, "every" => Op::Every, "chance" => Op::ChanceExec, "prob" => Op::ProbExec, diff --git a/crates/forth/src/words/sequencing.rs b/crates/forth/src/words/sequencing.rs index b514fa0..e9af1f4 100644 --- a/crates/forth/src/words/sequencing.rs +++ b/crates/forth/src/words/sequencing.rs @@ -103,6 +103,26 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: true, }, + Word { + name: "bounce", + aliases: &[], + category: "Probability", + stack: "(v1..vn n -- selected)", + desc: "Ping-pong cycle through n items by step runs", + example: "60 64 67 72 4 bounce", + compile: Simple, + varargs: true, + }, + Word { + name: "wchoose", + aliases: &[], + category: "Probability", + stack: "(v1 w1 v2 w2 ... n -- selected)", + desc: "Weighted random pick from n value/weight pairs", + example: "60 0.6 64 0.3 67 0.1 3 wchoose", + compile: Simple, + varargs: true, + }, Word { name: "always", aliases: &[], diff --git a/tests/forth/randomness.rs b/tests/forth/randomness.rs index 5304ae8..239b5cd 100644 --- a/tests/forth/randomness.rs +++ b/tests/forth/randomness.rs @@ -176,3 +176,80 @@ fn logrand_requires_positive() { fn rand_equal_bounds() { expect_float("5.0 5.0 rand", 5.0); } + +// bounce + +#[test] +fn bounce_sequence() { + let expected = [60, 64, 67, 72, 67, 64, 60, 64]; + for (runs, &exp) in expected.iter().enumerate() { + let ctx = ctx_with(|c| c.runs = runs); + let f = run_ctx("60 64 67 72 4 bounce", &ctx); + assert_eq!(stack_int(&f), exp, "bounce at runs={}", runs); + } +} + +#[test] +fn bounce_single() { + for runs in 0..5 { + let ctx = ctx_with(|c| c.runs = runs); + let f = run_ctx("42 1 bounce", &ctx); + assert_eq!(stack_int(&f), 42, "bounce single at runs={}", runs); + } +} + +#[test] +fn bounce_zero_count() { + expect_error("1 2 3 0 bounce", "bounce count must be > 0"); +} + +#[test] +fn bounce_underflow() { + expect_error("1 2 5 bounce", "stack underflow"); +} + +// wchoose + +#[test] +fn wchoose_all_weight_one_item() { + for _ in 0..10 { + let f = forth_seeded(42); + f.evaluate("10 0.0 20 1.0 2 wchoose", &default_ctx()) + .unwrap(); + assert_eq!(stack_int(&f), 20); + } +} + +#[test] +fn wchoose_deterministic() { + let f1 = forth_seeded(99); + let f2 = forth_seeded(99); + f1.evaluate("60 0.6 64 0.3 67 0.1 3 wchoose", &default_ctx()) + .unwrap(); + f2.evaluate("60 0.6 64 0.3 67 0.1 3 wchoose", &default_ctx()) + .unwrap(); + assert_eq!(f1.stack(), f2.stack()); +} + +#[test] +fn wchoose_zero_count() { + expect_error("0 wchoose", "wchoose count must be > 0"); +} + +#[test] +fn wchoose_underflow() { + expect_error("10 0.5 2 wchoose", "stack underflow"); +} + +#[test] +fn wchoose_negative_weight() { + expect_error("10 -0.5 20 1.0 2 wchoose", "negative weight"); +} + +#[test] +fn wchoose_quotation() { + let f = forth_seeded(42); + f.evaluate("{ 10 } 0.0 { 20 } 1.0 2 wchoose", &default_ctx()) + .unwrap(); + assert_eq!(stack_int(&f), 20); +}