use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{Event as CtEvent, EventStream, KeyEvent, KeyEventKind};
use futures::StreamExt;
use ratatui::{backend::CrosstermBackend, Terminal};
use rtcom_config::Profile;
use rtcom_core::{
command::Command, Event, EventBus, ModemLineSnapshot, Parity, SerialConfig, StopBits,
};
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use crate::{
app::TuiApp,
input::Dispatch,
modal::DialogAction,
profile_bridge::{
line_ending_config_to_section, line_endings_from_profile, serial_config_to_section,
serial_section_to_config,
},
terminal::{AltScreenGuard, MouseCaptureGuard, RawModeGuard},
toast::ToastLevel,
};
pub async fn run(
mut app: TuiApp,
bus: EventBus,
mut bus_rx: broadcast::Receiver<Event>,
cancel: CancellationToken,
profile_path: Option<PathBuf>,
mut profile: Profile,
) -> Result<()> {
let _raw = RawModeGuard::enter()?;
let _alt = AltScreenGuard::enter()?;
let _mouse = MouseCaptureGuard::enable()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).context("build ratatui terminal")?;
let mut keys = EventStream::new();
let mut toast_tick = tokio::time::interval(Duration::from_millis(100));
toast_tick.tick().await;
terminal
.draw(|f| app.render(f))
.context("initial terminal draw")?;
loop {
tokio::select! {
biased;
() = cancel.cancelled() => break,
ev = keys.next() => {
match ev {
Some(Ok(CtEvent::Key(key))) => {
if key.kind == KeyEventKind::Press
&& handle_key_event(
key,
&mut app,
&bus,
&cancel,
profile_path.as_deref(),
&mut profile,
)
{
break;
}
}
Some(Ok(CtEvent::Resize(cols, rows))) => {
app.serial_pane_mut().resize(rows, cols);
}
Some(Ok(CtEvent::Mouse(m_ev))) => {
let _ = app.handle_mouse(m_ev);
}
Some(Ok(_)) => {
}
Some(Err(err)) => {
tracing::error!(%err, "terminal event stream error");
break;
}
None => {
break;
}
}
}
bus_ev = bus_rx.recv() => {
if !handle_bus_event(bus_ev, &mut app) {
break;
}
}
_ = toast_tick.tick() => {
}
}
terminal.draw(|f| app.render(f)).context("terminal draw")?;
}
Ok(())
}
fn handle_key_event(
key: KeyEvent,
app: &mut TuiApp,
bus: &EventBus,
cancel: &CancellationToken,
profile_path: Option<&Path>,
profile: &mut Profile,
) -> bool {
match app.handle_key(key) {
Dispatch::TxBytes(bytes) => {
bus.publish(Event::TxBytes(bytes::Bytes::from(bytes)));
false
}
Dispatch::OpenedMenu | Dispatch::ClosedMenu | Dispatch::Noop => false,
Dispatch::Quit => {
cancel.cancel();
true
}
Dispatch::Action(action) => {
apply_dialog_action(&action, app, bus, profile_path, profile);
false
}
}
}
fn apply_dialog_action(
action: &DialogAction,
app: &mut TuiApp,
bus: &EventBus,
profile_path: Option<&Path>,
profile: &mut Profile,
) {
if let DialogAction::ApplyModalStyleLive(style) = action {
app.set_modal_style(*style);
return;
}
if let Some(cmd) = action_to_command(action) {
bus.publish(Event::Command(cmd));
return;
}
match action {
DialogAction::ApplyLineEndingsLive(_) => {
tracing::warn!("live line-ending change not yet supported; restart rtcom to apply");
}
DialogAction::ApplyLineEndingsAndSave(le) => {
tracing::info!(
"line endings saved to profile; restart rtcom to apply to the live session"
);
profile.line_endings = line_ending_config_to_section(le);
app.set_line_endings(*le);
persist_profile(profile, profile_path, bus);
}
DialogAction::ApplyAndSave(cfg) => {
bus.publish(Event::Command(Command::ApplyConfig(*cfg)));
profile.serial = serial_config_to_section(cfg);
persist_profile(profile, profile_path, bus);
}
DialogAction::ApplyModalStyleAndSave(style) => {
app.set_modal_style(*style);
profile.screen.modal_style = *style;
persist_profile(profile, profile_path, bus);
}
DialogAction::WriteProfile => {
persist_profile(profile, profile_path, bus);
}
DialogAction::ReadProfile => {
reload_profile(profile, app, profile_path, bus);
}
_ => {
tracing::warn!(?action, "unhandled DialogAction");
}
}
}
fn persist_profile(profile: &Profile, path: Option<&Path>, bus: &EventBus) {
let Some(path) = path else {
tracing::warn!("profile save requested but no profile path is available");
return;
};
match rtcom_config::write(path, profile) {
Ok(()) => {
bus.publish(Event::ProfileSaved {
path: path.to_path_buf(),
});
}
Err(e) => {
let err = rtcom_core::Error::InvalidConfig(format!("profile write: {e}"));
bus.publish(Event::ProfileLoadFailed {
path: path.to_path_buf(),
error: Arc::new(err),
});
}
}
}
fn reload_profile(profile: &mut Profile, app: &mut TuiApp, path: Option<&Path>, bus: &EventBus) {
let Some(path) = path else {
tracing::warn!("profile reload requested but no profile path is available");
return;
};
match rtcom_config::read(path) {
Ok(new_profile) => {
let serial_cfg = serial_section_to_config(&new_profile.serial);
bus.publish(Event::Command(Command::ApplyConfig(serial_cfg)));
app.set_line_endings(line_endings_from_profile(&new_profile));
app.set_modal_style(new_profile.screen.modal_style);
*profile = new_profile;
bus.publish(Event::ProfileSaved {
path: path.to_path_buf(),
});
}
Err(e) => {
let err = rtcom_core::Error::InvalidConfig(format!("profile read: {e}"));
bus.publish(Event::ProfileLoadFailed {
path: path.to_path_buf(),
error: Arc::new(err),
});
}
}
}
#[must_use]
const fn action_to_command(action: &DialogAction) -> Option<Command> {
match action {
DialogAction::ApplyLive(cfg) => Some(Command::ApplyConfig(*cfg)),
DialogAction::SetDtr(state) => Some(Command::SetDtrAbs(*state)),
DialogAction::SetRts(state) => Some(Command::SetRtsAbs(*state)),
DialogAction::SendBreak => Some(Command::SendBreak),
DialogAction::ApplyModalStyleLive(_)
| DialogAction::ApplyAndSave(_)
| DialogAction::ApplyModalStyleAndSave(_)
| DialogAction::WriteProfile
| DialogAction::ReadProfile
| DialogAction::ApplyLineEndingsLive(_)
| DialogAction::ApplyLineEndingsAndSave(_) => None,
}
}
fn handle_bus_event(
bus_ev: std::result::Result<Event, broadcast::error::RecvError>,
app: &mut TuiApp,
) -> bool {
match bus_ev {
Ok(Event::RxBytes(b)) => {
app.serial_pane_mut().ingest(&b);
}
Ok(Event::ConfigChanged(cfg)) => {
app.set_serial_config(cfg);
app.set_config_summary(summarise(&cfg));
}
Ok(Event::SystemMessage(msg)) => {
app.serial_pane_mut()
.ingest(format!("\r\n*** rtcom: {msg}\r\n").as_bytes());
}
Ok(Event::DeviceDisconnected { reason }) => {
app.serial_pane_mut()
.ingest(format!("\r\n*** device disconnected: {reason}\r\n").as_bytes());
}
Ok(Event::Error(e)) => {
tracing::error!(%e, "bus error");
app.push_toast(format!("error: {e}"), ToastLevel::Error);
}
Ok(Event::ModemLinesChanged { dtr, rts }) => {
app.set_modem_lines(ModemLineSnapshot { dtr, rts });
}
Ok(Event::ProfileSaved { path }) => {
app.push_toast(
format!("profile saved: {}", path.display()),
ToastLevel::Info,
);
}
Ok(Event::ProfileLoadFailed { path, error }) => {
tracing::error!(%error, path = %path.display(), "profile IO failed");
app.push_toast(
format!("profile IO failed ({}): {error}", path.display()),
ToastLevel::Error,
);
}
Ok(_) => {}
Err(broadcast::error::RecvError::Closed) => {
return false;
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("bus lagged by {n} events");
}
}
true
}
#[must_use]
pub fn summarise(cfg: &SerialConfig) -> String {
format!(
"{} {}{}{} {}",
cfg.baud_rate,
cfg.data_bits.bits(),
parity_letter(cfg.parity),
stop_bits_number(cfg.stop_bits),
flow_word(cfg.flow_control),
)
}
const fn parity_letter(p: Parity) -> char {
match p {
Parity::None => 'N',
Parity::Even => 'E',
Parity::Odd => 'O',
Parity::Mark => 'M',
Parity::Space => 'S',
}
}
const fn stop_bits_number(s: StopBits) -> u8 {
match s {
StopBits::One => 1,
StopBits::Two => 2,
}
}
const fn flow_word(f: rtcom_core::FlowControl) -> &'static str {
match f {
rtcom_core::FlowControl::None => "none",
rtcom_core::FlowControl::Hardware => "hw",
rtcom_core::FlowControl::Software => "sw",
}
}
#[cfg(test)]
mod tests {
use super::*;
use rtcom_core::{DataBits, FlowControl, SerialConfig, StopBits};
#[test]
fn summarise_default_is_115200_8n1_none() {
let cfg = SerialConfig::default();
assert_eq!(summarise(&cfg), "115200 8N1 none");
}
#[test]
fn summarise_custom_config() {
let cfg = SerialConfig {
baud_rate: 9600,
data_bits: DataBits::Seven,
stop_bits: StopBits::Two,
parity: Parity::Even,
flow_control: FlowControl::Hardware,
..SerialConfig::default()
};
assert_eq!(summarise(&cfg), "9600 7E2 hw");
}
#[tokio::test]
async fn handle_bus_event_rx_bytes_reaches_pane() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
assert!(handle_bus_event(
Ok(Event::RxBytes(bytes::Bytes::from_static(b"hi"))),
&mut app
));
}
#[tokio::test]
async fn handle_bus_event_closed_breaks_loop() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
assert!(!handle_bus_event(
Err(broadcast::error::RecvError::Closed),
&mut app
));
}
#[tokio::test]
async fn handle_bus_event_lagged_is_logged_but_continues() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
assert!(handle_bus_event(
Err(broadcast::error::RecvError::Lagged(7)),
&mut app
));
}
#[tokio::test]
async fn handle_bus_event_config_changed_updates_summary() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
app.set_device_summary("/dev/ttyUSB0", "old");
let cfg = SerialConfig {
baud_rate: 9600,
..SerialConfig::default()
};
assert!(handle_bus_event(Ok(Event::ConfigChanged(cfg)), &mut app));
}
#[tokio::test]
async fn handle_bus_event_modem_lines_changed_reaches_app() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
assert!(handle_bus_event(
Ok(Event::ModemLinesChanged {
dtr: false,
rts: true
}),
&mut app
));
}
#[test]
fn apply_live_maps_to_apply_config_command() {
let cfg = SerialConfig::default();
let cmd = action_to_command(&DialogAction::ApplyLive(cfg)).expect("maps to ApplyConfig");
match cmd {
Command::ApplyConfig(out) => assert_eq!(out, cfg),
other => panic!("expected ApplyConfig, got {other:?}"),
}
}
#[test]
fn set_dtr_maps_to_set_dtr_abs_command() {
assert!(matches!(
action_to_command(&DialogAction::SetDtr(true)),
Some(Command::SetDtrAbs(true))
));
assert!(matches!(
action_to_command(&DialogAction::SetDtr(false)),
Some(Command::SetDtrAbs(false))
));
}
#[test]
fn set_rts_maps_to_set_rts_abs_command() {
assert!(matches!(
action_to_command(&DialogAction::SetRts(true)),
Some(Command::SetRtsAbs(true))
));
assert!(matches!(
action_to_command(&DialogAction::SetRts(false)),
Some(Command::SetRtsAbs(false))
));
}
#[test]
fn send_break_maps_to_send_break_command() {
assert!(matches!(
action_to_command(&DialogAction::SendBreak),
Some(Command::SendBreak)
));
}
#[test]
fn apply_modal_style_live_returns_none_because_handled_locally() {
assert!(action_to_command(&DialogAction::ApplyModalStyleLive(
rtcom_config::ModalStyle::DimmedOverlay,
))
.is_none());
}
#[test]
fn apply_line_endings_live_returns_none_pending_v021() {
let le = rtcom_core::LineEndingConfig::default();
assert!(action_to_command(&DialogAction::ApplyLineEndingsLive(le)).is_none());
}
#[test]
fn save_flavored_actions_return_none_pending_t18() {
let cfg = SerialConfig::default();
assert!(action_to_command(&DialogAction::ApplyAndSave(cfg)).is_none());
assert!(action_to_command(&DialogAction::WriteProfile).is_none());
assert!(action_to_command(&DialogAction::ReadProfile).is_none());
assert!(action_to_command(&DialogAction::ApplyModalStyleAndSave(
rtcom_config::ModalStyle::DimmedOverlay,
))
.is_none());
}
#[tokio::test]
async fn apply_dialog_action_apply_live_publishes_apply_config_command() {
let bus = EventBus::new(8);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
let mut profile = Profile::default();
let cfg = SerialConfig {
baud_rate: 9600,
..SerialConfig::default()
};
apply_dialog_action(
&DialogAction::ApplyLive(cfg),
&mut app,
&bus,
None,
&mut profile,
);
match rx.try_recv().expect("Command on the bus") {
Event::Command(Command::ApplyConfig(out)) => assert_eq!(out, cfg),
other => panic!("expected Event::Command(ApplyConfig), got {other:?}"),
}
}
#[tokio::test]
async fn apply_dialog_action_modal_style_live_does_not_publish_and_updates_cache() {
let bus = EventBus::new(8);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
let mut profile = Profile::default();
apply_dialog_action(
&DialogAction::ApplyModalStyleLive(rtcom_config::ModalStyle::DimmedOverlay),
&mut app,
&bus,
None,
&mut profile,
);
match rx.try_recv() {
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
other => panic!("expected Empty, got {other:?}"),
}
}
#[tokio::test]
async fn persist_profile_writes_to_disk_and_publishes_profile_saved() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.toml");
let profile = Profile::default();
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
persist_profile(&profile, Some(&path), &bus);
assert!(path.exists(), "profile file should be written");
match rx.try_recv().expect("ProfileSaved on the bus") {
Event::ProfileSaved { path: p } => assert_eq!(p, path),
other => panic!("expected ProfileSaved, got {other:?}"),
}
}
#[tokio::test]
async fn persist_profile_without_path_is_noop() {
let profile = Profile::default();
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
persist_profile(&profile, None, &bus);
assert!(matches!(
rx.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn persist_profile_io_error_publishes_profile_load_failed() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"").unwrap();
let path = blocker.join("nested.toml");
let profile = Profile::default();
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
persist_profile(&profile, Some(&path), &bus);
match rx.try_recv().expect("ProfileLoadFailed on the bus") {
Event::ProfileLoadFailed { path: p, error } => {
assert_eq!(p, path);
assert!(error.to_string().contains("profile write"));
}
other => panic!("expected ProfileLoadFailed, got {other:?}"),
}
}
#[tokio::test]
async fn reload_profile_reads_and_dispatches_apply_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.toml");
let mut disk_profile = Profile::default();
disk_profile.serial.baud = 9600;
rtcom_config::write(&path, &disk_profile).unwrap();
let mut memory_profile = Profile::default();
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
reload_profile(&mut memory_profile, &mut app, Some(&path), &bus);
assert_eq!(memory_profile.serial.baud, 9600);
match rx.try_recv().expect("ApplyConfig on the bus") {
Event::Command(Command::ApplyConfig(cfg)) => assert_eq!(cfg.baud_rate, 9600),
other => panic!("expected Command::ApplyConfig, got {other:?}"),
}
match rx.try_recv().expect("ProfileSaved on the bus") {
Event::ProfileSaved { path: p } => assert_eq!(p, path),
other => panic!("expected ProfileSaved, got {other:?}"),
}
}
#[tokio::test]
async fn reload_profile_malformed_toml_publishes_profile_load_failed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, b"not valid =~~ toml [\n").unwrap();
let mut memory_profile = Profile::default();
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
reload_profile(&mut memory_profile, &mut app, Some(&path), &bus);
match rx.try_recv().expect("ProfileLoadFailed on the bus") {
Event::ProfileLoadFailed { path: p, error } => {
assert_eq!(p, path);
assert!(error.to_string().contains("profile read"));
}
other => panic!("expected ProfileLoadFailed, got {other:?}"),
}
}
#[tokio::test]
async fn apply_dialog_action_apply_and_save_updates_profile_and_writes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("save.toml");
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
let mut profile = Profile::default();
let cfg = SerialConfig {
baud_rate: 57_600,
..SerialConfig::default()
};
apply_dialog_action(
&DialogAction::ApplyAndSave(cfg),
&mut app,
&bus,
Some(&path),
&mut profile,
);
assert_eq!(profile.serial.baud, 57_600);
assert!(path.exists());
match rx.try_recv().expect("ApplyConfig on the bus") {
Event::Command(Command::ApplyConfig(out)) => assert_eq!(out, cfg),
other => panic!("expected Command::ApplyConfig, got {other:?}"),
}
match rx.try_recv().expect("ProfileSaved on the bus") {
Event::ProfileSaved { path: p } => assert_eq!(p, path),
other => panic!("expected ProfileSaved, got {other:?}"),
}
}
#[tokio::test]
async fn handle_bus_event_profile_saved_pushes_info_toast() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
assert_eq!(app.toasts_mut().visible_count(), 0);
assert!(handle_bus_event(
Ok(Event::ProfileSaved {
path: std::path::PathBuf::from("/tmp/x.toml"),
}),
&mut app,
));
assert_eq!(app.toasts_mut().visible_count(), 1);
assert_eq!(
app.toasts_mut().visible()[0].level,
crate::toast::ToastLevel::Info
);
assert!(app.toasts_mut().visible()[0]
.message
.contains("/tmp/x.toml"));
}
#[tokio::test]
async fn handle_bus_event_profile_load_failed_pushes_error_toast() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
let err = rtcom_core::Error::InvalidConfig("bad toml".to_string());
assert!(handle_bus_event(
Ok(Event::ProfileLoadFailed {
path: std::path::PathBuf::from("/tmp/bad.toml"),
error: Arc::new(err),
}),
&mut app,
));
assert_eq!(app.toasts_mut().visible_count(), 1);
assert_eq!(
app.toasts_mut().visible()[0].level,
crate::toast::ToastLevel::Error
);
assert!(app.toasts_mut().visible()[0]
.message
.contains("/tmp/bad.toml"));
}
#[tokio::test]
async fn handle_bus_event_error_pushes_error_toast() {
let bus = EventBus::new(8);
let mut app = TuiApp::new(bus);
let err = rtcom_core::Error::InvalidConfig("boom".to_string());
assert!(handle_bus_event(Ok(Event::Error(Arc::new(err))), &mut app));
assert_eq!(app.toasts_mut().visible_count(), 1);
assert_eq!(
app.toasts_mut().visible()[0].level,
crate::toast::ToastLevel::Error
);
assert!(app.toasts_mut().visible()[0].message.contains("boom"));
}
#[tokio::test]
async fn apply_line_endings_and_save_persists_profile() {
use rtcom_core::{LineEnding, LineEndingConfig};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("le.toml");
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
let mut profile = Profile::default();
let le = LineEndingConfig {
omap: LineEnding::AddCrToLf,
imap: LineEnding::None,
emap: LineEnding::None,
};
apply_dialog_action(
&DialogAction::ApplyLineEndingsAndSave(le),
&mut app,
&bus,
Some(&path),
&mut profile,
);
assert_eq!(profile.line_endings.omap, "crlf");
let on_disk = rtcom_config::read(&path).unwrap();
assert_eq!(on_disk.line_endings.omap, "crlf");
let mut saw_saved = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, Event::ProfileSaved { .. }) {
saw_saved = true;
}
}
assert!(saw_saved, "expected ProfileSaved event");
}
#[tokio::test]
async fn apply_line_endings_live_still_warns_and_does_not_persist() {
use rtcom_core::{LineEnding, LineEndingConfig};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("le_live.toml");
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
let mut profile = Profile::default();
let le = LineEndingConfig {
omap: LineEnding::AddCrToLf,
imap: LineEnding::None,
emap: LineEnding::None,
};
apply_dialog_action(
&DialogAction::ApplyLineEndingsLive(le),
&mut app,
&bus,
Some(&path),
&mut profile,
);
assert_eq!(profile.line_endings.omap, "none");
assert!(!path.exists(), "live-only path must not write profile");
assert!(matches!(
rx.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn apply_dialog_action_apply_modal_style_and_save_persists_and_updates_app() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("style.toml");
let bus = EventBus::new(64);
let mut rx = bus.subscribe();
let mut app = TuiApp::new(bus.clone());
let mut profile = Profile::default();
apply_dialog_action(
&DialogAction::ApplyModalStyleAndSave(rtcom_config::ModalStyle::Fullscreen),
&mut app,
&bus,
Some(&path),
&mut profile,
);
assert_eq!(
profile.screen.modal_style,
rtcom_config::ModalStyle::Fullscreen
);
assert!(path.exists());
match rx.try_recv().expect("ProfileSaved on the bus") {
Event::ProfileSaved { path: p } => assert_eq!(p, path),
other => panic!("expected ProfileSaved, got {other:?}"),
}
}
}