Implement complete keyboard navigation and UI enhancement

Phase 1 - Panel Navigation:
- Add PanelType enum and panel focus state management
- Implement Shift+Tab cycling between panels (System → Services → Backup → Network)
- Add visual focus indicators with blue borders for focused panels
- Preserve existing Tab behavior for host switching

Phase 2 - Dynamic Statusbar:
- Add bottom statusbar with context-aware shortcuts
- Display different shortcuts based on focused panel
- Global shortcuts: Tab, Shift+Tab, Up/Down arrows, Q
- Panel-specific shortcuts: R (Rebuild), Space/R (Services), B (Backup), N (Network)

Phase 3 - Scrolling Support:
- Add scroll state management per host and panel type
- Implement Up/Down arrow key scrolling within focused panels
- Smart scrolling that activates only when content exceeds panel height
- Scroll bounds checking to prevent over-scrolling

Complete keyboard navigation experience with visual feedback and contextual help.
This commit is contained in:
Christoffer Martinsson 2025-10-23 20:34:45 +02:00
parent 51375e8020
commit 8cb5650fbb
5 changed files with 278 additions and 23 deletions

View File

@ -40,20 +40,35 @@ Storage:
└─ ● 18% 167.4GB/928.2GB
```
**Outstanding Issue:**
- Dashboard displays "Build: unknown" despite agent collecting metrics correctly
- Need to investigate ZMQ communication or dashboard metric filtering
**Current Status - October 23, 2025:**
- System panel layout fully implemented with blue tree symbols ✅
- Backup panel layout restructured per specification ✅
- Tree symbols now use consistent blue theming across all panels ✅
- Overflow handling restored for all widgets ("... and X more") ✅
- Agent hash display working correctly ✅
### Future Priorities
### Next Implementation Phase: Keyboard Navigation & UI Enhancement
**Keyboard Navigation (Dashboard):**
- Change host switching to "Shift-Tab"
- Add panel navigation with "Tab"
- Add scrolling support for overflow content
**Phase 1 - Panel Navigation (In Progress):**
- Add panel focus state management to TuiApp
- Implement Shift-Tab for panel cycling (System → Services → Backup → Network)
- Keep Tab for host switching as current behavior
- Add visual focus indicators to panel borders
**Remote Execution (Agent/Dashboard):**
- Dynamic statusbar with context shortcuts
- Remote nixos rebuild commands
**Phase 2 - Dynamic Statusbar:**
- Create bottom statusbar showing context-aware shortcuts
- System panel: "R: Rebuild", "S: Services"
- Services panel: "Space: Start/Stop", "R: Restart"
- Backup panel: "B: Trigger Backup"
- Global: "Tab: Switch Host", "Shift-Tab: Switch Panel"
**Phase 3 - Scrolling Support:**
- Add Up/Down arrow key handling within focused panels
- Implement scroll offset tracking for overflow content
- Add scroll position indicators (↑/↓) when needed
**Future - Remote Execution:**
- Remote nixos rebuild commands via dashboard
- Service start/stop/restart controls
- Backup trigger functionality

View File

@ -1,5 +1,5 @@
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
@ -18,6 +18,42 @@ use cm_dashboard_shared::{Metric, Status};
use theme::{Components, Layout as ThemeLayout, StatusIcons, Theme, Typography};
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
/// Panel types for focus management
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelType {
System,
Services,
Backup,
Network,
}
impl PanelType {
/// Get all panel types in order
pub fn all() -> [PanelType; 4] {
[PanelType::System, PanelType::Services, PanelType::Backup, PanelType::Network]
}
/// Get the next panel in cycle
pub fn next(self) -> PanelType {
match self {
PanelType::System => PanelType::Services,
PanelType::Services => PanelType::Backup,
PanelType::Backup => PanelType::Network,
PanelType::Network => PanelType::System,
}
}
/// Get the previous panel in cycle
pub fn previous(self) -> PanelType {
match self {
PanelType::System => PanelType::Network,
PanelType::Services => PanelType::System,
PanelType::Backup => PanelType::Services,
PanelType::Network => PanelType::Backup,
}
}
}
/// Widget states for a specific host
#[derive(Clone)]
pub struct HostWidgets {
@ -27,6 +63,11 @@ pub struct HostWidgets {
pub services_widget: ServicesWidget,
/// Backup widget state
pub backup_widget: BackupWidget,
/// Scroll offsets for each panel
pub system_scroll_offset: usize,
pub services_scroll_offset: usize,
pub backup_scroll_offset: usize,
pub network_scroll_offset: usize,
/// Last update time for this host
pub last_update: Option<Instant>,
}
@ -37,6 +78,10 @@ impl HostWidgets {
system_widget: SystemWidget::new(),
services_widget: ServicesWidget::new(),
backup_widget: BackupWidget::new(),
system_scroll_offset: 0,
services_scroll_offset: 0,
backup_scroll_offset: 0,
network_scroll_offset: 0,
last_update: None,
}
}
@ -52,6 +97,8 @@ pub struct TuiApp {
available_hosts: Vec<String>,
/// Host index for navigation
host_index: usize,
/// Currently focused panel
focused_panel: PanelType,
/// Should quit application
should_quit: bool,
/// Track if user manually navigated away from localhost
@ -65,6 +112,7 @@ impl TuiApp {
current_host: None,
available_hosts: Vec::new(),
host_index: 0,
focused_panel: PanelType::System, // Start with System panel focused
should_quit: false,
user_navigated_away: false,
}
@ -196,7 +244,25 @@ impl TuiApp {
// Refresh will be handled by main loop
}
KeyCode::Tab => {
self.navigate_host(1); // Tab cycles to next host
if key.modifiers.contains(KeyModifiers::SHIFT) {
// Shift+Tab cycles through panels
self.next_panel();
} else {
// Tab cycles to next host
self.navigate_host(1);
}
}
KeyCode::BackTab => {
// BackTab (Shift+Tab on some terminals) also cycles panels
self.next_panel();
}
KeyCode::Up => {
// Scroll up in focused panel
self.scroll_focused_panel(-1);
}
KeyCode::Down => {
// Scroll down in focused panel
self.scroll_focused_panel(1);
}
_ => {}
}
@ -236,6 +302,66 @@ impl TuiApp {
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
}
/// Switch to next panel (Shift+Tab)
pub fn next_panel(&mut self) {
self.focused_panel = self.focused_panel.next();
info!("Switched to panel: {:?}", self.focused_panel);
}
/// Switch to previous panel (Shift+Tab in reverse)
pub fn previous_panel(&mut self) {
self.focused_panel = self.focused_panel.previous();
info!("Switched to panel: {:?}", self.focused_panel);
}
/// Get the currently focused panel
pub fn get_focused_panel(&self) -> PanelType {
self.focused_panel
}
/// Scroll the focused panel up or down
pub fn scroll_focused_panel(&mut self, direction: i32) {
if let Some(hostname) = self.current_host.clone() {
let focused_panel = self.focused_panel; // Get the value before borrowing
let host_widgets = self.get_or_create_host_widgets(&hostname);
match focused_panel {
PanelType::System => {
if direction > 0 {
host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_add(1);
} else {
host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_sub(1);
}
info!("System panel scroll offset: {}", host_widgets.system_scroll_offset);
}
PanelType::Services => {
if direction > 0 {
host_widgets.services_scroll_offset = host_widgets.services_scroll_offset.saturating_add(1);
} else {
host_widgets.services_scroll_offset = host_widgets.services_scroll_offset.saturating_sub(1);
}
info!("Services panel scroll offset: {}", host_widgets.services_scroll_offset);
}
PanelType::Backup => {
if direction > 0 {
host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_add(1);
} else {
host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_sub(1);
}
info!("Backup panel scroll offset: {}", host_widgets.backup_scroll_offset);
}
PanelType::Network => {
if direction > 0 {
host_widgets.network_scroll_offset = host_widgets.network_scroll_offset.saturating_add(1);
} else {
host_widgets.network_scroll_offset = host_widgets.network_scroll_offset.saturating_sub(1);
}
info!("Network panel scroll offset: {}", host_widgets.network_scroll_offset);
}
}
}
}
/// Render the dashboard (real btop-style multi-panel layout)
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
let size = frame.size();
@ -247,13 +373,13 @@ impl TuiApp {
);
// Create real btop-style layout: multi-panel with borders
// Top section: title bar
// Bottom section: split into left (mem + disks) and right (CPU + processes)
// Three-section layout: title bar, main content, statusbar
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Title bar
Constraint::Min(0), // Main content area
Constraint::Length(1), // Statusbar
])
.split(size);
@ -264,7 +390,7 @@ impl TuiApp {
Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup
Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height)
])
.split(main_chunks[1]);
.split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar)
// Check if backup panel should be shown
let show_backup = if let Some(hostname) = self.current_host.clone() {
@ -303,11 +429,15 @@ impl TuiApp {
// Render services widget for current host
if let Some(hostname) = self.current_host.clone() {
let is_focused = self.focused_panel == PanelType::Services;
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets
.services_widget
.render(frame, content_chunks[1]); // Services takes full right side
.render_with_focus(frame, content_chunks[1], is_focused); // Services takes full right side
}
// Render statusbar at the bottom
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area
}
/// Render btop-style minimal title with host status colors
@ -406,19 +536,77 @@ impl TuiApp {
}
}
/// Render dynamic statusbar with context-aware shortcuts
fn render_statusbar(&self, frame: &mut Frame, area: Rect) {
let shortcuts = self.get_context_shortcuts();
let statusbar_text = shortcuts.join("");
let statusbar = Paragraph::new(statusbar_text)
.style(Typography::secondary())
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(statusbar, area);
}
/// Get context-aware shortcuts based on focused panel
fn get_context_shortcuts(&self) -> Vec<String> {
let mut shortcuts = Vec::new();
// Global shortcuts
shortcuts.push("Tab: Switch Host".to_string());
shortcuts.push("Shift+Tab: Switch Panel".to_string());
// Scroll shortcuts (always available)
shortcuts.push("↑↓: Scroll".to_string());
// Panel-specific shortcuts
match self.focused_panel {
PanelType::System => {
shortcuts.push("R: Rebuild".to_string());
}
PanelType::Services => {
shortcuts.push("Space: Start/Stop".to_string());
shortcuts.push("R: Restart".to_string());
}
PanelType::Backup => {
shortcuts.push("B: Trigger Backup".to_string());
}
PanelType::Network => {
shortcuts.push("N: Network Info".to_string());
}
}
// Always show quit
shortcuts.push("Q: Quit".to_string());
shortcuts
}
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {
let system_block = Components::widget_block("system");
let system_block = if self.focused_panel == PanelType::System {
Components::focused_widget_block("system")
} else {
Components::widget_block("system")
};
let inner_area = system_block.inner(area);
frame.render_widget(system_block, area);
// Get current host widgets, create if none exist
if let Some(hostname) = self.current_host.clone() {
let scroll_offset = {
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_scroll_offset
};
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_widget.render(frame, inner_area);
host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset);
}
}
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
let backup_block = Components::widget_block("backup");
let backup_block = if self.focused_panel == PanelType::Backup {
Components::focused_widget_block("backup")
} else {
Components::widget_block("backup")
};
let inner_area = backup_block.inner(area);
frame.render_widget(backup_block, area);

View File

@ -292,6 +292,19 @@ impl Components {
.bg(Theme::background()),
)
}
/// Widget block with focus indicator (blue border)
pub fn focused_widget_block(title: &str) -> Block<'_> {
Block::default()
.title(title)
.borders(Borders::ALL)
.style(Style::default().fg(Theme::highlight()).bg(Theme::background())) // Blue border for focus
.title_style(
Style::default()
.fg(Theme::highlight()) // Blue title for focus
.bg(Theme::background()),
)
}
}
impl Typography {

View File

@ -308,7 +308,18 @@ impl Widget for ServicesWidget {
}
fn render(&mut self, frame: &mut Frame, area: Rect) {
let services_block = Components::widget_block("services");
self.render_with_focus(frame, area, false);
}
}
impl ServicesWidget {
/// Render with optional focus indicator
pub fn render_with_focus(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
let services_block = if is_focused {
Components::focused_widget_block("services")
} else {
Components::widget_block("services")
};
let inner_area = services_block.inner(area);
frame.render_widget(services_block, area);

View File

@ -399,6 +399,13 @@ impl Widget for SystemWidget {
}
fn render(&mut self, frame: &mut Frame, area: Rect) {
self.render_with_scroll(frame, area, 0);
}
}
impl SystemWidget {
/// Render with scroll offset support
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) {
let mut lines = Vec::new();
// NixOS section
@ -509,7 +516,28 @@ impl Widget for SystemWidget {
}
}
let paragraph = Paragraph::new(Text::from(lines));
frame.render_widget(paragraph, area);
// Apply scroll offset
let total_lines = lines.len();
let available_height = area.height as usize;
if total_lines > available_height {
// Content is larger than area, apply scrolling
let max_scroll = total_lines.saturating_sub(available_height);
let effective_scroll = scroll_offset.min(max_scroll);
// Take only the visible portion after scrolling
let visible_lines: Vec<Line> = lines
.into_iter()
.skip(effective_scroll)
.take(available_height)
.collect();
let paragraph = Paragraph::new(Text::from(visible_lines));
frame.render_widget(paragraph, area);
} else {
// All content fits, render normally
let paragraph = Paragraph::new(Text::from(lines));
frame.render_widget(paragraph, area);
}
}
}