From e2e643cbc894d43e7c25d330c5bdba27e161ca10 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 23 Jul 2026 23:52:22 +0200 Subject: [PATCH] TUI: real colors, diff-patch rendering, centered grid resize, MIDI config - Replace Bold/Dim attributes with explicit colors that survive light themes (Reverse instead of hardcoded black/white for cursor/selection). - Only redraw cells that actually changed each frame instead of a full clear, and center the field within the live viewport. - Add orca-c's ruler-snapped grid resize keys (( ) _ +) and its fancy ruler-frame corners, ported from tui_main.c. - Add ~/.config/orca-rs/config.toml for a default MIDI port name. --- README.md | 15 ++- src/config.rs | 39 +++++++ src/lib.rs | 1 + src/main.rs | 7 +- src/tui.rs | 315 ++++++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 323 insertions(+), 54 deletions(-) create mode 100644 src/config.rs diff --git a/README.md b/README.md index 30f1f04..732623d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,15 @@ MIDI is on by default: orca-rs creates a virtual ALSA sequencer port named `orca-rs`, which PipeWire exposes through its Midi-Bridge — connect it to a synth with qpwgraph/Helvum, or find it under `wpctl status`. +A default MIDI port to connect to (instead of creating a virtual one) can be +set in `~/.config/orca-rs/config.toml`: + +```toml +midi_name = "IAC Driver" +``` + +`--midi-name` on the command line overrides it. + There is also a headless runner, equivalent to orca-c's `cli`: ```sh @@ -54,14 +63,15 @@ orca-cli -t 60 file.orca # simulate 60 ticks, print the grid |---|---| | Arrow keys | Move cursor (Alt+arrows: leap by 8) | | Shift+arrows | Grow/shrink selection | -| Ctrl+arrows | Resize grid | +| Ctrl+arrows | Resize grid by 1 cell | +| `(` / `)` | Resize grid width, snapped to the nearest ruler division | +| `_` / `+` | Resize grid height, snapped to the nearest ruler division | | `0-9 a-z A-Z ! : ; = # * . ?` | Place glyph (fills selection) | | Backspace / Delete | Erase selection | | Space | Play / pause | | Ctrl+F | Step one frame (while paused) | | Ctrl+R | Reset frame counter | | `<` / `>` | BPM −1 / +1 | -| `(` / `)` | BPM −10 / +10 | | Ctrl+Z or Ctrl+U | Undo | | Ctrl+X / Ctrl+C / Ctrl+V | Cut / copy / paste selection | | Ctrl+S | Save | @@ -73,6 +83,7 @@ orca-cli -t 60 file.orca # simulate 60 ticks, print the grid - `src/sim/` — the VM: all operators, ported from orca-c's `sim.c` - `src/tui.rs` — terminal UI (crossterm), counterpart of `tui_main.c` - `src/io.rs` — UDP / OSC / MIDI event output +- `src/config.rs` — `~/.config/orca-rs/config.toml` (default MIDI port) - `src/bin/orca-cli.rs` — headless runner, counterpart of `cli_main.c` ## Releasing diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a397a32 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,39 @@ +//! Minimal `key = "value"` config file (a subset of TOML), read from +//! `$XDG_CONFIG_HOME/orca-rs/config.toml` or `~/.config/orca-rs/config.toml`. +//! Currently just holds the default MIDI port to connect to. + +#[derive(Default)] +pub struct Config { + pub midi_name: Option, +} + +impl Config { + pub fn load() -> Config { + let text = std::fs::read_to_string(config_path()).unwrap_or_default(); + let mut cfg = Config::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches('"').to_string(); + if key.trim() == "midi_name" { + cfg.midi_name = Some(value); + } + } + cfg + } +} + +fn config_path() -> std::path::PathBuf { + let base = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string())) + .join(".config") + }); + base.join("orca-rs").join("config.toml") +} diff --git a/src/lib.rs b/src/lib.rs index 9392ab4..63fb8e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod io; pub mod sim; pub mod tui; diff --git a/src/main.rs b/src/main.rs index 6e4aa46..e4e5e37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use std::process::exit; +use orca_rs::config::Config; use orca_rs::io::{EventOutputs, MidiOut, OscOut, UdpOut}; use orca_rs::tui::{self, TuiConfig}; @@ -22,6 +23,10 @@ Options: instead of creating a virtual port --list-midi List available MIDI output ports and exit -h, --help Print this message and exit + +A default MIDI port can be set in ~/.config/orca-rs/config.toml: + midi_name = \"IAC Driver\" +--midi-name on the command line overrides it. "; fn die(msg: &str) -> ! { @@ -44,7 +49,7 @@ fn main() { let mut osc_host = "127.0.0.1".to_string(); let mut osc_port: Option = None; let mut midi = true; - let mut midi_name: Option = None; + let mut midi_name: Option = Config::load().midi_name; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { diff --git a/src/tui.rs b/src/tui.rs index bfb0a45..8fd0ce0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -8,7 +8,7 @@ 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::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor}; use crossterm::terminal::{ self, disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, @@ -23,8 +23,9 @@ use crate::sim::{ 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"; + "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, @@ -71,6 +72,22 @@ 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) @@ -227,6 +244,28 @@ impl App { 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; } @@ -311,11 +350,12 @@ fn default_field() -> Result { 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)?; + draw(stdout, app, &mut renderer)?; let timeout = if app.playing { let now = Instant::now(); @@ -385,8 +425,10 @@ fn handle_key(app: &mut App, key: KeyEvent, next_tick: &mut Instant) -> bool { } 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::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; @@ -420,51 +462,209 @@ fn handle_key(app: &mut App, key: KeyEvent, next_tick: &mut Instant) -> bool { true } -fn draw(out: &mut io::Stdout, app: &App) -> io::Result<()> { +// 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(); - 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))?; + // 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 { - queue!(out, MoveTo(0, y as u16))?; + let mut need_move = true; 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 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; - let ch = if g == b'.' { - if is_cursor { - '@' - } else if y % 8 == 0 && x % 8 == 0 { - '+' + // 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 { - g as char + (' ', Style::Normal) }; - 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))?; + 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); } } @@ -475,20 +675,33 @@ fn draw(out: &mut io::Stdout, app: &App) -> io::Result<()> { ); let mut line1: String = status.chars().take(tw as usize).collect(); line1 = format!("{: