From c851590aaa8de1f66030b077d98f986e36980ddf Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Thu, 23 Oct 2025 21:21:25 +0200 Subject: [PATCH] Implement service selection cursor and improve panel navigation Service Selection Features: - Add selection cursor for Services panel with visual highlighting - Up/Down arrows move service selection instead of scrolling - Track selected service for future action implementation - Selection state maintained per host Panel Navigation Improvements: - Fix panel switching to only cycle through visible panels - Dynamic panel list based on backup data availability - Smart recovery when focused panel becomes invisible - No more navigation to hidden backup panel Backup Panel Scrolling Fix: - Fix backup panel scroll to show actual repository content - Replace static overflow indicator with proper scroll behavior - Add scroll position indicators (above/below) - Show all repositories when scrolling instead of truncated list Navigation now works correctly with actual UI layout and provides proper service selection for future action implementation. --- dashboard/src/ui/mod.rs | 75 +++++++++++++++++++++++--- dashboard/src/ui/widgets/backup.rs | 51 +++++++++--------- dashboard/src/ui/widgets/services.rs | 78 +++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 33 deletions(-) diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index eee6690..5a2a59d 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -297,15 +297,47 @@ impl TuiApp { info!("Switched to host: {}", self.current_host.as_ref().unwrap()); } - /// Switch to next panel (Shift+Tab) + /// Switch to next panel (Shift+Tab) - only cycles through visible panels pub fn next_panel(&mut self) { - self.focused_panel = self.focused_panel.next(); + let visible_panels = self.get_visible_panels(); + if visible_panels.len() <= 1 { + return; // Can't switch if only one or no panels visible + } + + // Find current panel index in visible panels + if let Some(current_index) = visible_panels.iter().position(|&p| p == self.focused_panel) { + // Move to next visible panel + let next_index = (current_index + 1) % visible_panels.len(); + self.focused_panel = visible_panels[next_index]; + } else { + // Current panel not visible, switch to first visible panel + self.focused_panel = visible_panels[0]; + } + info!("Switched to panel: {:?}", self.focused_panel); } - /// Switch to previous panel (Shift+Tab in reverse) + /// Switch to previous panel (Shift+Tab in reverse) - only cycles through visible panels pub fn previous_panel(&mut self) { - self.focused_panel = self.focused_panel.previous(); + let visible_panels = self.get_visible_panels(); + if visible_panels.len() <= 1 { + return; // Can't switch if only one or no panels visible + } + + // Find current panel index in visible panels + if let Some(current_index) = visible_panels.iter().position(|&p| p == self.focused_panel) { + // Move to previous visible panel + let prev_index = if current_index == 0 { + visible_panels.len() - 1 + } else { + current_index - 1 + }; + self.focused_panel = visible_panels[prev_index]; + } else { + // Current panel not visible, switch to last visible panel + self.focused_panel = visible_panels[visible_panels.len() - 1]; + } + info!("Switched to panel: {:?}", self.focused_panel); } @@ -330,12 +362,16 @@ impl TuiApp { info!("System panel scroll offset: {}", host_widgets.system_scroll_offset); } PanelType::Services => { + // For services panel, Up/Down moves selection cursor, not scroll + let total_services = host_widgets.services_widget.get_total_services_count(); + if direction > 0 { - host_widgets.services_scroll_offset = host_widgets.services_scroll_offset.saturating_add(1); + host_widgets.services_widget.select_next(total_services); + info!("Services selection moved down"); } else { - host_widgets.services_scroll_offset = host_widgets.services_scroll_offset.saturating_sub(1); + host_widgets.services_widget.select_previous(); + info!("Services selection moved up"); } - info!("Services panel scroll offset: {}", host_widgets.services_scroll_offset); } PanelType::Backup => { if direction > 0 { @@ -349,6 +385,31 @@ impl TuiApp { } } + /// Get total count of services for bounds checking + fn get_total_services_count(&self, hostname: &str) -> usize { + if let Some(host_widgets) = self.host_widgets.get(hostname) { + host_widgets.services_widget.get_total_services_count() + } else { + 0 + } + } + + /// Get list of currently visible panels + fn get_visible_panels(&self) -> Vec { + let mut visible_panels = vec![PanelType::System, PanelType::Services]; + + // Check if backup panel should be shown + if let Some(hostname) = &self.current_host { + if let Some(host_widgets) = self.host_widgets.get(hostname) { + if host_widgets.backup_widget.has_data() { + visible_panels.push(PanelType::Backup); + } + } + } + + visible_panels + } + /// Render the dashboard (real btop-style multi-panel layout) pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) { let size = frame.size(); diff --git a/dashboard/src/ui/widgets/backup.rs b/dashboard/src/ui/widgets/backup.rs index fbc8dac..36d8a66 100644 --- a/dashboard/src/ui/widgets/backup.rs +++ b/dashboard/src/ui/widgets/backup.rs @@ -397,37 +397,15 @@ impl BackupWidget { ratatui::text::Span::styled("Repos:", Typography::widget_title()) ])); - // Repository list with overflow handling - let remaining_space = area.height.saturating_sub(lines.len() as u16); - let mut repo_lines = Vec::new(); - + // Add all repository lines (no truncation here - scroll will handle display) for service in &self.service_metrics { if let (Some(archives), Some(size_gb)) = (service.archive_count, service.repo_size_gb) { let size_str = Self::format_size_with_proper_units(size_gb); let repo_text = format!("{} ({}) {}", service.name, archives, size_str); let repo_spans = StatusIcons::create_status_spans(service.status, &repo_text); - repo_lines.push(ratatui::text::Line::from(repo_spans)); + lines.push(ratatui::text::Line::from(repo_spans)); } } - - if repo_lines.len() <= remaining_space as usize { - // All repos fit - lines.extend(repo_lines); - } else if remaining_space >= 2 { - // Show what we can and add overflow indicator - let lines_to_show = (remaining_space - 1) as usize; // Reserve 1 line for overflow - lines.extend(repo_lines.iter().take(lines_to_show).cloned()); - - let hidden_repos = repo_lines.len() - lines_to_show; - let overflow_text = format!( - "... and {} more repo{}", - hidden_repos, - if hidden_repos == 1 { "" } else { "s" } - ); - lines.push(ratatui::text::Line::from(vec![ - ratatui::text::Span::styled(overflow_text, Typography::muted()) - ])); - } // Apply scroll offset let total_lines = lines.len(); @@ -443,11 +421,34 @@ impl BackupWidget { // Apply scrolling if needed if scroll_offset > 0 || total_lines > available_height { - let visible_lines: Vec<_> = lines + let mut visible_lines: Vec<_> = lines .into_iter() .skip(effective_scroll) .take(available_height) .collect(); + + // Add scroll indicator if there are hidden lines + if total_lines > available_height { + let hidden_above = effective_scroll; + let hidden_below = total_lines.saturating_sub(effective_scroll + available_height); + + if (hidden_above > 0 || hidden_below > 0) && !visible_lines.is_empty() { + let scroll_text = if hidden_above > 0 && hidden_below > 0 { + format!("... {} above, {} below", hidden_above, hidden_below) + } else if hidden_above > 0 { + format!("... {} more above", hidden_above) + } else { + format!("... {} more below", hidden_below) + }; + + // Replace last line with scroll indicator + visible_lines.pop(); + visible_lines.push(ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(scroll_text, Typography::muted()) + ])); + } + } + let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines)); frame.render_widget(paragraph, area); } else { diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index d9aa5c9..c433a97 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -22,6 +22,8 @@ pub struct ServicesWidget { status: Status, /// Last update indicator has_data: bool, + /// Currently selected service index (for navigation cursor) + selected_index: usize, } #[derive(Clone)] @@ -40,6 +42,7 @@ impl ServicesWidget { sub_services: HashMap::new(), status: Status::Unknown, has_data: false, + selected_index: 0, } } @@ -193,6 +196,65 @@ impl ServicesWidget { ), ] } + + /// Move selection up + pub fn select_previous(&mut self) { + if self.selected_index > 0 { + self.selected_index -= 1; + } + } + + /// Move selection down + pub fn select_next(&mut self, total_services: usize) { + if self.selected_index < total_services.saturating_sub(1) { + self.selected_index += 1; + } + } + + /// Get currently selected service name (for actions) + pub fn get_selected_service(&self) -> Option { + // Build the same display list to find the selected service + let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new(); + + let mut parent_services: Vec<_> = self.parent_services.iter().collect(); + parent_services.sort_by(|(a, _), (b, _)| a.cmp(b)); + + for (parent_name, parent_info) in parent_services { + display_lines.push((parent_name.clone(), parent_info.widget_status, false, None)); + + if let Some(sub_list) = self.sub_services.get(parent_name) { + let mut sorted_subs = sub_list.clone(); + sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b)); + + for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() { + let is_last_sub = i == sorted_subs.len() - 1; + display_lines.push(( + format!("{}_{}", parent_name, sub_name), // Use parent_sub format for sub-services + sub_info.widget_status, + true, + Some((sub_info.clone(), is_last_sub)), + )); + } + } + } + + display_lines.get(self.selected_index).map(|(name, _, _, _)| name.clone()) + } + + /// Get total count of services (parent + sub-services) + pub fn get_total_services_count(&self) -> usize { + let mut count = 0; + + // Count parent services + count += self.parent_services.len(); + + // Count sub-services + for sub_list in self.sub_services.values() { + count += sub_list.len(); + } + + count + } } impl Widget for ServicesWidget { @@ -408,6 +470,9 @@ impl ServicesWidget { for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate() { + let actual_index = effective_scroll + i; // Real index in the full list + let is_selected = actual_index == self.selected_index; + let spans = if *is_sub && sub_info.is_some() { // Use custom sub-service span creation let (service_info, is_last) = sub_info.as_ref().unwrap(); @@ -416,7 +481,18 @@ impl ServicesWidget { // Use regular status spans for parent services StatusIcons::create_status_spans(*line_status, line_text) }; - let service_para = Paragraph::new(ratatui::text::Line::from(spans)); + + let mut service_para = Paragraph::new(ratatui::text::Line::from(spans)); + + // Apply selection highlighting + if is_selected { + service_para = service_para.style( + Style::default() + .bg(Theme::highlight()) + .fg(Theme::background()) + ); + } + frame.render_widget(service_para, service_chunks[i]); } }