1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// Copyright (C) 2026 Michael Wilson <mike@mdwn.dev>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//
mod app;
pub mod logging;
mod ui;
use std::error::Error;
use std::io;
use std::sync::Arc;
use std::time::Duration;
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::sync::{mpsc, watch};
use crate::player::Player;
use crate::state::StateSnapshot;
use app::{Action, App};
/// Runs the TUI as the main blocking loop.
///
/// Controllers (gRPC/OSC/MIDI) continue running in background tokio tasks.
/// The TUI replaces `Controller::join()` as the main loop when stdin is a TTY.
pub async fn run(
player: Arc<Player>,
state_rx: watch::Receiver<Arc<StateSnapshot>>,
) -> Result<(), Box<dyn Error>> {
// Set up terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Install a panic hook that restores the terminal before printing the panic
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(panic_info);
}));
// Spawn a dedicated OS thread for crossterm event reading.
// crossterm::event::read() is blocking, so we can't use it in async directly.
let (event_tx, mut event_rx) = mpsc::channel::<Event>(32);
std::thread::Builder::new()
.name("tui-events".to_string())
.spawn(move || {
loop {
// Poll with a timeout so the thread can detect when the receiver is dropped
if event::poll(Duration::from_millis(100)).unwrap_or(false) {
if let Ok(evt) = event::read() {
if event_tx.blocking_send(evt).is_err() {
// Receiver dropped, TUI is shutting down
break;
}
}
}
}
})?;
let mut app = App::new(player, state_rx);
// Initial tick to populate state before first render
app.tick().await;
let tick_rate = Duration::from_millis(66); // ~15 FPS
let result = run_loop(&mut terminal, &mut app, &mut event_rx, tick_rate).await;
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
/// The main event loop.
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
event_rx: &mut mpsc::Receiver<Event>,
tick_rate: Duration,
) -> Result<(), Box<dyn Error>> {
let mut tick_interval = tokio::time::interval(tick_rate);
loop {
// Draw
terminal.draw(|frame| ui::draw(frame, app))?;
tokio::select! {
_ = tick_interval.tick() => {
app.tick().await;
}
Some(event) = event_rx.recv() => {
if let Event::Key(key) = event {
// Only handle key press events (not release/repeat)
if key.kind == KeyEventKind::Press {
match app.handle_key_event(key).await {
Action::Quit => return Ok(()),
Action::None => {}
}
}
}
}
}
}
}