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.
This commit is contained in:
Christoffer Martinsson 2025-10-23 21:21:25 +02:00
parent 6b18cdf562
commit c851590aaa
3 changed files with 171 additions and 33 deletions

View File

@ -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<PanelType> {
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();

View File

@ -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 {

View File

@ -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<String> {
// 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]);
}
}