//! 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, Color, Print, SetAttribute, SetForegroundColor}; 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 RULER_SPACING: usize = 8; 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, '!' | '#' | '%' | '*' | '.' | ':' | ';' | '=' | '?') } // Snaps `size` to the next/previous ruler-aligned length: one more or fewer // full `ruler`-cell division, landing one past the division boundary (a // ruler of 8 wants sizes like 25 or 33, not 24 or 32). Ported from orca-c's // adjust_rulers_humanized. fn ruler_snap(ruler: usize, size: usize, delta_rulers: isize) -> usize { if size == 0 { return if delta_rulers > 0 { ruler * delta_rulers as usize } else { 1 }; } let mut size = size; if delta_rulers < 0 { size += ruler - 1; } let n = ((size as isize - 1) / ruler as isize + delta_rulers).max(0); (ruler as isize * n + 1) as usize } 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; self.apply_resize(nh, nw); } // orca-c's `( ) _ +`: resize by one ruler division, snapped to the // nearest ruler-aligned size rather than a plain +/-1. fn resize_grid_by_ruler(&mut self, delta_h: isize, delta_w: isize) { let (h, w) = self.dims(); let nh = if delta_h != 0 { ruler_snap(RULER_SPACING, h, delta_h).min(MAX_DIM) } else { h }; let nw = if delta_w != 0 { ruler_snap(RULER_SPACING, w, delta_w).min(MAX_DIM) } else { w }; self.apply_resize(nh, nw); } fn apply_resize(&mut self, nh: usize, nw: usize) { let (h, w) = self.dims(); 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(); let mut renderer = Renderer::new(); loop { if app.needs_preview && !app.playing { app.preview(); } draw(stdout, app, &mut renderer)?; 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.resize_grid_by_ruler(0, -1), KeyCode::Char(')') => app.resize_grid_by_ruler(0, 1), KeyCode::Char('_') => app.resize_grid_by_ruler(-1, 0), KeyCode::Char('+') => app.resize_grid_by_ruler(1, 0), 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 } // A cell's visual style. Colors, not Bold/Dim, carry the contrast — Dim in // particular renders inconsistently (or not at all) across terminal emulators. #[derive(Clone, Copy, PartialEq, Eq)] enum Style { Normal, Dim, Input, Output, Selected, Cursor, } fn cell_style(is_cursor: bool, in_sel: bool, m: u8, is_dot: bool) -> Style { if is_cursor { Style::Cursor } else if in_sel { Style::Selected } else if m & MARK_OUTPUT != 0 { Style::Output } else if m & (MARK_INPUT | MARK_HASTE) != 0 { Style::Input } else if is_dot || m & (MARK_LOCK | MARK_SLEEP) != 0 { Style::Dim } else { Style::Normal } } // Explicit foreground colors everywhere content is unmarked, so cells don't // fall back to the terminal's unset default (which just renders as plain // white on black/transparent). Highlighted cells use Reverse — a swap of // whatever the terminal's own colors are — rather than hardcoded black/white, // so a light-themed terminal doesn't get an invisible white-on-white block. // The cursor tile gets a plain, untinted swap (the strongest contrast, and // literally black-on-white on a standard dark terminal); selection and // output tint the swap so all three stay distinguishable. fn apply_style(out: &mut io::Stdout, s: Style) -> io::Result<()> { queue!(out, SetAttribute(Attribute::Reset))?; match s { Style::Cursor => queue!(out, SetAttribute(Attribute::Reverse))?, Style::Selected => { queue!(out, SetForegroundColor(Color::Blue), SetAttribute(Attribute::Reverse))? } Style::Output => { queue!(out, SetForegroundColor(Color::Grey), SetAttribute(Attribute::Reverse))? } Style::Input => queue!(out, SetForegroundColor(Color::Yellow))?, Style::Dim => queue!(out, SetForegroundColor(Color::DarkGrey))?, Style::Normal => queue!(out, SetForegroundColor(Color::Cyan))?, } Ok(()) } // Tracks what was last drawn so `draw()` can emit a diff patch (only the // cells that actually changed) instead of clearing and reprinting the whole // grid every frame — most ticks only touch a handful of cells. struct Renderer { cells: Vec<(char, Style)>, vis_w: usize, vis_h: usize, tw: u16, th: u16, field_h: usize, field_w: usize, line1: String, help_drawn: bool, } impl Renderer { fn new() -> Self { Renderer { cells: Vec::new(), vis_w: 0, vis_h: 0, tw: 0, th: 0, field_h: 0, field_w: 0, line1: String::new(), help_drawn: false, } } } // Ruler-tick glyph at a `.` intersection, framing the field's actual edges — // orca-c's fancy ruler border (┌┐└┘┬┴├┤), not a plain '+' that keeps going // past where the grid really ends. Mirrors orca-c's exact bit-flag lookup: // only a single edge (or exactly one corner pair) gets a special glyph, so a // 1-row/1-column field — top and bottom edge at once — still falls to '+'. fn ruler_glyph(y: usize, x: usize, gh: usize, gw: usize) -> char { const T: u8 = 1; const B: u8 = 2; const L: u8 = 4; const R: u8 = 8; let mut p = 0u8; if y == 0 { p |= T; } if y + 1 == gh { p |= B; } if x == 0 { p |= L; } if x + 1 == gw { p |= R; } match p { v if v == T | L => '┌', v if v == T | R => '┐', v if v == B | L => '└', v if v == B | R => '┘', T => '┬', B => '┴', L => '├', R => '┤', _ => '+', } } fn draw(out: &mut io::Stdout, app: &App, r: &mut Renderer) -> io::Result<()> { let (tw, th) = terminal::size()?; let (gh, gw) = app.dims(); // The canvas always matches the live viewport: it fills the terminal at // launch, and re-measuring it every frame (rather than freezing it at // startup) is what lets a grid resize actually stay centered — a frozen // canvas has no headroom to grow into once the starting field already // fills it. let vis_h = th.saturating_sub(2) as usize; let vis_w = tw as usize; let offset_y = vis_h.saturating_sub(gh) / 2; let offset_x = vis_w.saturating_sub(gw) / 2; let dims_changed = vis_w != r.vis_w || vis_h != r.vis_h || gh != r.field_h || gw != r.field_w || tw != r.tw || th != r.th; if dims_changed { queue!(out, Clear(ClearType::All))?; r.cells = vec![(' ', Style::Normal); vis_w * vis_h]; r.vis_w = vis_w; r.vis_h = vis_h; r.field_h = gh; r.field_w = gw; r.tw = tw; r.th = th; r.line1.clear(); r.help_drawn = false; } queue!(out, BeginSynchronizedUpdate, Hide)?; for y in 0..vis_h { let mut need_move = true; for x in 0..vis_w { let fy_s = y as isize - offset_y as isize; let fx_s = x as isize - offset_x as isize; let in_field = fy_s >= 0 && fx_s >= 0 && (fy_s as usize) < gh && (fx_s as usize) < gw; // Outside the field's own bounds is blank canvas, not a dot fill. let (ch, style) = if in_field { let fy = fy_s as usize; let fx = fx_s as usize; let g = app.field.buffer[fy * gw + fx]; let m = app.mbuf.buffer.get(fy * gw + fx).copied().unwrap_or(0); let is_cursor = fy == app.cur_y && fx == app.cur_x; let in_sel = fy >= app.cur_y && fy < app.cur_y + app.sel_h && fx >= app.cur_x && fx < app.cur_x + app.sel_w; let is_dot = g == b'.'; let ch = if is_dot { if is_cursor { '@' } else if fy.is_multiple_of(RULER_SPACING) && fx.is_multiple_of(RULER_SPACING) { ruler_glyph(fy, fx, gh, gw) } else { '.' } } else { g as char }; (ch, cell_style(is_cursor, in_sel, m, is_dot)) } else { (' ', Style::Normal) }; let idx = y * vis_w + x; if !dims_changed && r.cells[idx] == (ch, style) { need_move = true; continue; } if need_move { queue!(out, MoveTo(x as u16, y as u16))?; need_move = false; } apply_style(out, style)?; queue!(out, Print(ch))?; r.cells[idx] = (ch, style); } } 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!("{: