thd75_tui/
main.rs

1mod app;
2mod event;
3mod radio_task;
4mod ui;
5
6use std::io;
7
8use app::App;
9use clap::Parser;
10use crossterm::execute;
11use crossterm::terminal::{
12    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
13};
14use ratatui::Terminal;
15use ratatui::backend::CrosstermBackend;
16
17/// Terminal UI for the Kenwood TH-D75 transceiver.
18#[derive(Parser, Debug)]
19#[command(version, about)]
20struct Cli {
21    /// Serial port path (default: auto-discover USB).
22    #[arg(short, long)]
23    port: Option<String>,
24
25    /// Baud rate for CAT commands.
26    #[arg(short, long, default_value_t = 115_200)]
27    baud: u32,
28
29    /// MCP transfer speed: safe or fast.
30    #[arg(long, default_value = "safe")]
31    mcp_speed: String,
32}
33
34fn main() -> Result<(), Box<dyn std::error::Error>> {
35    type BtResult = Result<(String, kenwood_thd75::transport::EitherTransport), String>;
36
37    let cli = Cli::parse();
38
39    if std::env::var("RUST_LOG").is_ok() {
40        let log_file = std::fs::File::create("thd75-tui.log").expect("failed to create log file");
41        tracing_subscriber::fmt()
42            .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
43            .with_writer(log_file)
44            .with_ansi(false)
45            .init();
46    }
47
48    let original_hook = std::panic::take_hook();
49    std::panic::set_hook(Box::new(move |panic_info| {
50        let _ = disable_raw_mode();
51        let _ = execute!(io::stdout(), LeaveAlternateScreen);
52        original_hook(panic_info);
53    }));
54
55    // Open BT connection on the main thread (IOBluetooth needs main CFRunLoop).
56    let transport = radio_task::discover_and_open_transport(cli.port.as_deref(), cli.baud);
57
58    // Terminal setup on main thread before spawning
59    enable_raw_mode()?;
60    let mut stdout = io::stdout();
61    execute!(stdout, EnterAlternateScreen)?;
62    let backend = CrosstermBackend::new(stdout);
63    let mut terminal = Terminal::new(backend)?;
64
65    let (done_tx, done_rx) = std::sync::mpsc::channel::<Result<(), String>>();
66
67    // Channel for BT reconnect requests from the tokio thread.
68    // IOBluetooth RFCOMM must be opened on the main thread (needs CFRunLoop).
69    // The tokio thread sends (port, baud) and the main thread replies with the transport.
70    let (bt_req_tx, bt_req_rx) = std::sync::mpsc::channel::<(Option<String>, u32)>();
71    let (bt_resp_tx, bt_resp_rx) = std::sync::mpsc::channel::<BtResult>();
72
73    let mcp_speed = cli.mcp_speed;
74
75    let _thread = std::thread::spawn(move || {
76        let rt = tokio::runtime::Builder::new_multi_thread()
77            .enable_all()
78            .build()
79            .expect("failed to build tokio runtime");
80
81        let result = rt.block_on(async {
82            run_app(&mut terminal, transport, mcp_speed, bt_req_tx, bt_resp_rx)
83                .await
84                .map_err(|e| e.to_string())
85        });
86
87        let _ = disable_raw_mode();
88        let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
89        let _ = terminal.show_cursor();
90
91        let _ = done_tx.send(result);
92    });
93
94    // Main thread: pump CFRunLoop for IOBluetooth callbacks
95    loop {
96        #[cfg(target_os = "macos")]
97        #[allow(unsafe_code)]
98        unsafe {
99            unsafe extern "C" {
100                fn CFRunLoopRunInMode(
101                    mode: *const std::ffi::c_void,
102                    seconds: f64,
103                    returnAfterSourceHandled: u8,
104                ) -> i32;
105                static kCFRunLoopDefaultMode: *const std::ffi::c_void;
106            }
107            let _ = CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, 0);
108        }
109
110        #[cfg(not(target_os = "macos"))]
111        std::thread::sleep(std::time::Duration::from_millis(10));
112
113        // Handle BT reconnect requests from the tokio thread.
114        // BluetoothTransport::open() must happen on the main thread.
115        if let Ok((port, baud)) = bt_req_rx.try_recv() {
116            let result = radio_task::discover_and_open_transport(port.as_deref(), baud);
117            let _ = bt_resp_tx.send(result);
118        }
119
120        if let Ok(result) = done_rx.try_recv() {
121            if let Err(e) = result {
122                eprintln!("Error: {e}");
123            }
124            break;
125        }
126    }
127
128    Ok(())
129}
130
131async fn run_app(
132    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
133    transport: Result<(String, kenwood_thd75::transport::EitherTransport), String>,
134    mcp_speed: String,
135    bt_req_tx: std::sync::mpsc::Sender<(Option<String>, u32)>,
136    bt_resp_rx: std::sync::mpsc::Receiver<
137        Result<(String, kenwood_thd75::transport::EitherTransport), String>,
138    >,
139) -> Result<(), Box<dyn std::error::Error>> {
140    let mut events = event::EventHandler::new();
141    let tx = events.sender();
142    let cmd_rx = events.take_command_receiver();
143
144    let (path, transport) = transport.map_err(|e| format!("Could not connect to radio: {e}"))?;
145
146    let port_display = match radio_task::spawn_with_transport(
147        path, transport, mcp_speed, tx, cmd_rx, bt_req_tx, bt_resp_rx,
148    )
149    .await
150    {
151        Ok(p) => p,
152        Err(e) => return Err(format!("Could not connect to radio: {e}").into()),
153    };
154
155    let mut app = App::new(port_display);
156    app.connected = true;
157    app.cmd_tx = Some(events.command_sender());
158
159    let _ = terminal.draw(|frame| ui::render(&app, frame))?;
160
161    loop {
162        let msg = events.next().await;
163        let needs_render = app.update(msg);
164        if app.should_quit {
165            break;
166        }
167        if needs_render {
168            let _ = terminal.draw(|frame| ui::render(&app, frame))?;
169        }
170    }
171
172    Ok(())
173}