Skip to main content

flashkraft_tui/
lib.rs

1//! FlashKraft TUI — Library crate
2//!
3//! This crate exposes the full Ratatui terminal UI as a library so that:
4//! - The `flashkraft-tui` binary can stay thin (argument parsing + `lib::run()`)
5//! - Examples can import types directly from `flashkraft_tui::`
6//!
7//! ## Module layout
8//!
9//! ```text
10//! flashkraft_tui
11//! ├── domain  ← re-exported from flashkraft_core::domain
12//! ├── core    ← re-exported from flashkraft_core (commands, flash_writer, …)
13//! └── tui     ← Ratatui front-end (app / events / flash_runner / ui)
14//! ```
15//!
16//! The file-browser widget is provided by the [`tui-file-explorer`](https://crates.io/crates/tui-file-explorer)
17//! crate and consumed directly via `tui_file_explorer::*`.
18
19// ── Core re-exports ───────────────────────────────────────────────────────────
20
21/// Re-export `flashkraft_core` under the short alias `core` so that
22/// `crate::core::commands::load_drives()`, `crate::core::flash_helper::*`,
23/// etc. resolve correctly from every submodule and from examples via
24/// `flashkraft_tui::core::*`.
25pub mod core {
26    pub use flashkraft_core::commands;
27    pub use flashkraft_core::domain;
28    pub use flashkraft_core::flash_helper;
29    pub use flashkraft_core::utils;
30}
31
32/// Re-export `flashkraft_core::domain` at the crate root so that
33/// `crate::domain::DriveInfo` / `crate::domain::ImageInfo` resolve in
34/// submodules, and so that examples can write `flashkraft_tui::domain::*`.
35pub use flashkraft_core::domain;
36
37// ── TUI submodules ────────────────────────────────────────────────────────────
38
39/// Ratatui front-end — app state, event handling, flash runner, UI rendering.
40///
41/// Submodules: `app` (state machine), `events` (key handling),
42/// `flash_runner` (background flash task), `ui` (frame rendering).
43pub mod tui;
44
45// ── Convenience re-exports for examples and tests ────────────────────────────
46
47pub use flashkraft_core::flash_helper;
48pub use tui::app::{App, AppScreen, FlashEvent, InputMode, UsbEntry};
49pub use tui::events::handle_key;
50pub use tui::ui::render;
51pub use tui_file_explorer::{ExplorerOutcome, FileExplorer, FsEntry};
52
53// ── Public event-loop API ─────────────────────────────────────────────────────
54
55use std::io;
56use std::panic;
57use std::time::Duration;
58
59use anyhow::Result;
60use crossterm::{
61    event::{self, Event},
62    execute,
63    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
64};
65use ratatui::{backend::CrosstermBackend, Terminal};
66
67/// Set up the terminal, run the application event loop, then restore the
68/// terminal on exit (or on panic).
69///
70/// This is the single entry point called by `main.rs`.  Having it here lets
71/// integration tests and examples exercise the loop without spawning a
72/// subprocess.
73pub async fn run() -> Result<()> {
74    // Install a panic hook that restores the terminal before printing the
75    // panic message — otherwise the output is invisible in raw / alt-screen
76    // mode.
77    let default_hook = panic::take_hook();
78    panic::set_hook(Box::new(move |info| {
79        let _ = restore_terminal();
80        default_hook(info);
81    }));
82
83    // Initialise raw mode + alternate screen.
84    enable_raw_mode()?;
85    let mut stdout = io::stdout();
86    execute!(stdout, EnterAlternateScreen)?;
87    let backend = CrosstermBackend::new(stdout);
88    let mut terminal = Terminal::new(backend)?;
89
90    // Drive the application.
91    let run_result = run_app(&mut terminal).await;
92
93    // Restore unconditionally (even if the app returned Err).
94    restore_terminal()?;
95    terminal.show_cursor()?;
96
97    run_result
98}
99
100/// Drive the [`App`] state machine until `should_quit` is set.
101///
102/// Each iteration:
103/// 1. Tick the internal counter (used for animations).
104/// 2. Drain any pending async channel messages (drive detection / flash / hotplug).
105/// 3. Render a single frame.
106/// 4. Block for up to 100 ms waiting for a keyboard event.
107///
108/// The generic backend parameter makes the function testable with ratatui's
109/// `TestBackend` without touching real terminal infrastructure.
110pub async fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>) -> Result<()>
111where
112    B::Error: Send + Sync + 'static,
113{
114    let mut app = App::new();
115
116    // ── USB hotplug watcher ───────────────────────────────────────────────────
117    //
118    // Spawn a background task that listens for block-device connect / disconnect
119    // events via `watch_usb_events()` (inotify on Linux, FSEvents on macOS,
120    // ReadDirectoryChangesW on Windows) and forwards a bare `()` trigger over
121    // an unbounded channel.  `poll_hotplug()` drains the channel each tick and
122    // starts a fresh drive enumeration when triggered.
123    //
124    // The task lives for the entire lifetime of the application.  If the OS
125    // refuses to create the watch (no USB subsystem) we log and move on —
126    // the manual r/F5 refresh path still works normally.
127    {
128        use flashkraft_core::commands::watch_usb_events;
129        use futures::StreamExt as _;
130        use tokio::sync::mpsc;
131
132        let (tx, rx) = mpsc::unbounded_channel::<()>();
133        app.hotplug_rx = Some(rx);
134
135        tokio::spawn(async move {
136            match watch_usb_events() {
137                Ok(mut stream) => {
138                    while stream.next().await.is_some() {
139                        // A send failure means the App (and its receiver) was
140                        // dropped — the TUI is shutting down; exit the task.
141                        if tx.send(()).is_err() {
142                            break;
143                        }
144                    }
145                }
146                Err(e) => {
147                    eprintln!("[hotplug] watch_usb_events failed: {e}");
148                }
149            }
150        });
151    }
152
153    loop {
154        // ── Tick ─────────────────────────────────────────────────────────────
155        app.tick_count = app.tick_count.wrapping_add(1);
156
157        // ── Poll async channels ───────────────────────────────────────────────
158        app.poll_hotplug();
159        app.poll_drives();
160        app.poll_flash();
161
162        // ── Render ────────────────────────────────────────────────────────────
163        terminal.draw(|frame| render(&mut app, frame))?;
164
165        // ── Keyboard events (100 ms timeout) ──────────────────────────────────
166        if event::poll(Duration::from_millis(100))? {
167            if let Event::Key(key) = event::read()? {
168                handle_key(&mut app, key);
169            }
170        }
171
172        // ── Quit guard ────────────────────────────────────────────────────────
173        if app.should_quit {
174            break;
175        }
176    }
177
178    Ok(())
179}
180
181/// Disable raw mode and leave the alternate screen.
182///
183/// Called both on normal exit and from the panic hook so the terminal is
184/// always left in a usable state.
185pub fn restore_terminal() -> Result<()> {
186    disable_raw_mode()?;
187    execute!(io::stdout(), LeaveAlternateScreen)?;
188    Ok(())
189}