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:
parent
51375e8020
commit
8cb5650fbb
37
CLAUDE.md
37
CLAUDE.md
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user