Add mouse support and improve terminal resize handling
All checks were successful
Build and Release / build-and-release (push) Successful in 1m21s

- Add mouse click support for hostname selection in title bar
- Fix right-aligned hostname position calculation
- Add mouse scroll support for both panels
- Add mouse click to select service rows
- Add right-click popup menu for service actions (Start/Stop/Logs)
- Add hover highlighting for popup menu items
- Improve terminal resize crash protection with 90x15 minimum size
- Add "Host:" prefix and separators to status bar
- Move NixOS metrics from system panel to status bar
- Change "... X more below" indicator to use border color
- Remove service name from popup menu title
This commit is contained in:
2025-12-08 19:56:06 +01:00
parent aaec8e691c
commit bc9015e96b
8 changed files with 927 additions and 113 deletions

View File

@@ -1,12 +1,13 @@
use cm_dashboard_shared::Status;
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span, Text},
widgets::Paragraph,
Frame,
};
use crate::ui::theme::{StatusIcons, Typography};
use crate::ui::theme::{StatusIcons, Theme, Typography};
/// System widget displaying NixOS info, Network, CPU, RAM, and Storage in unified layout
#[derive(Clone)]
@@ -49,6 +50,11 @@ pub struct SystemWidget {
// Overall status
has_data: bool,
// Scroll offset for viewport
pub scroll_offset: usize,
/// Last rendered viewport height (for accurate scroll bounds)
last_viewport_height: usize,
}
#[derive(Clone)]
@@ -110,6 +116,8 @@ impl SystemWidget {
backup_repository_status: Status::Unknown,
backup_disks: Vec::new(),
has_data: false,
scroll_offset: 0,
last_viewport_height: 0,
}
}
@@ -153,6 +161,16 @@ impl SystemWidget {
pub fn _get_agent_hash(&self) -> Option<&String> {
self.agent_hash.as_ref()
}
/// Get the build version
pub fn get_build_version(&self) -> Option<String> {
self.nixos_build.clone()
}
/// Get the agent version
pub fn get_agent_version(&self) -> Option<String> {
self.agent_hash.clone()
}
}
use super::Widget;
@@ -206,6 +224,16 @@ impl Widget for SystemWidget {
self.backup_repositories = backup.repositories.clone();
self.backup_repository_status = backup.repository_status;
self.backup_disks = backup.disks.clone();
// Clamp scroll offset to valid range after update
// This prevents scroll issues when switching between hosts
let total_lines = self.get_total_lines();
if total_lines == 0 {
self.scroll_offset = 0;
} else if self.scroll_offset >= total_lines {
// Clamp to max valid value, not reset to 0
self.scroll_offset = total_lines.saturating_sub(1);
}
}
}
@@ -781,23 +809,90 @@ impl SystemWidget {
}
/// Render system widget
pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, _config: Option<&crate::config::DashboardConfig>) {
let mut lines = Vec::new();
/// Scroll down by one line
pub fn scroll_down(&mut self, _visible_height: usize, _total_lines: usize) {
let total_lines = self.get_total_lines();
// NixOS section
lines.push(Line::from(vec![
Span::styled(format!("NixOS {}:", hostname), Typography::widget_title())
]));
let build_text = self.nixos_build.as_deref().unwrap_or("unknown");
lines.push(Line::from(vec![
Span::styled(format!("Build: {}", build_text), Typography::secondary())
]));
let agent_version_text = self.agent_hash.as_deref().unwrap_or("unknown");
lines.push(Line::from(vec![
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
]));
// Use last_viewport_height if available (more accurate), otherwise can't scroll
let viewport_height = if self.last_viewport_height > 0 {
self.last_viewport_height
} else {
return; // Can't scroll without knowing viewport size
};
// Max scroll should allow us to see all remaining content
// When scroll_offset + viewport_height >= total_lines, we can see everything
let max_scroll = if total_lines > viewport_height {
total_lines - viewport_height
} else {
0
};
if self.scroll_offset < max_scroll {
self.scroll_offset += 1;
}
}
/// Scroll up by one line
pub fn scroll_up(&mut self) {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
}
}
/// Get total line count (needs to be calculated before rendering)
pub fn get_total_lines(&self) -> usize {
let mut count = 0;
// CPU section (2+ lines for load/cstate, +1 if has model/cores)
count += 2;
if self.cpu_model_name.is_some() || self.cpu_core_count.is_some() {
count += 1;
}
// RAM section (1 + tmpfs mounts)
count += 2;
count += self.tmpfs_mounts.len();
// Network section
if !self.network_interfaces.is_empty() {
count += 1; // Header
// Count network lines (would need to mirror render_network logic)
for iface in &self.network_interfaces {
count += 1; // Interface name
count += iface.ipv4_addresses.len();
count += iface.ipv6_addresses.len();
}
}
// Storage section
count += 1; // Header
for pool in &self.storage_pools {
count += 1; // Pool header
count += pool.drives.len();
count += pool.data_drives.len();
count += pool.parity_drives.len();
count += pool.filesystems.len();
}
// Backup section
if !self.backup_repositories.is_empty() || !self.backup_disks.is_empty() {
count += 1; // Header
if !self.backup_repositories.is_empty() {
count += 1; // Repo header
count += self.backup_repositories.len();
}
count += self.backup_disks.len() * 3; // Each disk has 3 lines
}
count
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, _hostname: &str, _config: Option<&crate::config::DashboardConfig>) {
// Store viewport height for accurate scroll calculations
self.last_viewport_height = area.height as usize;
let mut lines = Vec::new();
// CPU section
lines.push(Line::from(vec![
@@ -905,29 +1000,51 @@ impl SystemWidget {
// Apply scroll offset
let total_lines = lines.len();
let available_height = area.height as usize;
// Show only what fits, with "X more below" if needed
if total_lines > available_height {
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
let mut visible_lines: Vec<Line> = lines
.into_iter()
.take(lines_for_content)
.collect();
let hidden_below = total_lines.saturating_sub(lines_for_content);
if hidden_below > 0 {
let more_line = Line::from(vec![
Span::styled(format!("... {} more below", hidden_below), Typography::muted())
]);
visible_lines.push(more_line);
}
let paragraph = Paragraph::new(Text::from(visible_lines));
frame.render_widget(paragraph, area);
// Clamp scroll_offset to valid range based on current viewport and content
// This handles dynamic viewport size changes
let max_valid_scroll = total_lines.saturating_sub(available_height);
let clamped_scroll = self.scroll_offset.min(max_valid_scroll);
// Calculate how many lines remain after scroll offset
let remaining_lines = total_lines.saturating_sub(clamped_scroll);
// Check if all remaining content fits in viewport
let will_show_more_below = remaining_lines > available_height;
// Reserve one line for "X more below" only if we can't fit everything
let lines_for_content = if will_show_more_below {
available_height.saturating_sub(1)
} else {
// All content fits and no scroll offset, render normally
let paragraph = Paragraph::new(Text::from(lines));
frame.render_widget(paragraph, area);
available_height.min(remaining_lines)
};
// Apply clamped scroll offset and take only what fits
let mut visible_lines: Vec<Line> = lines
.into_iter()
.skip(clamped_scroll)
.take(lines_for_content)
.collect();
// Note: we don't update self.scroll_offset here due to borrow checker constraints
// It will be clamped on next render if still out of bounds
// Only calculate hidden_below if we actually reserved space for the message
let hidden_below = if will_show_more_below {
remaining_lines.saturating_sub(lines_for_content)
} else {
0
};
// Add "more below" message if needed
if hidden_below > 0 {
let more_line = Line::from(vec![
Span::styled(format!("... {} more below", hidden_below), Style::default().fg(Theme::border()))
]);
visible_lines.push(more_line);
}
let paragraph = Paragraph::new(Text::from(visible_lines));
frame.render_widget(paragraph, area);
}
}