M2: terminal UI (ratatui)

- layout: header, top menu (quicklook, cpu, mem, swap, load), left sidebar,
  process list and alert footer
- decorations/colors, bars, per-plugin render implementations
- hotkeys: quit, help, modes, sorting, cursor, nice, kill, filters, toggles
- TestBackend layout test
This commit is contained in:
glances-rs
2026-10-09 23:35:23 +00:00
parent 975385da5d
commit 8385900ac1
27 changed files with 2053 additions and 31 deletions
+121 -2
View File
@@ -9,7 +9,7 @@ use crate::history::History;
use crate::state::Runtime;
use crate::thresholds::thresholds;
use crate::timer::Timer;
use crate::units::as_f64;
use crate::units::{as_f64, auto_unit};
use indexmap::IndexMap;
use serde_json::{Map, Value, json};
use std::collections::VecDeque;
@@ -399,6 +399,18 @@ impl PluginModel {
}
}
/// Return the list-valued limit (used for nice/status alerts).
pub fn get_limit_list(&self, criticality: &str, stat_name: &str) -> Option<Vec<String>> {
let stat_name = stat_name.to_lowercase();
if let Some(Limit::List(items)) = self.limits.get(&format!("{stat_name}_{criticality}")) {
return Some(items.clone());
}
if let Some(Limit::List(items)) = self.limits.get(&format!("{}_{criticality}", self.name)) {
return Some(items.clone());
}
None
}
/// Return the limit value for the given criticality.
pub fn get_limit(&self, criticality: &str, stat_name: &str) -> Option<f64> {
let stat_name = stat_name.to_lowercase();
@@ -958,6 +970,113 @@ impl PluginModel {
self.stats.clone()
}
/// Create a TUI line (port of `curse_add_line`).
pub fn curse_add_line(
&self,
msg: impl Into<String>,
decoration: Decoration,
optional: bool,
additional: bool,
splittable: bool,
) -> StatLine {
StatLine {
msg: msg.into(),
decoration,
optional,
additional,
splittable,
}
}
/// New line marker (port of `curse_new_line`).
pub fn curse_new_line(&self) -> StatLine {
StatLine::new("\n")
}
/// Add a `key: value` pair to the TUI (port of `curse_add_stat`).
pub fn curse_add_stat(&self, key: &str, width: Option<usize>, header: &str, separator: &str, trailer: &str) -> Vec<StatLine> {
let Some(value) = self.stats.get(key) else {
return Vec::new();
};
let definition = self.fields.get(key);
let key_name = definition
.and_then(|f| f.short_name.clone())
.unwrap_or_else(|| key.to_string());
let unit_short = definition.map(|f| f.short_unit()).unwrap_or("");
let unit_type = definition.map(|f| f.unit_type()).unwrap_or("float");
let value = if definition.map(|f| f.rate).unwrap_or(false) {
self.stats.get(&format!("{key}_rate_per_sec")).unwrap_or(&Value::Null)
} else {
value
};
let value = as_f64(value);
let (msg_item, msg_value) = match width {
None => {
let item = format!("{header}{key_name}{separator}");
let value_text = match value {
None => "-".to_string(),
Some(v) => {
if unit_type == "float" {
format!("{v:.1}{unit_short}")
} else if let Some(min_symbol) = definition.and_then(|f| f.min_symbol.clone()) {
format!("{}{unit_short}", auto_unit(v, false, &min_symbol, "-"))
} else {
format!("{}{unit_short}", v as i64)
}
}
};
(item, value_text)
}
Some(width) => {
let item = format!("{header}{key_name:<width$}{separator}", width = width.saturating_sub(7));
let value_text = match value {
None => format!("{:>5}", "-"),
Some(v) => {
if unit_type == "float" {
format!("{v:>5.1}{unit_short}")
} else if let Some(min_symbol) = definition.and_then(|f| f.min_symbol.clone()) {
format!("{:>5}{unit_short}", auto_unit(v, false, &min_symbol, "-"))
} else {
format!("{:>5}{unit_short}", v as i64)
}
}
};
(item, value_text)
}
};
let item = format!("{msg_item}{trailer}");
let decoration = if value.is_some() { self.view_decoration(None, key) } else { Decoration::Default };
let optional = self.view_bool(None, key, "optional");
vec![
self.curse_add_line(item, Decoration::Default, optional, false, false),
self.curse_add_line(msg_value, decoration, optional, false, false),
]
}
/// Return the fields to display for a plugin (port of the display flags).
pub fn field_optional(&self, field: &str) -> bool {
self.fields.get(field).map(|f| f.optional).unwrap_or(false)
}
/// Return the trend message (port of `trend_msg`).
pub fn trend_msg(&self, trend: Option<f64>, significant: f64) -> String {
match trend {
None => " ".to_string(),
Some(trend) if trend > significant => "↑".to_string(),
Some(trend) if trend < significant => "↓".to_string(),
_ => "-".to_string(),
}
}
/// Human readable auto unit (port of the plugin `auto_unit` method).
pub fn auto_unit(&self, number: f64, low_precision: bool, min_symbol: &str) -> String {
auto_unit(number, low_precision, min_symbol, "-")
}
/// Update the history with the current stats.
pub fn update_stats_history(&mut self) {
if !self.history_enabled {
@@ -1137,7 +1256,7 @@ pub trait Plugin: Send + Sync {
fn exit(&mut self) {}
/// Render the plugin block in the TUI.
fn render(&self, ctx: &RenderContext) -> Vec<StatLine> {
fn render(&mut self, ctx: &RenderContext) -> Vec<StatLine> {
let _ = ctx;
Vec::new()
}
+11
View File
@@ -3,6 +3,10 @@
/// Runtime state shared between the engine, the plugins and the TUI.
#[derive(Debug, Clone)]
pub struct Runtime {
/// Effective set of enabled plugins (kept up to date by the engine/TUI).
pub enabled_plugins: std::collections::HashSet<String>,
/// Irix mode (task's CPU usage divided by the number of CPUs).
pub disable_irix: bool,
/// Display Rx and Tx sum instead of per interface values.
pub network_sum: bool,
/// Display cumulative network values instead of rates.
@@ -38,6 +42,8 @@ pub struct Runtime {
impl Runtime {
pub fn new(args: &crate::args::Args, fs_free_space_config: bool) -> Self {
Self {
enabled_plugins: std::collections::HashSet::new(),
disable_irix: args.disable_irix,
network_sum: false,
network_cumul: false,
percpu: args.percpu,
@@ -55,4 +61,9 @@ impl Runtime {
reset_minmax_tag: false,
}
}
/// Return True if the given plugin is enabled.
pub fn is_plugin_enabled(&self, name: &str) -> bool {
self.enabled_plugins.contains(name)
}
}
+29
View File
@@ -67,6 +67,35 @@ pub fn auto_unit(number: f64, low_precision: bool, min_symbol: &str, none_symbol
format!("{number:.decimal_precision$}")
}
/// Render a progress bar the way the Python `Bar` class does.
pub fn bar_percent(percent: f64, size: usize, bar_char: &str, display_value: bool) -> String {
let size = if display_value { size.saturating_sub(6) } else { size };
let value = percent.clamp(0.0, 100.0);
let scaled = size as f64 * value / 100.0;
let whole = scaled.floor() as usize;
let frac = scaled - whole as f64;
let mut bar = bar_char.repeat(whole);
let mut whole = whole;
if frac > 0.0 {
// curses_bars = [empty; 5] + [bar; 5]: index 5..7 draws a partial block.
if (frac * 8.0) as usize >= 5 {
bar.push_str(bar_char);
} else {
bar.push(' ');
}
whole += 1;
}
bar.push_str(&" ".repeat(size.saturating_sub(whole)));
if display_value {
if percent >= 100.0 {
bar = format!("{bar} {}{:3.0}%", if percent > 100.0 { ">" } else { " " }, 100.0);
} else {
bar = format!("{bar}{percent:5.1}%");
}
}
bar
}
/// Parse a value like `'12.5 MB'` into a float (port of `string_value_to_float`).
pub fn string_value_to_float(s: &str) -> Option<f64> {
let cleaned: String = s.chars().filter(|c| *c != ' ').collect();