565 lines
22 KiB
Rust
565 lines
22 KiB
Rust
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use amane::{Blend, Canvas, Center, Color, Column, Full, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, Weight, Widget};
|
|
|
|
use crate::config::appearance::{overlays, text};
|
|
use crate::config::pills::{layout, monitor as settings};
|
|
use crate::glyph::{Glyph, glyph_shapes};
|
|
use crate::island::faded;
|
|
use crate::parse;
|
|
use crate::system::Gpu;
|
|
use crate::{pods, theme};
|
|
|
|
/*
|
|
* the system monitor card: cpu, the integrated gpu, the external gpu, memory, swap,
|
|
* network, disk reads and writes as graphs over the last readings; the external gpu's
|
|
* memory and the programs on it; the hottest temperature sensors in their colours.
|
|
* everything comes from /proc and /sys on this Service's thread (the external gpu from
|
|
* system::Gpu, which reads only while this card is open); no process list here, its
|
|
* button opens btm's in a terminal on a special workspace instead
|
|
*/
|
|
#[derive(Default)]
|
|
pub struct Stats {
|
|
// 0 to 1
|
|
cpu: Vec<f32>,
|
|
// the integrated gpu's clock as a share of its highest, and in words
|
|
gpu: Vec<f32>,
|
|
gpu_words: String,
|
|
// the external gpu's busy share
|
|
egpu: Vec<f32>,
|
|
ram: Vec<f32>,
|
|
ram_words: String,
|
|
swap: Vec<f32>,
|
|
swap_words: String,
|
|
// bytes a second
|
|
down: Vec<f32>,
|
|
up: Vec<f32>,
|
|
read: Vec<f32>,
|
|
write: Vec<f32>,
|
|
// (sensor, its icon, °C), hottest first
|
|
temps: Vec<(String, Glyph, f32)>,
|
|
}
|
|
|
|
struct Sample {
|
|
at: Instant,
|
|
cpu: (u64, u64),
|
|
net: (u64, u64),
|
|
disk: (u64, u64),
|
|
}
|
|
|
|
impl Service for Stats {
|
|
fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
fn listen() {
|
|
let mut before = sample();
|
|
|
|
loop {
|
|
thread::sleep(Duration::from_millis(settings::interval_ms()));
|
|
|
|
let now = sample();
|
|
let seconds = now.at.duration_since(before.at).as_secs_f32().max(0.001);
|
|
let rate = |new: u64, old: u64| new.saturating_sub(old) as f32 / seconds;
|
|
|
|
let busy = now.cpu.0.saturating_sub(before.cpu.0) as f32 / now.cpu.1.saturating_sub(before.cpu.1).max(1) as f32;
|
|
let memory = parse::memory(&read("/proc/meminfo")).unwrap_or_default();
|
|
let used = memory.total_kb.saturating_sub(memory.available_kb);
|
|
let swapped = memory.swap_total_kb.saturating_sub(memory.swap_free_kb);
|
|
let (clock, clock_words) = gpu_clock();
|
|
let egpu = Gpu::read().busy as f32 / 100.0;
|
|
let temps = temperatures();
|
|
|
|
{
|
|
let mut stats = Self::write();
|
|
let keep = settings::history();
|
|
|
|
push(&mut stats.cpu, busy, keep);
|
|
push(&mut stats.gpu, clock, keep);
|
|
push(&mut stats.egpu, egpu, keep);
|
|
push(&mut stats.ram, used as f32 / memory.total_kb.max(1) as f32, keep);
|
|
push(&mut stats.swap, swapped as f32 / memory.swap_total_kb.max(1) as f32, keep);
|
|
push(&mut stats.down, rate(now.net.0, before.net.0), keep);
|
|
push(&mut stats.up, rate(now.net.1, before.net.1), keep);
|
|
push(&mut stats.read, rate(now.disk.0, before.disk.0) * 512.0, keep);
|
|
push(&mut stats.write, rate(now.disk.1, before.disk.1) * 512.0, keep);
|
|
|
|
stats.gpu_words = clock_words;
|
|
stats.ram_words = format!("{} of {}", gigabytes(used), gigabytes(memory.total_kb));
|
|
stats.swap_words = if memory.swap_total_kb == 0 { String::from("no swap") } else { format!("{} of {}", gigabytes(swapped), gigabytes(memory.swap_total_kb)) };
|
|
stats.temps = temps;
|
|
}
|
|
|
|
before = now;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn gigabytes(kb: u64) -> String {
|
|
format!("{:.1} GB", kb as f32 / 1048576.0)
|
|
}
|
|
|
|
fn push(history: &mut Vec<f32>, value: f32, keep: usize) {
|
|
history.push(value);
|
|
|
|
let extra = history.len().saturating_sub(keep);
|
|
|
|
history.drain(..extra);
|
|
}
|
|
|
|
fn read(path: impl Into<PathBuf>) -> String {
|
|
fs::read_to_string(path.into()).unwrap_or_default()
|
|
}
|
|
|
|
fn sample() -> Sample {
|
|
Sample { at: Instant::now(), cpu: parse::cpu_jiffies(&read("/proc/stat")).unwrap_or((0, 0)), net: parse::network_bytes(&read("/proc/net/dev")), disk: disk_sectors() }
|
|
}
|
|
|
|
// sectors read and written by the whole physical disks: ones with a device behind them, not loop, ram or zram
|
|
fn disk_sectors() -> (u64, u64) {
|
|
let mut sum = (0, 0);
|
|
|
|
for entry in fs::read_dir("/sys/block").into_iter().flatten().flatten() {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
|
|
if ["loop", "ram", "zram"].iter().any(|virtual_disk| name.starts_with(virtual_disk)) || !entry.path().join("device").exists() {
|
|
continue;
|
|
}
|
|
|
|
if let Some((reads, writes)) = parse::block_sectors(&read(entry.path().join("stat"))) {
|
|
sum.0 += reads;
|
|
sum.1 += writes;
|
|
}
|
|
}
|
|
|
|
sum
|
|
}
|
|
|
|
// the first gpu that tells its clock (xe or i915): its share of the highest clock, and the clock in words
|
|
fn gpu_clock() -> (f32, String) {
|
|
for entry in fs::read_dir("/sys/class/drm").into_iter().flatten().flatten() {
|
|
let card = entry.path();
|
|
|
|
for (now, top) in [("device/tile0/gt0/freq0/act_freq", "device/tile0/gt0/freq0/max_freq"), ("gt_act_freq_mhz", "gt_max_freq_mhz")] {
|
|
let mhz = |file: &str| read(card.join(file)).trim().parse::<f32>().ok();
|
|
|
|
if let (Some(now), Some(top)) = (mhz(now), mhz(top)) {
|
|
return ((now / top.max(1.0)).clamp(0.0, 1.0), format!("{now:.0} of {top:.0} MHz"));
|
|
}
|
|
}
|
|
}
|
|
|
|
(0.0, String::from("no clock to read"))
|
|
}
|
|
|
|
// what the temperatures are shown in, always: a sensor keeps its place whatever its reading
|
|
const ORDER: [&str; 8] = ["CPU", "iGPU", "eGPU", "NVMe", "Memory", "Board", "ThinkPad", "Wi-Fi"];
|
|
|
|
// every hwmon device's hottest sensor, named for what it is, in ORDER (then by name, then by device)
|
|
fn temperatures() -> Vec<(String, Glyph, f32)> {
|
|
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
|
|
|
|
// the devices in their numbers' order (hwmon2 before hwmon10), so two of a kind keep theirs too
|
|
let mut devices: Vec<PathBuf> = fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten().map(|entry| entry.path()).collect();
|
|
|
|
devices.sort_by_key(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.trim_start_matches("hwmon").parse::<u32>().ok()).unwrap_or(u32::MAX));
|
|
|
|
for device in devices {
|
|
let kind = read(device.join("name")).trim().to_string();
|
|
|
|
let hottest = fs::read_dir(&device)
|
|
.into_iter()
|
|
.flatten()
|
|
.flatten()
|
|
.filter(|file| file.file_name().to_string_lossy().starts_with("temp") && file.file_name().to_string_lossy().ends_with("_input"))
|
|
.filter_map(|file| read(file.path()).trim().parse::<f32>().ok())
|
|
.map(|milli| milli / 1000.0)
|
|
.filter(|celsius| *celsius > 0.0 && *celsius < 150.0)
|
|
.fold(None, |hottest: Option<f32>, celsius| Some(hottest.map_or(celsius, |hot| hot.max(celsius))));
|
|
|
|
let Some(celsius) = hottest else { continue };
|
|
|
|
let (name, glyph) = match kind.as_str() {
|
|
"coretemp" | "k10temp" | "zenpower" => ("CPU", Glyph::Chip),
|
|
"nvme" => ("NVMe", Glyph::Drive),
|
|
"amdgpu" | "nouveau" => ("eGPU", Glyph::Gpu),
|
|
"xe" | "i915" => ("iGPU", Glyph::Gpu),
|
|
"acpitz" => ("Board", Glyph::Thermometer),
|
|
"thinkpad" => ("ThinkPad", Glyph::Laptop),
|
|
"spd5118" | "jc42" => ("Memory", Glyph::Memory),
|
|
other if other.starts_with("iwlwifi") => ("Wi-Fi", Glyph::Thermometer),
|
|
other => (other, Glyph::Thermometer),
|
|
};
|
|
|
|
found.push((name.to_string(), glyph, celsius));
|
|
}
|
|
|
|
// a stable sort: same kinds keep the devices' order
|
|
found.sort_by_key(|(name, ..)| (ORDER.iter().position(|kind| kind == name).unwrap_or(ORDER.len()), name.clone()));
|
|
found.truncate(settings::temperatures());
|
|
|
|
// a kind shown more than once is numbered, in the order shown
|
|
let names: Vec<String> = found.iter().map(|(name, ..)| name.clone()).collect();
|
|
|
|
for (index, (name, ..)) in found.iter_mut().enumerate() {
|
|
if names.iter().filter(|other| *other == name).count() > 1 {
|
|
let number = names[..index].iter().filter(|other| *other == name).count() + 1;
|
|
|
|
*name = format!("{name} {number}");
|
|
}
|
|
}
|
|
|
|
found
|
|
}
|
|
|
|
// a temperature's colour: cool below `warm`, then warm turning hot up to `hot`
|
|
fn temperature_color(celsius: f32) -> Color {
|
|
let (warm, hot) = (settings::warm(), settings::hot());
|
|
|
|
if celsius < warm {
|
|
settings::cool_color()
|
|
} else if celsius >= hot {
|
|
settings::hot_color()
|
|
} else {
|
|
Color::blend(settings::warm_color(), settings::hot_color(), (celsius - warm) / (hot - warm).max(0.1))
|
|
}
|
|
}
|
|
|
|
/*
|
|
* opens btm's process list in a terminal on a special workspace, or shows and hides that
|
|
* workspace when it runs already. the window is told apart by its class
|
|
*/
|
|
pub fn processes() {
|
|
pods::close();
|
|
|
|
let (terminal, command, space) = (settings::terminal(), settings::processes(), settings::workspace());
|
|
|
|
thread::spawn(move || {
|
|
let class = format!("amane.{space}");
|
|
let running = Command::new("pgrep").args(["-f", "--", &format!("--class={class}")]).output().is_ok_and(|done| done.status.success());
|
|
|
|
if !running {
|
|
let line = format!("{terminal} --class={class} -e {command}");
|
|
let lua = format!("hl.exec_cmd({}, {{ workspace = {} }})", parse::quoted(&line), parse::quoted(&format!("special:{space} silent")));
|
|
|
|
let _ = Command::new("hyprctl").args(["eval", &lua]).output();
|
|
}
|
|
|
|
let _ = Command::new("hyprctl").args(["dispatch", &format!("hl.dsp.workspace.toggle_special({})", parse::quoted(&space))]).output();
|
|
});
|
|
}
|
|
|
|
fn label_height() -> f32 {
|
|
text::small() + 8.0
|
|
}
|
|
|
|
fn temp_row_height() -> f32 {
|
|
text::small() + 16.0
|
|
}
|
|
|
|
fn temp_rows() -> usize {
|
|
settings::temperatures().div_ceil(2)
|
|
}
|
|
|
|
fn program_row_height() -> f32 {
|
|
text::small() + 8.0
|
|
}
|
|
|
|
// the external gpu's rows under its graph: its memory, then the programs on it (at least one line)
|
|
fn egpu_rows(gpu: &Gpu) -> usize {
|
|
1 + gpu.programs.len().clamp(1, settings::programs())
|
|
}
|
|
|
|
fn graph_count(gpu: &Gpu) -> usize {
|
|
if gpu.name.is_empty() { 6 } else { 7 }
|
|
}
|
|
|
|
/*
|
|
* the card's height, from settings and from whether there is an external gpu and how
|
|
* many programs use it, so the window's size doesn't follow the readings
|
|
*/
|
|
pub fn height() -> f32 {
|
|
let gpu = Gpu::read();
|
|
let count = graph_count(&gpu) as f32;
|
|
let graphs = count * (label_height() + settings::graph_height()) + (count - 1.0) * 10.0;
|
|
|
|
// header, graphs, the external gpu's rows, rule, the temperatures' label and rows, with the column's gaps
|
|
let (egpu, parts) = if gpu.name.is_empty() { (0.0, 5.0) } else { (egpu_rows(&gpu) as f32 * program_row_height(), 6.0) };
|
|
|
|
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + (parts - 1.0) * 10.0
|
|
}
|
|
|
|
// the card's height at its tallest: an external gpu with its whole list of programs
|
|
pub fn tallest() -> f32 {
|
|
let graphs = 7.0 * (label_height() + settings::graph_height()) + 6.0 * 10.0;
|
|
let egpu = (1 + settings::programs()) as f32 * program_row_height();
|
|
|
|
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 5.0 * 10.0
|
|
}
|
|
|
|
// bytes a second in words
|
|
fn rate(bytes: f32) -> String {
|
|
match bytes {
|
|
b if b >= 1e9 => format!("{:.1} GB/s", b / 1e9),
|
|
b if b >= 1e6 => format!("{:.1} MB/s", b / 1e6),
|
|
b if b >= 1e3 => format!("{:.0} kB/s", b / 1e3),
|
|
b => format!("{b:.0} B/s"),
|
|
}
|
|
}
|
|
|
|
// video memory in KiB, the way a person reads it
|
|
fn size(kib: u64) -> String {
|
|
match kib {
|
|
0..1024 => String::from("<1 MB"),
|
|
1024..1_048_576 => format!("{} MB", kib / 1024),
|
|
_ => format!("{:.1} GB", kib as f64 / 1_048_576.0),
|
|
}
|
|
}
|
|
|
|
// one graph's readings, the value at its top, its colour
|
|
type Series<'a> = (&'a [f32], f32, Color);
|
|
|
|
// a graph's line and the faint area under it, scaled so `top` reaches the top
|
|
fn graph(history: &[f32], top: f32, origin: (f32, f32), size: (f32, f32), color: Color) -> Vec<Box<dyn Shape>> {
|
|
let (x0, y0) = origin;
|
|
let (width, height) = size;
|
|
let keep = settings::history().max(2) as f32;
|
|
|
|
if history.len() < 2 {
|
|
return Vec::new();
|
|
}
|
|
|
|
// newest on the right; older readings fill in leftwards
|
|
let point = |index: usize, value: f32| {
|
|
let age = (history.len() - 1 - index) as f32;
|
|
|
|
(x0 + width - age / (keep - 1.0) * width, y0 + height - (value / top.max(f32::EPSILON)).clamp(0.0, 1.0) * height)
|
|
};
|
|
|
|
let mut line = Path::new();
|
|
let mut area = Path::new();
|
|
|
|
for (index, value) in history.iter().enumerate() {
|
|
let (x, y) = point(index, *value);
|
|
|
|
if index == 0 {
|
|
line = line.move_to(x, y);
|
|
area = area.move_to(x, y0 + height).line_to(x, y);
|
|
} else {
|
|
line = line.line_to(x, y);
|
|
area = area.line_to(x, y);
|
|
}
|
|
}
|
|
|
|
let (last_x, _) = point(history.len() - 1, 0.0);
|
|
|
|
vec![Box::new(area.line_to(last_x, y0 + height).close().fill(faded(color, 40))), Box::new(line.stroke(1.5, color))]
|
|
}
|
|
|
|
// a label on the left and its value on the right, one line
|
|
fn line_of(name: impl Into<String>, value: impl Into<String>, width: f32, height: f32, name_color: Color) -> Rectangle {
|
|
let theme = theme::now();
|
|
|
|
Rectangle::new().width(width).height(height).align_child(Start, Center).child(
|
|
Row::new(vec![
|
|
Box::new(Text::new(name).size(text::small()).color(name_color).elide()) as Box<dyn Widget>,
|
|
Box::new(Text::new(value).size(text::small()).color(theme.dim)),
|
|
])
|
|
.width(width)
|
|
.justify(SpaceBetween)
|
|
.align(Center),
|
|
)
|
|
}
|
|
|
|
// the external gpu's memory as a bar, and the programs on it, the most memory first
|
|
fn egpu_detail(gpu: &Gpu, width: f32) -> Column {
|
|
let theme = theme::now();
|
|
let row_h = program_row_height();
|
|
|
|
let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 };
|
|
let words = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024));
|
|
let bar_w = width * 0.32;
|
|
|
|
let memory = Row::new(vec![
|
|
Box::new(Text::new("Video memory").size(text::small()).color(theme.text)) as Box<dyn Widget>,
|
|
Box::new(
|
|
Row::new(vec![
|
|
Box::new(Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)))
|
|
as Box<dyn Widget>,
|
|
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
|
|
])
|
|
.gap(10.0)
|
|
.align(Center),
|
|
),
|
|
])
|
|
.width(width)
|
|
.height(row_h)
|
|
.justify(SpaceBetween)
|
|
.align(Center);
|
|
|
|
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(memory)];
|
|
let most = settings::programs();
|
|
let shown = if gpu.programs.len() > most { most - 1 } else { most };
|
|
|
|
for program in gpu.programs.iter().take(shown) {
|
|
let amount = match program.busy {
|
|
Some(busy) => format!("{} · {busy}%", size(program.vram_kib)),
|
|
None => size(program.vram_kib),
|
|
};
|
|
|
|
rows.push(Box::new(line_of(program.name.as_str(), amount, width, row_h, theme.text)));
|
|
}
|
|
|
|
if gpu.programs.len() > most {
|
|
rows.push(Box::new(line_of(format!("and {} more", gpu.programs.len() - shown), "", width, row_h, theme.dim)));
|
|
}
|
|
|
|
if gpu.programs.is_empty() {
|
|
rows.push(Box::new(line_of("No programs on it", "", width, row_h, theme.dim)));
|
|
}
|
|
|
|
Column::new(rows)
|
|
}
|
|
|
|
// the temperatures in two columns: icon, name, value in its colour, a bar up to `hot`
|
|
fn temperature_grid(temps: &[(String, Glyph, f32)], width: f32) -> Stack {
|
|
let theme = theme::now();
|
|
let cell_w = width / 2.0 - 8.0;
|
|
let (row_h, line_h, icon) = (temp_row_height(), text::small() + 6.0, 14.0);
|
|
let bar_w = cell_w - icon - 8.0;
|
|
|
|
// every icon into one canvas behind the rows
|
|
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
|
|
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
|
|
|
|
for (row, pair) in temps.chunks(2).enumerate() {
|
|
let mut cells: Vec<Box<dyn Widget>> = Vec::new();
|
|
|
|
for (column, (name, glyph, celsius)) in pair.iter().enumerate() {
|
|
let color = temperature_color(*celsius);
|
|
let share = (celsius / settings::hot().max(1.0)).clamp(0.0, 1.0);
|
|
|
|
shapes.extend(glyph_shapes(*glyph, icon, color, 0.0, (column as f32 * (cell_w + 16.0), row as f32 * row_h + (line_h - icon) / 2.0)));
|
|
|
|
cells.push(Box::new(
|
|
Rectangle::new().width(cell_w).height(row_h).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: icon + 8.0 }).child(
|
|
Column::new(vec![
|
|
Box::new(
|
|
Row::new(vec![
|
|
Box::new(Text::new(name.as_str()).size(text::small()).color(theme.text)) as Box<dyn Widget>,
|
|
Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).weight(Weight::Bold).color(color)),
|
|
])
|
|
.width(bar_w)
|
|
.height(line_h)
|
|
.justify(SpaceBetween)
|
|
.align(Center),
|
|
) as Box<dyn Widget>,
|
|
Box::new(Rectangle::new().width(bar_w).height(3.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(3.0)).height(3.0).radius(Full).fill(color))),
|
|
])
|
|
.gap(2.0),
|
|
),
|
|
));
|
|
}
|
|
|
|
rows.push(Box::new(Row::new(cells).gap(16.0)));
|
|
}
|
|
|
|
let height = temp_rows() as f32 * row_h;
|
|
|
|
Stack::new(vec![Box::new(Canvas::new().width(width).height(height).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows))]).width(width).height(height)
|
|
}
|
|
|
|
pub fn card(width: f32, height: f32) -> Rectangle {
|
|
let stats = Stats::read();
|
|
let gpu = Gpu::read();
|
|
let theme = theme::now();
|
|
|
|
let header = pods::header("System", vec![Box::new(pods::chip("Processes", Some(Glyph::Terminal), processes))], width);
|
|
|
|
let last = |history: &[f32]| history.last().copied().unwrap_or(0.0);
|
|
let peak = |a: &[f32], b: &[f32]| a.iter().chain(b).copied().fold(1024.0_f32, f32::max);
|
|
|
|
let net_top = peak(&stats.down, &stats.up);
|
|
let disk_top = peak(&stats.read, &stats.write);
|
|
|
|
// (name, value in words, graphs as (history, top, colour))
|
|
let mut blocks: Vec<(String, String, Vec<Series>)> = vec![
|
|
(String::from("CPU"), format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
|
|
(String::from("iGPU"), stats.gpu_words.clone(), vec![(&stats.gpu, 1.0, theme.accent)]),
|
|
];
|
|
|
|
if !gpu.name.is_empty() {
|
|
let mut facts = vec![format!("{}%", gpu.busy)];
|
|
|
|
if let Some(celsius) = gpu.celsius {
|
|
facts.push(format!("{celsius} °C"));
|
|
}
|
|
|
|
if let Some(watts) = gpu.watts {
|
|
facts.push(format!("{watts:.0} W"));
|
|
}
|
|
|
|
blocks.push((gpu.name.clone(), facts.join(" · "), vec![(&stats.egpu, 1.0, theme.flame)]));
|
|
}
|
|
|
|
blocks.extend([
|
|
(String::from("Memory"), stats.ram_words.clone(), vec![(stats.ram.as_slice(), 1.0, theme.accent)]),
|
|
(String::from("Swap"), stats.swap_words.clone(), vec![(stats.swap.as_slice(), 1.0, theme.flame)]),
|
|
(String::from("Network"), format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(stats.down.as_slice(), net_top, theme.accent), (stats.up.as_slice(), net_top, theme.flame)]),
|
|
(String::from("Disk"), format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(stats.read.as_slice(), disk_top, theme.accent), (stats.write.as_slice(), disk_top, theme.flame)]),
|
|
]);
|
|
|
|
let (label_h, graph_h) = (label_height(), settings::graph_height());
|
|
|
|
// every graph into one canvas under the labels: each canvas costs the gpu on its own
|
|
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
|
|
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
|
|
|
|
for (index, (name, words, series)) in blocks.into_iter().enumerate() {
|
|
let top = index as f32 * (label_h + graph_h + 10.0);
|
|
|
|
for (history, peak, color) in series {
|
|
shapes.extend(graph(history, peak, (0.0, top + label_h), (width, graph_h), color));
|
|
}
|
|
|
|
rows.push(Box::new(
|
|
Rectangle::new().width(width).height(label_h + graph_h).child(
|
|
Row::new(vec![
|
|
Box::new(Text::new(name).size(text::small()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
|
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
|
|
])
|
|
.width(width)
|
|
.justify(SpaceBetween),
|
|
),
|
|
));
|
|
}
|
|
|
|
let count = graph_count(&gpu) as f32;
|
|
let graphs_h = count * (label_h + graph_h) + (count - 1.0) * 10.0;
|
|
|
|
let graphs = Stack::new(vec![Box::new(Canvas::new().width(width).height(graphs_h).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h);
|
|
|
|
let mut parts: Vec<Box<dyn Widget>> = vec![Box::new(header), Box::new(graphs)];
|
|
|
|
if !gpu.name.is_empty() {
|
|
parts.push(Box::new(egpu_detail(&gpu, width)));
|
|
}
|
|
|
|
parts.push(Box::new(pods::rule(width)));
|
|
parts.push(Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text))));
|
|
|
|
if stats.temps.is_empty() {
|
|
let words = if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" };
|
|
|
|
parts.push(Box::new(Rectangle::new().width(width).height(temp_rows() as f32 * temp_row_height()).child(Text::new(words).size(text::small()).color(theme.dim))));
|
|
} else {
|
|
parts.push(Box::new(temperature_grid(&stats.temps, width)));
|
|
}
|
|
|
|
Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0))
|
|
}
|