From 11fac82dbdf772aa50874a4c7fd0a9f5999c1d95 Mon Sep 17 00:00:00 2001 From: EmixamPP Date: Fri, 19 Dec 2025 23:02:55 +0100 Subject: [PATCH 1/2] refactor: separate UI keys (custom type around crossterm keys) from controller keys (raw crossterm keys) for allowing more UI choices while keeping the abstraction at the controller side --- src/configure/app/ir_enabler.rs | 49 +++++++++++++++--------- src/configure/app/tool_menu.rs | 11 +++--- src/configure/ui.rs | 68 +++++++++++++-------------------- 3 files changed, 62 insertions(+), 66 deletions(-) diff --git a/src/configure/app/ir_enabler.rs b/src/configure/app/ir_enabler.rs index 7a67ae17..c8eff76c 100644 --- a/src/configure/app/ir_enabler.rs +++ b/src/configure/app/ir_enabler.rs @@ -1,6 +1,6 @@ use super::helper::*; use crate::configure::ui::ir_enabler::{IrEnablerCtx, View, ui}; -use crate::configure::ui::keys::*; +use crate::configure::ui::{DeviceSettingsCtx, SearchSettingsCtx}; use crate::video::ir::analyzer::{ self, IsIrWorking as AnalyzerResponse, Message as AnalyzerRequest, StreamAnalyzer, }; @@ -24,6 +24,13 @@ use tokio::{ task, }; +const KEY_YES: KeyCode = KeyCode::Char('y'); +const KEY_NO: KeyCode = KeyCode::Char('n'); +const KEY_EXIT: KeyCode = KeyCode::Esc; +const KEY_NAVIGATE: KeyCode = KeyCode::Tab; +const KEY_CONTINUE: KeyCode = KeyCode::Enter; +const KEY_DELETE: KeyCode = KeyCode::Backspace; + #[derive(Debug)] pub struct Config { /// Path to the video device. @@ -422,13 +429,13 @@ impl App { } /// Handles a key event based on the current application state. - async fn handle_key_press(&mut self, key: Key) -> Result<()> { + async fn handle_key_press(&mut self, key: KeyCode) -> Result<()> { match self.state() { State::Menu => match key { KEY_EXIT => self.set_state(State::Failure), KEY_NAVIGATE => self.next_setting(), KEY_DELETE => self.edit_setting(None), - Key(KeyCode::Char(c)) => self.edit_setting(Some(c)), + KeyCode::Char(c) => self.edit_setting(Some(c)), KEY_CONTINUE => self.set_state(State::ConfirmStart), _ => {} }, @@ -458,7 +465,7 @@ impl App { /// In both of the two case, also changes the state to [`State::Running`]. /// /// Otherwise, does nothing. - async fn confirm_working(&mut self, k: Key) -> Result<()> { + async fn confirm_working(&mut self, k: KeyCode) -> Result<()> { let mut response = IREnablerResponse::No; if k == KEY_YES { response = IREnablerResponse::Yes; @@ -477,7 +484,7 @@ impl App { /// and sends [`IREnablerResponse::Abort`] to the configurator task. /// /// If the key is [`KEY_NO`], change the state back to [`State::Running`]. - async fn abort_or_continue(&mut self, k: Key) -> Result<()> { + async fn abort_or_continue(&mut self, k: KeyCode) -> Result<()> { match k { KEY_NO | KEY_EXIT => self.set_state(self.prev_state()), KEY_YES => { @@ -498,7 +505,7 @@ impl App { /// If the key is [`KEY_NO`], change the state back to the previous state. /// /// Returns directly an error if the video stream is already started. - fn start_or_back(&mut self, k: Key) -> Result<()> { + fn start_or_back(&mut self, k: KeyCode) -> Result<()> { // check that the path exists if !self.is_device_valid() { self.set_state(State::Menu); @@ -594,6 +601,18 @@ impl IrEnablerCtx for App { fn show_menu_start_prompt(&self) -> bool { self.state() == State::ConfirmStart } + fn controls_list_state(&mut self) -> &mut ListState { + &mut self.controls_list_state + } + fn controls(&self) -> &[XuControl] { + &self.controls + } + fn image(&self) -> Option<&Image> { + self.image.as_ref() + } +} + +impl DeviceSettingsCtx for App { fn device_settings_list_state(&mut self) -> &mut ListState { &mut self.device_settings_list_state } @@ -615,6 +634,9 @@ impl IrEnablerCtx for App { fn fps(&self) -> Option { self.config.fps } +} + +impl SearchSettingsCtx for App { fn search_settings_list_state(&mut self) -> &mut ListState { &mut self.search_settings_list_state } @@ -633,15 +655,6 @@ impl IrEnablerCtx for App { fn inc_step(&self) -> u8 { self.config.inc_step } - fn controls_list_state(&mut self) -> &mut ListState { - &mut self.controls_list_state - } - fn controls(&self) -> &[XuControl] { - &self.controls - } - fn image(&self) -> Option<&Image> { - self.image.as_ref() - } } #[cfg(test)] @@ -655,16 +668,16 @@ mod tests { App::new() } - fn make_key_event(keycode: Key) -> KeyEvent { + fn make_key_event(keycode: KeyCode) -> KeyEvent { KeyEvent { - code: keycode.into(), + code: keycode, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: crossterm::event::KeyEventState::NONE, } } - fn make_term_key_event(keycode: Key) -> Event { + fn make_term_key_event(keycode: KeyCode) -> Event { Event::Key(make_key_event(keycode)) } diff --git a/src/configure/app/tool_menu.rs b/src/configure/app/tool_menu.rs index e304b1ff..8e5bdc2b 100644 --- a/src/configure/app/tool_menu.rs +++ b/src/configure/app/tool_menu.rs @@ -1,9 +1,8 @@ use crate::configure::app::ir_enabler::App as IREnablerApp; -use crate::configure::ui::keys::{KEY_CONTINUE, KEY_EXIT, KEY_NAVIGATE}; use crate::configure::ui::tool_menu::ui; use anyhow::Result; -use crossterm::event; +use crossterm::event::{self, KeyCode}; use crossterm::event::{Event, KeyEventKind}; /// Application state for the tool menu. @@ -31,10 +30,10 @@ impl App { terminal.draw(|f| ui(f, self))?; match event::read()? { Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { - match key_event.code.into() { - KEY_NAVIGATE => self.next_tool(), - KEY_CONTINUE => return self.start_tool(terminal).await, - KEY_EXIT => return Ok(""), + match key_event.code { + KeyCode::Tab => self.next_tool(), + KeyCode::Enter => return self.start_tool(terminal).await, + KeyCode::Esc => return Ok(""), _ => {} } } diff --git a/src/configure/ui.rs b/src/configure/ui.rs index 68174193..0de37745 100644 --- a/src/configure/ui.rs +++ b/src/configure/ui.rs @@ -3,55 +3,40 @@ use helper::*; pub mod ir_enabler; mod shared; use shared::*; +pub use shared::{DeviceSettingsCtx, SearchSettingsCtx}; pub mod tool_menu; +pub mod tweaker; -pub mod keys { +mod keys { use crossterm::event::KeyCode; - use derive_more::{From, Into}; use ratatui::{ style::Stylize, style::{Color, Style}, text::{Line, Span}, }; - #[derive(Debug, Clone, Copy, PartialEq, Eq, From, Into)] - pub(crate) struct Key(pub KeyCode); + #[derive(Debug, Clone, Copy)] + pub struct Key { + code: KeyCode, + name: &'static str, + color: Color, + } + + impl Key { + const fn new(code: KeyCode, name: &'static str, color: Color) -> Self { + Self { code, name, color } + } + } pub fn keys_to_line(keys: &[Key]) -> Line<'static> { let mut spans = Vec::with_capacity(keys.len() * 3); for (i, key) in keys.iter().enumerate() { - match key.0 { - KeyCode::Esc => { - spans.push("Quit <".bold()); - spans.push(Span::styled("Esc", Style::default().fg(Color::Red))); - spans.push(">".bold()); - } - KeyCode::Tab => { - spans.push("Navigate <".bold()); - spans.push(Span::styled("Tab", Style::default().fg(Color::Yellow))); - spans.push(">".bold()); - } - KeyCode::Enter => { - spans.push("Continue <".bold()); - spans.push(Span::styled("Enter", Style::default().fg(Color::Green))); - spans.push(">".bold()); - } - KeyCode::Char('y') => { - spans.push("Yes <".bold()); - spans.push(Span::styled("y", Style::default().fg(Color::Green))); - spans.push(">".bold()); - } - KeyCode::Char('n') => { - spans.push("No <".bold()); - spans.push(Span::styled("n", Style::default().fg(Color::Red))); - spans.push(">".bold()); - } - _ => { - spans.push("? <".bold()); - spans.push(Span::raw(format!("{:?}", key.0))); - spans.push(">".bold()); - } - } + spans.push(format!("{} <", key.name).bold()); + spans.push(Span::styled( + key.code.to_string(), + Style::default().fg(key.color), + )); + spans.push(">".bold()); if i != keys.len() - 1 { spans.push(Span::raw(" ")); @@ -60,12 +45,11 @@ pub mod keys { Line::from(spans) } - pub const KEY_EXIT: Key = Key(KeyCode::Esc); - pub const KEY_NAVIGATE: Key = Key(KeyCode::Tab); - pub const KEY_CONTINUE: Key = Key(KeyCode::Enter); - pub const KEY_YES: Key = Key(KeyCode::Char('y')); - pub const KEY_NO: Key = Key(KeyCode::Char('n')); - pub const KEY_DELETE: Key = Key(KeyCode::Backspace); + pub const KEY_EXIT: Key = Key::new(KeyCode::Esc, "Quit", Color::Red); + pub const KEY_NAVIGATE: Key = Key::new(KeyCode::Tab, "Navigate", Color::Yellow); + pub const KEY_CONTINUE: Key = Key::new(KeyCode::Enter, "Continue", Color::Green); + pub const KEY_YES: Key = Key::new(KeyCode::Char('y'), "Yes", Color::Green); + pub const KEY_NO: Key = Key::new(KeyCode::Char('n'), "No", Color::Red); } #[cfg(test)] From 6865e4d552fd9461136270ffe3667f486cc615c8 Mon Sep 17 00:00:00 2001 From: EmixamPP Date: Fri, 19 Dec 2025 23:07:28 +0100 Subject: [PATCH 2/2] wip: refactor(tweaker): revamp tweak tool in Rust --- src/configure.rs | 4 +- src/configure/app/ir_enabler.rs | 2 +- src/configure/ui.rs | 1 + src/configure/ui/ir_enabler.rs | 161 ++--------- src/configure/ui/shared.rs | 157 +++++++++- ...igure__ui__tweaker__tests__main_empty.snap | 34 +++ ...weaker__tests__main_with_abort_prompt.snap | 34 +++ ...i__tweaker__tests__main_with_controls.snap | 34 +++ ...__ui__tweaker__tests__main_with_image.snap | 34 +++ ...i__tweaker__tests__main_with_question.snap | 34 +++ ...igure__ui__tweaker__tests__menu_empty.snap | 34 +++ ...ui__tweaker__tests__menu_valid_values.snap | 34 +++ src/configure/ui/tweaker.rs | 272 ++++++++++++++++++ 13 files changed, 694 insertions(+), 141 deletions(-) create mode 100644 src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_empty.snap create mode 100644 src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_abort_prompt.snap create mode 100644 src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_controls.snap create mode 100644 src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_image.snap create mode 100644 src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_question.snap create mode 100644 src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_empty.snap create mode 100644 src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_valid_values.snap create mode 100644 src/configure/ui/tweaker.rs diff --git a/src/configure.rs b/src/configure.rs index 4348e444..44f97e4f 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -4,10 +4,12 @@ mod ui; pub async fn configure() -> anyhow::Result<()> { let res = app::run(&mut ratatui::init()).await; ratatui::restore(); + + // Print any successful message to the user once the TUI is closed if let Ok(msg) = &res && !msg.is_empty() { println!("{}", msg); } - res.map(|_| ()) + res.map(|_| ()) // Delete the success message } diff --git a/src/configure/app/ir_enabler.rs b/src/configure/app/ir_enabler.rs index c8eff76c..2972e09b 100644 --- a/src/configure/app/ir_enabler.rs +++ b/src/configure/app/ir_enabler.rs @@ -341,7 +341,7 @@ impl App { async fn handle_term_event(&mut self, event: Event) -> Result<()> { match event { Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { - self.handle_key_press(key_event.code.into()).await?; + self.handle_key_press(key_event.code).await?; } _ => {} }; diff --git a/src/configure/ui.rs b/src/configure/ui.rs index 0de37745..95a1ab3e 100644 --- a/src/configure/ui.rs +++ b/src/configure/ui.rs @@ -50,6 +50,7 @@ mod keys { pub const KEY_CONTINUE: Key = Key::new(KeyCode::Enter, "Continue", Color::Green); pub const KEY_YES: Key = Key::new(KeyCode::Char('y'), "Yes", Color::Green); pub const KEY_NO: Key = Key::new(KeyCode::Char('n'), "No", Color::Red); + pub const KEY_EDIT: Key = Key::new(KeyCode::Enter, "Edit", Color::Green); } #[cfg(test)] diff --git a/src/configure/ui/ir_enabler.rs b/src/configure/ui/ir_enabler.rs index 7f7231ef..a675eddd 100644 --- a/src/configure/ui/ir_enabler.rs +++ b/src/configure/ui/ir_enabler.rs @@ -1,12 +1,13 @@ use super::{ + DeviceSettingsCtx, SearchSettingsCtx, keys::{KEY_CONTINUE, KEY_EXIT, KEY_NAVIGATE, KEY_NO, KEY_YES, keys_to_line}, - popup_area, render_main_window, render_video_preview, + popup_area, render_full_menu, render_main_window, render_video_preview, }; use crate::video::stream::Image; use ratatui::{ Frame, - layout::{Constraint, Flex, Layout, Rect}, + layout::{Constraint, Layout, Rect}, style::{Color, Style, Stylize}, text::{Line, Text, ToLine}, widgets::{Block, BorderType, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph}, @@ -25,20 +26,6 @@ pub trait IrEnablerCtx { fn show_main_abort_prompt(&self) -> bool; fn show_menu_start_prompt(&self) -> bool; - fn device_settings_list_state(&mut self) -> &mut ListState; - fn device_valid(&self) -> (String, bool); - fn height(&self) -> Option; - fn width(&self) -> Option; - fn emitters(&self) -> usize; - fn fps(&self) -> Option; - - fn search_settings_list_state(&mut self) -> &mut ListState; - fn limit(&self) -> Option; - fn manual(&self) -> bool; - fn analyzer_img_count(&self) -> u64; - fn ref_intensity_var_coef(&self) -> u64; - fn inc_step(&self) -> u8; - fn controls_list_state(&mut self) -> &mut ListState; fn controls(&self) -> &[crate::video::uvc::XuControl]; fn image(&self) -> Option<&Image>; @@ -86,115 +73,6 @@ fn render_confirm_abort_popup(frame: &mut Frame, area: Rect) { frame.render_widget(command_paragraph, command_area); } -/// Renders the main menu with device and search settings. -fn render_menu(frame: &mut Frame, area: Rect, app: &mut A) -where - A: IrEnablerCtx, -{ - let [top_info_area, device_area, search_area, bot_info_area] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(7), - Constraint::Length(7), - Constraint::Length(1), - ]) - .flex(Flex::Center) - .areas(area); - - let top_info_line = - Line::from("The tool will iterate through the UVC camera controls and modify them.") - .blue() - .centered(); - frame.render_widget(top_info_line, top_info_area); - - let bot_info_line = Line::from(vec![ - "For more explanation, visit ".into(), - "https://github.com/EmixamPP/linux-enable-ir-emitter".underlined(), - ]) - .blue() - .centered(); - frame.render_widget(bot_info_line, bot_info_area); - - let device_block = - Block::bordered().title(Line::from(" Device settings ".bold()).left_aligned()); - let device_settings_list = List::new(vec![ - ListItem::new(Line::from(vec!["Path: ".into(), { - let (device, valid_device) = app.device_valid(); - if valid_device { - device.green() - } else { - format!("{device} (not a grey camera device)").red() - } - }])), - ListItem::new(Line::from(vec![ - "Number of emitters: ".into(), - app.emitters().to_string().green(), - ])), - ListItem::new(Line::from(vec![ - "Resolution height: ".into(), - app.height() - .map_or("auto".to_string(), |h| h.to_string()) - .green(), - ])), - ListItem::new(Line::from(vec![ - "Resolution width: ".into(), - app.width() - .map_or("auto".to_string(), |w| w.to_string()) - .green(), - ])), - ListItem::new(Line::from(vec![ - "FPS: ".into(), - app.fps() - .map_or("auto".to_string(), |f| f.to_string()) - .green(), - ])), - ]) - .highlight_style(Style::default().fg(Color::Yellow)) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::Always) - .block(device_block); - frame.render_stateful_widget( - device_settings_list, - device_area, - app.device_settings_list_state(), - ); - - let search_block = Block::bordered() - .title(Line::from(vec![" Search settings".bold(), " (advanced) ".dim()]).left_aligned()); - let search_settings_list = List::new(vec![ - ListItem::new(Line::from(vec![ - "Manual validation: ".into(), - app.manual().to_string().green(), - ])), - ListItem::new(Line::from(vec![ - "Images to analyze in auto validation: ".into(), - app.analyzer_img_count().to_string().green(), - ])), - ListItem::new(Line::from(vec![ - "Light difference significance factor: ".into(), - app.ref_intensity_var_coef().to_string().green(), - ])), - ListItem::new(Line::from(vec![ - "Rejection threshold per control: ".into(), - app.limit() - .map_or("none".to_string(), |w| w.to_string()) - .green(), - ])), - ListItem::new(Line::from(vec![ - "Increment step: ".into(), - app.inc_step().to_string().green(), - ])), - ]) - .highlight_style(Style::default().fg(Color::Yellow)) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::Always) - .block(search_block); - frame.render_stateful_widget( - search_settings_list, - search_area, - app.search_settings_list_state(), - ); -} - /// Renders the main application interface showing the camera preview and the list of controls, /// as well as extra information for the current control. /// @@ -259,12 +137,17 @@ where /// Renders the application UI based on the current application state. pub fn ui(frame: &mut Frame, app: &mut A) where - A: IrEnablerCtx, + A: IrEnablerCtx + DeviceSettingsCtx + SearchSettingsCtx, { match app.view() { View::Menu => { let main_area = render_main_window(frame, &[KEY_NAVIGATE, KEY_CONTINUE, KEY_EXIT]); - render_menu(frame, main_area, app); + render_full_menu( + frame, + main_area, + app, + "The tool will iterate through the UVC camera controls and modify them.", + ); if app.show_menu_start_prompt() { render_confirm_start_popup(frame, main_area); } @@ -320,6 +203,18 @@ mod tests { fn show_menu_start_prompt(&self) -> bool { self.show_menu_start_prompt } + fn controls_list_state(&mut self) -> &mut ListState { + &mut self.controls_list_state + } + fn controls(&self) -> &[crate::video::uvc::XuControl] { + &self.controls + } + fn image(&self) -> Option<&Image> { + self.image.as_ref() + } + } + + impl DeviceSettingsCtx for App { fn device_settings_list_state(&mut self) -> &mut ListState { &mut self.device_settings_list_state } @@ -338,6 +233,9 @@ mod tests { fn fps(&self) -> Option { self.fps } + } + + impl SearchSettingsCtx for App { fn search_settings_list_state(&mut self) -> &mut ListState { &mut self.search_settings_list_state } @@ -356,15 +254,6 @@ mod tests { fn inc_step(&self) -> u8 { self.inc_step } - fn controls_list_state(&mut self) -> &mut ListState { - &mut self.controls_list_state - } - fn controls(&self) -> &[crate::video::uvc::XuControl] { - &self.controls - } - fn image(&self) -> Option<&Image> { - self.image.as_ref() - } } #[test] diff --git a/src/configure/ui/shared.rs b/src/configure/ui/shared.rs index 0c237557..00ee948d 100644 --- a/src/configure/ui/shared.rs +++ b/src/configure/ui/shared.rs @@ -4,10 +4,10 @@ use crate::video::stream::Image; use ansi_to_tui::IntoText as _; use ratatui::{ Frame, - layout::{Constraint, Layout, Rect}, - style::Stylize, + layout::{Constraint, Flex, Layout, Rect}, + style::{Color, Style, Stylize}, text::Line, - widgets::{Block, BorderType, Borders, Paragraph}, + widgets::{Block, BorderType, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph}, }; pub fn render_main_window(frame: &mut Frame, commands: &[keys::Key]) -> Rect { @@ -44,6 +44,157 @@ pub fn render_video_preview(frame: &mut Frame, area: Rect, img: Option<&Image>) frame.render_widget(video_block, area); } +pub trait DeviceSettingsCtx { + fn device_settings_list_state(&mut self) -> &mut ListState; + fn device_valid(&self) -> (String, bool); + fn height(&self) -> Option; + fn width(&self) -> Option; + fn emitters(&self) -> usize; + fn fps(&self) -> Option; +} + +pub trait SearchSettingsCtx { + fn search_settings_list_state(&mut self) -> &mut ListState; + fn limit(&self) -> Option; + fn manual(&self) -> bool; + fn analyzer_img_count(&self) -> u64; + fn ref_intensity_var_coef(&self) -> u64; + fn inc_step(&self) -> u8; +} + +/// Renders the device settings section of the menu. +fn render_device_settings(frame: &mut Frame, area: Rect, app: &mut A) +where + A: DeviceSettingsCtx, +{ + let device_block = + Block::bordered().title(Line::from(" Device settings ".bold()).left_aligned()); + let device_settings_list = List::new(vec![ + ListItem::new(Line::from(vec!["Path: ".into(), { + let (device, valid_device) = app.device_valid(); + if valid_device { + device.green() + } else { + format!("{device} (not a grey camera device)").red() + } + }])), + ListItem::new(Line::from(vec![ + "Number of emitters: ".into(), + app.emitters().to_string().green(), + ])), + ListItem::new(Line::from(vec![ + "Resolution height: ".into(), + app.height() + .map_or("auto".to_string(), |h| h.to_string()) + .green(), + ])), + ListItem::new(Line::from(vec![ + "Resolution width: ".into(), + app.width() + .map_or("auto".to_string(), |w| w.to_string()) + .green(), + ])), + ListItem::new(Line::from(vec![ + "FPS: ".into(), + app.fps() + .map_or("auto".to_string(), |f| f.to_string()) + .green(), + ])), + ]) + .highlight_style(Style::default().fg(Color::Yellow)) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always) + .block(device_block); + frame.render_stateful_widget(device_settings_list, area, app.device_settings_list_state()); +} + +/// Renders the search settings section of the menu. +fn render_search_settings(frame: &mut Frame, area: Rect, app: &mut A) +where + A: SearchSettingsCtx, +{ + let search_block = Block::bordered() + .title(Line::from(vec![" Search settings".bold(), " (advanced) ".dim()]).left_aligned()); + let search_settings_list = List::new(vec![ + ListItem::new(Line::from(vec![ + "Manual validation: ".into(), + app.manual().to_string().green(), + ])), + ListItem::new(Line::from(vec![ + "Images to analyze in auto validation: ".into(), + app.analyzer_img_count().to_string().green(), + ])), + ListItem::new(Line::from(vec![ + "Light difference significance factor: ".into(), + app.ref_intensity_var_coef().to_string().green(), + ])), + ListItem::new(Line::from(vec![ + "Rejection threshold per control: ".into(), + app.limit() + .map_or("none".to_string(), |w| w.to_string()) + .green(), + ])), + ListItem::new(Line::from(vec![ + "Increment step: ".into(), + app.inc_step().to_string().green(), + ])), + ]) + .highlight_style(Style::default().fg(Color::Yellow)) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always) + .block(search_block); + frame.render_stateful_widget(search_settings_list, area, app.search_settings_list_state()); +} + +/// Renders the info lines of the menu. +fn render_info(frame: &mut Frame, top_info_area: Rect, top_info: &str, bot_info_area: Rect) { + let top_info_line = Line::from(top_info).blue().centered(); + frame.render_widget(top_info_line, top_info_area); + + let bot_info_line = Line::from(vec![ + "For more explanation, visit ".into(), + "https://github.com/EmixamPP/linux-enable-ir-emitter".underlined(), + ]) + .blue() + .centered(); + frame.render_widget(bot_info_line, bot_info_area); +} + +/// Renders the main menu with device and search settings. +pub fn render_full_menu(frame: &mut Frame, area: Rect, app: &mut A, top_info: &str) +where + A: DeviceSettingsCtx + SearchSettingsCtx, +{ + let [top_info_area, device_area, search_area, bot_info_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(7), + Constraint::Length(7), + Constraint::Length(1), + ]) + .flex(Flex::Center) + .areas(area); + + render_info(frame, top_info_area, top_info, bot_info_area); + render_device_settings(frame, device_area, app); + render_search_settings(frame, search_area, app); +} + +/// Renders the main menu with only the device settings. +pub fn render_device_menu(frame: &mut Frame, area: Rect, app: &mut A, top_info: &str) +where + A: DeviceSettingsCtx, +{ + let [top_info_area, device_area, bot_info_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(7), + Constraint::Length(1), + ]) + .flex(Flex::Center) + .areas(area); + + render_info(frame, top_info_area, top_info, bot_info_area); + render_device_settings(frame, device_area, app); +} #[cfg(test)] mod tests { use super::*; diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_empty.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_empty.snap new file mode 100644 index 00000000..b4571088 --- /dev/null +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_empty.snap @@ -0,0 +1,34 @@ +--- +source: src/configure/ui/tweaker.rs +expression: terminal.backend() +--- +"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ linux-enable-ir-emitter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" +"┃┌ UVC Controls ─────────────────────────────────┐┌ Camera Preview ───────────────────────────────┐┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" +"┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" +"┃ Navigate Edit Quit ┃" +"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_abort_prompt.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_abort_prompt.snap new file mode 100644 index 00000000..e6286bf5 --- /dev/null +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_abort_prompt.snap @@ -0,0 +1,34 @@ +--- +source: src/configure/ui/tweaker.rs +expression: terminal.backend() +--- +"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ linux-enable-ir-emitter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" +"┃┌ UVC Controls ─────────────────────────────────┐┌ Camera Preview ───────────────────────────────┐┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ╔════════════════════════════════════════════════╗ │┃" +"┃│ ║ Do you want to save this configuration? ║ │┃" +"┃│ ║ Yes No ║ │┃" +"┃│ ╚════════════════════════════════════════════════╝ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" +"┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" +"┃ Navigate Edit Quit ┃" +"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_controls.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_controls.snap new file mode 100644 index 00000000..d4de3004 --- /dev/null +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_controls.snap @@ -0,0 +1,34 @@ +--- +source: src/configure/ui/tweaker.rs +expression: terminal.backend() +--- +"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ linux-enable-ir-emitter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" +"┃┌ UVC Controls ─────────────────────────────────┐┌ Camera Preview ───────────────────────────────┐┃" +"┃│unit: 1 selector: 1 ││ │┃" +"┃│ initial: [1] ││ │┃" +"┃│ current: [1] ││ │┃" +"┃│ maximum: [1] ││ │┃" +"┃│unit: 2 selector: 2 ││ │┃" +"┃│ initial: [2, 2] ││ │┃" +"┃│ current: [2, 2] ││ │┃" +"┃│ maximum: [2, 2] ││ │┃" +"┃│unit: 3 selector: 3 ││ │┃" +"┃│ initial: [3, 3, 3] ││ │┃" +"┃│ current: [3, 3, 3] ││ │┃" +"┃│ maximum: [3, 3, 3] ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" +"┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" +"┃ Navigate Edit Quit ┃" +"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_image.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_image.snap new file mode 100644 index 00000000..d2c0a7f4 --- /dev/null +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_image.snap @@ -0,0 +1,34 @@ +--- +source: src/configure/ui/tweaker.rs +expression: terminal.backend() +--- +"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ linux-enable-ir-emitter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" +"┃┌ UVC Controls ─────────────────────────────────┐┌ Camera Preview ───────────────────────────────┐┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ ▄ ▄▄ ▄▄ │┃" +"┃│ ││ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄ │┃" +"┃│ ││ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▄▄ │┃" +"┃│ ││ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▄ │┃" +"┃│ ││ ▄▄ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▄▄ ▄ │┃" +"┃│ ││ ▄ ▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄ ▄ │┃" +"┃│ ││ ▄▄ ▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄ │┃" +"┃│ ││ ▄▄▄▄▄▄ ▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀ ▄▄▄▄▄▀ │┃" +"┃│ ││ ▀▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▀ │┃" +"┃│ ││ ▀▀▄▄ ▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀ │┃" +"┃│ ││ ▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀ │┃" +"┃│ ││ ▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ │┃" +"┃│ ││ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ │┃" +"┃│ ││ ▀▄▄▄▀▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀▀▄▀▄▄▀ │┃" +"┃│ ││ ▀▄▄ ▀▄▀▀▀▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀▀▀ ▄▀▄▄▀ │┃" +"┃│ ││ ▀▄▄ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▄▀ ▄▀ │┃" +"┃│ ││ ▀▀▄ ▀ ▄▀ │┃" +"┃│ ││ ▀▄ ▀ │┃" +"┃│ ││ ▀ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" +"┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" +"┃ Navigate Edit Quit ┃" +"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_question.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_question.snap new file mode 100644 index 00000000..73bb8e7d --- /dev/null +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__main_with_question.snap @@ -0,0 +1,34 @@ +--- +source: src/configure/ui/tweaker.rs +expression: terminal.backend() +--- +"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ linux-enable-ir-emitter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" +"┃┌ Modifiable UVC Controls ──────────────────────┐┌ Camera Preview ───────────────────────────────┐┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃│ ││ │┃" +"┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" +"┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" +"┃ Navigate Quit ┃" +"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_empty.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_empty.snap new file mode 100644 index 00000000..1c992244 --- /dev/null +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_empty.snap @@ -0,0 +1,34 @@ +--- +source: src/configure/ui/tweaker.rs +expression: terminal.backend() +--- +"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ linux-enable-ir-emitter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ The tool allows you to modify the UVC camera controls. ┃" +"┃┌ Device settings ───────────────────────────────────────────────────────────────────────────────┐┃" +"┃│ Path: (not a grey camera device) │┃" +"┃│ Number of emitters: 0 │┃" +"┃│ Resolution height: auto │┃" +"┃│ Resolution width: auto │┃" +"┃│ FPS: auto │┃" +"┃└────────────────────────────────────────────────────────────────────────────────────────────────┘┃" +"┃ For more explanation, visit https://github.com/EmixamPP/linux-enable-ir-emitter ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" +"┃ Navigate Continue Quit ┃" +"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_valid_values.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_valid_values.snap new file mode 100644 index 00000000..3dbd6eaf --- /dev/null +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__tweaker__tests__menu_valid_values.snap @@ -0,0 +1,34 @@ +--- +source: src/configure/ui/tweaker.rs +expression: terminal.backend() +--- +"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ linux-enable-ir-emitter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ The tool allows you to modify the UVC camera controls. ┃" +"┃┌ Device settings ───────────────────────────────────────────────────────────────────────────────┐┃" +"┃│ Path: /dev/video2 │┃" +"┃│ Number of emitters: 1 │┃" +"┃│ Resolution height: 720 │┃" +"┃│ Resolution width: 1280 │┃" +"┃│ FPS: 30 │┃" +"┃└────────────────────────────────────────────────────────────────────────────────────────────────┘┃" +"┃ For more explanation, visit https://github.com/EmixamPP/linux-enable-ir-emitter ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃ ┃" +"┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" +"┃ Navigate Continue Quit ┃" +"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/tweaker.rs b/src/configure/ui/tweaker.rs new file mode 100644 index 00000000..dba71b97 --- /dev/null +++ b/src/configure/ui/tweaker.rs @@ -0,0 +1,272 @@ +use super::{ + keys::{KEY_CONTINUE, KEY_EDIT, KEY_EXIT, KEY_NAVIGATE, KEY_NO, KEY_YES, keys_to_line}, + popup_area, render_device_menu, render_main_window, render_video_preview, +}; +use crate::{configure::ui::DeviceSettingsCtx, video::stream::Image}; + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Style, Stylize}, + text::Line, + widgets::{Block, BorderType, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph}, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub enum View { + #[default] + Menu, + Main, +} + +pub trait TweakerCtx { + fn view(&self) -> View; + fn show_save_exit_prompt(&self) -> bool; + + fn controls_list_state(&mut self) -> &mut ListState; + fn controls(&self) -> &[crate::video::uvc::XuControl]; + + fn image(&self) -> Option<&Image>; +} + +/// Renders a confirmation popup to exit the process without saving. +fn render_save_exit_popup(frame: &mut Frame, area: Rect) { + let block = Block::bordered().border_type(BorderType::Double); + let area = popup_area(area, 4, 50); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [info_area, command_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]) + .margin(1) + .areas(area); + + let paragraph = Paragraph::new("Do you want to save this configuration?").centered(); + frame.render_widget(paragraph, info_area); + + let command_line = keys_to_line(&[KEY_YES, KEY_NO]); + let command_paragraph = Paragraph::new(command_line).centered(); + frame.render_widget(command_paragraph, command_area); +} + +/// Renders the main application interface showing the camera preview and the list of controls, +/// as well as extra information for the current control. +/// +/// Returns the area used for the list of controls. +fn render_main(frame: &mut Frame, area: Rect, app: &mut A) +where + A: TweakerCtx, +{ + let [list_area, video_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area); + + let list_block = Block::bordered().title(" UVC Controls ".bold()); + let items: Vec = app + .controls() + .iter() + .map(|ctrl| { + let mut lines = vec![ + Line::from(format!( + "unit: {} selector: {}", + ctrl.unit(), + ctrl.selector() + )), + Line::from(format!(" initial: {:?}", ctrl.init())), + Line::from(format!(" current: {:?}", ctrl.cur())), + ]; + if let Some(max) = ctrl.max() { + lines.push(Line::from(format!(" maximum: {:?}", max))); + } + ListItem::new(lines) + }) + .collect(); + let controls_list = List::new(items) + .highlight_style(Style::default().fg(Color::Yellow)) + .highlight_spacing(HighlightSpacing::Always) + .scroll_padding(1) + .block(list_block); + frame.render_stateful_widget(controls_list, list_area, app.controls_list_state()); + + render_video_preview(frame, video_area, app.image()); +} + +/// Renders the application UI based on the current application state. +pub fn ui(frame: &mut Frame, app: &mut A) +where + A: TweakerCtx + DeviceSettingsCtx, +{ + match app.view() { + View::Menu => { + let main_area = render_main_window(frame, &[KEY_NAVIGATE, KEY_CONTINUE, KEY_EXIT]); + render_device_menu( + frame, + main_area, + app, + "The tool allows you to modify the UVC camera controls.", + ); + } + View::Main => { + let main_area = render_main_window(frame, &[KEY_NAVIGATE, KEY_EDIT, KEY_EXIT]); + render_main(frame, main_area, app); + if app.show_save_exit_prompt() { + render_save_exit_popup(frame, main_area); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assert_ui_snapshot; + + #[derive(Default)] + struct App { + view: View, + show_save_exit_prompt: bool, + device_settings_list_state: ListState, + controls_list_state: ListState, + device_valid: (String, bool), + height: Option, + width: Option, + emitters: usize, + fps: Option, + controls: Vec, + image: Option, + } + + impl TweakerCtx for App { + fn view(&self) -> View { + self.view + } + fn show_save_exit_prompt(&self) -> bool { + self.show_save_exit_prompt + } + fn controls_list_state(&mut self) -> &mut ListState { + &mut self.controls_list_state + } + fn controls(&self) -> &[crate::video::uvc::XuControl] { + &self.controls + } + fn image(&self) -> Option<&Image> { + self.image.as_ref() + } + } + + impl DeviceSettingsCtx for App { + fn device_settings_list_state(&mut self) -> &mut ListState { + &mut self.device_settings_list_state + } + fn device_valid(&self) -> (String, bool) { + self.device_valid.clone() + } + fn height(&self) -> Option { + self.height + } + fn width(&self) -> Option { + self.width + } + fn emitters(&self) -> usize { + self.emitters + } + fn fps(&self) -> Option { + self.fps + } + } + + #[test] + fn test_menu_empty() { + assert_ui_snapshot!(|frame| { + let mut app = App::default(); + ui(frame, &mut app); + }); + } + + #[test] + fn test_menu_valid_values() { + assert_ui_snapshot!(|frame| { + let mut app = App::default(); + app.device_valid = ("/dev/video2".to_string(), true); + app.height = Some(720); + app.width = Some(1280); + app.emitters = 1; + app.fps = Some(30); + ui(frame, &mut app); + }); + } + + #[test] + fn test_main_empty() { + assert_ui_snapshot!(|frame| { + let mut app = App::default(); + app.view = View::Main; + ui(frame, &mut app); + }); + } + + #[test] + fn test_main_with_abort_prompt() { + assert_ui_snapshot!(|frame| { + let mut app = App::default(); + app.view = View::Main; + app.show_save_exit_prompt = true; + ui(frame, &mut app); + }); + } + + #[test] + fn test_main_with_controls() { + assert_ui_snapshot!(|frame| { + let mut app = App::default(); + app.view = View::Main; + app.controls = vec![ + crate::video::uvc::XuControl::new( + 1, + 1, + vec![1], + Some(vec![1]), + Some(vec![1]), + Some(vec![1]), + Some(vec![1]), + true, + ) + .unwrap(), + crate::video::uvc::XuControl::new( + 2, + 2, + vec![2, 2], + Some(vec![2, 2]), + Some(vec![2, 2]), + Some(vec![2, 2]), + Some(vec![2, 2]), + true, + ) + .unwrap(), + crate::video::uvc::XuControl::new( + 3, + 3, + vec![3, 3, 3], + Some(vec![3, 3, 3]), + Some(vec![3, 3, 3]), + Some(vec![3, 3, 3]), + Some(vec![3, 3, 3]), + true, + ) + .unwrap(), + ]; + + ui(frame, &mut app); + }); + } + + #[test] + fn test_main_with_image() { + assert_ui_snapshot!(|frame| { + let mut app = App::default(); + app.view = View::Main; + let img = image::open("tests/data/ferris.png").unwrap(); + app.image = Some(img); + ui(frame, &mut app); + }); + } +}