diff --git a/Cargo.toml b/Cargo.toml index d0eda29..e06639a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ toml = "0.8" serde = { version = "1", features = ["derive"] } clap = { version = "4", features = ["derive", "cargo"] } terminal-light = "1" -tui-input = "0.11" +tui-input = "0.11.1" [profile.release] strip = true diff --git a/src/alias_filter.rs b/src/alias_filter.rs new file mode 100644 index 0000000..52a14e0 --- /dev/null +++ b/src/alias_filter.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; +use std::time::Instant; + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Style, Stylize}, + widgets::{Block, BorderType, Borders, Clear, Padding, Row, Table, TableState}, + Frame, +}; +use tui_input::{Input, InputRequest}; + +use crate::config::Config; + +#[derive(Debug)] +pub struct AliasFilter { + pub filter: Option, + input: Input, + state: TableState, + start: Instant, +} + +impl AliasFilter { + pub fn new(_config: Arc) -> Self { + let mut state = TableState::new().with_offset(0); + state.select(Some(0)); + + let input = Input::new("".to_string()); + + let start = Instant::now(); + + Self { + state, + input, + filter: None, + start, + } + } + + pub fn insert_char(&mut self, c: char) { + let req = InputRequest::InsertChar(c); + self.input.handle(req); + self.filter = Some(self.input.value().to_string()); + } + + pub fn delete_char(&mut self) { + let req = InputRequest::DeletePrevChar; + self.input.handle(req); + self.filter = Some(self.input.value().to_string()); + } + + pub fn render(&mut self, frame: &mut Frame) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Length(5), + ]) + .flex(ratatui::layout::Flex::SpaceBetween) + .split(frame.area()); + + let block = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Min(100), + Constraint::Fill(1), + ]) + .flex(ratatui::layout::Flex::SpaceBetween) + .split(layout[2])[1]; + + let mut text = match &self.filter { + Some(f) => f.to_string(), + None => "".to_string(), + }; + + self.insert_cursor(&mut text); + + let row = Row::new(vec![text]); + + let table = Table::new([row], [Constraint::Length(20)]).block( + Block::default() + .padding(Padding::uniform(1)) + .title(" Filter Device Names ") + .title_style(Style::default().bold().fg(Color::Green)) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .style(Style::default()) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(Color::Green)), + ); + + frame.render_widget(Clear, block); + frame.render_stateful_widget(table, block, &mut self.state); + } + + fn insert_cursor(&self, s: &mut String) { + let time = Instant::now().duration_since(self.start).as_secs(); + + let c = if time % 2 == 0 { '_' } else { ' ' }; + + s.insert(self.input.cursor(), c); + } +} diff --git a/src/app.rs b/src/app.rs index e2aa102..84da886 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,6 +16,7 @@ use ratatui::{ use tui_input::Input; use crate::{ + alias_filter::AliasFilter, bluetooth::{request_confirmation, Controller}, config::Config, confirmation::PairingConfirmation, @@ -38,6 +39,7 @@ pub enum FocusedBlock { Help, PassKeyConfirmation, SetDeviceAliasBox, + AliasFilterPopup, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -52,6 +54,7 @@ pub struct App { pub session: Arc, pub agent: AgentHandle, pub help: Help, + pub alias_filter: AliasFilter, pub spinner: Spinner, pub notifications: Vec, pub controllers: Vec, @@ -110,7 +113,8 @@ impl App { running: true, session, agent: handle, - help: Help::new(config), + help: Help::new(config.clone()), + alias_filter: AliasFilter::new(config.clone()), spinner: Spinner::default(), notifications: Vec::new(), controllers, @@ -613,14 +617,37 @@ impl App { let rows: Vec = selected_controller .new_devices .iter() + .filter(|d| match &self.alias_filter.filter { + Some(pattern) => d.alias.contains(pattern), + None => true, + }) .map(|d| { - Row::new(vec![d.addr.to_string(), { + let device_name = match &self.alias_filter.filter { + Some(pattern) => { + let match_idxs: Vec = + d.alias.match_indices(pattern).map(|(i, _)| i).collect(); + + let highlighted = Style::default().bg(Color::LightBlue); + + Line::from(vec![ + Span::raw(&d.alias[0..match_idxs[0]]), + Span::styled(pattern, highlighted), + Span::raw(&d.alias[match_idxs[0] + pattern.len()..]), + ]) + } + None => Line::raw(d.alias.clone()), + }; + + let icon = Line::styled( if let Some(icon) = &d.icon { format!("{} {}", icon, &d.alias) } else { d.alias.to_owned() - } - }]) + }, + Style::default(), + ); + + Row::new(vec![device_name, icon]) }) .collect(); let rows_len = rows.len(); diff --git a/src/handler.rs b/src/handler.rs index cd34d10..44b0be7 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -56,6 +56,28 @@ pub async fn handle_key_events( .handle_event(&crossterm::event::Event::Key(key_event)); } }, + + FocusedBlock::AliasFilterPopup => match key_event.code { + KeyCode::Backspace => { + app.alias_filter.delete_char(); + } + + KeyCode::Char(c) => { + app.alias_filter.insert_char(c); + } + + KeyCode::Enter => { + app.focused_block = FocusedBlock::NewDevices; + } + + KeyCode::Esc => { + app.alias_filter.filter = None; + app.focused_block = FocusedBlock::NewDevices; + } + + _ => {} + }, + _ => { match key_event.code { // Exit the app @@ -72,6 +94,15 @@ pub async fn handle_key_events( app.focused_block = FocusedBlock::Help; } + KeyCode::Char('/') => { + if let Some(selected_controller_index) = app.controller_state.selected() { + let selected_controller = &app.controllers[selected_controller_index]; + if !selected_controller.new_devices.is_empty() { + app.focused_block = FocusedBlock::AliasFilterPopup; + } + } + } + // Discard help popup KeyCode::Esc => { if app.focused_block == FocusedBlock::Help { diff --git a/src/help.rs b/src/help.rs index 5c0455a..a68501f 100644 --- a/src/help.rs +++ b/src/help.rs @@ -99,6 +99,7 @@ impl Help { Cell::from(config.new_device.pair.to_string()).bold(), "Pair the device", ), + (Cell::from("/").bold(), "Filter device names"), ], } } @@ -137,7 +138,7 @@ impl Help { .direction(Direction::Vertical) .constraints([ Constraint::Fill(1), - Constraint::Length(28), + Constraint::Length(29), Constraint::Fill(1), ]) .flex(ratatui::layout::Flex::SpaceBetween) diff --git a/src/lib.rs b/src/lib.rs index 11f4851..e43ec4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,8 @@ pub mod spinner; pub mod help; +pub mod alias_filter; + pub mod config; pub mod rfkill; diff --git a/src/ui.rs b/src/ui.rs index 26d0662..2dcc6aa 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,6 +8,7 @@ pub fn render(app: &mut App, frame: &mut Frame) { match app.focused_block { FocusedBlock::Help => app.help.render(frame, app.color_mode), + FocusedBlock::AliasFilterPopup => app.alias_filter.render(frame), FocusedBlock::SetDeviceAliasBox => app.render_set_alias(frame), _ => {} }