Words and universal macOS installer

This commit is contained in:
2026-02-06 00:37:08 +01:00
parent 3c518e4c5a
commit f1af4d2cdb
7 changed files with 232 additions and 2 deletions

View File

@@ -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"

View File

@@ -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-<version>-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.

View File

@@ -72,6 +72,8 @@ pub enum Op {
Cycle,
PCycle,
Choose,
Bounce,
WChoose,
ChanceExec,
ProbExec,
Coin,

View File

@@ -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::<f64>() * 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")?;

View File

@@ -66,6 +66,8 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"cycle" => Op::Cycle,
"pcycle" => Op::PCycle,
"choose" => Op::Choose,
"bounce" => Op::Bounce,
"wchoose" => Op::WChoose,
"every" => Op::Every,
"chance" => Op::ChanceExec,
"prob" => Op::ProbExec,

View File

@@ -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: &[],

View File

@@ -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);
}