Restructure project: 1 file = 1 responsibility, max 4 files/folder, 250 lines/file

This commit is contained in:
loki5512344 2026-08-02 23:33:13 +02:00
parent 72769f4615
commit a6032b2d18
Signed by: boba
GPG key ID: 253067914055423B
49 changed files with 3408 additions and 3492 deletions

215
src/ui/render.rs Normal file
View file

@ -0,0 +1,215 @@
//! Frame rendering for each screen, mouse input and the main TUI run loop.
use crate::dsp::control::Control;
use crate::dsp::control::Stats;
use crate::dsp::preset::presets;
use crate::ui::app::{App, View};
use crate::ui::screens::{
header_line, help, params_paragraph, presets_paragraph, settings_paragraph, splash,
stats_column, StatsSnap,
};
use crate::ui::widgets::{self, main_layout, settings_layout};
use crate::ui::SessionInfo;
use crossterm::event::{self, Event, MouseButton, MouseEvent, MouseEventKind};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal};
use std::io::{stdout, IsTerminal, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
impl App {
fn render(&mut self, f: &mut Frame) {
if Instant::now() < self.splash_until {
splash(f, f.area());
return;
}
match self.view {
View::Main => self.render_main(f),
View::Settings => self.render_settings(f),
View::Help => help(f, f.area()),
}
}
fn render_main(&mut self, f: &mut Frame) {
let area = f.area();
let (head, body, foot) = main_layout(area);
self.render_header(f, head);
let s = self.snapshot();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(38),
Constraint::Percentage(28),
Constraint::Percentage(34),
])
.split(body);
f.render_widget(presets_paragraph(s.preset_idx), cols[0]);
f.render_widget(params_paragraph(&s), cols[1]);
self.render_stats(f, cols[2]);
self.render_footer(
f,
foot,
" ↑↓ preset | ←→ pitch | S settings | H help | Q quit ",
);
}
fn render_settings(&mut self, f: &mut Frame) {
let area = f.area();
let (head, list_rect, foot) = settings_layout(area);
self.render_header(f, head);
let values: Vec<String> = (0..9).map(|i| self.setting_value(i)).collect();
f.render_widget(settings_paragraph(&values, self.settings_cursor), list_rect);
self.render_footer(
f,
foot,
" ↑↓ select | ←→ change | Esc back | H help | Q quit ",
);
}
fn render_header(&self, f: &mut Frame, area: Rect) {
f.render_widget(Paragraph::new(header_line(&self.snapshot())), area);
}
fn render_footer(&self, f: &mut Frame, area: Rect, hint: &str) {
let footer = Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray)));
f.render_widget(Paragraph::new(footer), area);
}
fn render_stats(&self, f: &mut Frame, area: Rect) {
let snap = self
.stats
.lock()
.map(|s| StatsSnap {
in_db: widgets::db(s.input_rms),
out_db: widgets::db(s.output_rms),
peak_in: widgets::db(s.peak_in),
peak_out: widgets::db(s.peak_out),
cpu: s.cpu_pct,
latency: s.dsp_latency_ms,
blocks: s.blocks,
samples: s.samples,
uptime: s.started.elapsed().as_secs(),
})
.unwrap_or_default();
stats_column(f, area, &self.info, &snap);
}
/// Map a mouse click onto the current screen (preset / settings row).
fn on_mouse(&mut self, mouse: MouseEvent) {
if Instant::now() < self.splash_until {
self.splash_until = Instant::now();
return;
}
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
let area = crossterm::terminal::size()
.map(|(w, h)| Rect::new(0, 0, w, h))
.unwrap_or(Rect::new(0, 0, 100, 30));
match self.view {
View::Main => {
let presets_rect = main_layout(area).0;
if mouse.row >= presets_rect.y
&& mouse.row < presets_rect.y + presets_rect.height
{
let idx = (mouse.row - presets_rect.y) as usize;
if idx < presets().len() {
self.with_ctrl(|c| c.preset_idx = idx);
}
}
}
View::Settings => {
let list_rect = settings_layout(area).0;
if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height {
let idx = (mouse.row - list_rect.y) as usize;
if idx < 9 {
self.settings_cursor = idx;
}
}
}
View::Help => self.view = View::Main,
}
}
}
}
/// Run headless until `run_seconds` elapse or `quit` is set.
fn headless_wait(control: &Arc<Mutex<Control>>, run_seconds: u32) {
let started = Instant::now();
loop {
let done = control
.lock()
.map(|c| c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64)
.unwrap_or(true);
if done {
if let Ok(mut c) = control.lock() {
c.quit = true;
}
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: SessionInfo) {
let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0);
if !std::io::stdin().is_terminal() || enable_raw_mode().is_err() {
// No real terminal: keep the audio running headless.
headless_wait(&control, run_seconds);
return;
}
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
Ok(t) => t,
Err(e) => {
disable_raw_mode().ok();
eprintln!("tui init failed: {e}");
return;
}
};
let _ = terminal.clear();
let mut app = App {
control,
stats,
info,
view: View::Main,
settings_cursor: 0,
splash_until: Instant::now() + Duration::from_millis(1500),
started: Instant::now(),
running: true,
};
while app.running {
let _ = terminal.draw(|f| app.render(f));
let _ = stdout().flush();
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
if let Ok(ev) = event::read() {
match ev {
Event::Key(key) => app.on_key(key),
Event::Mouse(m) => app.on_mouse(m),
_ => {}
}
}
}
if run_seconds > 0 && app.started.elapsed().as_secs() >= run_seconds as u64 {
app.set_quit();
}
}
let _ = terminal.show_cursor();
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
disable_raw_mode().ok();
println!("shutting down...");
}