diff --git a/build.rs b/build.rs index ba08071..40ef5ac 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,7 @@ use clap::CommandFactory; #[path = "src/cli_args.rs"] +#[allow(dead_code)] mod cli_args; fn main() -> std::io::Result<()> { diff --git a/src/bluetooth.rs b/src/bluetooth.rs index ea18f3b..f051da2 100644 --- a/src/bluetooth.rs +++ b/src/bluetooth.rs @@ -1,3 +1,4 @@ +use crate::cli_args::{GeneralSort, GeneralSortable}; use crate::error::{Error, Result}; use crate::tui::ui::StableListItem; use crate::Ctx; @@ -13,6 +14,7 @@ use tokio::time::{self, sleep, timeout}; pub mod ble_default_services; +const DEFAULT_DEVICE_NAME: &str = "Unknown device"; const TIMEOUT: Duration = Duration::from_secs(10); pub async fn disconnect_with_timeout(peripheral: &btleplug::platform::Peripheral) { @@ -49,6 +51,24 @@ pub struct HandledPeripheral pub services_names: Vec>, } +impl GeneralSortable for HandledPeripheral { + const AVAILABLE_SORTS: &'static [GeneralSort] = &[GeneralSort::Name, GeneralSort::DefaultSort]; + + fn cmp(&self, sort: &GeneralSort, a: &Self, b: &Self) -> std::cmp::Ordering { + match sort { + // Specifically put all the "unknown devices" to the end of the list. + GeneralSort::Name if a.name == b.name && a.name == DEFAULT_DEVICE_NAME => { + std::cmp::Ordering::Equal + } + GeneralSort::Name if b.name == DEFAULT_DEVICE_NAME => std::cmp::Ordering::Less, + GeneralSort::Name if a.name == DEFAULT_DEVICE_NAME => std::cmp::Ordering::Greater, + + GeneralSort::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + GeneralSort::DefaultSort => a.rssi.cmp(&b.rssi), + } + } +} + impl StableListItem for HandledPeripheral { fn id(&self) -> PeripheralId { self.ble_peripheral.id() @@ -67,6 +87,38 @@ pub struct ConnectedCharacteristic { pub service_uuid: uuid::Uuid, } +impl GeneralSortable for ConnectedCharacteristic { + const AVAILABLE_SORTS: &'static [GeneralSort] = &[GeneralSort::Name, GeneralSort::DefaultSort]; + + fn cmp(&self, sort: &GeneralSort, a: &Self, b: &Self) -> std::cmp::Ordering { + match sort { + GeneralSort::Name => { + if a.service_name() == b.service_name() && a.char_name() == b.char_name() { + return std::cmp::Ordering::Equal; + } + + if a.has_readable_char_name() && !b.has_readable_char_name() { + return std::cmp::Ordering::Less; + } + + if !a.has_readable_char_name() && b.has_readable_char_name() { + return std::cmp::Ordering::Greater; + } + + ( + a.service_name().to_lowercase(), + a.char_name().to_lowercase(), + ) + .cmp(&( + b.service_name().to_lowercase(), + b.char_name().to_lowercase(), + )) + } + GeneralSort::DefaultSort => (a.service_uuid, a.uuid).cmp(&(b.service_uuid, b.uuid)), + } + } +} + impl StableListItem for ConnectedCharacteristic { fn id(&self) -> uuid::Uuid { self.uuid @@ -74,6 +126,14 @@ impl StableListItem for ConnectedCharacteristic { } impl ConnectedCharacteristic { + pub fn has_readable_char_name(&self) -> bool { + self.custom_char_name.is_some() || self.standard_gatt_char_name.is_some() + } + + pub fn has_readable_service_name(&self) -> bool { + self.custom_service_name.is_some() || self.standard_gatt_service_name.is_some() + } + pub fn char_name(&self) -> Cow<'_, str> { if let Some(custom_name) = &self.custom_char_name { return Cow::from(format!("{} ({})", custom_name, self.uuid)); @@ -110,9 +170,17 @@ pub struct ConnectedPeripheral { } impl ConnectedPeripheral { + pub fn apply_sort(&mut self, ctx: &Ctx) { + let options = ctx.general_options.read(); + + if let Ok(options) = options.as_ref() { + options.sort.sort(&mut self.characteristics) + } + } + pub fn new(ctx: &Ctx, peripheral: HandledPeripheral) -> Self { let chars = peripheral.ble_peripheral.characteristics(); - let characteristics = chars + let characteristics: Vec<_> = chars .into_iter() .map(|char| ConnectedCharacteristic { custom_char_name: ctx @@ -137,10 +205,13 @@ impl ConnectedPeripheral { }) .collect(); - Self { + let mut view = Self { peripheral, characteristics, - } + }; + + view.apply_sort(ctx); + view } } @@ -170,7 +241,7 @@ pub async fn start_scan(context: Arc) -> Result<()> { .map(Peripheral::properties) .collect::>(); - let peripherals = try_join_all(properties_futures) + let mut peripherals = try_join_all(properties_futures) .await? .into_iter() .zip(peripherals.into_iter()) @@ -179,7 +250,7 @@ pub async fn start_scan(context: Arc) -> Result<()> { let name_unset = properties.local_name.is_none(); let name = properties .local_name - .unwrap_or_else(|| "Unknown device".to_string()); + .unwrap_or_else(|| DEFAULT_DEVICE_NAME.to_string()); HandledPeripheral { ble_peripheral: peripheral, @@ -215,6 +286,9 @@ pub async fn start_scan(context: Arc) -> Result<()> { }) .collect::>(); + let sort = context.general_options.read()?.sort; + sort.sort(&mut peripherals); + context.latest_scan.write()?.replace(BleScan { peripherals, sync_time: chrono::Local::now(), diff --git a/src/cli_args.rs b/src/cli_args.rs index 744244e..574600d 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -65,6 +65,35 @@ fn test_parse_name_map() { std::fs::remove_file(test_path).expect("Unable to delete file"); } +#[derive(Default, PartialEq, Eq, Debug, Clone, Copy, clap::ValueEnum)] +pub enum GeneralSort { + Name, + #[default] + /// The default sort based on the trait implementer. + DefaultSort, +} + +impl GeneralSort { + pub fn sort(&self, data: &mut [T]) { + let should_sort = T::AVAILABLE_SORTS.contains(&self); + + if should_sort { + data.sort_by(|a, b| a.cmp(self, a, b)); + } + } +} + +pub trait GeneralSortable { + const AVAILABLE_SORTS: &'static [GeneralSort]; + fn cmp(&self, sort: &GeneralSort, a: &Self, b: &Self) -> std::cmp::Ordering; +} + +impl GeneralSort { + pub fn apply_sort(&self, a: &T, b: &T) -> std::cmp::Ordering { + a.cmp(self, a, b) + } +} + #[derive(Debug, Parser)] #[command( version=env!("CARGO_PKG_VERSION"), @@ -79,7 +108,7 @@ pub struct Args { #[clap(default_value_t = 0)] pub adapter_index: usize, - #[clap(long, short)] + #[clap(long, short = 'i')] /// Scan interval in milliseconds. #[clap(default_value_t = 1000)] pub scan_interval: u64, @@ -109,4 +138,9 @@ pub struct Args { /// ``` #[clap(long, value_parser = clap::builder::ValueParser::new(parse_name_map))] pub names_map_file: Option>, + + /// Default sort type for all the views and lists. + #[clap(long)] + #[arg(value_enum)] + pub sort: Option, } diff --git a/src/general_options.rs b/src/general_options.rs new file mode 100644 index 0000000..0550414 --- /dev/null +++ b/src/general_options.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use cli_args::GeneralSort; +use crossterm::event::KeyCode; + +use crate::{ + cli_args::{self, Args}, + Ctx, +}; + +#[derive(Default, Debug)] +pub struct GeneralOptions { + pub sort: GeneralSort, +} + +impl GeneralOptions { + pub fn new(args: &Args) -> Self { + Self { + sort: args.sort.unwrap_or_default(), + } + } + + pub fn handle_keystroke(keycode: &KeyCode, ctx: &Arc) -> bool { + match keycode { + KeyCode::Char('n') => { + let mut general_options = ctx.general_options.write().unwrap(); + general_options.sort = match general_options.sort { + GeneralSort::Name => GeneralSort::DefaultSort, + GeneralSort::DefaultSort => GeneralSort::Name, + }; + + true + } + _ => false, + } + } +} diff --git a/src/main.rs b/src/main.rs index d26800f..961d945 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod bluetooth; mod cli_args; mod error; +mod general_options; mod route; mod tui; @@ -9,6 +10,7 @@ use crate::{bluetooth::BleScan, tui::run_tui_app}; use btleplug::platform::Manager; use clap::Parser; use cli_args::Args; +use general_options::GeneralOptions; use std::sync::RwLock; use std::sync::{Arc, Mutex, RwLockReadGuard}; @@ -21,6 +23,7 @@ pub struct Ctx { active_side_effect_handle: Mutex>>, request_scan_restart: Mutex, global_error: Mutex>, + general_options: RwLock, } impl Ctx { @@ -37,7 +40,6 @@ impl Ctx { async fn main() { let args = Args::parse(); let ctx = Arc::new(Ctx { - args, latest_scan: RwLock::new(None), active_route: RwLock::new(route::Route::PeripheralList), active_side_effect_handle: Mutex::new(None), @@ -46,6 +48,8 @@ async fn main() { .expect("Can not establish BLE connection."), request_scan_restart: Mutex::new(false), global_error: Mutex::new(None), + general_options: RwLock::new(GeneralOptions::new(&args)), + args, }); let ctx_clone = Arc::clone(&ctx); diff --git a/src/tui/connection_view.rs b/src/tui/connection_view.rs index d7f62d6..3776407 100644 --- a/src/tui/connection_view.rs +++ b/src/tui/connection_view.rs @@ -290,7 +290,7 @@ impl AppRoute for ConnectionView { use ansi_to_tui::IntoText; if let Ok(output) = hexyl_output_buf.into_text() { - text.extend(output.into_iter()); + text.extend(output); } else { tracing::error!( ?hexyl_output_buf, @@ -331,34 +331,37 @@ impl AppRoute for ConnectionView { f.render_widget(paragraph, chunks[0]); if chunks[1].height > 0 { f.render_widget( - block::render_help([ - Some(("<-", "Previous value", false)), - Some(("->", "Next value", false)), - Some(("d", "[D]isconnect from device", false)), - Some(("u", "Parse numeric as [u]nsigned", self.unsigned_numbers)), - Some(("f", "Parse numeric as [f]loats", self.float_numbers)), - historical_index.map(|_| { - ( - "l", - "Go to the [l]atest values", - self.highlight_copy_char_renders_delay_stack > 0, - ) - }), - self.clipboard.as_ref().map(|_| { - ( - "c", - "Copy [c]haracteristic UUID", - self.highlight_copy_char_renders_delay_stack > 0, - ) - }), - self.clipboard.as_ref().map(|_| { - ( - "s", - "Copy [s]ervice UUID", - self.highlight_copy_service_renders_delay_stack > 0, - ) - }), - ]), + block::render_help( + Arc::clone(&self.ctx), + [ + Some(("<-", "Previous value", false)), + Some(("->", "Next value", false)), + Some(("d", "[D]isconnect from device", false)), + Some(("u", "Parse numeric as [u]nsigned", self.unsigned_numbers)), + Some(("f", "Parse numeric as [f]loats", self.float_numbers)), + historical_index.map(|_| { + ( + "l", + "Go to the [l]atest values", + self.highlight_copy_char_renders_delay_stack > 0, + ) + }), + self.clipboard.as_ref().map(|_| { + ( + "c", + "Copy [c]haracteristic UUID", + self.highlight_copy_char_renders_delay_stack > 0, + ) + }), + self.clipboard.as_ref().map(|_| { + ( + "s", + "Copy [s]ervice UUID", + self.highlight_copy_service_renders_delay_stack > 0, + ) + }), + ], + ), chunks[1], ); } diff --git a/src/tui/peripheral_list.rs b/src/tui/peripheral_list.rs index 4257899..c1d931f 100644 --- a/src/tui/peripheral_list.rs +++ b/src/tui/peripheral_list.rs @@ -3,6 +3,7 @@ use crate::error::Result; use crate::tui::ui::{block, list::StableListState, search_input, BlendrBlock, ShouldUpdate}; use crate::tui::ui::{HandleInputResult, StableIndexList}; use crate::tui::{AppRoute, HandleKeydownResult}; +use crate::GeneralOptions; use crate::{route::Route, Ctx}; use btleplug::platform::PeripheralId; use crossterm::event::{KeyCode, KeyEvent}; @@ -95,6 +96,10 @@ impl AppRoute for PeripheralList { self.list_state .stabilize_selected_index(&filtered_peripherals); + if GeneralOptions::handle_keystroke(&key.code, &self.ctx) { + return HandleKeydownResult::Handled; + } + match self.focus { Focus::Search => { search_input::handle_search_input(&mut self.search, key); @@ -139,6 +144,10 @@ impl AppRoute for PeripheralList { } } KeyCode::Char('u') => self.to_remove_unknowns = !self.to_remove_unknowns, + KeyCode::Char('s') => { + // let mut sort_by_name = self.ctx.sort_by_name.lock().unwrap(); + // *sort_by_name = !*sort_by_name; + } _ => {} } } @@ -265,13 +274,18 @@ impl AppRoute for PeripheralList { f.render_stateful_widget(items, chunks[1], self.list_state.get_ratatui_state()); if chunks[2].height > 0 { f.render_widget( - block::render_help([ - Some(("q", "Quit", false)), - Some(("u", "Hide unknown devices", self.to_remove_unknowns)), - Some(("->", "Connect to device", false)), - Some(("r", "Restart scan", false)), - Some(("h/j or arrows", "Navigate", false)), - ]), + block::render_help( + Arc::clone(&self.ctx), + [ + Some(("q", "Quit", false)), + Some(("u", "Hide unknown devices", self.to_remove_unknowns)), + Some(("->", "Connect to device", false)), + Some(("r", "Restart scan", false)), + // FIXME genearl sort + // Some(("s", "Sort by name", *self.ctx.sort_by_name.lock().unwrap())), + Some(("h/j or arrows", "Navigate", false)), + ], + ), chunks[2], ); } diff --git a/src/tui/peripheral_view.rs b/src/tui/peripheral_view.rs index 780735e..81cefcc 100644 --- a/src/tui/peripheral_view.rs +++ b/src/tui/peripheral_view.rs @@ -20,7 +20,7 @@ use crate::{ }, HandleKeydownResult, }, - Ctx, + Ctx, GeneralOptions, }; use std::{ ops::Deref, @@ -125,6 +125,20 @@ impl AppRoute for PeripheralView { self.list_state.stabilize_selected_index(&filtered_chars); + if GeneralOptions::handle_keystroke(&key.code, &self.ctx) { + drop(active_route); + + let mut active_route = self.ctx.active_route.write(); + match active_route.as_deref_mut() { + Ok(Route::PeripheralConnectedView(peripheral)) => { + peripheral.apply_sort(&self.ctx); + } + _ => (), + } + + return HandleKeydownResult::Handled; + } + match self.focus { Focus::Search => { search_input::handle_search_input(&mut self.search, key); @@ -339,12 +353,15 @@ impl AppRoute for PeripheralView { f.render_stateful_widget(items, chunks[1], self.list_state.get_ratatui_state()); if chunks[2].height > 0 { f.render_widget( - block::render_help([ - Some(("/", "Search", false)), - Some(("<- | d", "Disconnect from device", false)), - Some(("->", "View characteristic", false)), - Some(("r", "Reconnect to device scan", false)), - ]), + block::render_help( + Arc::clone(&self.ctx), + [ + Some(("/", "Search", false)), + Some(("<- | d", "Disconnect from device", false)), + Some(("->", "View characteristic", false)), + Some(("r", "Reconnect to device scan", false)), + ], + ), chunks[2], ); } diff --git a/src/tui/ui/block.rs b/src/tui/ui/block.rs index 76f67af..d87fd4a 100644 --- a/src/tui/ui/block.rs +++ b/src/tui/ui/block.rs @@ -1,3 +1,5 @@ +use crate::{cli_args, Ctx}; +use std::sync::Arc; use tui::{ style::{Color, Modifier, Style}, text::{Line, Span}, @@ -62,12 +64,42 @@ impl<'a, TTitle: Into> + Default> From> } } -pub fn render_help(help: [Option<(&str, &str, bool)>; N]) -> impl Widget { +pub fn render_help( + ctx: Arc, + help: [Option<(&str, &str, bool)>; N], +) -> impl Widget { + const SPACING: &str = " "; + let general_options_guard = &ctx.general_options.read(); + let general_options = general_options_guard.as_ref().unwrap(); + + let general_options_spans = [ + Span::from("Sort by: "), + Span::styled( + "[n]ame", + if general_options.sort == cli_args::GeneralSort::Name { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ), + Span::from(" | "), + Span::styled( + "default", + if general_options.sort == cli_args::GeneralSort::DefaultSort { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ), + Span::from(SPACING), + ]; + let spans: Vec<_> = help .into_iter() .flatten() .map(|(key, text, bold)| { - const SPACING: &str = " "; let mut key_span = Span::from(format!("[{key}] {text}{SPACING}")); if bold { @@ -79,9 +111,10 @@ pub fn render_help(help: [Option<(&str, &str, bool)>; N]) -> imp // 4 spaces is a good spacing between the two helpers key_span }) + .chain(general_options_spans) .collect(); Paragraph::new(Line::from(spans)) - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(Color::Gray)) .wrap(Wrap { trim: true }) } diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000..23426ab --- /dev/null +++ b/typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +ratatui="ratatui"