From 2c016695f98f249d8fb21f5512bedbd26200c7a8 Mon Sep 17 00:00:00 2001 From: EmixamPP Date: Fri, 19 Dec 2025 23:02:55 +0100 Subject: [PATCH 1/5] 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 578800e662587847c2f3cb7d1b155e238258c5df Mon Sep 17 00:00:00 2001 From: EmixamPP Date: Fri, 19 Dec 2025 23:07:28 +0100 Subject: [PATCH 2/5] 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); + }); + } +} From 9529324e257105cdf1fa072f0f78adbe449d9fe2 Mon Sep 17 00:00:00 2001 From: EmixamPP Date: Fri, 26 Dec 2025 17:43:24 +0100 Subject: [PATCH 3/5] wip: refactor(tweaker): revamp tweak tool in Rust --- CONTRIBUTING.md | 4 +--- README.md | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0500a043..48a8a2b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ When building a package for distribution, please ensure the following: ``` The resulting binary will be located at `target/release/linux-enable-ir-emitter`. You can also use `cargo install --path <...>` to your convenience. 4. The v7 is incompatible with the v6. If applicable, please make sure to use the provided [migration script]() on the saved configuration. - > [!Important] + > [!IMPORTANT] > This script is not yet available. It will be provided when the v7 will be officially released (currently in beta). ## Contributing Code @@ -24,10 +24,8 @@ This project is using the usual Rust conventions. Here are some additional expla cargo build ``` The resulting binary will be located at `target/debug/linux-enable-ir-emitter` - > [!NOTE] > With a debug build, any camera can be used, even not infrared ones. This is useful for development and testing. - 2. Add the pre-commit hooks to make sure the linting checks and tests are passing before each commit: ``` git config core.hooksPath .githooks diff --git a/README.md b/README.md index f14af2c2..742dfe83 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,15 @@ auth optional pam_exec.so /home//.local/bin/linux-enable-ir-emitter run -- ``` > [!TIP] -> The installation paths may vary depending on your installation method. You can determine the correct binary absolute paths by running `which linux-enable-ir-emitter` and use that path instead. For the configuration path, it will be written when you can execute `linux-enable-ir-emitter --config`. +> The installation path may vary depending on your installation method. You can determine the correct binary absolute path by running `which linux-enable-ir-emitter` and use it instead. For the configuration path, it will be written when you execute `linux-enable-ir-emitter --config`. ### Integration with other program You will need to execute the `linux-enable-ir-emitter run` command before the program that uses the infrared camera. Alternatively, if you can and/or want to integrate better with the program that uses the camera, you can pass an opened file descriptor for the camera to the command: `linux-enable-ir-emitter run --device --fd `. -> [!Important] -> You will need to pass the config path as argument to `linux-enable-ir-emitter run --config ` **when executed as root** if `linux-enable-ir-emitter configure` was executed as a normal user. +> [!IMPORTANT] +> You will need to pass the config path as argument to `linux-enable-ir-emitter run --config ` **when executed as root**, if `linux-enable-ir-emitter configure` was executed as a normal user. ## How do I enable my infrared emitter? 0. For a better experience, use a large terminal window. From c09975bd5bfb0b6e5fffe3b0d63aa5032b73c58d Mon Sep 17 00:00:00 2001 From: EmixamPP Date: Sat, 10 Jan 2026 13:36:27 +0100 Subject: [PATCH 4/5] wip --- src/configure/app.rs | 1 + src/configure/app/tool_menu.rs | 3 +- src/configure/app/tweaker.rs | 388 +++++++++++++++++++++++++++++++++ src/configure/ui/tweaker.rs | 23 +- 4 files changed, 407 insertions(+), 8 deletions(-) create mode 100644 src/configure/app/tweaker.rs diff --git a/src/configure/app.rs b/src/configure/app.rs index 5be1b8d2..b33de575 100644 --- a/src/configure/app.rs +++ b/src/configure/app.rs @@ -1,6 +1,7 @@ mod helper; pub mod ir_enabler; pub mod tool_menu; +pub mod tweaker; pub async fn run(terminal: &mut ratatui::DefaultTerminal) -> anyhow::Result<&'static str> { tool_menu::App::new().run(terminal).await diff --git a/src/configure/app/tool_menu.rs b/src/configure/app/tool_menu.rs index 8e5bdc2b..5fd4fa1f 100644 --- a/src/configure/app/tool_menu.rs +++ b/src/configure/app/tool_menu.rs @@ -1,4 +1,5 @@ use crate::configure::app::ir_enabler::App as IREnablerApp; +use crate::configure::app::tweaker::App as TweakerApp; use crate::configure::ui::tool_menu::ui; use anyhow::Result; @@ -56,7 +57,7 @@ impl App { ) -> Result<&'static str> { match self.state { State::IREnablerSelected => IREnablerApp::new().run(terminal).await, - State::UVCTweakerSelected => anyhow::bail!("UVC Tweaker is not yet implemented"), + State::UVCTweakerSelected => TweakerApp::new().run(terminal).await, } } } diff --git a/src/configure/app/tweaker.rs b/src/configure/app/tweaker.rs new file mode 100644 index 00000000..aa8e361a --- /dev/null +++ b/src/configure/app/tweaker.rs @@ -0,0 +1,388 @@ +use super::helper::*; +use crate::configure::ui::DeviceSettingsCtx; +use crate::configure::ui::tweaker::{TweakerCtx, View, ui}; +use crate::video::stream::{Image, Stream, grey_devices}; +use crate::video::uvc::{Device, XuControl}; + +use std::cell::OnceCell; +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow, bail}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind}; +use futures::{FutureExt, StreamExt}; +use ratatui::{DefaultTerminal, widgets::ListState}; +use tokio::{ + select, + sync::mpsc, + sync::mpsc::{Receiver, Sender}, + 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; +// TODO add key to delete from the config + +#[derive(Debug)] +pub struct Config { + /// Path to the video device. + pub device: PathBuf, + /// Height of the video stream. + pub height: Option, + /// Width of the video stream. + pub width: Option, + /// Number of emitters to configure. + pub emitters: usize, + /// Frames per second of the video stream. + pub fps: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + device: PathBuf::from("/dev/video"), + height: None, + width: None, + emitters: 1, + fps: None, + } + } +} + +/// Application states. +#[derive(Debug)] +pub enum State<'a> { + /// Configuration settings menu + Menu, + /// Configuration is waiting + Waiting, + /// Editing a XuControl + Editing(&'a XuControl), + /// Confirm to save or not the configuration before exiting + SaveBeforeExit, + /// Exit + Exit, +} + +/// "Enabler" application. +pub struct App<'a> { + /// Receiver for captured images from the video stream task. + image_rx: Receiver, + /// Sender used by the video stream task for captured images. + image_tx: Option>, + /// Current application state. + /// Should be accessed through [`Self::state()`] + _state: State<'a>, + /// List of all the video devices that support grey scale pixel format. + grey_devices: Vec, + /// List of all the device controls that can be modified. + controls: Vec, + /// State of the list of device controls. + controls_list_state: ListState, + /// Settings for the whole application. + config: Config, + /// State of the list of device settings. + device_settings_list_state: ListState, + /// The last captured image from the video stream. + image: Option, + /// Device open to apply controls. Should be accessed through [`Self::get_device()`]. + _device: OnceCell, + /// The optional error that occurred during last control configuration. + error: Option, +} + +impl<'a> App<'a> { + /// Creates a new "Tweaker" application. + pub fn new() -> Self { + let mut device_settings_list_state = ListState::default(); + device_settings_list_state.select_first(); // already select the first item + + let (image_tx, image_rx) = mpsc::channel(1); // do not increase, we drop images to avoid "video lag" + Self { + _state: State::Menu, + config: Config::default(), + device_settings_list_state, + image: None, + image_tx: Some(image_tx), + image_rx, + error: None, + controls: Vec::new(), + controls_list_state: ListState::default(), + grey_devices: Vec::new(), + _device: OnceCell::new(), + } + } + + /// Initializes the application by detecting grey scale video devices. + /// Returns an error if no grey scale video device is found. + fn init(&mut self) -> Result<()> { + let grey_devices = grey_devices(); + log::debug!("Grey scale video devices: {:?}.", grey_devices); + if grey_devices.is_empty() { + bail!( + "no V4L grey scale video device found (the system probably does not support your infrared camera)" + ); + } else { + self.config.device = grey_devices[0].clone(); + self.grey_devices = grey_devices; + } + Ok(()) + } + + /// Returns the current application state. + pub fn state(&self) -> &State<'_> { + &self._state + } + + /// Sets the current application state and updates the previous state. + fn set_state(&mut self, state: State<'a>) { + self._state = state; + } + + /// Gets the video device specified in the config. + /// + /// If this is the first time, open it and load the list of controls. + /// + /// # Note + /// This operation is blocking. + fn get_device(&mut self) -> Result<&Device> { + match self._device.get() { + Some(device) => Ok(device), + None => { + let device = Device::open(&self.config.device) + .with_context(|| format!("failed to open device {:?}", self.config.device))?; + self.controls = device.controls(); + self._device.set(device).unwrap(); // safe because we checked that it was None + Ok(self._device.get().unwrap()) + } + } + } + + /// Spawns a new asynchronous task that continuously captures video frames from the stream device + /// and sends them through [`Self::image_tx`]. + /// + /// Returns an error if the video stream is already started. + fn spawn_video_stream_task(&mut self) -> Result<()> { + let device = self.config.device.clone(); + let width = self.config.width; + let height = self.config.height; + let fps = self.config.fps; + + // we take the sender, so if the task exit, the receiver will be notified + let image_tx = self + .image_tx + .take() + .context("video stream task already started")?; + + task::spawn_blocking(move || { + let mut stream = match Stream::open(device, width, height, fps) { + Ok(s) => s, + Err(err) => { + log::error!("Error opening video stream: {err:?}"); + return; + } + }; + log::debug!("{stream}"); + + loop { + match stream.capture() { + Ok(img) => { + if image_tx.blocking_send(img).is_err() { + log::debug!("Exiting video stream task."); + return; + } + } + Err(err) => { + log::error!("Error while capturing image: {err:?}"); + return; + } + } + } + }); + + Ok(()) + } + + /// Runs the application. + pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<&'static str> { + self.init()?; + let mut term_event_stream = EventStream::new(); + + // Draw the UI then wait an event in loop + loop { + terminal.draw(|f| ui(f, self))?; + select! { + Some(event) = term_event_stream.next().fuse() => self.handle_term_event(event?).await?, + image = self.image_rx.recv() => self.handle_image(image)?, + } + + if matches!(self.state(), State::Exit) { + return Ok("Tweaker exited successfully."); + } + } + } + + /// Handles terminal events. + 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).await?; + } + _ => {} + }; + Ok(()) + } + + /// Handles a new captured image. + /// + /// Returns an error if the image is None. + fn handle_image(&mut self, image: Option) -> Result<()> { + if let Some(image) = image { + self.image = Some(image); + Ok(()) + } else { + Err(anyhow!("the video stream has been closed unexpectedly")) + } + } + + /// Handles a key event based on the current application state. + async fn handle_key_press(&mut self, key: KeyCode) -> Result<()> { + match self.state() { + State::Menu => match key { + KEY_EXIT => self.set_state(State::Exit), + KEY_NAVIGATE => self.next_setting(), + KEY_DELETE => self.edit_setting(None), + KeyCode::Char(c) => self.edit_setting(Some(c)), + KEY_CONTINUE => self.start_or_back()?, + _ => {} + }, + State::Waiting => { + if key == KEY_EXIT { + self.set_state(State::SaveBeforeExit) + } + } + State::Editing(_control) => { + // TODO modify the control depending on the key + if key == KEY_EXIT { + self.set_state(State::Waiting); + } + } + State::SaveBeforeExit => self.save_before_exit(key).await?, + _ => {} + } + Ok(()) + } + + /// Set the state to exit, if: + /// the key is [`KEY_YES`], save the current configuration, + /// or the key is [`KEY_NO`], discard the current configuration + /// + /// For any other key, set the state back to [`State::Waiting`]. + async fn save_before_exit(&mut self, k: KeyCode) -> Result<()> { + // TODO + match k { + KEY_NO => (), + KEY_YES => (), + _ => { + self.set_state(State::Waiting); + return Ok(()); + } + } + self.set_state(State::Exit); + Ok(()) + } + + /// If the configuration is valid, tries to spawn the video stream task + /// and change the state to [`State::Waiting`]. If it fails, returns the error. + /// + /// Returns directly an error if the video stream is already started. + fn start_or_back(&mut self) -> Result<()> { + // check that the path exists + if !self.is_device_valid() { + return Ok(()); + } + self.spawn_video_stream_task()?; + self.get_device()?.controls(); + self.set_state(State::Waiting); + Ok(()) + } + + /// Check that the current device is grey scale. + fn is_device_valid(&self) -> bool { + self.grey_devices.contains(&self.config.device) + } + + /// Moves the selection to the next setting in the settings lists. + fn next_setting(&mut self) { + self.device_settings_list_state.select_next(); + } + + /// Edits the currently selected setting in the settings lists by adding or removing a character. + /// + /// If `ch` is `Some(c)`, adds the character `c` (depending on the setting type). + /// If `ch` is `None`, removes the last character. + fn edit_setting(&mut self, ch: Option) { + match self.device_settings_list_state.selected() { + Some(0) => add_or_remove_char_from_path(&mut self.config.device, ch, 10), + Some(1) => add_or_remove_char_from_numeric(&mut self.config.emitters, ch), + Some(2) => add_or_remove_char_from_opt_numeric(&mut self.config.height, ch), + Some(3) => add_or_remove_char_from_opt_numeric(&mut self.config.width, ch), + Some(4) => add_or_remove_char_from_opt_numeric(&mut self.config.fps, ch), + _ => {} + } + } +} + +impl<'a> TweakerCtx for App<'a> { + fn view(&self) -> View<'_> { + match self.state() { + State::Menu => View::Menu, + State::Waiting => View::Main, + State::SaveBeforeExit => View::Main, + State::Exit => View::Menu, + State::Editing(control) => View::Edition(control), + } + } + 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() + } + fn show_save_exit_prompt(&self) -> bool { + matches!(self.state(), State::SaveBeforeExit) + } + fn error_message(&self) -> Option<&String> { + self.error.as_ref() + } +} + +impl<'a> DeviceSettingsCtx for App<'a> { + fn device_settings_list_state(&mut self) -> &mut ListState { + &mut self.device_settings_list_state + } + fn device_valid(&self) -> (String, bool) { + ( + self.config.device.to_string_lossy().to_string(), + self.is_device_valid(), + ) + } + fn height(&self) -> Option { + self.config.height + } + fn width(&self) -> Option { + self.config.width + } + fn emitters(&self) -> usize { + self.config.emitters + } + fn fps(&self) -> Option { + self.config.fps + } +} diff --git a/src/configure/ui/tweaker.rs b/src/configure/ui/tweaker.rs index dba71b97..93b65c21 100644 --- a/src/configure/ui/tweaker.rs +++ b/src/configure/ui/tweaker.rs @@ -2,6 +2,7 @@ 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::video::uvc::XuControl; use crate::{configure::ui::DeviceSettingsCtx, video::stream::Image}; use ratatui::{ @@ -13,20 +14,22 @@ use ratatui::{ }; #[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] -pub enum View { +pub enum View<'a> { #[default] Menu, Main, + Edition(&'a XuControl), } pub trait TweakerCtx { - fn view(&self) -> View; + 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>; + fn error_message(&self) -> Option<&String>; } /// Renders a confirmation popup to exit the process without saving. @@ -112,6 +115,9 @@ where render_save_exit_popup(frame, main_area); } } + View::Edition(_control) => { + //TODO + } } } @@ -121,8 +127,8 @@ mod tests { use crate::assert_ui_snapshot; #[derive(Default)] - struct App { - view: View, + struct App<'a> { + view: View<'a>, show_save_exit_prompt: bool, device_settings_list_state: ListState, controls_list_state: ListState, @@ -135,8 +141,8 @@ mod tests { image: Option, } - impl TweakerCtx for App { - fn view(&self) -> View { + impl<'a> TweakerCtx for App<'a> { + fn view(&self) -> View<'_> { self.view } fn show_save_exit_prompt(&self) -> bool { @@ -151,9 +157,12 @@ mod tests { fn image(&self) -> Option<&Image> { self.image.as_ref() } + fn error_message(&self) -> Option<&String> { + None // TODO test + } } - impl DeviceSettingsCtx for App { + impl<'a> DeviceSettingsCtx for App<'a> { fn device_settings_list_state(&mut self) -> &mut ListState { &mut self.device_settings_list_state } From bdfef0b1d4034a29c1f74a1fc5d04c3e94109764 Mon Sep 17 00:00:00 2001 From: EmixamPP Date: Fri, 16 Jan 2026 23:18:17 +0100 Subject: [PATCH 5/5] refactor: use key up and down to navigate in menus --- src/configure/app/ir_enabler.rs | 64 +++++++++++++++---- ...er__configure__ui__tests__render_keys.snap | 2 +- src/configure/ui.rs | 22 +++++-- src/configure/ui/ir_enabler.rs | 4 +- src/configure/ui/shared.rs | 2 +- ...re__ui__ir_enabler__tests__menu_empty.snap | 2 +- ...e__ui__ir_enabler__tests__menu_strart.snap | 2 +- ..._ir_enabler__tests__menu_valid_values.snap | 2 +- ...ui__shared__tests__render_main_window.snap | 2 +- ...igure__ui__tweaker__tests__main_empty.snap | 2 +- ...weaker__tests__main_with_abort_prompt.snap | 2 +- ...i__tweaker__tests__main_with_controls.snap | 2 +- ...__ui__tweaker__tests__main_with_image.snap | 2 +- ...igure__ui__tweaker__tests__menu_empty.snap | 2 +- ...ui__tweaker__tests__menu_valid_values.snap | 2 +- 15 files changed, 85 insertions(+), 29 deletions(-) diff --git a/src/configure/app/ir_enabler.rs b/src/configure/app/ir_enabler.rs index 2972e09b..c878e52c 100644 --- a/src/configure/app/ir_enabler.rs +++ b/src/configure/app/ir_enabler.rs @@ -27,7 +27,8 @@ use tokio::{ 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_NAVIGATE_UP: KeyCode = KeyCode::Up; +const KEY_NAVIGATE_DOWN: KeyCode = KeyCode::Down; const KEY_CONTINUE: KeyCode = KeyCode::Enter; const KEY_DELETE: KeyCode = KeyCode::Backspace; @@ -433,7 +434,8 @@ impl App { match self.state() { State::Menu => match key { KEY_EXIT => self.set_state(State::Failure), - KEY_NAVIGATE => self.next_setting(), + KEY_NAVIGATE_UP => self.prev_setting(), + KEY_NAVIGATE_DOWN => self.next_setting(), KEY_DELETE => self.edit_setting(None), KeyCode::Char(c) => self.edit_setting(Some(c)), KEY_CONTINUE => self.set_state(State::ConfirmStart), @@ -541,15 +543,22 @@ impl App { self.device_settings_list_state.select(None); self.search_settings_list_state.select_first(); } - } else if let Some(i) = self.search_settings_list_state.selected() { - if i < 3 { - self.search_settings_list_state.select_next(); + } else { + self.search_settings_list_state.select_next(); + } + } + + /// Moves the selection to the previous setting in the settings lists. + fn prev_setting(&mut self) { + if let Some(i) = self.search_settings_list_state.selected() { + if i > 0 { + self.search_settings_list_state.select_previous(); } else { self.search_settings_list_state.select(None); - self.device_settings_list_state.select_first(); + self.device_settings_list_state.select_last(); } } else { - self.device_settings_list_state.select_first(); + self.device_settings_list_state.select_previous(); } } @@ -726,7 +735,7 @@ mod tests { } #[test] - fn test_next_setting_device_to_search_and_back() { + fn test_next_setting_device_to_search() { let mut app = make_app(); // Move through device settings (0..4) for i in 0..4 { @@ -737,14 +746,47 @@ mod tests { app.next_setting(); assert!(app.device_settings_list_state.selected().is_none()); assert_eq!(app.search_settings_list_state.selected(), Some(0)); - // Move through search settings (0..2) + // Move through search settings (0..3) for i in 0..3 { app.next_setting(); assert_eq!(app.search_settings_list_state.selected(), Some(i + 1)); } - // After 2, should wrap to device settings first + // After 3 we should stay at the last search setting app.next_setting(); + assert_eq!(app.search_settings_list_state.selected(), Some(4)); + } + + #[test] + fn test_prev_setting_device_to_search() { + let mut app = make_app(); + // Start at the end of search settings + app.device_settings_list_state.select(None); + app.search_settings_list_state.select(Some(4)); + + // Move through search settings (4..0) + for i in (1..=4).rev() { + assert_eq!(app.search_settings_list_state.selected(), Some(i)); + app.prev_setting(); + } + + // At index 0 of search settings, prev_setting should move to device settings last + assert_eq!(app.search_settings_list_state.selected(), Some(0)); + app.prev_setting(); assert!(app.search_settings_list_state.selected().is_none()); + + // NOTE: select_last() sets to usize::MAX until screen is rendered to know that it is actually 4 + assert_eq!(app.device_settings_list_state.selected(), Some(usize::MAX)); + // so let's cheat + app.device_settings_list_state.select(Some(4)); + + // Move through device settings (4..0) + for i in (1..=4).rev() { + app.prev_setting(); + assert_eq!(app.device_settings_list_state.selected(), Some(i - 1)); + } + + // At the first device setting, we should stay there + app.prev_setting(); assert_eq!(app.device_settings_list_state.selected(), Some(0)); } @@ -893,7 +935,7 @@ mod tests { let mut app = make_app(); app.set_state(State::Menu); app.device_settings_list_state.select(Some(0)); - let key_event = make_term_key_event(KEY_NAVIGATE); + let key_event = make_term_key_event(KEY_NAVIGATE_DOWN); let res = app.handle_term_event(key_event).await; assert!(res.is_ok(), "{:?}", res.err()); assert_eq!(app.device_settings_list_state.selected(), Some(1)); diff --git a/src/configure/snapshots/linux_enable_ir_emitter__configure__ui__tests__render_keys.snap b/src/configure/snapshots/linux_enable_ir_emitter__configure__ui__tests__render_keys.snap index 8e9e3e15..2b435bc0 100644 --- a/src/configure/snapshots/linux_enable_ir_emitter__configure__ui__tests__render_keys.snap +++ b/src/configure/snapshots/linux_enable_ir_emitter__configure__ui__tests__render_keys.snap @@ -3,7 +3,7 @@ source: src/configure/ui.rs expression: terminal.backend() --- "Quit " -"Navigate " +"Navigate <↑↓> " "Continue " "Yes " "No " diff --git a/src/configure/ui.rs b/src/configure/ui.rs index 95a1ab3e..f3bee20e 100644 --- a/src/configure/ui.rs +++ b/src/configure/ui.rs @@ -15,16 +15,26 @@ mod keys { text::{Line, Span}, }; + #[derive(Debug, Clone, Copy)] + enum KeyRepr { + Code(KeyCode), + Str(&'static str), + } + #[derive(Debug, Clone, Copy)] pub struct Key { - code: KeyCode, + repr: KeyRepr, name: &'static str, color: Color, } impl Key { const fn new(code: KeyCode, name: &'static str, color: Color) -> Self { - Self { code, name, color } + Self { repr: KeyRepr::Code(code), name, color } + } + + const fn custom(repr: &'static str, name: &'static str, color: Color) -> Self { + Self { repr: KeyRepr::Str(repr), name, color } } } @@ -33,7 +43,10 @@ mod keys { for (i, key) in keys.iter().enumerate() { spans.push(format!("{} <", key.name).bold()); spans.push(Span::styled( - key.code.to_string(), + match key.repr { + KeyRepr::Code(code) => code.to_string(), + KeyRepr::Str(s) => s.to_string(), + }, Style::default().fg(key.color), )); spans.push(">".bold()); @@ -46,6 +59,7 @@ mod keys { } pub const KEY_EXIT: Key = Key::new(KeyCode::Esc, "Quit", Color::Red); + pub const KEYS_NAVIGATE: Key = Key::custom("↑↓", "Navigate", Color::Yellow); 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); @@ -62,7 +76,7 @@ mod tests { #[test] fn test_render_keys() { assert_ui_snapshot!(|frame| { - let keys = [KEY_EXIT, KEY_NAVIGATE, KEY_CONTINUE, KEY_YES, KEY_NO]; + let keys = [KEY_EXIT, KEYS_NAVIGATE, KEY_CONTINUE, KEY_YES, KEY_NO]; let chunks = Layout::vertical(vec![Constraint::Length(1); keys.len()]).split(frame.area()); diff --git a/src/configure/ui/ir_enabler.rs b/src/configure/ui/ir_enabler.rs index a675eddd..7fba1d7a 100644 --- a/src/configure/ui/ir_enabler.rs +++ b/src/configure/ui/ir_enabler.rs @@ -1,6 +1,6 @@ use super::{ DeviceSettingsCtx, SearchSettingsCtx, - keys::{KEY_CONTINUE, KEY_EXIT, KEY_NAVIGATE, KEY_NO, KEY_YES, keys_to_line}, + keys::{KEY_CONTINUE, KEY_EXIT, KEYS_NAVIGATE, KEY_NO, KEY_YES, keys_to_line}, popup_area, render_full_menu, render_main_window, render_video_preview, }; use crate::video::stream::Image; @@ -141,7 +141,7 @@ where { match app.view() { View::Menu => { - let main_area = render_main_window(frame, &[KEY_NAVIGATE, KEY_CONTINUE, KEY_EXIT]); + let main_area = render_main_window(frame, &[KEYS_NAVIGATE, KEY_CONTINUE, KEY_EXIT]); render_full_menu( frame, main_area, diff --git a/src/configure/ui/shared.rs b/src/configure/ui/shared.rs index 00ee948d..45370d1f 100644 --- a/src/configure/ui/shared.rs +++ b/src/configure/ui/shared.rs @@ -205,7 +205,7 @@ mod tests { assert_ui_snapshot!(|frame| { let _ = render_main_window( frame, - &[keys::KEY_NAVIGATE, keys::KEY_CONTINUE, keys::KEY_EXIT], + &[keys::KEYS_NAVIGATE, keys::KEY_CONTINUE, keys::KEY_EXIT], ); }); } diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_empty.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_empty.snap index ed5fa54e..7765e8b1 100644 --- a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_empty.snap +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_empty.snap @@ -30,5 +30,5 @@ expression: terminal.backend() "┃ ┃" "┃ ┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Continue Quit ┃" +"┃ Navigate <↑↓> Continue Quit ┃" "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_strart.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_strart.snap index 2f8226f7..80a3a1ac 100644 --- a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_strart.snap +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_strart.snap @@ -30,5 +30,5 @@ expression: terminal.backend() "┃ ┃" "┃ ┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Continue Quit ┃" +"┃ Navigate <↑↓> Continue Quit ┃" "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_valid_values.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_valid_values.snap index 8557adc4..5eb916f0 100644 --- a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_valid_values.snap +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__ir_enabler__tests__menu_valid_values.snap @@ -30,5 +30,5 @@ expression: terminal.backend() "┃ ┃" "┃ ┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Continue Quit ┃" +"┃ Navigate <↑↓> Continue Quit ┃" "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" diff --git a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__shared__tests__render_main_window.snap b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__shared__tests__render_main_window.snap index 58918af7..f7ab0fb1 100644 --- a/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__shared__tests__render_main_window.snap +++ b/src/configure/ui/snapshots/linux_enable_ir_emitter__configure__ui__shared__tests__render_main_window.snap @@ -30,5 +30,5 @@ expression: terminal.backend() "┃ ┃" "┃ ┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Continue Quit ┃" +"┃ Navigate <↑↓> Continue Quit ┃" "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" 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 index b4571088..9b89833f 100644 --- 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 @@ -30,5 +30,5 @@ expression: terminal.backend() "┃│ ││ │┃" "┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Edit Quit ┃" +"┃ 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 index e6286bf5..b03c0e43 100644 --- 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 @@ -30,5 +30,5 @@ expression: terminal.backend() "┃│ ││ │┃" "┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Edit Quit ┃" +"┃ 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 index d4de3004..7add7c1f 100644 --- 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 @@ -30,5 +30,5 @@ expression: terminal.backend() "┃│ ││ │┃" "┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Edit Quit ┃" +"┃ 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 index d2c0a7f4..b7b1375a 100644 --- 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 @@ -30,5 +30,5 @@ expression: terminal.backend() "┃│ ││ │┃" "┃└───────────────────────────────────────────────┘└───────────────────────────────────────────────┘┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Edit Quit ┃" +"┃ Navigate <↑↓> Edit 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 index 1c992244..6a399d01 100644 --- 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 @@ -30,5 +30,5 @@ expression: terminal.backend() "┃ ┃" "┃ ┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Continue Quit ┃" +"┃ 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 index 3dbd6eaf..9fabf277 100644 --- 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 @@ -30,5 +30,5 @@ expression: terminal.backend() "┃ ┃" "┃ ┃" "┃──────────────────────────────────────────────────────────────────────────────────────────────────┃" -"┃ Navigate Continue Quit ┃" +"┃ Navigate <↑↓> Continue Quit ┃" "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"