Add mouse support and improve terminal resize handling
All checks were successful
Build and Release / build-and-release (push) Successful in 1m21s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user