Initial release: Orca VM, TUI, MIDI/OSC/UDP output, and release pipeline

This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-07 17:40:00 +02:00
commit bf388576a3
19 changed files with 3009 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
//! Interactive terminal UI — the crossterm counterpart of orca-c's
//! ncurses tui_main.c: grid editor, playback clock, selection, undo,
//! and event delivery to the configured outputs.
use std::fs::File;
use std::io::{self, Write};
use std::time::{Duration, Instant};
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::style::{Attribute, Print, SetAttribute};
use crossterm::terminal::{
self, disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType,
EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::{execute, queue};
use crate::io::EventOutputs;
use crate::sim::{
copy_subrect, fill_subrect, orca_run, Field, MarkBuffer, Oevent, MARK_HASTE, MARK_INPUT,
MARK_LOCK, MARK_OUTPUT, MARK_SLEEP,
};
const MAX_UNDO: usize = 256;
const MAX_DIM: usize = 512;
const HELP_LINE: &str =
"space play ^F step ^S save ^Z undo ^X/^C/^V clip shift+arrows select ^arrows resize < > ( ) bpm ^Q quit";
pub struct TuiConfig {
pub path: Option<String>,
pub bpm: usize,
pub seed: usize,
pub outputs: EventOutputs,
}
struct Clip {
data: Vec<u8>,
h: usize,
w: usize,
}
struct Snapshot {
data: Vec<u8>,
h: usize,
w: usize,
tick: usize,
}
struct App {
field: Field,
mbuf: MarkBuffer,
scratch: Vec<u8>,
tick: usize,
bpm: usize,
playing: bool,
cur_y: usize,
cur_x: usize,
sel_h: usize,
sel_w: usize,
clipboard: Option<Clip>,
undo: Vec<Snapshot>,
outputs: EventOutputs,
events: Vec<Oevent>,
seed: usize,
path: String,
msg: String,
needs_preview: bool,
}
fn is_valid_glyph(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '!' | '#' | '%' | '*' | '.' | ':' | ';' | '=' | '?')
}
fn tick_period(bpm: usize) -> Duration {
// One Orca frame is a 16th note: 60000 ms / bpm / 4.
Duration::from_millis((60_000 / bpm.max(1) / 4).max(1) as u64)
}
impl App {
fn dims(&self) -> (usize, usize) {
(self.field.height as usize, self.field.width as usize)
}
fn clamp_cursor(&mut self) {
let (h, w) = self.dims();
self.cur_y = self.cur_y.min(h - 1);
self.cur_x = self.cur_x.min(w - 1);
self.sel_h = self.sel_h.clamp(1, h - self.cur_y);
self.sel_w = self.sel_w.clamp(1, w - self.cur_x);
}
fn do_tick(&mut self) {
let (h, w) = self.dims();
self.mbuf.ensure_size(h, w);
self.mbuf.clear(h, w);
self.outputs.begin_tick();
orca_run(
&mut self.field.buffer,
&mut self.mbuf.buffer[..h * w],
h,
w,
self.tick,
&mut self.events,
self.seed,
);
self.outputs.dispatch(&mut self.events);
self.tick += 1;
self.needs_preview = false;
}
// Run the sim on a scratch copy so port highlighting stays live while
// paused, without mutating the grid or emitting events.
fn preview(&mut self) {
let (h, w) = self.dims();
self.mbuf.ensure_size(h, w);
self.mbuf.clear(h, w);
self.scratch.clear();
self.scratch.extend_from_slice(&self.field.buffer);
let mut discarded: Vec<Oevent> = Vec::new();
orca_run(
&mut self.scratch,
&mut self.mbuf.buffer[..h * w],
h,
w,
self.tick,
&mut discarded,
self.seed,
);
self.needs_preview = false;
}
fn push_undo(&mut self) {
if self.undo.len() >= MAX_UNDO {
self.undo.remove(0);
}
let (h, w) = self.dims();
self.undo.push(Snapshot {
data: self.field.buffer.clone(),
h,
w,
tick: self.tick,
});
}
fn undo(&mut self) {
if let Some(s) = self.undo.pop() {
self.field.buffer = s.data;
self.field.height = s.h as u16;
self.field.width = s.w as u16;
self.tick = s.tick;
self.clamp_cursor();
self.needs_preview = true;
self.msg = "undid".into();
} else {
self.msg = "nothing to undo".into();
}
}
fn fill_selection(&mut self, g: u8) {
self.push_undo();
let (h, w) = self.dims();
fill_subrect(
&mut self.field.buffer,
h,
w,
self.cur_y,
self.cur_x,
self.sel_h,
self.sel_w,
g,
);
self.needs_preview = true;
}
fn copy_selection(&mut self) {
let (h, w) = self.dims();
let mut data = vec![b'.'; self.sel_h * self.sel_w];
copy_subrect(
&self.field.buffer,
&mut data,
h,
w,
self.sel_h,
self.sel_w,
self.cur_y,
self.cur_x,
0,
0,
self.sel_h,
self.sel_w,
);
self.clipboard = Some(Clip {
data,
h: self.sel_h,
w: self.sel_w,
});
self.msg = "copied".into();
}
fn paste(&mut self) {
let Some(clip) = self.clipboard.take() else {
self.msg = "clipboard empty".into();
return;
};
self.push_undo();
let (h, w) = self.dims();
copy_subrect(
&clip.data,
&mut self.field.buffer,
clip.h,
clip.w,
h,
w,
0,
0,
self.cur_y,
self.cur_x,
clip.h,
clip.w,
);
self.msg = "pasted".into();
self.clipboard = Some(clip);
self.needs_preview = true;
}
fn resize_grid(&mut self, dy: isize, dx: isize) {
let (h, w) = self.dims();
let nh = (h as isize + dy).clamp(1, MAX_DIM as isize) as usize;
let nw = (w as isize + dx).clamp(1, MAX_DIM as isize) as usize;
if (nh, nw) == (h, w) {
return;
}
self.push_undo();
self.field.resize(nh, nw);
self.mbuf.ensure_size(nh, nw);
self.clamp_cursor();
self.needs_preview = true;
self.msg = format!("grid {nw}x{nh}");
}
fn save(&mut self) {
let result = File::create(&self.path).and_then(|mut f| self.field.write_to(&mut f));
self.msg = match result {
Ok(()) => format!("saved {}", self.path),
Err(e) => format!("save failed: {e}"),
};
}
}
// Restore the terminal even if we exit via error or panic.
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, Show);
}
}
pub fn run(cfg: TuiConfig) -> Result<(), String> {
let (path, field) = match cfg.path {
Some(p) => {
if std::path::Path::new(&p).exists() {
let f = Field::load_file(&p)
.map_err(|e| format!("cannot load {p}: {}", e.as_str()))?;
(p, f)
} else {
(p, default_field()?)
}
}
None => ("untitled.orca".to_string(), default_field()?),
};
let mut app = App {
mbuf: MarkBuffer::new(),
scratch: Vec::new(),
tick: 0,
bpm: cfg.bpm,
playing: false,
cur_y: 0,
cur_x: 0,
sel_h: 1,
sel_w: 1,
clipboard: None,
undo: Vec::new(),
outputs: cfg.outputs,
events: Vec::new(),
seed: cfg.seed,
path,
msg: String::new(),
needs_preview: true,
field,
};
enable_raw_mode().map_err(|e| e.to_string())?;
let _guard = TermGuard;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, Hide).map_err(|e| e.to_string())?;
main_loop(&mut app, &mut stdout).map_err(|e| e.to_string())?;
app.outputs.silence();
Ok(())
}
fn default_field() -> Result<Field, String> {
let (cols, rows) = terminal::size().unwrap_or((80, 25));
let w = (cols as usize).clamp(20, 200);
let h = (rows.saturating_sub(3) as usize).clamp(10, 200);
Ok(Field::with_fill(h, w, b'.'))
}
fn main_loop(app: &mut App, stdout: &mut io::Stdout) -> io::Result<()> {
let mut next_tick = Instant::now();
loop {
if app.needs_preview && !app.playing {
app.preview();
}
draw(stdout, app)?;
let timeout = if app.playing {
let now = Instant::now();
if now >= next_tick {
app.do_tick();
next_tick += tick_period(app.bpm);
if next_tick < now {
next_tick = now + tick_period(app.bpm);
}
continue;
}
next_tick - now
} else {
Duration::from_millis(200)
};
if event::poll(timeout)? {
match event::read()? {
Event::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !handle_key(app, key, &mut next_tick) =>
{
return Ok(());
}
_ => {}
}
}
}
}
// Returns false when the app should quit.
fn handle_key(app: &mut App, key: KeyEvent, next_tick: &mut Instant) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let (h, w) = app.dims();
match key.code {
KeyCode::Char('q') if ctrl => return false,
KeyCode::Char('s') if ctrl => app.save(),
KeyCode::Char('z') | KeyCode::Char('u') if ctrl => app.undo(),
KeyCode::Char('c') if ctrl => app.copy_selection(),
KeyCode::Char('x') if ctrl => {
app.copy_selection();
app.fill_selection(b'.');
app.msg = "cut".into();
}
KeyCode::Char('v') if ctrl => app.paste(),
KeyCode::Char('f') if ctrl
&& !app.playing => {
app.do_tick();
app.needs_preview = true;
}
KeyCode::Char('r') if ctrl => {
app.tick = 0;
app.needs_preview = true;
app.msg = "frame reset".into();
}
KeyCode::Char(' ') => {
app.playing = !app.playing;
if app.playing {
*next_tick = Instant::now();
} else {
app.outputs.silence();
app.needs_preview = true;
}
}
KeyCode::Char('<') => app.bpm = (app.bpm.saturating_sub(1)).max(10),
KeyCode::Char('>') => app.bpm = (app.bpm + 1).min(999),
KeyCode::Char('(') => app.bpm = (app.bpm.saturating_sub(10)).max(10),
KeyCode::Char(')') => app.bpm = (app.bpm + 10).min(999),
KeyCode::Esc => {
app.sel_h = 1;
app.sel_w = 1;
app.msg.clear();
}
KeyCode::Backspace | KeyCode::Delete => app.fill_selection(b'.'),
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
let (dy, dx): (isize, isize) = match key.code {
KeyCode::Up => (-1, 0),
KeyCode::Down => (1, 0),
KeyCode::Left => (0, -1),
_ => (0, 1),
};
if ctrl {
app.resize_grid(dy, dx);
} else if shift {
app.sel_h = (app.sel_h as isize + dy).clamp(1, (h - app.cur_y) as isize) as usize;
app.sel_w = (app.sel_w as isize + dx).clamp(1, (w - app.cur_x) as isize) as usize;
} else {
let step: isize = if alt { 8 } else { 1 };
app.cur_y = (app.cur_y as isize + dy * step).clamp(0, h as isize - 1) as usize;
app.cur_x = (app.cur_x as isize + dx * step).clamp(0, w as isize - 1) as usize;
app.clamp_cursor();
}
}
KeyCode::Char(c) if !ctrl && !alt && is_valid_glyph(c) => {
app.fill_selection(c as u8);
}
_ => {}
}
true
}
fn draw(out: &mut io::Stdout, app: &App) -> io::Result<()> {
let (tw, th) = terminal::size()?;
let (gh, gw) = app.dims();
let grid_rows = th.saturating_sub(2) as usize;
let vis_h = gh.min(grid_rows);
let vis_w = gw.min(tw as usize);
queue!(out, BeginSynchronizedUpdate, Hide, Clear(ClearType::All))?;
for y in 0..vis_h {
queue!(out, MoveTo(0, y as u16))?;
for x in 0..vis_w {
let g = app.field.buffer[y * gw + x];
let m = app.mbuf.buffer.get(y * gw + x).copied().unwrap_or(0);
let is_cursor = y == app.cur_y && x == app.cur_x;
let in_sel = y >= app.cur_y
&& y < app.cur_y + app.sel_h
&& x >= app.cur_x
&& x < app.cur_x + app.sel_w;
let ch = if g == b'.' {
if is_cursor {
'@'
} else if y % 8 == 0 && x % 8 == 0 {
'+'
} else {
'.'
}
} else {
g as char
};
queue!(out, SetAttribute(Attribute::Reset))?;
if is_cursor {
queue!(out, SetAttribute(Attribute::Reverse), SetAttribute(Attribute::Bold))?;
} else if in_sel {
queue!(out, SetAttribute(Attribute::Reverse))?;
} else if m & MARK_OUTPUT != 0 {
queue!(out, SetAttribute(Attribute::Reverse))?;
} else if m & (MARK_INPUT | MARK_HASTE) != 0 {
queue!(out, SetAttribute(Attribute::Bold))?;
} else if g == b'.' || m & (MARK_LOCK | MARK_SLEEP) != 0 {
queue!(out, SetAttribute(Attribute::Dim))?;
}
queue!(out, Print(ch))?;
}
}
let state = if app.playing { "playing" } else { "paused" };
let status = format!(
"{} {}f {}bpm {} {},{} {}x{} {}",
app.path, app.tick, app.bpm, state, app.cur_x, app.cur_y, gw, gh, app.msg
);
let mut line1: String = status.chars().take(tw as usize).collect();
line1 = format!("{:<width$}", line1, width = tw as usize);
let help: String = HELP_LINE.chars().take(tw as usize).collect();
queue!(
out,
SetAttribute(Attribute::Reset),
MoveTo(0, th.saturating_sub(2)),
SetAttribute(Attribute::Reverse),
Print(line1),
SetAttribute(Attribute::Reset),
MoveTo(0, th.saturating_sub(1)),
SetAttribute(Attribute::Dim),
Print(help),
SetAttribute(Attribute::Reset),
EndSynchronizedUpdate
)?;
out.flush()
}