diff --git a/Cargo.lock b/Cargo.lock index fd47ae3..5b9aae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,7 +36,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -891,6 +891,21 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "mime_guess2", + "profiling", +] + [[package]] name = "egui_glow" version = "0.36.2" @@ -943,6 +958,26 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1782,6 +1817,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf 0.11.3", + "phf_shared 0.11.3", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2329,17 +2382,37 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -2347,7 +2420,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", + "unicase", ] [[package]] @@ -2356,13 +2443,23 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -2559,6 +2656,21 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "range-alloc" version = "0.1.5" @@ -3083,6 +3195,7 @@ dependencies = [ "anyhow", "crossbeam-channel", "eframe", + "egui_extras", "egui_plot", "embed-manifest", "serde", @@ -3279,6 +3392,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index c96292a..4f39467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ license = "MIT" anyhow = "1.0.103" crossbeam-channel = "0.5.16" eframe = "0.36.2" +egui_extras = "0.36.2" egui_plot = "0.37.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" diff --git a/src/gui.rs b/src/gui.rs index 8077faf..d556ad9 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -8,6 +8,7 @@ use std::time::Duration; use crossbeam_channel::{Receiver, Sender}; use eframe::egui; +use eframe::egui::Widget; use egui_plot::{Legend, Line, Plot, PlotPoints}; use crate::backend::rules; @@ -584,104 +585,86 @@ impl ThrottleApp { if sort_desc { ord.reverse() } else { ord } }); + const SORT_COLUMN_LABELS: [(SortColumn, &str); 6] = [ + (SortColumn::Name, "Process"), + (SortColumn::Flows, "Flows"), + (SortColumn::Down, "Down"), + (SortColumn::Up, "Up"), + (SortColumn::DownTotal, "Total down"), + (SortColumn::UpTotal, "Total up"), + ]; + egui::CentralPanel::default().show(ui, |ui| { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - // Spread the columns across the full available width so the - // table doesn't huddle in the left corner of wide windows. - let col_width = ((ui.available_width() - 6.0 * 16.0) / 7.0).max(60.0); - egui::Grid::new("proc_grid") - .num_columns(7) - .striped(true) - .spacing([16.0, 4.0]) - .min_col_width(col_width) - .show(ui, |ui| { - self.sort_header(ui, "Process", SortColumn::Name); - self.sort_header(ui, "Flows", SortColumn::Flows); - self.sort_header(ui, "Down", SortColumn::Down); - self.sort_header(ui, "Up", SortColumn::Up); - self.sort_header(ui, "Total down", SortColumn::DownTotal); - self.sort_header(ui, "Total up", SortColumn::UpTotal); - ui.label(egui::RichText::new("Rule").strong()); - ui.end_row(); - - for (key, stats) in &rows { - let selected = self.selected.as_deref() == Some(key.as_str()); - - let rule = snap.rules.get(key); - let summary = rule_summary(rule); - let rule_text = if rule.map(|r| r.blocked).unwrap_or(false) { - egui::RichText::new(summary).color(COLOR_BLOCK).strong() - } else { - egui::RichText::new(summary) - }; - - let cells: [egui::RichText; 7] = [ - egui::RichText::new(&stats.name), - egui::RichText::new(stats.flow_count.to_string()), - egui::RichText::new(humanize_rate(stats.down_rate)) - .color(COLOR_DOWN), - egui::RichText::new(humanize_rate(stats.up_rate)) - .color(COLOR_UP), - egui::RichText::new(humanize_bytes(stats.down_total)), - egui::RichText::new(humanize_bytes(stats.up_total)), - rule_text, - ]; - - // Reserve a shape slot before the cells so the - // row highlight paints behind the text; it is - // filled once we know the row's vertical extent. - let bg_idx = ui.painter().add(egui::Shape::Noop); - let mut y_min = f32::INFINITY; - let mut y_max = f32::NEG_INFINITY; - - // Cells are plain labels; interaction is handled - // by one full-width region below so clicking - // anywhere on the row (gaps included) selects it. - for text in cells { - let resp = ui.add(egui::Label::new(text).selectable(false)); - y_min = y_min.min(resp.rect.top()); - y_max = y_max.max(resp.rect.bottom()); - } - - // Full-width row rect: spans the grid's whole - // width, covering column gaps and trailing space. - let row_rect = egui::Rect::from_x_y_ranges( - ui.max_rect().x_range(), - egui::Rangef::new(y_min, y_max), - ); - - // One interactive region over the entire row. - let row_resp = ui.interact( - row_rect, - egui::Id::new(("proc_row", key.as_str())), - egui::Sense::click(), - ); - if row_resp.clicked() { - self.selected = Some(key.clone()); - } - self.row_context_menu(&row_resp, key, &stats.name); - - // Highlight the selected row, or tint on hover. - let fill = if selected { - Some(ui.visuals().selection.bg_fill) - } else if row_resp.hovered() { - Some(ui.visuals().widgets.hovered.bg_fill.gamma_multiply(0.5)) - } else { - None - }; - if let Some(fill) = fill { - ui.painter() - .set(bg_idx, egui::Shape::rect_filled(row_rect, 3.0, fill)); - } - ui.end_row(); - } - }); + egui_extras::TableBuilder::new(ui) + .resizable(true) + .auto_shrink(false) + .striped(true) + .sense(egui::Sense::CLICK | egui::Sense::HOVER) + .column(egui_extras::Column::remainder().at_least(200.)) // Name + .columns(egui_extras::Column::auto(), 5) // Numeric + .column(egui_extras::Column::remainder()) // Rule + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .header(20., |mut row| { + for (sort_column, label) in SORT_COLUMN_LABELS { + row.col(|ui| self.sort_header(ui, label, sort_column)); + } + row.col(|ui| { + ui.label(egui::RichText::new("Rule").strong()); + }); + }) + .body(|body| { + body.rows(20., rows.len(), |row| { + let (key, stats) = rows.get(row.index()).expect("row index in bounds"); + + let resp = self.draw_table_row(row, key, stats, snap); + if resp.clicked() || resp.secondary_clicked() { + self.selected = Some(key.clone()); + } + self.row_context_menu(&resp, key, &stats.name); + }); }); }); } + fn draw_table_row( + &mut self, + mut row: egui_extras::TableRow, + key: &String, + stats: &ProcessStats, + snap: &Snapshot, + ) -> egui::Response { + let selected = self.selected.as_deref() == Some(key.as_str()); + + let rule = snap.rules.get(key); + let summary = rule_summary(rule); + let rule_text = if rule.map(|r| r.blocked).unwrap_or(false) { + egui::RichText::new(summary).color(COLOR_BLOCK).strong() + } else { + egui::RichText::new(summary) + }; + + let cells: [egui::RichText; 7] = [ + egui::RichText::new(&stats.name), + egui::RichText::new(stats.flow_count.to_string()), + egui::RichText::new(humanize_rate(stats.down_rate)).color(COLOR_DOWN), + egui::RichText::new(humanize_rate(stats.up_rate)).color(COLOR_UP), + egui::RichText::new(humanize_bytes(stats.down_total)), + egui::RichText::new(humanize_bytes(stats.up_total)), + rule_text, + ]; + row.set_selected(selected); + for content in cells { + row.col(|ui| { + egui::Label::new(content) + .wrap_mode(egui::TextWrapMode::Truncate) + .selectable(false) + .ui(ui); + }); + } + + row.response() + } + fn row_context_menu(&mut self, resp: &egui::Response, key: &str, name: &str) { resp.context_menu(|ui| { // The "Unknown" row is not a process but the traffic we failed to