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}