From fded9796fa95e7ab8a4bca0c00e63a4e8352bd14 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 13 Sep 2026 14:13:16 +0200 Subject: [PATCH] TUI: '?' help overlay, minimal status bar, diff renderer (fix SSH flicker) Full clear+redraw each frame flickered on terminals that ignore synchronized-update (macOS Terminal over SSH). Now only changed cells repaint; full clear happens only on resize or help toggle. The glyph legend moves into a '?'-toggled help overlay; the bottom bar just shows voices/latency and the quit/help hints. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx --- src/tui.rs | 238 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 154 insertions(+), 84 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 9289a33..823002c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,6 +1,8 @@ -//! Grid editor. crossterm renderer adapted from orca-rs's grid view (same -//! centered-canvas + per-glyph colour approach); no embedded scope - run a -//! spectrum analyzer (e.g. spectrust) on the side if you want one. +//! Grid editor. crossterm renderer adapted from orca-rs's grid view: a centered +//! canvas with per-glyph colour and a change-diff so only cells that actually +//! change get repainted (a full clear+redraw each frame flickers over SSH on +//! terminals that ignore synchronized-update, e.g. macOS Terminal). `?` toggles +//! a detailed help overlay; run a spectrum analyzer on the side if you want one. use std::io::{self, Write}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -12,30 +14,70 @@ use crossterm::cursor::{Hide, MoveTo, Show}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor}; use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType, - EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, + disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, }; use crossterm::{execute, queue, terminal}; use crate::dsp::{compile, Program}; use crate::grid::{Grid, H, W}; -// Foreground colour by glyph category. (orca-rs uses one Normal colour; galdr -// tints by role so a patch reads at a glance.) +const HELP: &[&str] = &[ + "galdr — grid synthesizer", + "", + "Signal flows DOWN: a cell's input is the cell above it. Operators are", + "UPPERCASE. A digit 0-9 to the left/right is a constant param; a lowercase", + "a-z reads MIDI CC (a = CC 0). Every note plays the whole grid; each ! sums", + "into the master, so parallel columns stack voices/timbres.", + "", + "SOURCES O sine A saw U pulse V triangle N noise", + " left param = pitch ratio (O/A) or pulse width (U)", + "FILTERS L lowpass H highpass B bandpass (state-variable)", + " left = cutoff, right = resonance (higher glyph = more)", + "SHAPERS S soft clip/tanh (left=drive) C hard clip (left=threshold)", + " F wavefolder (left=amount) D decimate/bitcrush (left=rate)", + "AMP/MOD E envelope, note-gated (left=attack, right=release)", + " G gain (left=amount) R ring modulator (left=ratio)", + "ROUTING M mix — adds the cell to its LEFT ! output to master", + "", + "KEYS arrows move · type a glyph to place · Backspace clears", + " ? toggle this help · Esc quit", +]; + +#[derive(Clone, Copy, PartialEq)] +enum Cs { + Fg(Color), + Cursor, +} + fn glyph_color(c: char) -> Color { match c { - 'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan, // sources - 'L' | 'H' | 'B' => Color::Blue, // filters - 'S' | 'C' | 'F' | 'D' => Color::Yellow, // shapers - 'E' | 'G' | 'R' | 'M' => Color::Green, // amp / mod - '!' => Color::Magenta, // output - '0'..='9' => Color::Grey, // constants - 'a'..='z' => Color::DarkYellow, // CC registers - '.' => Color::DarkGrey, // empty + 'O' | 'A' | 'U' | 'V' | 'N' => Color::Cyan, + 'L' | 'H' | 'B' => Color::Blue, + 'S' | 'C' | 'F' | 'D' => Color::Yellow, + 'E' | 'G' | 'R' | 'M' => Color::Green, + '!' => Color::Magenta, + '0'..='9' => Color::Grey, + 'a'..='z' => Color::DarkYellow, + '.' => Color::DarkGrey, _ => Color::White, } } +struct Renderer { + cells: Vec<(char, Cs)>, + status: String, + tw: u16, + th: u16, + help: bool, + init: bool, +} + +impl Renderer { + fn new() -> Self { + Renderer { cells: Vec::new(), status: String::new(), tw: 0, th: 0, help: false, init: false } + } +} + pub fn run( prog: Arc>, voices: Arc, @@ -46,61 +88,55 @@ pub fn run( let mut out = io::stdout(); execute!(out, EnterAlternateScreen, Hide)?; let (mut cx, mut cy) = (0usize, 0usize); + let mut help = false; + let mut r = Renderer::new(); - let res = main_loop(&mut out, &prog, &voices, &mut grid, latency, &mut cx, &mut cy); + let res = (|| -> io::Result<()> { + loop { + draw(&mut out, &grid, cx, cy, voices.load(Ordering::Relaxed), latency, help, &mut r)?; + if !event::poll(Duration::from_millis(33))? { + continue; + } + let Event::Key(k) = event::read()? else { continue }; + if k.kind != KeyEventKind::Press { + continue; + } + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + match k.code { + KeyCode::Esc => { + if help { + help = false; + } else { + return Ok(()); + } + } + KeyCode::Char('c') if ctrl => return Ok(()), + KeyCode::Char('?') => help = !help, + _ if help => {} // any other key: ignore while help is open + KeyCode::Left => cx = cx.saturating_sub(1), + KeyCode::Right => cx = (cx + 1).min(W - 1), + KeyCode::Up => cy = cy.saturating_sub(1), + KeyCode::Down => cy = (cy + 1).min(H - 1), + KeyCode::Backspace | KeyCode::Delete => { + grid.set(cx, cy, '.'); + prog.store(Arc::new(compile(&grid))); + } + KeyCode::Char(c) => { + grid.set(cx, cy, if c == ' ' { '.' } else { c }); + prog.store(Arc::new(compile(&grid))); + cx = (cx + 1).min(W - 1); + } + _ => {} + } + } + })(); execute!(out, Show, LeaveAlternateScreen)?; disable_raw_mode()?; res } -fn main_loop( - out: &mut io::Stdout, - prog: &Arc>, - voices: &Arc, - grid: &mut Grid, - latency: u32, - cx: &mut usize, - cy: &mut usize, -) -> io::Result<()> { - loop { - draw(out, grid, *cx, *cy, voices.load(Ordering::Relaxed), latency)?; - - if event::poll(Duration::from_millis(33))? { - if let Event::Key(k) = event::read()? { - if k.kind != KeyEventKind::Press { - continue; - } - if k.code == KeyCode::Esc - || (k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL)) - { - return Ok(()); - } - let mut changed = false; - match k.code { - KeyCode::Left => *cx = cx.saturating_sub(1), - KeyCode::Right => *cx = (*cx + 1).min(W - 1), - KeyCode::Up => *cy = cy.saturating_sub(1), - KeyCode::Down => *cy = (*cy + 1).min(H - 1), - KeyCode::Backspace | KeyCode::Delete => { - grid.set(*cx, *cy, '.'); - changed = true; - } - KeyCode::Char(c) => { - grid.set(*cx, *cy, if c == ' ' { '.' } else { c }); - changed = true; - *cx = (*cx + 1).min(W - 1); - } - _ => {} - } - if changed { - prog.store(Arc::new(compile(grid))); - } - } - } - } -} - +#[allow(clippy::too_many_arguments)] fn draw( out: &mut io::Stdout, grid: &Grid, @@ -108,38 +144,72 @@ fn draw( cy: usize, voices: u32, latency: u32, + help: bool, + r: &mut Renderer, ) -> io::Result<()> { let (tw, th) = terminal::size()?; + + // Full repaint only on first frame, resize, or a help toggle - never every + // frame (that is the flicker). + let full = !r.init || tw != r.tw || th != r.th || help != r.help; + if full { + queue!(out, Clear(ClearType::All))?; + r.cells = vec![('\0', Cs::Fg(Color::Reset)); W * H]; + r.status.clear(); + r.tw = tw; + r.th = th; + r.help = help; + r.init = true; + } + + if help { + if full { + let oy = (th as usize).saturating_sub(HELP.len()) / 2; + let ox = (tw as usize).saturating_sub(74) / 2; + for (i, line) in HELP.iter().enumerate() { + queue!( + out, + MoveTo(ox as u16, (oy + i) as u16), + SetAttribute(Attribute::Reset), + SetForegroundColor(if i == 0 { Color::Magenta } else { Color::Grey }), + Print(line) + )?; + } + } + return out.flush(); + } + let off_x = (tw as usize).saturating_sub(W) / 2; let off_y = (th as usize).saturating_sub(H + 1) / 2; - - queue!(out, BeginSynchronizedUpdate, Clear(ClearType::All))?; for row in 0..H { - queue!(out, MoveTo(off_x as u16, (off_y + row) as u16))?; for col in 0..W { let c = grid.get(col, row); - queue!(out, SetAttribute(Attribute::Reset))?; - if col == cx && row == cy { - queue!(out, SetAttribute(Attribute::Reverse))?; - } else { - queue!(out, SetForegroundColor(glyph_color(c)))?; + let cs = if col == cx && row == cy { Cs::Cursor } else { Cs::Fg(glyph_color(c)) }; + let idx = row * W + col; + if r.cells[idx] == (c, cs) { + continue; + } + r.cells[idx] = (c, cs); + queue!(out, MoveTo((off_x + col) as u16, (off_y + row) as u16), SetAttribute(Attribute::Reset))?; + match cs { + Cs::Cursor => queue!(out, SetAttribute(Attribute::Reverse))?, + Cs::Fg(color) => queue!(out, SetForegroundColor(color))?, } queue!(out, Print(c))?; } } - // Status line beneath the grid. - let status = format!( - "voices {voices} latency {latency}f O~ A^ U pulse V tri N noise | L H B filt | S C F D shape | E env G gain R ring M mix | ! out Esc quit" - ); - let s: String = status.chars().take(tw as usize).collect(); - queue!( - out, - MoveTo(0, th.saturating_sub(1)), - SetAttribute(Attribute::Reset), - SetForegroundColor(Color::DarkGrey), - Print(s) - )?; - queue!(out, EndSynchronizedUpdate)?; + let status = format!("voices {voices} latency {latency}f ? help Esc quit"); + if status != r.status { + r.status = status.clone(); + let pad: String = format!("{status:()) + )?; + } out.flush() }