概述
终端用户界面(TUI)为固件刷写工具提供了直观的交互体验。OpenixCLI 使用 ratatui 框架构建事件驱动的 TUI 应用,通过 mpsc 通道实现异步事件通信,支持设备扫描、固件加载、选项配置和进度追踪的完整流程。
模块结构
src/tui/ ├── mod.rs # 模块导出,run() 入口 ├── app.rs # App 状态、事件循环、键盘处理 ├── event.rs # AppEvent 事件类型定义 ├── bridge.rs # scan_devices、run_flash 后台任务 ├── ui.rs # UI 渲染函数 └── widgets/ # Widget 组件 ├── device_list.rs # 设备列表渲染 ├── firmware_info.rs # 固件信息和选项 ├── progress.rs # 进度条显示 └── log_view.rs # 日志显示
|
核心导出
核心数据结构
AppState 状态枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppState { Idle,
Ready,
Flashing,
Done,
Error, }
|
FocusPanel 焭点枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FocusPanel { Devices,
Options, }
impl FocusPanel { pub fn toggle(&self) -> Self { match self { FocusPanel::Devices => FocusPanel::Options, FocusPanel::Options => FocusPanel::Devices, } } }
|
AppEvent 事件类型
#[derive(Debug)] pub enum AppEvent { Key(KeyEvent),
Tick,
FlashStageStart(StageType),
FlashProgress { stage_progress: u64, total: u64, speed: f64, },
FlashPartitionStart(String),
FlashStageComplete,
FlashDone,
FlashError(String),
DevicesFound(Vec<DeviceInfo>),
LogMessage(LogLevel, String), }
|
DeviceInfo 设备信息
#[derive(Debug, Clone)] pub struct DeviceInfo { pub bus: u8,
pub port: u8,
pub mode: String,
pub chip: String,
pub chip_id: u32,
pub is_fel: bool, }
|
App 主结构
pub struct App { pub state: AppState,
pub devices: Vec<DeviceInfo>,
pub selected_device: usize,
pub device_scroll_offset: usize,
pub firmware: FirmwareState,
pub progress: ProgressState,
pub log: LogState,
pub focus: FocusPanel,
pub show_help: bool,
pub input_mode: bool,
pub input_buffer: String,
flash_start_time: Option<Instant>,
packer: Option<OpenixPacker>,
should_quit: bool, }
|
事件驱动架构
事件循环
pub async fn event_loop(tx: mpsc::UnboundedSender<AppEvent>) { let tick_rate = Duration::from_millis(100);
loop { let has_event = tokio::task::block_in_place(|| { event::poll(tick_rate).unwrap_or(false) });
if has_event { if let Ok(evt) = tokio::task::block_in_place(event::read) { match evt { Event::Key(key) => { if tx.send(AppEvent::Key(key)).is_err() { return; } } Event::Resize(_, _) => { } _ => {} } } } else { if tx.send(AppEvent::Tick).is_err() { return; } } } }
|
主应用循环
pub async fn run() -> anyhow::Result<()> { set_tui_mode(true);
enable_raw_mode()?; io::stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
let (event_tx, event_rx) = mpsc::unbounded_channel(); let (log_tx, log_rx) = mpsc::unbounded_channel();
terminal::set_tui_log_sender(Some(log_tx.clone()));
tokio::spawn(event_loop(event_tx.clone()));
tokio::spawn(bridge::scan_devices(event_tx.clone()));
let mut app = App::new();
while !app.should_quit { while let Ok(msg) = log_rx.try_recv() { app.log.push(msg.level, &msg.message); }
if app.is_flashing() { app.poll_progress(); }
terminal.draw(|frame| ui::render(frame, &mut app))?;
if let Some(event) = event_rx.recv().await { match event { AppEvent::Key(key) => app.handle_key(key), AppEvent::Tick => { } AppEvent::DevicesFound(devices) => { app.devices = devices; app.update_state(); } AppEvent::LogMessage(level, msg) => { app.log.push(level, &msg); } AppEvent::FlashDone => { app.state = AppState::Done; app.reload_firmware(); } AppEvent::FlashError(msg) => { app.state = AppState::Error; app.log.push(LogLevel::Error, &msg); } } } }
terminal::set_tui_log_sender(None); disable_raw_mode()?; io::stdout().execute(LeaveAlternateScreen)?;
Ok(()) }
|
事件流程图
sequenceDiagram
participant User as 用户
participant EventLoop as event_loop
participant MainLoop as 主循环
participant App as App 状态
participant Bridge as bridge
participant Flasher as Flasher
EventLoop->>MainLoop: Key(KeyEvent)
EventLoop->>MainLoop: Tick (每 100ms)
User->>Bridge: 按下 's' 键
MainLoop->>App: handle_key(KeyCode::Char('s'))
App->>Bridge: spawn scan_devices()
Bridge->>MainLoop: DevicesFound([DeviceInfo])
MainLoop->>App: 更新设备列表
User->>Bridge: 按下 'f' 键(刷写)
MainLoop->>App: handle_key(KeyCode::Char('f'))
App->>Bridge: spawn run_flash()
Bridge->>Flasher: execute()
loop 刷写过程
Flasher->>MainLoop: FlashProgress
MainLoop->>App: poll_progress()
MainLoop->>App: 更新进度显示
end
Flasher->>MainLoop: FlashDone/FlashError
MainLoop->>App: 更新状态
Bridge 函数详解
scan_devices 设备扫描
pub async fn scan_devices(tx: mpsc::UnboundedSender<AppEvent>) { let _ = tx.send(AppEvent::LogMessage( LogLevel::Info, "Scanning for devices...".into(), ));
match libefex::Context::scan_usb_devices() { Ok(devices) => { if devices.is_empty() { let _ = tx.send(AppEvent::LogMessage(LogLevel::Warn, "No devices found".into())); let _ = tx.send(AppEvent::DevicesFound(vec![])); return; }
let mut infos = Vec::new(); for dev in &devices { let mut ctx = libefex::Context::new(); if ctx.scan_usb_device_at(dev.bus, dev.port).is_err() { continue; } if ctx.usb_init().is_err() { continue; } if ctx.efex_init().is_err() { continue; }
let mode = ctx.get_device_mode(); let is_fel = mode == libefex::DeviceMode::Fel; let mode_str = match mode { libefex::DeviceMode::Fel => "FEL", libefex::DeviceMode::Srv => "FES", };
let chip = ctx.get_device_mode_str().to_string(); let chip_id = unsafe { (*ctx.as_ptr()).resp.id };
infos.push(DeviceInfo { bus: dev.bus, port: dev.port, mode: mode_str.into(), chip, chip_id, is_fel, }); }
let _ = tx.send(AppEvent::DevicesFound(infos)); } Err(e) => { let _ = tx.send(AppEvent::LogMessage(LogLevel::Error, format!("Scan failed: {}", e))); } } }
|
run_flash 后台刷写
pub async fn run_flash( tx: mpsc::UnboundedSender<AppEvent>, packer: OpenixPacker, bus: Option<u8>, port: Option<u8>, mode: CmdFlashMode, verify: bool, partitions: Option<Vec<String>>, post_action: String, ) { let flash_mode = match mode { CmdFlashMode::Partition => FlashMode::Partition, CmdFlashMode::KeepData => FlashMode::KeepData, CmdFlashMode::PartitionErase => FlashMode::PartitionErase, CmdFlashMode::FullErase => FlashMode::FullErase, };
let options = FlashOptions { bus, port, verify, mode: flash_mode, partitions, post_action: post_action.clone(), };
let result = tokio::task::spawn_blocking(move || { let rt = tokio::runtime::Handle::current(); rt.block_on(async { let mut flasher = Flasher::new(packer, options, cli_logger); flasher.execute().await }) }).await;
match result { Ok(Ok(())) => { let _ = tx.send(AppEvent::FlashDone); } Ok(Err(e)) => { let _ = tx.send(AppEvent::FlashError(format!("{}", e))); } Err(e) => { let _ = tx.send(AppEvent::FlashError(format!("Flash task panicked: {}", e))); } } }
|
键盘处理
handle_key 实现
fn handle_key(&mut self, key: KeyEvent) { if key.kind != KeyEventKind::Press { return; }
if self.show_help { if key.code == KeyCode::Esc || key.code == KeyCode::Char('h') { self.show_help = false; } return; }
if self.input_mode { self.handle_input_key(key); return; }
if self.is_flashing() { if key.code == KeyCode::Esc || key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.should_quit = true; } return; }
match key.code { KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, KeyCode::Char('h') => self.show_help = true, KeyCode::Tab => self.focus = self.focus.toggle(), KeyCode::Char('s') => self.start_scan(), KeyCode::Char('r') => self.reload_firmware(), KeyCode::Char('f') => self.start_flash(), KeyCode::Char('p') => self.firmware.toggle_partition_selection(), KeyCode::Up => self.handle_up(), KeyCode::Down => self.handle_down(), KeyCode::Enter => self.handle_enter(), _ => {} } }
|
快捷键列表
| 键 |
功能 |
q / Esc |
退出 |
h |
显示帮助 |
s |
扫描设备 |
r |
重新加载固件 |
f |
开始刷写 |
p |
切换分区选择 |
Tab |
切换焦点面板 |
↑ / ↓ |
滚动选择 |
Enter |
确认选择 |
Ctrl+C |
强制退出 |
进度轮询机制
poll_progress 实现
fn poll_progress(&mut self) { let gp = global_progress(); let snap = gp.snapshot();
self.progress.overall_percent = snap.precise_progress; self.progress.stage_progress = snap.stage_progress; self.progress.stage_total = snap.total_bytes; self.progress.speed = snap.speed;
if !snap.current_partition.is_empty() { self.progress.current_partition = snap.current_partition; }
if snap.current_stage_index < snap.stages.len() { let current = snap.stages[snap.current_stage_index].stage_type; self.progress.current_stage = Some(current); self.progress.stage_index = snap.current_stage_index; }
self.progress.completed_stages.clear(); for stage_info in &snap.stages { if stage_info.completed { self.progress.completed_stages.push(stage_info.stage_type); } } }
|
UI 渲染
布局结构
pub fn render(frame: &mut Frame, app: &mut App) { let layout = if frame.area().width >= 100 { Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(frame.area()) } else { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(frame.area()) };
let left_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(layout[0]);
render_device_list(frame, left_layout[0], app);
render_firmware_info(frame, left_layout[1], app);
render_progress(frame, layout[1], app);
render_log_view(frame, layout[2], app);
render_title_bar(frame, frame.area());
if app.show_help { render_help_overlay(frame, frame.area()); } }
|
布局图
┌──────────────────────────────────────────────────────────────────────────┐ │ OpenixCLI - Firmware Flasher │ ├─────────────────────────────────────────┬────────────────────────────────┤ │ │ │ │ ┌─────────────────────────────────┐ │ ┌────────────────────────────┐│ │ │ Device List │ │ │ Progress ││ │ │ [1] Bus 001, Port 002 │ │ │ ████████████░░░░░░ 75% ││ │ │ Chip: sun50iw10 │ │ │ [rootfs] 3.2 MB/s ││ │ │ Mode: FEL │ │ │ ││ │ │ [2] Bus 002, Port 001 │ │ │ Stages: ││ │ │ Mode: FES │ │ │ ✓ Init ││ │ │ │ │ │ ✓ DRAM Init ││ │ │ Press 's' to scan │ │ │ ✓ U-Boot ││ │ └─────────────────────────────────┘ │ │ → Flashing Partitions ││ │ │ │ ││ │ ┌─────────────────────────────────┐ │ └────────────────────────────┘│ │ │ Firmware Options │ │ │ │ │ Path: firmware.fex │ │ ┌────────────────────────────┐│ │ │ Size: 128 MB │ │ │ Log View ││ │ │ Files: 12 │ │ │ [INFO] Loading firmware ││ │ │ Mode: Full Erase │ │ │ [INFO] Device connected ││ │ │ Partitions: boot, rootfs │ │ │ [OKAY] DRAM initialized ││ │ │ │ │ │ [WARN] Partition skipped ││ │ │ [Flash] Press 'f' │ │ │ [ERRO] Transfer failed ││ │ └─────────────────────────────────┘ │ └────────────────────────────┘│ │ │ │ ├─────────────────────────────────────────┴────────────────────────────────┤ │ Status: Ready | Help: 'h' | Scan: 's' | Flash: 'f' | Quit: 'q' │ └──────────────────────────────────────────────────────────────────────────┘
|
代码亮点
1. spawn_blocking 处理 !Send 类型
let result = tokio::task::spawn_blocking(move || { let rt = tokio::runtime::Handle::current(); rt.block_on(async { let mut flasher = Flasher::new(packer, options, cli_logger); flasher.execute().await }) }).await;
|
2. block_in_place 在异步中阻塞
let has_event = tokio::task::block_in_place(|| { event::poll(tick_rate).unwrap_or(false) });
|
3. mpsc::unbounded_channel 无界通道
let (event_tx, event_rx) = mpsc::unbounded_channel();
event_tx.send(AppEvent::Key(key))?;
if let Some(event) = event_rx.recv().await { }
|
4. ratatui 状态驱动渲染
terminal.draw(|frame| { ui::render(frame, &mut app); })?;
|
5. GlobalProgress 快照轮询
let snap = global_progress().snapshot(); self.progress.overall_percent = snap.precise_progress;
|
实践示例
启动 TUI
$ openixcli
$ openixcli tui
|
交互流程
- 扫描设备:按
s 扫描 USB 设备
- 选择设备:使用
↑/↓ 选择,Enter 确认
- 加载固件:输入固件路径或拖拽文件
- 配置选项:
Tab 切换到选项面板,设置刷写模式
- 开始刷写:按
f 开始刷写
- 查看进度:实时显示刷写进度和日志
- 完成:按
q 或 Esc 退出
状态转换图
stateDiagram-v2
[*] --> Idle: 启动
Idle --> Ready: 有设备 + 有固件
Ready --> Idle: 无设备 或 无固件
Ready --> Flashing: 按 'f'
Flashing --> Done: 刷写成功
Flashing --> Error: 刷写失败
Done --> Ready: 可以再次刷写
Error --> Ready: 修复后可再次刷写
Idle --> [*]: 按 'q'
Ready --> [*]: 按 'q'
Done --> [*]: 按 'q'
Error --> [*]: 按 'q'
ProgressState
pub struct ProgressState { pub overall_percent: f64,
pub stage_progress: u64,
pub stage_total: u64,
pub speed: f64,
pub current_partition: String,
pub current_stage: Option<StageType>,
pub completed_stages: Vec<StageType>, }
|
LogState
pub struct LogState { pub entries: Vec<LogEntry>,
pub scroll_offset: usize, }
pub struct LogEntry { pub timestamp: Instant,
pub level: LogLevel,
pub message: String, }
|