diff --git a/src/app.rs b/src/app.rs index 63cd931..39ef672 100644 --- a/src/app.rs +++ b/src/app.rs @@ -38,6 +38,30 @@ pub type AppResult = anyhow::Result; const STAR_SYMBOL: &str = "★"; +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum HelpAction { + ScrollUp, + ScrollDown, + ToggleConnect, + Unpair, + ToggleTrust, + ToggleFavorite, + Rename, + ToggleScan, + Pair, + TogglePairing, + TogglePower, + ToggleDiscovery, +} + +#[derive(Debug, Clone)] +pub struct ClickableHelpItem { + pub x_start: u16, + pub x_end: u16, + pub y: u16, + pub action: Option, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum FocusedBlock { Adapter, @@ -68,6 +92,11 @@ pub struct App { pub config: Arc, pub requests: Requests, pub auth_agent: AuthAgent, + pub adapter_block_bounds: Option, + pub paired_devices_block_bounds: Option, + pub new_devices_block_bounds: Option, + pub help_block_bounds: Option, + pub help_sections: Vec, } impl App { @@ -130,6 +159,11 @@ impl App { config, requests: Requests::default(), auth_agent, + adapter_block_bounds: None, + paired_devices_block_bounds: None, + new_devices_block_bounds: None, + help_block_bounds: None, + help_sections: Vec::new(), }) } @@ -308,6 +342,10 @@ impl App { (chunks[0], chunks[1], chunks[2], chunks[3]) }; + self.adapter_block_bounds = Some(controller_block); + self.paired_devices_block_bounds = Some(paired_devices_block); + self.new_devices_block_bounds = render_new_devices.then_some(new_devices_block); + //Adapters let rows: Vec = self .controllers @@ -697,7 +735,8 @@ impl App { let area = self.area(frame); // Help - Help::render( + self.help_block_bounds = Some(help_block); + self.help_sections = Help::render( frame, area, self.focused_block, diff --git a/src/handler.rs b/src/handler.rs index 46e9bcf..ad85c2d 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,8 +1,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use crate::app::FocusedBlock; -use crate::app::{App, AppResult}; +use crate::app::{App, AppResult, FocusedBlock, HelpAction}; use crate::config::Config; use crate::event::Event; use crate::notification::{Notification, NotificationLevel}; @@ -12,6 +11,58 @@ use tokio::sync::mpsc::UnboundedSender; use tui_input::backend::crossterm::EventHandler; +async fn toggle_scan(app: &mut App, sender: UnboundedSender) -> AppResult<()> { + if let Some(selected_controller) = app.controller_state.selected() { + let controller = &app.controllers[selected_controller]; + + if controller.is_scanning.load(Ordering::Relaxed) { + controller + .is_scanning + .store(false, std::sync::atomic::Ordering::Relaxed); + + Notification::send( + "Scanning stopped".into(), + NotificationLevel::Info, + sender, + )?; + + app.spinner.active = false; + } else { + controller + .is_scanning + .store(true, std::sync::atomic::Ordering::Relaxed); + app.spinner.active = true; + let adapter = controller.adapter.clone(); + let is_scanning = controller.is_scanning.clone(); + tokio::spawn(async move { + let _ = Notification::send( + "Scanning started".into(), + NotificationLevel::Info, + sender.clone(), + ); + + match adapter.discover_devices().await { + Ok(mut discover) => { + while let Some(_evt) = discover.next().await { + if !is_scanning.load(Ordering::Relaxed) { + break; + } + } + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + }); + } + } + Ok(()) +} + async fn toggle_connect(app: &mut App, sender: UnboundedSender) { if let Some(selected_controller) = app.controller_state.selected() { let controller = &app.controllers[selected_controller]; @@ -157,6 +208,277 @@ async fn pair(app: &mut App, sender: UnboundedSender) { } } +async fn unpair(app: &mut App, sender: UnboundedSender) -> AppResult<()> { + if let Some(selected_controller) = app.controller_state.selected() { + let controller = &app.controllers[selected_controller]; + if let Some(index) = app.paired_devices_state.selected() { + let addr = controller.paired_devices[index].addr; + match controller.adapter.remove_device(addr).await { + Ok(_) => { + let _ = Notification::send( + "Device unpaired".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } + } + Ok(()) +} + +async fn toggle_trust(app: &mut App, sender: UnboundedSender) -> AppResult<()> { + if let Some(selected_controller) = app.controller_state.selected() { + let controller = &app.controllers[selected_controller]; + if let Some(index) = app.paired_devices_state.selected() { + let addr = controller.paired_devices[index].addr; + match controller.adapter.device(addr) { + Ok(device) => { + tokio::spawn(async move { + match device.is_trusted().await { + Ok(is_trusted) => { + if is_trusted { + match device.set_trusted(false).await { + Ok(_) => { + let _ = Notification::send( + "Device untrusted".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } else { + match device.set_trusted(true).await { + Ok(_) => { + let _ = Notification::send( + "Device trusted".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + }); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } + } + Ok(()) +} + +async fn toggle_favorite(app: &mut App, sender: UnboundedSender) -> AppResult<()> { + if let Some(selected_controller) = app.controller_state.selected() { + let controller = &app.controllers[selected_controller]; + if let Some(index) = app.paired_devices_state.selected() { + let address = controller.paired_devices[index].addr; + let _ = sender.send(Event::ToggleFavorite(address)); + } + } + Ok(()) +} + +async fn toggle_pairing(app: &mut App, sender: UnboundedSender) -> AppResult<()> { + if let Some(selected_controller) = app.controller_state.selected() { + let adapter = app.controllers[selected_controller].adapter.clone(); + tokio::spawn(async move { + match adapter.is_pairable().await { + Ok(is_pairable) => { + if is_pairable { + match adapter.set_pairable(false).await { + Ok(_) => { + let _ = Notification::send( + "Adapter unpairable".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } else { + match adapter.set_pairable(true).await { + Ok(_) => { + let _ = Notification::send( + "Adapter pairable".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + }); + } + Ok(()) +} + +async fn toggle_power(app: &mut App, sender: UnboundedSender) -> AppResult<()> { + if let Some(selected_controller) = app.controller_state.selected() { + let adapter = app.controllers[selected_controller].adapter.clone(); + tokio::spawn(async move { + match adapter.is_powered().await { + Ok(is_powered) => { + if is_powered { + match adapter.set_powered(false).await { + Ok(_) => { + let _ = Notification::send( + "Adapter powered off".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } else { + match adapter.set_powered(true).await { + Ok(_) => { + let _ = Notification::send( + "Adapter powered on".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + }); + } + Ok(()) +} + +async fn toggle_discovery(app: &mut App, sender: UnboundedSender) -> AppResult<()> { + if let Some(selected_controller) = app.controller_state.selected() { + let adapter = app.controllers[selected_controller].adapter.clone(); + tokio::spawn(async move { + match adapter.is_discoverable().await { + Ok(is_discoverable) => { + if is_discoverable { + match adapter.set_discoverable(false).await { + Ok(_) => { + let _ = Notification::send( + "Adapter undiscoverable".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } else { + match adapter.set_discoverable(true).await { + Ok(_) => { + let _ = Notification::send( + "Adapter discoverable".into(), + NotificationLevel::Info, + sender.clone(), + ); + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + } + } + Err(e) => { + let _ = Notification::send( + e.into(), + NotificationLevel::Error, + sender.clone(), + ); + } + } + }); + } + Ok(()) +} + pub async fn handle_key_events( key_event: KeyEvent, app: &mut App, @@ -443,54 +765,7 @@ pub async fn handle_key_events( // Start/Stop Scan KeyCode::Char(c) if c == config.toggle_scanning => { - if let Some(selected_controller) = app.controller_state.selected() { - let controller = &app.controllers[selected_controller]; - - if controller.is_scanning.load(Ordering::Relaxed) { - controller - .is_scanning - .store(false, std::sync::atomic::Ordering::Relaxed); - - Notification::send( - "Scanning stopped".into(), - NotificationLevel::Info, - sender, - )?; - - app.spinner.active = false; - } else { - controller - .is_scanning - .store(true, std::sync::atomic::Ordering::Relaxed); - app.spinner.active = true; - let adapter = controller.adapter.clone(); - let is_scanning = controller.is_scanning.clone(); - tokio::spawn(async move { - let _ = Notification::send( - "Scanning started".into(), - NotificationLevel::Info, - sender.clone(), - ); - - match adapter.discover_devices().await { - Ok(mut discover) => { - while let Some(_evt) = discover.next().await { - if !is_scanning.load(Ordering::Relaxed) { - break; - } - } - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - }); - } - } + toggle_scan(app, sender).await?; } _ => { @@ -499,30 +774,7 @@ pub async fn handle_key_events( match key_event.code { // Unpair KeyCode::Char(c) if c == config.paired_device.unpair => { - if let Some(selected_controller) = - app.controller_state.selected() - { - let controller = &app.controllers[selected_controller]; - if let Some(index) = app.paired_devices_state.selected() { - let addr = controller.paired_devices[index].addr; - match controller.adapter.remove_device(addr).await { - Ok(_) => { - let _ = Notification::send( - "Device unpaired".into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - } + unpair(app, sender.clone()).await?; } // Connect / Disconnect @@ -531,95 +783,12 @@ pub async fn handle_key_events( // Trust / Untrust KeyCode::Char(c) if c == config.paired_device.toggle_trust => { - if let Some(selected_controller) = - app.controller_state.selected() - { - let controller = &app.controllers[selected_controller]; - if let Some(index) = app.paired_devices_state.selected() { - let addr = controller.paired_devices[index].addr; - match controller.adapter.device(addr) { - Ok(device) => { - tokio::spawn(async move { - match device.is_trusted().await { - Ok(is_trusted) => { - if is_trusted { - match device - .set_trusted(false) - .await - { - Ok(_) => { - let _ = Notification::send( - "Device untrusted" - .into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } else { - match device - .set_trusted(true) - .await - { - Ok(_) => { - let _ = Notification::send( - "Device trusted" - .into(), - NotificationLevel::Info, - sender.clone(), - ); - } - - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - }); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - } + toggle_trust(app, sender.clone()).await?; } // Favorite / Unfavorite KeyCode::Char(c) if c == config.paired_device.toggle_favorite => { - if let Some(selected_controller) = - app.controller_state.selected() - { - let controller = &app.controllers[selected_controller]; - if let Some(index) = app.paired_devices_state.selected() { - let address = controller.paired_devices[index].addr; - let _ = sender.send(Event::ToggleFavorite(address)); - } - } + toggle_favorite(app, sender.clone()).await?; } KeyCode::Char(c) if c == config.paired_device.rename => { @@ -634,192 +803,17 @@ pub async fn handle_key_events( match key_event.code { // toggle pairing KeyCode::Char(c) if c == config.adapter.toggle_pairing => { - if let Some(selected_controller) = - app.controller_state.selected() - { - let adapter = &app.controllers[selected_controller].adapter; - tokio::spawn({ - let adapter = adapter.clone(); - async move { - match adapter.is_pairable().await { - Ok(is_pairable) => { - if is_pairable { - match adapter.set_pairable(false).await - { - Ok(_) => { - let _ = Notification::send( - "Adapter unpairable".into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } else { - match adapter.set_pairable(true).await { - Ok(_) => { - let _ = Notification::send( - "Adapter pairable".into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - }); - } + toggle_pairing(app, sender.clone()).await?; } // toggle power KeyCode::Char(c) if c == config.adapter.toggle_power => { - if let Some(selected_controller) = - app.controller_state.selected() - { - let adapter = &app.controllers[selected_controller].adapter; - tokio::spawn({ - let adapter = adapter.clone(); - async move { - match adapter.is_powered().await { - Ok(is_powered) => { - if is_powered { - match adapter.set_powered(false).await { - Ok(_) => { - let _ = Notification::send( - "Adapter powered off" - .into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } else { - match adapter.set_powered(true).await { - Ok(_) => { - let _ = Notification::send( - "Adapter powered on".into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - }); - } + toggle_power(app, sender.clone()).await?; } // toggle discovery KeyCode::Char(c) if c == config.adapter.toggle_discovery => { - if let Some(selected_controller) = - app.controller_state.selected() - { - let adapter = &app.controllers[selected_controller].adapter; - tokio::spawn({ - let adapter = adapter.clone(); - async move { - match adapter.is_discoverable().await { - Ok(is_discoverable) => { - if is_discoverable { - match adapter - .set_discoverable(false) - .await - { - Ok(_) => { - let _ = Notification::send( - "Adapter undiscoverable" - .into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } else { - match adapter - .set_discoverable(true) - .await - { - Ok(_) => { - let _ = Notification::send( - "Adapter discoverable" - .into(), - NotificationLevel::Info, - sender.clone(), - ); - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - } - Err(e) => { - let _ = Notification::send( - e.into(), - NotificationLevel::Error, - sender.clone(), - ); - } - } - } - }); - } + toggle_discovery(app, sender.clone()).await?; } _ => {} @@ -844,3 +838,217 @@ pub async fn handle_key_events( Ok(()) } + +fn is_within_bounds(col: u16, row: u16, bounds: ratatui::layout::Rect) -> bool { + let right = bounds.x.saturating_add(bounds.width); + let bottom = bounds.y.saturating_add(bounds.height); + + col >= bounds.x && col < right && row >= bounds.y && row < bottom +} + +async fn handle_mouse_down(mouse_event: crossterm::event::MouseEvent, app: &mut App, sender: UnboundedSender) -> AppResult<()> { + let col = mouse_event.column; + let row = mouse_event.row; + + if let Some(bounds) = app.adapter_block_bounds { + if is_within_bounds(col, row, bounds) { + app.focused_block = FocusedBlock::Adapter; + + // Todo: use actual header height here instead of hardcoding + let header_offset = 3; + if row >= bounds.y + header_offset && !app.controllers.is_empty() { + let clicked_row = (row - bounds.y - header_offset) as usize; + if clicked_row < app.controllers.len() { + app.controller_state.select(Some(clicked_row)); + app.reset_devices_state(); + } + } + return Ok(()); + } + } + + if let Some(bounds) = app.paired_devices_block_bounds { + if is_within_bounds(col, row, bounds) { + app.focused_block = FocusedBlock::PairedDevices; + + if let Some(selected_controller) = app.controller_state.selected() { + let controller = &app.controllers[selected_controller]; + let header_offset = 3; + if row >= bounds.y + header_offset && !controller.paired_devices.is_empty() + { + let clicked_row = (row - bounds.y - header_offset) as usize; + if clicked_row < controller.paired_devices.len() { + app.paired_devices_state.select(Some(clicked_row)); + } + } + } + return Ok(()); + } + } + + if let Some(bounds) = app.new_devices_block_bounds { + if is_within_bounds(col, row, bounds) { + app.focused_block = FocusedBlock::NewDevices; + + if let Some(selected_controller) = app.controller_state.selected() { + let controller = &app.controllers[selected_controller]; + let header_offset = 3; + if row >= bounds.y + header_offset && !controller.new_devices.is_empty() { + let clicked_row = (row - bounds.y - header_offset) as usize; + if clicked_row < controller.new_devices.len() { + app.new_devices_state.select(Some(clicked_row)); + } + } + } + return Ok(()); + } + } + + if let Some(help_bounds) = app.help_block_bounds { + if !is_within_bounds(col, row, help_bounds) { + return Ok(()); + } + + let found_item = app + .help_sections + .iter() + .find(|s| row == s.y && col >= s.x_start && col < s.x_end); + + if found_item.is_some() { + eprintln!("Clicked on help section: {:?}", found_item); + } + let clicked_action = found_item.and_then(|s| s.action); + + if let Some(action) = clicked_action { + match action { + HelpAction::ScrollUp => { + match app.focused_block { + FocusedBlock::Adapter => { + if !app.controllers.is_empty() { + let i = app.controller_state.selected() + .map(|i| if i > 0 { i - 1 } else { app.controllers.len() - 1 }) + .unwrap_or(0); + app.controller_state.select(Some(i)); + } + } + FocusedBlock::PairedDevices => { + if let Some(sel) = app.controller_state.selected() { + let controller = &app.controllers[sel]; + if !controller.paired_devices.is_empty() { + let i = app.paired_devices_state.selected() + .map(|i| if i > 0 { i - 1 } else { controller.paired_devices.len() - 1 }) + .unwrap_or(0); + app.paired_devices_state.select(Some(i)); + } + } + } + FocusedBlock::NewDevices => { + if let Some(sel) = app.controller_state.selected() { + let controller = &app.controllers[sel]; + if !controller.new_devices.is_empty() { + let i = app.new_devices_state.selected() + .map(|i| if i > 0 { i - 1 } else { controller.new_devices.len() - 1 }) + .unwrap_or(0); + app.new_devices_state.select(Some(i)); + } + } + } + _ => {} + } + } + HelpAction::ScrollDown => { + match app.focused_block { + FocusedBlock::Adapter => { + if !app.controllers.is_empty() { + let i = app.controller_state.selected() + .map(|i| if i < app.controllers.len() - 1 { i + 1 } else { 0 }) + .unwrap_or(0); + app.controller_state.select(Some(i)); + } + } + FocusedBlock::PairedDevices => { + if let Some(sel) = app.controller_state.selected() { + let controller = &app.controllers[sel]; + if !controller.paired_devices.is_empty() { + let i = app.paired_devices_state.selected() + .map(|i| if i < controller.paired_devices.len() - 1 { i + 1 } else { 0 }) + .unwrap_or(0); + app.paired_devices_state.select(Some(i)); + } + } + } + FocusedBlock::NewDevices => { + if let Some(sel) = app.controller_state.selected() { + let controller = &app.controllers[sel]; + if !controller.new_devices.is_empty() { + let i = app.new_devices_state.selected() + .map(|i| if i < controller.new_devices.len() - 1 { i + 1 } else { 0 }) + .unwrap_or(0); + app.new_devices_state.select(Some(i)); + } + } + } + _ => {} + } + } + HelpAction::ToggleConnect => { + toggle_connect(app, sender).await; + } + HelpAction::Unpair => { + unpair(app, sender).await?; + } + HelpAction::ToggleTrust => { + toggle_trust(app, sender).await?; + } + HelpAction::ToggleFavorite => { + toggle_favorite(app, sender).await?; + } + HelpAction::Rename => { + app.focused_block = FocusedBlock::SetDeviceAliasBox; + } + HelpAction::ToggleScan => { + toggle_scan(app, sender).await?; + } + HelpAction::Pair => { + pair(app, sender).await; + } + HelpAction::TogglePairing => { + toggle_pairing(app, sender).await?; + } + HelpAction::TogglePower => { + toggle_power(app, sender).await?; + } + HelpAction::ToggleDiscovery => { + toggle_discovery(app, sender).await?; + } + } + } + } + return Ok(()); +} + +pub async fn handle_mouse_events( + mouse_event: crossterm::event::MouseEvent, + app: &mut App, + sender: UnboundedSender, + _config: Arc, +) -> AppResult<()> { + use crossterm::event::{MouseButton, MouseEventKind}; + + match mouse_event.kind { + MouseEventKind::Down(MouseButton::Left) => { + let _ = handle_mouse_down(mouse_event, app, sender).await; + } + MouseEventKind::ScrollDown => { + // I'll add this later once we have a basic sestup in place for clicking + // and defining sections for click interactions. + } + MouseEventKind::ScrollUp => { + // I'll add this later once we have a basic sestup in place for clicking + // and defining sections for click interactions. + } + _ => {} + } + + Ok(()) +} diff --git a/src/help.rs b/src/help.rs index 12d00d6..1c5b1b3 100644 --- a/src/help.rs +++ b/src/help.rs @@ -8,7 +8,74 @@ use ratatui::{ widgets::Paragraph, }; -use crate::{app::FocusedBlock, config::Config}; +use crate::{ + app::{FocusedBlock, HelpAction, ClickableHelpItem}, + config::Config, +}; + +struct HelpItem<'a> { + spans: Vec>, + x_start: u16, + x_end: u16, + action: Option, +} + +impl<'a> HelpItem<'a> { + fn new(spans: Vec>, action: Option) -> Self { + let width: u16 = spans.iter().map(|s| s.content.len() as u16).sum(); + Self { + spans, + x_start: 0, + x_end: width, + action, + } + } + + fn width(&self) -> u16 { + self.x_end - self.x_start + } + + fn set_position(&mut self, x_start: u16) { + let width = self.width(); + self.x_start = x_start; + self.x_end = x_start + width; + } + + fn get_spans(&self) -> Vec> { + self.spans.clone() + } + + fn to_clickable_item(&self, y: u16) -> ClickableHelpItem { + ClickableHelpItem { + x_start: self.x_start, + x_end: self.x_end, + y, + action: self.action, + } + } +} + +fn place_items_in_row( + items: &mut [&mut HelpItem<'_>], + start_x: u16, + sep_width: u16, + y: u16, + clickable_items: &mut Vec, +) { + let mut current_x = start_x; + let last_idx = items.len().saturating_sub(1); + + for (idx, item) in items.iter_mut().enumerate() { + item.set_position(current_x); + clickable_items.push(item.to_clickable_item(y)); + + if idx < last_idx { + current_x = current_x + .saturating_add(item.width()) + .saturating_add(sep_width); + } + } +} pub struct Help; @@ -19,126 +86,368 @@ impl Help { focused_block: FocusedBlock, rendering_block: Rect, config: Arc, - ) { + ) -> Vec { + let mut clickable_items: Vec = Vec::new(); + let help = match focused_block { FocusedBlock::PairedDevices => { if area.width > 120 { - vec![Line::from(vec![ - Span::from("k,").bold(), - Span::from(" Up"), - Span::from(" | "), - Span::from("j,").bold(), - Span::from(" Down"), - Span::from(" | "), - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), + let mut up_item = + HelpItem::new(vec![Span::from("k,").bold(), Span::from(" Up")], Some(HelpAction::ScrollUp)); + let mut down_item = + HelpItem::new(vec![Span::from("j,").bold(), Span::from(" Down")], Some(HelpAction::ScrollDown)); + let mut scan_item = + HelpItem::new(vec![Span::from("s").bold(), Span::from(" Scan on/off")], Some(HelpAction::ToggleScan)); + let mut unpair_item = HelpItem::new(vec![ Span::from(config.paired_device.unpair.to_string()).bold(), - Span::from(" Unpair"), - Span::from(" | "), - Span::from("󱁐 or ↵ ").bold(), - Span::from(" Dis/Connect"), - Span::from(" | "), + Span::from(" Unpair----"), + ], Some(HelpAction::Unpair)); + let mut connect_item = + HelpItem::new(vec![Span::from("󱁐 or ↵ ").bold(), Span::from(" Dis/Connect")], Some(HelpAction::ToggleConnect)); + let mut trust_item = HelpItem::new(vec![ Span::from(config.paired_device.toggle_trust.to_string()).bold(), Span::from(" Un/Trust"), - Span::from(" | "), + ], Some(HelpAction::ToggleTrust)); + let mut favorite_item = HelpItem::new(vec![ Span::from(config.paired_device.toggle_favorite.to_string()).bold(), Span::from(" Un/Favorite"), - Span::from(" | "), + ], Some(HelpAction::ToggleFavorite)); + let mut rename_item = HelpItem::new(vec![ Span::from(config.paired_device.rename.to_string()).bold(), Span::from(" Rename"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ])] + ], Some(HelpAction::Rename)); + let mut nav_item = + HelpItem::new(vec![Span::from("⇄").bold(), Span::from(" Nav")], None); + + let separator = Span::from(" | "); + + let mut all_spans: Vec = Vec::new(); + all_spans.extend(up_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(down_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(scan_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(unpair_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(connect_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(trust_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(favorite_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(rename_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(nav_item.get_spans()); + + // Calculate start_x for centered line + let total_width: u16 = all_spans.iter().map(|s| s.content.len() as u16).sum(); + let start_x = + rendering_block.x + (rendering_block.width.saturating_sub(total_width)) / 2; + + // Set positions on each item + let sep_width = separator.content.len() as u16; + let mut items = [ + &mut up_item, + &mut down_item, + &mut scan_item, + &mut unpair_item, + &mut connect_item, + &mut trust_item, + &mut favorite_item, + &mut rename_item, + &mut nav_item, + ]; + place_items_in_row( + &mut items, + start_x, + sep_width, + rendering_block.y, + &mut clickable_items, + ); + + vec![Line::from(all_spans)] } else { - vec![ - Line::from(vec![ - Span::from("󱁐 or ↵ ").bold(), - Span::from(" Dis/Connect"), - Span::from(" | "), - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), + let mut connect_item = HelpItem::new( + vec![Span::from("󱁐 or ↵ ").bold(), Span::from(" Dis/Connect")], + Some(HelpAction::ToggleConnect), + ); + let mut scan_item = HelpItem::new( + vec![Span::from("s").bold(), Span::from(" Scan on/off")], + Some(HelpAction::ToggleScan), + ); + let mut unpair_item = HelpItem::new( + vec![ Span::from(config.paired_device.unpair.to_string()).bold(), Span::from(" Unpair"), - Span::from(" | "), + ], + Some(HelpAction::Unpair), + ); + let mut favorite_item = HelpItem::new( + vec![ Span::from(config.paired_device.toggle_favorite.to_string()).bold(), Span::from(" Un/Favorite"), - ]), - Line::from(vec![ + ], + Some(HelpAction::ToggleFavorite), + ); + + let mut trust_item = HelpItem::new( + vec![ Span::from(config.paired_device.toggle_trust.to_string()).bold(), Span::from(" Un/Trust"), - Span::from(" | "), + ], + Some(HelpAction::ToggleTrust), + ); + let mut rename_item = HelpItem::new( + vec![ Span::from(config.paired_device.rename.to_string()).bold(), Span::from(" Rename"), - Span::from(" | "), - Span::from("k,").bold(), - Span::from(" Up"), - Span::from(" | "), - Span::from("j,").bold(), - Span::from(" Down"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ]), - ] + ], + Some(HelpAction::Rename), + ); + let mut up_item = + HelpItem::new(vec![Span::from("k,").bold(), Span::from(" Up")], Some(HelpAction::ScrollUp)); + let mut down_item = + HelpItem::new(vec![Span::from("j,").bold(), Span::from(" Down")], Some(HelpAction::ScrollDown)); + let mut nav_item = + HelpItem::new(vec![Span::from("⇄").bold(), Span::from(" Nav")], None); + + let separator = Span::from(" | "); + let sep_width = separator.content.len() as u16; + + let mut line1: Vec = Vec::new(); + line1.extend(connect_item.get_spans()); + line1.push(separator.clone()); + line1.extend(scan_item.get_spans()); + line1.push(separator.clone()); + line1.extend(unpair_item.get_spans()); + line1.push(separator.clone()); + line1.extend(favorite_item.get_spans()); + + let total_width1: u16 = line1.iter().map(|s| s.content.len() as u16).sum(); + let start_x1 = + rendering_block.x + (rendering_block.width.saturating_sub(total_width1)) / 2; + let mut line1_items = [ + &mut connect_item, + &mut scan_item, + &mut unpair_item, + &mut favorite_item, + ]; + place_items_in_row( + &mut line1_items, + start_x1, + sep_width, + rendering_block.y, + &mut clickable_items, + ); + + let mut line2: Vec = Vec::new(); + line2.extend(trust_item.get_spans()); + line2.push(separator.clone()); + line2.extend(rename_item.get_spans()); + line2.push(separator.clone()); + line2.extend(up_item.get_spans()); + line2.push(separator.clone()); + line2.extend(down_item.get_spans()); + line2.push(separator.clone()); + line2.extend(nav_item.get_spans()); + + let total_width2: u16 = line2.iter().map(|s| s.content.len() as u16).sum(); + let start_x2 = + rendering_block.x + (rendering_block.width.saturating_sub(total_width2)) / 2; + let mut line2_items = [ + &mut trust_item, + &mut rename_item, + &mut up_item, + &mut down_item, + &mut nav_item, + ]; + place_items_in_row( + &mut line2_items, + start_x2, + sep_width, + rendering_block.y + 1, + &mut clickable_items, + ); + + vec![Line::from(line1), Line::from(line2)] } } - FocusedBlock::NewDevices => vec![Line::from(vec![ - Span::from("k,").bold(), - Span::from(" Up"), - Span::from(" | "), - Span::from("j,").bold(), - Span::from(" Down"), - Span::from(" | "), - Span::from("󱁐 or ↵ ").bold(), - Span::from(" Pair"), - Span::from(" | "), - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ])], + FocusedBlock::NewDevices => { + let mut up_item = + HelpItem::new(vec![Span::from("k,").bold(), Span::from(" Up")], Some(HelpAction::ScrollUp)); + let mut down_item = + HelpItem::new(vec![Span::from("j,").bold(), Span::from(" Down")], Some(HelpAction::ScrollDown)); + let mut pair_item = + HelpItem::new(vec![Span::from("󱁐 or ↵ ").bold(), Span::from(" Pair")], Some(HelpAction::Pair)); + let mut scan_item = + HelpItem::new(vec![Span::from("s").bold(), Span::from(" Scan on/off")], Some(HelpAction::ToggleScan)); + let mut nav_item = + HelpItem::new(vec![Span::from("⇄").bold(), Span::from(" Nav")], None); + + let separator = Span::from(" | "); + let sep_width = separator.content.len() as u16; + + let mut all_spans: Vec = Vec::new(); + all_spans.extend(up_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(down_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(pair_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(scan_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(nav_item.get_spans()); + + let total_width: u16 = all_spans.iter().map(|s| s.content.len() as u16).sum(); + let start_x = + rendering_block.x + (rendering_block.width.saturating_sub(total_width)) / 2; + let mut items = [ + &mut up_item, + &mut down_item, + &mut pair_item, + &mut scan_item, + &mut nav_item, + ]; + place_items_in_row( + &mut items, + start_x, + sep_width, + rendering_block.y, + &mut clickable_items, + ); + + vec![Line::from(all_spans)] + } FocusedBlock::Adapter => { if area.width > 80 { - vec![Line::from(vec![ - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), - Span::from(config.adapter.toggle_pairing.to_string()).bold(), - Span::from(" Pairing on/off"), - Span::from(" | "), - Span::from(config.adapter.toggle_power.to_string()).bold(), - Span::from(" Power on/off"), - Span::from(" | "), - Span::from(config.adapter.toggle_discovery.to_string()).bold(), - Span::from(" Discovery on/off"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ])] + let mut scan_item = + HelpItem::new(vec![Span::from("s").bold(), Span::from(" Scan on/off")], Some(HelpAction::ToggleScan)); + let mut pairing_item = HelpItem::new( + vec![ + Span::from(config.adapter.toggle_pairing.to_string()).bold(), + Span::from(" Pairing on/off"), + ], + Some(HelpAction::TogglePairing), + ); + let mut power_item = HelpItem::new( + vec![ + Span::from(config.adapter.toggle_power.to_string()).bold(), + Span::from(" Power on/off"), + ], + Some(HelpAction::TogglePower), + ); + let mut discovery_item = HelpItem::new( + vec![ + Span::from(config.adapter.toggle_discovery.to_string()).bold(), + Span::from(" Discovery on/off"), + ], + Some(HelpAction::ToggleDiscovery), + ); + let mut nav_item = + HelpItem::new(vec![Span::from("⇄").bold(), Span::from(" Nav")], None); + + let separator = Span::from(" | "); + let sep_width = separator.content.len() as u16; + + let mut all_spans: Vec = Vec::new(); + all_spans.extend(scan_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(pairing_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(power_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(discovery_item.get_spans()); + all_spans.push(separator.clone()); + all_spans.extend(nav_item.get_spans()); + + let total_width: u16 = all_spans.iter().map(|s| s.content.len() as u16).sum(); + let start_x = + rendering_block.x + (rendering_block.width.saturating_sub(total_width)) / 2; + let mut items = [ + &mut scan_item, + &mut pairing_item, + &mut power_item, + &mut discovery_item, + &mut nav_item, + ]; + place_items_in_row( + &mut items, + start_x, + sep_width, + rendering_block.y, + &mut clickable_items, + ); + + vec![Line::from(all_spans)] } else { - vec![ - Line::from(vec![ - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), + let mut scan_item = + HelpItem::new(vec![Span::from("s").bold(), Span::from(" Scan on/off")], Some(HelpAction::ToggleScan)); + let mut pairing_item = HelpItem::new( + vec![ Span::from(config.adapter.toggle_pairing.to_string()).bold(), Span::from(" Pairing on/off"), - ]), - Line::from(vec![ + ], + Some(HelpAction::TogglePairing), + ); + + let mut power_item = HelpItem::new( + vec![ Span::from(config.adapter.toggle_power.to_string()).bold(), Span::from(" Power on/off"), - Span::from(" | "), + ], + Some(HelpAction::TogglePower), + ); + let mut discovery_item = HelpItem::new( + vec![ Span::from(config.adapter.toggle_discovery.to_string()).bold(), Span::from(" Discovery on/off"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ]), - ] + ], + Some(HelpAction::ToggleDiscovery), + ); + let mut nav_item = + HelpItem::new(vec![Span::from("⇄").bold(), Span::from(" Nav")], None); + + let separator = Span::from(" | "); + let sep_width = separator.content.len() as u16; + + let mut line1: Vec = Vec::new(); + line1.extend(scan_item.get_spans()); + line1.push(separator.clone()); + line1.extend(pairing_item.get_spans()); + + let total_width1: u16 = line1.iter().map(|s| s.content.len() as u16).sum(); + let start_x1 = + rendering_block.x + (rendering_block.width.saturating_sub(total_width1)) / 2; + let mut line1_items = [&mut scan_item, &mut pairing_item]; + place_items_in_row( + &mut line1_items, + start_x1, + sep_width, + rendering_block.y, + &mut clickable_items, + ); + + let mut line2: Vec = Vec::new(); + line2.extend(power_item.get_spans()); + line2.push(separator.clone()); + line2.extend(discovery_item.get_spans()); + line2.push(separator.clone()); + line2.extend(nav_item.get_spans()); + + let total_width2: u16 = line2.iter().map(|s| s.content.len() as u16).sum(); + let start_x2 = + rendering_block.x + (rendering_block.width.saturating_sub(total_width2)) / 2; + let mut line2_items = [&mut power_item, &mut discovery_item, &mut nav_item]; + place_items_in_row( + &mut line2_items, + start_x2, + sep_width, + rendering_block.y + 1, + &mut clickable_items, + ); + + vec![Line::from(line1), Line::from(line2)] } } FocusedBlock::SetDeviceAliasBox => { @@ -187,7 +496,10 @@ impl Help { ])] } }; + let help = Paragraph::new(help).centered().blue(); frame.render_widget(help, rendering_block); + + clickable_items } } diff --git a/src/main.rs b/src/main.rs index e73e279..e234635 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use bluetui::{ cli, config::Config, event::{Event, EventHandler}, - handler::handle_key_events, + handler::{handle_key_events, handle_mouse_events}, rfkill, tui::Tui, }; @@ -49,7 +49,16 @@ async fn main() -> AppResult<()> { config.clone(), ) .await? - } + }, + Event::Mouse(mouse_event) => { + handle_mouse_events( + mouse_event, + &mut app, + tui.events.sender.clone(), + config.clone(), + ) + .await? + }, Event::Notification(notification) => { app.notifications.push(notification); } @@ -135,7 +144,7 @@ async fn main() -> AppResult<()> { app.focused_block = bluetui::app::FocusedBlock::PairedDevices; } - Event::Mouse(_) | Event::Resize(_, _) => {} + Event::Resize(_, _) => {} } }