Feat: lots of convenience stuff

This commit is contained in:
2026-02-24 00:52:40 +01:00
parent 78b20b5ff9
commit 7632bc76f7
15 changed files with 440 additions and 26 deletions

View File

@@ -143,6 +143,19 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
ops.push(Op::Quotation(Arc::from(quote_ops), Some(body_span)));
} else if word == "}" {
return Err("unexpected }".into());
} else if word == "[" {
let (bracket_ops, consumed, end_span) =
compile_bracket(&tokens[i + 1..], dict)?;
i += consumed;
ops.push(Op::Mark);
ops.extend(bracket_ops);
let count_span = SourceSpan {
start: span.start,
end: end_span.end,
};
ops.push(Op::Count(Some(count_span)));
} else if word == "]" {
return Err("unexpected ]".into());
} else if word == ":" {
let (consumed, name, body) = compile_colon_def(&tokens[i + 1..], dict)?;
i += consumed;
@@ -211,6 +224,38 @@ fn compile_quotation(
Ok((quote_ops, end_idx + 1, end_span))
}
fn compile_bracket(
tokens: &[Token],
dict: &Dictionary,
) -> Result<(Vec<Op>, usize, SourceSpan), String> {
let mut depth = 1;
let mut end_idx = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok {
match w.as_str() {
"[" => depth += 1,
"]" => {
depth -= 1;
if depth == 0 {
end_idx = Some(i);
break;
}
}
_ => {}
}
}
}
let end_idx = end_idx.ok_or("missing ]")?;
let end_span = match &tokens[end_idx] {
Token::Word(_, span) => *span,
_ => unreachable!(),
};
let body_ops = compile(&tokens[..end_idx], dict)?;
Ok((body_ops, end_idx + 1, end_span))
}
fn token_span(tok: &Token) -> Option<SourceSpan> {
match tok {
Token::Int(_, s) | Token::Float(_, s) | Token::Str(_, s) | Token::Word(_, s) => Some(*s),

View File

@@ -136,4 +136,8 @@ pub enum Op {
MidiStart,
MidiStop,
MidiContinue,
// Bracket syntax (mark/count for auto-counting)
Mark,
Count(Option<SourceSpan>),
Index(Option<SourceSpan>),
}

View File

@@ -140,6 +140,7 @@ impl Forth {
var_writes: &mut HashMap<String, Value>,
) -> Result<(), String> {
let mut pc = 0;
let mut marks: Vec<usize> = Vec::new();
let trace_cell = std::cell::RefCell::new(trace);
let var_writes_cell = std::cell::RefCell::new(Some(var_writes));
@@ -1541,6 +1542,29 @@ impl Forth {
.unwrap_or(0);
stack.push(Value::Int(val as i64, None));
}
Op::Mark => {
marks.push(stack.len());
}
Op::Count(span) => {
let mark = marks.pop().ok_or("count without mark")?;
stack.push(Value::Int((stack.len() - mark) as i64, *span));
}
Op::Index(word_span) => {
let idx = pop_int(stack)?;
let count = pop_int(stack)? as usize;
if count == 0 {
return Err("index count must be > 0".into());
}
let resolved_idx = ((idx % count as i64 + count as i64) % count as i64) as usize;
if let Some(span) = word_span {
if stack.len() >= count {
let start = stack.len() - count;
let selected = &stack[start + resolved_idx];
record_resolved_from_value(&trace_cell, Some(*span), selected);
}
}
drain_select_run(count, resolved_idx, stack, outputs, cmd)?;
}
Op::Forget => {
let name = pop(stack)?;
self.dict.lock().remove(name.as_str()?);

View File

@@ -110,6 +110,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"mstop" => Op::MidiStop,
"mcont" => Op::MidiContinue,
"forget" => Op::Forget,
"index" => Op::Index(None),
"key!" => Op::SetKey,
"tp" => Op::Transpose,
"inv" => Op::Invert,
@@ -205,7 +206,8 @@ fn attach_span(op: &mut Op, span: SourceSpan) {
| Op::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s)
| Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s)
| Op::Every(s)
| Op::Bjork(s) | Op::PBjork(s) => *s = Some(span),
| Op::Bjork(s) | Op::PBjork(s)
| Op::Count(s) | Op::Index(s) => *s = Some(span),
_ => {}
}
}

View File

@@ -113,6 +113,16 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple,
varargs: true,
},
Word {
name: "index",
aliases: &[],
category: "Probability",
stack: "(v1..vn n idx -- selected)",
desc: "Select item at explicit index",
example: "[ c4 e4 g4 ] step index",
compile: Simple,
varargs: true,
},
Word {
name: "wchoose",
aliases: &[],

View File

@@ -0,0 +1,39 @@
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (45, 53, 59),
surface: (52, 62, 68),
surface2: (68, 80, 86),
fg: (211, 198, 170),
fg_dim: (135, 131, 116),
fg_muted: (80, 80, 68),
accent: (167, 192, 128),
red: (230, 126, 128),
green: (167, 192, 128),
yellow: (219, 188, 127),
blue: (127, 187, 179),
purple: (214, 153, 182),
cyan: (131, 192, 146),
orange: (230, 152, 117),
tempo_color: (214, 153, 182),
bank_color: (127, 187, 179),
pattern_color: (131, 192, 146),
title_accent: (167, 192, 128),
title_author: (127, 187, 179),
secondary: (230, 152, 117),
link_bright: [
(167, 192, 128), (214, 153, 182), (230, 152, 117),
(127, 187, 179), (219, 188, 127),
],
link_dim: [
(56, 66, 46), (70, 52, 62), (72, 52, 42),
(44, 64, 60), (70, 62, 44),
],
sparkle: [
(167, 192, 128), (230, 152, 117), (131, 192, 146),
(214, 153, 182), (219, 188, 127),
],
meter: [(148, 172, 110), (200, 170, 108), (210, 108, 110)],
}
}

View File

@@ -0,0 +1,39 @@
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (28, 22, 18),
surface: (42, 33, 26),
surface2: (58, 46, 36),
fg: (240, 228, 210),
fg_dim: (170, 150, 130),
fg_muted: (100, 82, 66),
accent: (230, 60, 20),
red: (220, 38, 32),
green: (30, 170, 80),
yellow: (255, 210, 0),
blue: (20, 80, 200),
purple: (170, 40, 150),
cyan: (0, 150, 180),
orange: (240, 120, 0),
tempo_color: (230, 60, 20),
bank_color: (20, 80, 200),
pattern_color: (0, 150, 180),
title_accent: (230, 60, 20),
title_author: (20, 80, 200),
secondary: (170, 40, 150),
link_bright: [
(230, 60, 20), (20, 80, 200), (240, 120, 0),
(0, 150, 180), (30, 170, 80),
],
link_dim: [
(72, 24, 10), (10, 28, 65), (76, 40, 6),
(6, 48, 58), (14, 54, 28),
],
sparkle: [
(230, 60, 20), (255, 210, 0), (30, 170, 80),
(20, 80, 200), (170, 40, 150),
],
meter: [(26, 152, 72), (235, 190, 0), (200, 34, 28)],
}
}

View File

@@ -0,0 +1,39 @@
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (22, 24, 33),
surface: (30, 33, 46),
surface2: (45, 48, 64),
fg: (198, 200, 209),
fg_dim: (109, 112, 126),
fg_muted: (64, 66, 78),
accent: (132, 160, 198),
red: (226, 120, 120),
green: (180, 190, 130),
yellow: (226, 164, 120),
blue: (132, 160, 198),
purple: (160, 147, 199),
cyan: (137, 184, 194),
orange: (226, 164, 120),
tempo_color: (160, 147, 199),
bank_color: (132, 160, 198),
pattern_color: (137, 184, 194),
title_accent: (132, 160, 198),
title_author: (160, 147, 199),
secondary: (226, 164, 120),
link_bright: [
(132, 160, 198), (160, 147, 199), (226, 164, 120),
(137, 184, 194), (180, 190, 130),
],
link_dim: [
(45, 55, 70), (55, 50, 68), (70, 55, 42),
(46, 62, 66), (58, 62, 44),
],
sparkle: [
(132, 160, 198), (226, 164, 120), (180, 190, 130),
(160, 147, 199), (226, 120, 120),
],
meter: [(160, 175, 115), (210, 150, 105), (200, 105, 105)],
}
}

View File

@@ -0,0 +1,39 @@
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (30, 24, 22),
surface: (44, 36, 32),
surface2: (60, 48, 42),
fg: (238, 222, 200),
fg_dim: (165, 145, 125),
fg_muted: (95, 78, 65),
accent: (210, 90, 100),
red: (200, 44, 52),
green: (30, 160, 120),
yellow: (240, 180, 20),
blue: (60, 60, 180),
purple: (150, 50, 120),
cyan: (0, 155, 155),
orange: (220, 120, 50),
tempo_color: (210, 90, 100),
bank_color: (60, 60, 180),
pattern_color: (0, 155, 155),
title_accent: (210, 90, 100),
title_author: (60, 60, 180),
secondary: (220, 120, 50),
link_bright: [
(210, 90, 100), (60, 60, 180), (220, 120, 50),
(0, 155, 155), (30, 160, 120),
],
link_dim: [
(66, 30, 34), (22, 22, 58), (70, 40, 18),
(6, 48, 48), (12, 50, 38),
],
sparkle: [
(210, 90, 100), (240, 180, 20), (30, 160, 120),
(60, 60, 180), (150, 50, 120),
],
meter: [(26, 144, 106), (222, 164, 18), (184, 40, 46)],
}
}

View File

@@ -8,17 +8,22 @@ mod catppuccin_mocha;
mod dracula;
mod eden;
mod ember;
mod everforest;
mod georges;
mod fairyfloss;
mod gruvbox_dark;
mod hot_dog_stand;
mod iceberg;
mod jaipur;
mod kanagawa;
mod letz_light;
mod monochrome_black;
mod monochrome_white;
mod monokai;
mod nord;
mod fauve;
mod pitch_black;
mod tropicalia;
mod rose_pine;
mod tokyo_night;
pub mod transform;
@@ -51,6 +56,11 @@ pub const THEMES: &[ThemeEntry] = &[
ThemeEntry { id: "Ember", label: "Ember", palette: ember::palette },
ThemeEntry { id: "Eden", label: "Eden", palette: eden::palette },
ThemeEntry { id: "Georges", label: "Georges", palette: georges::palette },
ThemeEntry { id: "Iceberg", label: "Iceberg", palette: iceberg::palette },
ThemeEntry { id: "Everforest", label: "Everforest", palette: everforest::palette },
ThemeEntry { id: "Fauve", label: "Fauve", palette: fauve::palette },
ThemeEntry { id: "Tropicalia", label: "Tropicalia", palette: tropicalia::palette },
ThemeEntry { id: "Jaipur", label: "Jaipur", palette: jaipur::palette },
];
thread_local! {

View File

@@ -0,0 +1,39 @@
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (20, 26, 22),
surface: (30, 40, 34),
surface2: (44, 56, 48),
fg: (235, 225, 200),
fg_dim: (155, 145, 120),
fg_muted: (85, 80, 62),
accent: (230, 50, 120),
red: (240, 70, 70),
green: (80, 200, 50),
yellow: (255, 195, 0),
blue: (0, 160, 200),
purple: (180, 60, 180),
cyan: (0, 200, 170),
orange: (255, 140, 30),
tempo_color: (230, 50, 120),
bank_color: (0, 160, 200),
pattern_color: (0, 200, 170),
title_accent: (230, 50, 120),
title_author: (0, 160, 200),
secondary: (255, 140, 30),
link_bright: [
(230, 50, 120), (0, 160, 200), (255, 140, 30),
(0, 200, 170), (80, 200, 50),
],
link_dim: [
(72, 20, 40), (6, 50, 64), (80, 44, 12),
(6, 62, 54), (26, 62, 18),
],
sparkle: [
(230, 50, 120), (255, 195, 0), (80, 200, 50),
(0, 160, 200), (180, 60, 180),
],
meter: [(70, 182, 44), (236, 178, 0), (220, 62, 62)],
}
}

View File

@@ -68,7 +68,9 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
if ctx.app.ui.show_title {
ctx.dispatch(AppCommand::HideTitle);
return InputResult::Continue;
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
return InputResult::Continue;
}
}
ctx.dispatch(AppCommand::ClearStatus);

View File

@@ -60,15 +60,13 @@ pub fn convert_egui_events(ctx: &egui::Context) -> Vec<KeyEvent> {
let mut events = Vec::new();
for event in &ctx.input(|i| i.events.clone()) {
if let Some(key_event) = convert_event(event) {
events.push(key_event);
}
convert_event(event, &mut events);
}
events
}
fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
fn convert_event(event: &egui::Event, events: &mut Vec<KeyEvent>) {
match event {
egui::Event::Key {
key,
@@ -77,33 +75,39 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
..
} => {
if !*pressed {
return None;
return;
}
let mods = convert_modifiers(*modifiers);
// For character keys without ctrl/alt, let Event::Text handle it
if is_character_key(*key) && !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return None;
}
let code = convert_key(*key)?;
Some(KeyEvent::new(code, mods))
}
egui::Event::Text(text) => {
if text.len() == 1 {
let c = text.chars().next()?;
if !c.is_control() {
return Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()));
// For character keys, only handle Ctrl+key (without Alt) as shortcuts.
// All other character input (bare, Shift, Alt/Option, AltGr=Ctrl+Alt)
// defers to Event::Text which respects the active keyboard layout.
if is_character_key(*key) {
let ctrl_without_alt =
mods.contains(KeyModifiers::CONTROL) && !mods.contains(KeyModifiers::ALT);
if !ctrl_without_alt {
return;
}
}
if let Some(code) = convert_key(*key) {
events.push(KeyEvent::new(code, mods));
}
}
egui::Event::Text(text) => {
for c in text.chars() {
if !c.is_control() {
events.push(KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()));
}
}
None
}
// egui intercepts Ctrl+C/V/X and converts them to these high-level events
// instead of passing through raw Key events (see egui issue #4065).
// Synthesize the equivalent KeyEvent so the application's input handler receives them.
egui::Event::Copy => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
egui::Event::Cut => Some(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
egui::Event::Paste(_) => Some(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)),
_ => None,
egui::Event::Copy => events.push(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
egui::Event::Cut => events.push(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
egui::Event::Paste(_) => {
events.push(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
}
_ => {}
}
}
@@ -136,6 +140,14 @@ fn convert_key(key: egui::Key) -> Option<KeyCode> {
egui::Key::F10 => KeyCode::F(10),
egui::Key::F11 => KeyCode::F(11),
egui::Key::F12 => KeyCode::F(12),
egui::Key::F13 => KeyCode::F(13),
egui::Key::F14 => KeyCode::F(14),
egui::Key::F15 => KeyCode::F(15),
egui::Key::F16 => KeyCode::F(16),
egui::Key::F17 => KeyCode::F(17),
egui::Key::F18 => KeyCode::F(18),
egui::Key::F19 => KeyCode::F(19),
egui::Key::F20 => KeyCode::F(20),
egui::Key::A => KeyCode::Char('a'),
egui::Key::B => KeyCode::Char('b'),
egui::Key::C => KeyCode::Char('c'),
@@ -183,6 +195,13 @@ fn convert_key(key: egui::Key) -> Option<KeyCode> {
egui::Key::Backslash => KeyCode::Char('\\'),
egui::Key::Backtick => KeyCode::Char('`'),
egui::Key::Quote => KeyCode::Char('\''),
egui::Key::Colon => KeyCode::Char(':'),
egui::Key::Pipe => KeyCode::Char('|'),
egui::Key::Questionmark => KeyCode::Char('?'),
egui::Key::Exclamationmark => KeyCode::Char('!'),
egui::Key::OpenCurlyBracket => KeyCode::Char('{'),
egui::Key::CloseCurlyBracket => KeyCode::Char('}'),
egui::Key::Plus => KeyCode::Char('+'),
_ => return None,
})
}
@@ -252,5 +271,12 @@ fn is_character_key(key: egui::Key) -> bool {
| egui::Key::Backslash
| egui::Key::Backtick
| egui::Key::Quote
| egui::Key::Colon
| egui::Key::Pipe
| egui::Key::Questionmark
| egui::Key::Exclamationmark
| egui::Key::OpenCurlyBracket
| egui::Key::CloseCurlyBracket
| egui::Key::Plus
)
}

View File

@@ -465,7 +465,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
Page::Main => vec![
("Space", "Play"),
("Enter", "Edit"),
("t", "Toggle"),
("t", "On/Off"),
("Tab", "Samples"),
("?", "Keys"),
],

View File

@@ -83,3 +83,99 @@ fn cycle_zero_count_error() {
fn choose_zero_count_error() {
expect_error("1 2 3 0 choose", "choose count must be > 0");
}
// Bracket syntax tests
#[test]
fn bracket_cycle() {
let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("[ 10 20 30 ] cycle", &ctx);
assert_eq!(stack_int(&f), 10);
let ctx = ctx_with(|c| c.runs = 1);
let f = run_ctx("[ 10 20 30 ] cycle", &ctx);
assert_eq!(stack_int(&f), 20);
let ctx = ctx_with(|c| c.runs = 2);
let f = run_ctx("[ 10 20 30 ] cycle", &ctx);
assert_eq!(stack_int(&f), 30);
let ctx = ctx_with(|c| c.runs = 3);
let f = run_ctx("[ 10 20 30 ] cycle", &ctx);
assert_eq!(stack_int(&f), 10);
}
#[test]
fn bracket_with_quotations() {
let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("5 [ { 3 + } { 5 + } ] cycle", &ctx);
assert_eq!(stack_int(&f), 8);
let ctx = ctx_with(|c| c.runs = 1);
let f = run_ctx("5 [ { 3 + } { 5 + } ] cycle", &ctx);
assert_eq!(stack_int(&f), 10);
}
#[test]
fn bracket_nested() {
let ctx = ctx_with(|c| { c.runs = 0; c.iter = 0; });
let f = run_ctx("[ [ 10 20 ] cycle [ 30 40 ] cycle ] pcycle", &ctx);
assert_eq!(stack_int(&f), 10);
let ctx = ctx_with(|c| { c.runs = 0; c.iter = 1; });
let f = run_ctx("[ [ 10 20 ] cycle [ 30 40 ] cycle ] pcycle", &ctx);
assert_eq!(stack_int(&f), 30);
}
#[test]
fn bracket_with_generator() {
let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("[ 1 4 .. ] cycle", &ctx);
assert_eq!(stack_int(&f), 1);
let ctx = ctx_with(|c| c.runs = 3);
let f = run_ctx("[ 1 4 .. ] cycle", &ctx);
assert_eq!(stack_int(&f), 4);
}
#[test]
fn stray_bracket_error() {
expect_error("10 ] cycle", "unexpected ]");
}
#[test]
fn unclosed_bracket_error() {
expect_error("[ 10 20", "missing ]");
}
// Index tests
#[test]
fn index_basic() {
expect_int("10 20 30 3 1 index", 20);
}
#[test]
fn index_with_brackets() {
expect_int("[ 10 20 30 ] 1 index", 20);
}
#[test]
fn index_modulo_wraps() {
expect_int("[ 10 20 30 ] 5 index", 30);
}
#[test]
fn index_negative_wraps() {
expect_int("[ 10 20 30 ] -1 index", 30);
}
#[test]
fn index_with_quotation() {
expect_int("5 [ { 3 + } { 5 + } ] 0 index", 8);
}
#[test]
fn index_zero_count_error() {
expect_error("0 0 index", "index count must be > 0");
}