Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
b20158820b
feat: process list
this is unnacceptably unperformant;
I think I've reached the point at which sysinfo doesn't do what I want,
which is keep track of a table of processes efficiently enough.
2026-08-18 14:11:01 -07:00
afa6104c28
build: fix dead_code warnings without "app" feature 2026-08-17 15:11:18 -07:00
a894a02e66
fix: borders showing on launch 2026-08-17 15:07:18 -07:00
86f7fc12b4
build: remove nonexistent icon 2026-08-16 21:15:59 -07:00
5acc09b471
build: improve server-only derivation 2026-08-16 21:10:44 -07:00
cb2461b64f
feat: hide borders when only main graph is shown 2026-08-16 21:02:56 -07:00
d3f94b4acc
fix: improve anti-clipping for graphs 2026-08-16 20:34:21 -07:00
d1fb0ca78a
refactor: replace for with for_each 2026-08-16 18:39:25 -07:00
c39d48fd07
fix: don't require shift for number toggles 2026-08-16 14:39:50 -07:00
1546c67fa3
feat: add server-only build
it's drastically faster to build,
mostly due to only including 1/10th of the dependencies
2026-08-16 14:18:55 -07:00
e310ca570b
feat: keybinds for toggling displays 2026-08-16 13:56:18 -07:00
a689366324
feat: client-server mode 2026-08-16 12:15:46 -07:00
171194e99f
refactor: split info_stream into module
in preparation to create a client-server model
2026-08-16 11:02:32 -07:00
534b6cde99
feat: add uptime to title
it will be displayed somewhere else later,
hopefully with better formatting too.
2026-08-16 10:49:41 -07:00
79aff51856
fix: send before sleeping
should make the initial graph come in ~200ms faster, for free
2026-08-16 10:29:25 -07:00
865fecd694
refactor: clear cache in Graph::update 2026-08-16 10:18:24 -07:00
8589c0c666
refactor: consistency in widget creation 2026-08-16 10:16:52 -07:00
d068d110e2
feat: memory and swap graphs
the values are in the SysinfoSnapshot verbatim,
in case they might be displayed in another way (textually)
2026-08-16 08:41:53 -07:00
374cbd546d
refactor: qualify path slightly more consistently 2026-08-15 19:22:31 -07:00
9d0a6fdd07
fix: graphs clipping into the bottom of the box
this isn't the greatest solution, but it works as expected
2026-08-15 19:11:34 -07:00
fa0284f165
feat: half the history
this should probably be dynamic (based on screen space).... somehow....
2026-08-15 19:08:05 -07:00
96a972f9f0
refactor: generalize CpuGraph -> Graph 2026-08-15 18:48:45 -07:00
93de64f4b9
refactor: remove unused name attribute 2026-08-15 18:42:53 -07:00
7264d127d9
feat: improve style 2026-08-15 18:36:29 -07:00
98b7a8d201
feat: multiple graphs 2026-08-15 18:19:56 -07:00
12 changed files with 508 additions and 90 deletions

25
Cargo.lock generated
View file

@ -292,6 +292,9 @@ name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]]
name = "block"
@ -1380,6 +1383,8 @@ name = "itop"
version = "0.1.0"
dependencies = [
"iced",
"ron",
"serde",
"smol",
"sysinfo",
]
@ -2482,6 +2487,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "ron"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
dependencies = [
"bitflags 2.13.1",
"once_cell",
"serde",
"serde_derive",
"typeid",
"unicode-ident",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@ -3091,6 +3110,12 @@ dependencies = [
"core_maths",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "uds_windows"
version = "1.2.1"

View file

@ -5,10 +5,23 @@ license = "AGPL-3.0-or-later"
version = "0.1.0"
edition = "2024"
[features]
default = [ "app" ]
app = [ "dep:iced", "dep:smol" ]
[dependencies]
smol = "2.0.2"
ron = "0.12.2"
sysinfo = "0.39.6"
[dependencies.iced]
version = "0.14.0"
features = [ "canvas", "smol" ]
optional = true
[dependencies.smol]
version = "2.0.2"
optional = true
[dependencies.serde]
version = "1.0.229"
features = [ "derive" ]

View file

@ -58,7 +58,6 @@
desktopItem = pkgs.makeDesktopItem {
inherit name;
desktopName = name;
icon = name;
exec = name;
};
in
@ -89,6 +88,13 @@
{
packages.default = crate;
packages."${name}-server-only" = craneLib.buildPackage {
name = "${name}-server-only";
src = ./.;
doCheck = false;
cargoExtraArgs = "--locked --no-default-features";
};
checks = {
crate-clippy = craneLib.cargoClippy (
commonArgs

View file

@ -1,49 +0,0 @@
use std::collections::VecDeque;
use iced::{Color, Point, Renderer, Theme, mouse, widget::canvas};
use crate::app;
const CYAN: Color = Color::from_rgb8(0, 255, 255);
#[derive(Default)]
pub struct CpuGraph {
pub cache: canvas::Cache,
timeline: VecDeque<f32>,
}
impl CpuGraph {
pub fn update(&mut self, usage: f32) {
self.timeline.push_back(usage);
if self.timeline.len() > 1000 {
self.timeline.pop_front();
}
}
}
impl canvas::Program<app::Message, Theme, Renderer> for CpuGraph {
type State = ();
fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<Renderer>> {
let geometry = self.cache.draw(renderer, bounds.size(), |frame| {
frame.stroke(
&canvas::Path::new(|builder| {
for (index, value) in self.timeline.iter().enumerate() {
let x = index as f32 / self.timeline.len() as f32 * bounds.width;
let y = bounds.height - value / 100. * bounds.height;
builder.line_to(Point::new(x, y))
}
}),
canvas::Stroke::default().with_color(CYAN),
);
});
vec![geometry]
}
}

81
src/app/graph.rs Normal file
View file

@ -0,0 +1,81 @@
use std::collections::VecDeque;
use iced::{Color, Point, Renderer, Theme, mouse, widget::canvas};
use crate::app;
const CYAN: Color = Color::from_rgb8(0, 255, 255);
pub struct Graph {
timeline: VecDeque<f32>,
pub show_borders: bool,
pub cache: canvas::Cache,
}
impl Default for Graph {
fn default() -> Self {
Self {
show_borders: true,
timeline: Default::default(),
cache: Default::default(),
}
}
}
impl Graph {
pub fn new(value: f32) -> Self {
Self {
timeline: {
let mut vec = VecDeque::new();
vec.push_front(value);
vec
},
..Default::default()
}
}
pub fn update(&mut self, value: f32) {
self.timeline.push_back(value);
if self.timeline.len() > 500 {
self.timeline.pop_front();
}
self.cache.clear();
}
}
impl canvas::Program<app::Message, Theme, Renderer> for Graph {
type State = ();
fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<Renderer>> {
let geometry = self.cache.draw(renderer, bounds.size(), |frame| {
if self.show_borders {
frame.stroke_rectangle(
Point::new(0., 0.),
bounds.size(),
canvas::Stroke::default().with_color(Color::from_rgb8(255, 0, 0)),
);
}
frame.stroke(
&canvas::Path::new(|builder| {
for (index, value) in self.timeline.iter().enumerate() {
let x = index as f32 / self.timeline.len() as f32 * bounds.width;
let y = (bounds.height - value / 100. * bounds.height)
.clamp(1., bounds.height - 1.);
builder.line_to(Point::new(x, y))
}
}),
canvas::Stroke::default().with_color(CYAN),
);
});
vec![geometry]
}
}

64
src/app/info_stream.rs Normal file
View file

@ -0,0 +1,64 @@
use std::env;
use iced::{
Task,
futures::{self, SinkExt},
stream,
};
use ron::de;
use smol::{Timer, unblock};
use crate::{
app,
sysinfo_snapshot::{self, SysinfoSnapshot},
};
pub fn create() -> Task<app::Message> {
if env::args()
.nth(1)
.is_some_and(|command| command == "client")
{
Task::run(
stream::channel(
1,
async |mut sender: futures::channel::mpsc::Sender<SysinfoSnapshot>| {
let mut buf = String::new();
loop {
buf = unblock(move || {
std::io::stdin().read_line(&mut buf).unwrap();
buf
})
.await;
match de::from_str(&buf) {
Ok(info) => sender.send(info).await.unwrap(),
Err(e) => eprintln!("{e}"),
}
buf.clear();
}
},
),
app::Message::Info,
)
} else {
Task::run(
stream::channel(
1,
async |mut sender: futures::channel::mpsc::Sender<SysinfoSnapshot>| {
let mut sys = sysinfo::System::new();
let refreshes = sysinfo_snapshot::refreshes();
sys.refresh_specifics(refreshes);
Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await;
loop {
sys.refresh_specifics(refreshes);
sender.send(SysinfoSnapshot::from(&sys)).await.unwrap();
Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await;
}
},
),
app::Message::Info,
)
}
}

View file

@ -1,66 +1,198 @@
use std::time::Duration;
use iced::{
Color, Element, Length, Renderer, Task, Theme, application, futures::SinkExt, stream::channel,
Color, Error, Event, Length, Renderer, Subscription, Task, Theme, application, event,
keyboard::{self, Key},
theme, widget,
};
use smol::Timer;
use crate::app::cpu_graph::CpuGraph;
use crate::{
app::{graph::Graph, utils::maybe_widget},
sysinfo_snapshot::{ProcessSnapshot, SysinfoSnapshot},
utils::Toggle,
};
mod cpu_graph;
mod graph;
mod info_stream;
mod utils;
type Element<'a> = iced::Element<'a, Message, Theme, Renderer>;
enum Message {
Cpu(f32),
ToggleMain,
ToggleCpu,
ToggleMemory,
ToggleProcesses,
Info(SysinfoSnapshot),
}
#[derive(Default)]
struct State {
cpu_graph: CpuGraph,
uptime: Duration,
is_main_section_shown: bool,
total_cpu_graph: Graph,
is_cpu_section_shown: bool,
cpu_graphs: Vec<Graph>,
is_memory_section_shown: bool,
memory_graph: Graph,
swap_graph: Graph,
is_processes_section_shown: bool,
processes: Vec<ProcessSnapshot>,
}
impl State {
fn new() -> (Self, Task<Message>) {
(
Self {
cpu_graph: Default::default(),
},
Task::run(
channel(1, async |mut sender| {
let mut sys = sysinfo::System::new();
let refreshes = sysinfo::RefreshKind::nothing()
.with_cpu(sysinfo::CpuRefreshKind::nothing().with_cpu_usage());
let mut state = Self {
is_main_section_shown: true,
..Default::default()
};
state.total_cpu_graph.show_borders = false;
sys.refresh_specifics(refreshes);
Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await;
loop {
sys.refresh_specifics(refreshes);
Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await;
sender.send(sys.global_cpu_usage()).await.unwrap();
}
}),
Message::Cpu,
),
)
(state, info_stream::create())
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Cpu(value) => {
self.cpu_graph.update(value);
self.cpu_graph.cache.clear();
Message::ToggleMain => {
self.is_main_section_shown.toggle();
self.check_borders();
}
Message::ToggleCpu => {
self.is_cpu_section_shown.toggle();
self.check_borders();
}
Message::ToggleMemory => {
self.is_memory_section_shown.toggle();
self.check_borders();
}
Message::ToggleProcesses => {
self.is_processes_section_shown.toggle();
self.check_borders();
}
Message::Info(snapshot) => {
self.uptime = snapshot.uptime();
self.total_cpu_graph.update(snapshot.cpu_total);
self.memory_graph
.update(snapshot.memory as f32 / snapshot.memory_total as f32 * 100.);
self.swap_graph
.update(snapshot.swap as f32 / snapshot.swap_total as f32 * 100.);
if self.cpu_graphs.len() != snapshot.cpus.len() {
self.cpu_graphs = snapshot
.cpus
.into_iter()
.map(|value| Graph::new(value))
.collect();
} else {
self.cpu_graphs
.iter_mut()
.zip(snapshot.cpus.into_iter())
.for_each(|(graph, value)| graph.update(value));
}
self.processes = snapshot.processes;
self.processes
.sort_by(|a, b| b.cpu_usage.total_cmp(&a.cpu_usage));
}
}
Task::none()
}
fn view(&self) -> Element<'_, Message, Theme, Renderer> {
widget::Canvas::new(&self.cpu_graph)
.width(Length::Fill)
.height(Length::Fill)
.into()
fn view(&self) -> Element<'_> {
widget::column([
widget::row([
maybe_widget(self.is_main_section_shown, || {
widget::canvas(&self.total_cpu_graph)
.width(Length::Fill)
.height(Length::Fill)
.into()
}),
maybe_widget(self.is_cpu_section_shown, || {
widget::grid(
self.cpu_graphs
.iter()
.map(|graph| widget::Canvas::new(graph).into()),
)
.columns(2)
.height(Length::Fill)
.into()
}),
])
.into(),
widget::row([
maybe_widget(self.is_memory_section_shown, || {
widget::row([
widget::canvas(&self.memory_graph)
.width(Length::Fill)
.height(Length::Fill)
.into(),
widget::canvas(&self.swap_graph)
.width(Length::Fill)
.height(Length::Fill)
.into(),
])
.into()
}),
maybe_widget(self.is_processes_section_shown, || {
widget::scrollable(
widget::grid(self.processes.iter().flat_map(|process| {
[
widget::text(&process.name).into(),
widget::text(process.cpu_usage).into(),
widget::text(format!("{:?}", process.cpu_time())).into(),
widget::text(process.pid).into(),
]
}))
.height(Length::Shrink)
.columns(4),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
}),
])
.into(),
])
.into()
}
fn subscription(&self) -> Subscription<Message> {
event::listen().filter_map(|event| match event {
Event::Keyboard(keyboard::Event::KeyPressed { modified_key, .. }) => {
match modified_key.as_ref() {
Key::Character("M" | "1") => Some(Message::ToggleMain),
Key::Character("c" | "2") => Some(Message::ToggleCpu),
Key::Character("m" | "3") => Some(Message::ToggleMemory),
Key::Character("p" | "4") => Some(Message::ToggleProcesses),
_ => None,
}
}
_ => None,
})
}
fn title(&self) -> String {
format!("itop {:?}", self.uptime)
}
fn check_borders(&mut self) {
self.total_cpu_graph.show_borders =
self.is_cpu_section_shown || self.is_memory_section_shown;
}
}
pub fn run() -> Result<(), impl std::error::Error> {
pub fn run() -> Result<(), Error> {
application(State::new, State::update, State::view)
.subscription(State::subscription)
.title(State::title)
.theme(theme::Theme::custom(
"high-contrast-dark",
theme::Palette {

14
src/app/utils.rs Normal file
View file

@ -0,0 +1,14 @@
use iced::widget;
use crate::app;
pub fn maybe_widget<'a>(
condition: bool,
widget: impl FnOnce() -> app::Element<'a>,
) -> app::Element<'a> {
if condition {
widget()
} else {
widget::space().into()
}
}

View file

@ -1,5 +1,19 @@
mod app;
use std::env;
fn main() -> Result<(), impl std::error::Error> {
app::run()
#[cfg(feature = "app")]
mod app;
mod server;
mod sysinfo_snapshot;
mod utils;
fn main() {
match env::args().nth(1).as_deref() {
#[cfg(feature = "app")]
None | Some("client") => app::run().unwrap(),
#[cfg_attr(feature = "app", allow(unreachable_patterns))]
None | Some("server") => server::run().unwrap(),
Some(s) => panic!("invalid argument: {s}"),
}
}

21
src/server/mod.rs Normal file
View file

@ -0,0 +1,21 @@
use std::{io::Write, thread};
use ron::{Error, ser};
use crate::sysinfo_snapshot::{self, SysinfoSnapshot};
pub fn run() -> Result<(), Error> {
let mut sys = sysinfo::System::new();
let refreshes = sysinfo_snapshot::refreshes();
sys.refresh_specifics(refreshes);
thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
let mut stdout = std::io::stdout();
loop {
sys.refresh_specifics(refreshes);
stdout.write_all((ser::to_string(&SysinfoSnapshot::from(&sys))? + "\n").as_bytes())?;
thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
}
}

87
src/sysinfo_snapshot.rs Normal file
View file

@ -0,0 +1,87 @@
use std::time::Duration;
pub fn refreshes() -> sysinfo::RefreshKind {
sysinfo::RefreshKind::nothing()
.with_memory(sysinfo::MemoryRefreshKind::everything())
.with_cpu(sysinfo::CpuRefreshKind::nothing().with_cpu_usage())
.with_processes(
sysinfo::ProcessRefreshKind::nothing()
.with_cpu()
.with_cmd(sysinfo::UpdateKind::OnlyIfNotSet)
.with_exe(sysinfo::UpdateKind::OnlyIfNotSet),
)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SysinfoSnapshot {
/// in seconds
uptime: u64,
pub cpu_total: f32,
pub cpus: Vec<f32>,
pub memory: u64,
pub memory_total: u64,
pub swap: u64,
pub swap_total: u64,
pub processes: Vec<ProcessSnapshot>,
}
impl SysinfoSnapshot {
#[cfg_attr(not(feature = "app"), allow(dead_code))]
pub fn uptime(&self) -> Duration {
Duration::from_secs(self.uptime)
}
}
impl From<&sysinfo::System> for SysinfoSnapshot {
fn from(value: &sysinfo::System) -> Self {
Self {
uptime: sysinfo::System::uptime(),
cpu_total: value.global_cpu_usage(),
cpus: value.cpus().iter().map(|cpu| cpu.cpu_usage()).collect(),
memory: value.used_memory(),
memory_total: value.total_memory(),
swap: value.used_swap(),
swap_total: value.total_swap(),
processes: value
.processes()
.iter()
.map(|(_pid, process)| ProcessSnapshot::from(process))
.collect(),
}
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ProcessSnapshot {
pub pid: usize,
/// in CPU-milliseconds
accumulated_cpu_time: u64,
/// note: it's by core, and can be up to eg 1600 on a 16-core device
pub cpu_usage: f32,
pub name: String,
}
impl ProcessSnapshot {
pub fn cpu_time(&self) -> Duration {
Duration::from_millis(self.accumulated_cpu_time)
}
}
impl From<&sysinfo::Process> for ProcessSnapshot {
fn from(value: &sysinfo::Process) -> Self {
Self {
pid: value.pid().into(),
accumulated_cpu_time: value.accumulated_cpu_time(),
cpu_usage: value.cpu_usage(),
name: value.name().to_string_lossy().into_owned(),
}
}
}

10
src/utils.rs Normal file
View file

@ -0,0 +1,10 @@
#[cfg_attr(not(feature = "app"), allow(dead_code))]
pub trait Toggle {
fn toggle(&mut self);
}
impl Toggle for bool {
fn toggle(&mut self) {
*self = !*self
}
}