- 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.
708 lines
21 KiB
Rust
708 lines
21 KiB
Rust
//! 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<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, '!' | '#' | '%' | '*' | '.' | ':' | ';' | '=' | '?')
|
|
}
|
|
|
|
// 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<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;
|
|
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<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();
|
|
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!("{:<width$}", line1, width = tw as usize);
|
|
|
|
if line1 != r.line1 {
|
|
// Plain default text, no reversed block and no hardcoded color — a
|
|
// full-row invert reads as "black on white" on a normal dark
|
|
// terminal, which is exactly what shouldn't be here.
|
|
queue!(
|
|
out,
|
|
SetAttribute(Attribute::Reset),
|
|
MoveTo(0, th.saturating_sub(2)),
|
|
Print(&line1),
|
|
SetAttribute(Attribute::Reset)
|
|
)?;
|
|
r.line1 = line1;
|
|
}
|
|
|
|
if !r.help_drawn {
|
|
let help: String = HELP_LINE.chars().take(tw as usize).collect();
|
|
queue!(
|
|
out,
|
|
MoveTo(0, th.saturating_sub(1)),
|
|
SetForegroundColor(Color::DarkGrey),
|
|
Print(help),
|
|
SetAttribute(Attribute::Reset)
|
|
)?;
|
|
r.help_drawn = true;
|
|
}
|
|
|
|
queue!(out, EndSynchronizedUpdate)?;
|
|
out.flush()
|
|
}
|