diff --git a/CHANGELOG.md b/CHANGELOG.md
index 849c7f60..1c301a0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@ That said, these are more guidelines rather than hard rules, though the project
- [#2053](https://github.com/ClementTsang/bottom/pull/2053): Add a configurable default sort column for the process widget (`processes.default_sort` or `--process-default-sort`).
- [#2066](https://github.com/ClementTsang/bottom/pull/2066): Add search support in the help dialog.
- [#1791](https://github.com/ClementTsang/bottom/pull/1791), [#2072](https://github.com/ClementTsang/bottom/pull/2072): Add support for using a short name for the GPU in memory usage.
+- [#2073](https://github.com/ClementTsang/bottom/pull/2073): Add disk I/O time series graph.
### Changes
@@ -53,6 +54,7 @@ That said, these are more guidelines rather than hard rules, though the project
- [#2064](https://github.com/ClementTsang/bottom/pull/2064): Move CPU config file options from `[flags]` to be under `[cpu]`.
- [#2064](https://github.com/ClementTsang/bottom/pull/2064): Move memory config file options from `[flags]` to be under `[memory_graph]`.
- [#2066](https://github.com/ClementTsang/bottom/pull/2066): Take cgroup into account for CPU usage% calculations.
+- [#2074](https://github.com/ClementTsang/bottom/pull/2074): Treat uppercase 'Q' as a quit shortcut.
### Other
diff --git a/docs/content/assets/screenshots/disk_graph.webp b/docs/content/assets/screenshots/disk_graph.webp
new file mode 100644
index 00000000..081d317c
Binary files /dev/null and b/docs/content/assets/screenshots/disk_graph.webp differ
diff --git a/docs/content/assets/screenshots/temperature_graph.webp b/docs/content/assets/screenshots/temperature_graph.webp
new file mode 100644
index 00000000..bea62885
Binary files /dev/null and b/docs/content/assets/screenshots/temperature_graph.webp differ
diff --git a/docs/content/configuration/config-file/cpu-graph.md b/docs/content/configuration/config-file/cpu-graph.md
index 30915fe0..012645cd 100644
--- a/docs/content/configuration/config-file/cpu-graph.md
+++ b/docs/content/configuration/config-file/cpu-graph.md
@@ -1,4 +1,4 @@
-# CPU
+# CPU Configuration
If you want to change some of the default behaviour of the CPU graph widget, you can configure things under the `[cpu]` section.
diff --git a/docs/content/configuration/config-file/disk-io-graph.md b/docs/content/configuration/config-file/disk-io-graph.md
new file mode 100644
index 00000000..548f12a7
--- /dev/null
+++ b/docs/content/configuration/config-file/disk-io-graph.md
@@ -0,0 +1,73 @@
+# Disk I/O Graph Configuration
+
+The disk I/O graph widget is configured under `[disk_io_graph]`.
+
+## Showing Read/Write Lines
+
+By default, both the read and write rate lines are shown. They can be toggled off independently.
+
+```toml
+[disk_io_graph]
+# Whether to show the read rate line. Defaults to true.
+show_read = true
+
+# Whether to show the write rate line. Defaults to true.
+show_write = true
+```
+
+## Legend Labels
+
+By default, legend entries are labelled by disk name (e.g. `sda`, `nvme0n1`). To use mount points
+instead (e.g. `/`, `/home`):
+
+```toml
+[disk_io_graph]
+legend = "mount"
+```
+
+Valid values are `"disk"` (default) and `"mount"`.
+
+## Logarithmic Scale
+
+The y-axis can be switched to a logarithmic scale:
+
+```toml
+[disk_io_graph]
+use_log = true
+```
+
+## Legend Position
+
+The location of the legend can be set with `legend_position`. Valid values are `none`, `top-left`, `top`, `top-right`,
+`left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Defaults to `top-right`.
+
+```toml
+[disk_io_graph]
+legend_position = "top-right"
+```
+
+## Filtering Entries
+
+You can filter out what entries to show by configuring `[disk_io_graph.name_filter]` and `[disk_io_graph.mount_filter]` to filter by
+name and mount point respectively. In particular, you can set a list of things to filter with by setting `list`,
+and configure how that list is processed with the other options.
+
+If we wanted to ignoring any entry with a name that matches `/dev/sda`:
+
+```toml
+[disk_io_graph.name_filter]
+# Whether to ignore any matches. Defaults to true.
+is_list_ignored = true
+
+# A list of filters to try and match.
+list = ["/dev/sda"]
+
+# Whether to use regex. Defaults to false.
+regex = true
+
+# Whether to be case-sensitive. Defaults to false.
+case_sensitive = false
+
+# Whether to require matching the whole word. Defaults to false.
+whole_word = false
+```
diff --git a/docs/content/configuration/config-file/disk-table.md b/docs/content/configuration/config-file/disk-table.md
index 6904c7d8..c15dcc73 100644
--- a/docs/content/configuration/config-file/disk-table.md
+++ b/docs/content/configuration/config-file/disk-table.md
@@ -1,4 +1,4 @@
-# Disk Table
+# Disk Table Configuration
## Columns
diff --git a/docs/content/configuration/config-file/memory-graph.md b/docs/content/configuration/config-file/memory-graph.md
index 9cb19322..72516c56 100644
--- a/docs/content/configuration/config-file/memory-graph.md
+++ b/docs/content/configuration/config-file/memory-graph.md
@@ -1,4 +1,4 @@
-# Memory Graph
+# Memory Graph Configuration
If you want to change some of the default behaviour of the memory graph widget, you can configure things under the `[memory_graph]` (or `[memory]`) section.
diff --git a/docs/content/configuration/config-file/network-graph.md b/docs/content/configuration/config-file/network-graph.md
index 9eec9669..bc85a0a1 100644
--- a/docs/content/configuration/config-file/network-graph.md
+++ b/docs/content/configuration/config-file/network-graph.md
@@ -1,4 +1,4 @@
-# Network Graph
+# Network Graph Configuration
## Settings
diff --git a/docs/content/configuration/config-file/processes.md b/docs/content/configuration/config-file/processes.md
index 46a4c829..9050c426 100644
--- a/docs/content/configuration/config-file/processes.md
+++ b/docs/content/configuration/config-file/processes.md
@@ -1,4 +1,4 @@
-# Processes
+# Processes Configuration
## Settings
diff --git a/docs/content/configuration/config-file/temperature-graph.md b/docs/content/configuration/config-file/temperature-graph.md
index c033f625..3c819c92 100644
--- a/docs/content/configuration/config-file/temperature-graph.md
+++ b/docs/content/configuration/config-file/temperature-graph.md
@@ -1,4 +1,4 @@
-# Temperature Graph
+# Temperature Graph Configuration
The temperature graph widget is configured under `[temperature_graph]`.
diff --git a/docs/content/configuration/config-file/temperature-table.md b/docs/content/configuration/config-file/temperature-table.md
index e44f8be6..797af1ad 100644
--- a/docs/content/configuration/config-file/temperature-table.md
+++ b/docs/content/configuration/config-file/temperature-table.md
@@ -1,4 +1,4 @@
-# Temperature Table
+# Temperature Table Configuration
The temperature table widget is configured under `[temperature]`.
diff --git a/docs/content/usage/widgets/disk-io-graph.md b/docs/content/usage/widgets/disk-io-graph.md
new file mode 100644
index 00000000..8dd5f2dc
--- /dev/null
+++ b/docs/content/usage/widgets/disk-io-graph.md
@@ -0,0 +1,31 @@
+# Disk I/O Graph Widget
+
+The disk I/O graph widget shows read and write rates over time.
+
+
+
+
+
+## Features
+
+The legend displays the current reads and writes per second in bytes for each mount/disk item. The y-axis
+automatically scales based on shown read/write values. By default this is in a linear scale, but it can be
+switched to a logarithmic scale through the [config file](../../configuration/config-file/network-graph.md).
+
+The displayed time range can be adjusted through either the keyboard or mouse.
+
+## Key bindings
+
+Note that key bindings are generally case-sensitive.
+
+| Binding | Action |
+| --------- | --------------------------------------- |
+| ++plus++ | Zoom in on chart (decrease time range) |
+| ++minus++ | Zoom out on chart (increase time range) |
+| ++equal++ | Reset zoom |
+
+## Mouse bindings
+
+| Binding | Action |
+| ------------ | -------------------------------------------------------------- |
+| ++"Scroll"++ | Scrolling up or down zooms in or out of the graph respectively |
diff --git a/docs/content/usage/widgets/network-graph.md b/docs/content/usage/widgets/network-graph.md
index 9cf1e9e2..fd7f5668 100644
--- a/docs/content/usage/widgets/network-graph.md
+++ b/docs/content/usage/widgets/network-graph.md
@@ -12,7 +12,7 @@ received and transmitted.
The legend displays the current reads and writes per second in bits, as well as the total amount read/written.
The y-axis automatically scales based on shown read/write values, and by default, is a linear scale based on base-10 units (e.x. kilobit, gigabit, etc.).
-Through [configuration](../../configuration/command-line-options.md), the read/write per second unit can be changed to bytes, while the y-axis can be changed to a
+Through [configuration](../../configuration/config-file/network-graph.md), the read/write per second unit can be changed to bytes, while the y-axis can be changed to a
log scale and/or use base-2 units (e.x. kibibit, gibibit, etc.).
One can also adjust the displayed time range through either the keyboard or mouse, with a range of 30s to 600s.
diff --git a/docs/content/usage/widgets/temperature-graph.md b/docs/content/usage/widgets/temperature-graph.md
index 1074dc54..6dd5a03c 100644
--- a/docs/content/usage/widgets/temperature-graph.md
+++ b/docs/content/usage/widgets/temperature-graph.md
@@ -2,12 +2,17 @@
The temperature graph widget provides temperature readings over time.
+
+
+
+
## Features
Each detected sensor is drawn as its own line. The y-axis is in the configured temperature unit (Celsius by default;
see the `--celsius`/`--fahrenheit`/`--kelvin` arguments or `flags.temperature_type` config option).
-By default the y-axis is bounded at 100°C (or the equivalent in the configured unit) and grows automatically. An upper bound can also be set explicitly via the [config file](../../configuration/config-file/temperature-graph.md).
+By default the y-axis is bounded at 100°C (or the equivalent in the configured unit) and grows automatically.
+An upper bound can also be set explicitly via the [config file](../../configuration/config-file/temperature-graph.md).
The displayed time range can be adjusted through either the keyboard or mouse.
@@ -26,5 +31,3 @@ Note that key bindings are generally case-sensitive.
| Binding | Action |
| ------------ | -------------------------------------------------------------- |
| ++"Scroll"++ | Scrolling up or down zooms in or out of the graph respectively |
-
-
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 8738e4ff..1fdf1e78 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -168,6 +168,7 @@ nav:
- "Disk Widget": usage/widgets/disk.md
- "Temperature Widget": usage/widgets/temperature-table.md
- "Temperature Graph Widget": usage/widgets/temperature-graph.md
+ - "Disk I/O Graph Widget": usage/widgets/disk-io-graph.md
- "Battery Widget": usage/widgets/battery.md
- "Auto-Complete": usage/autocomplete.md
- "Configuration":
@@ -181,6 +182,7 @@ nav:
- "Processes Widget": configuration/config-file/processes.md
- "Temperature Table Widget": configuration/config-file/temperature-table.md
- "Temperature Graph Widget": configuration/config-file/temperature-graph.md
+ - "Disk I/O Graph Widget": configuration/config-file/disk-io-graph.md
- "Flags": configuration/config-file/flags.md
- "Layout": configuration/config-file/layout.md
- "Styling": configuration/config-file/styling.md
diff --git a/src/app.rs b/src/app.rs
index e2e934cd..c9cc61c3 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -81,6 +81,7 @@ pub struct AppConfigFields {
pub default_temp_sort_column: Option,
pub default_disk_sort_column: Option,
pub temperature_legend_position: Option,
+ pub disk_io_legend_position: Option,
}
/// For filtering out information
@@ -90,6 +91,7 @@ pub struct DataFilters {
pub mount_filter: Option,
pub temp_filter: Option,
pub temp_graph_filter: Option,
+ pub disk_io_graph_filter: Option,
pub net_filter: Option,
}
@@ -2073,6 +2075,14 @@ impl App {
{
Some(widget_state.graph.state_mut())
}
+ BottomWidgetType::DiskIoGraph
+ if let Some(widget_state) = self
+ .states
+ .disk_io_graph_state
+ .get_mut_widget_state(self.current_widget.widget_id) =>
+ {
+ Some(widget_state.graph.state_mut())
+ }
_ => None,
}
}
diff --git a/src/app/data/store.rs b/src/app/data/store.rs
index 0aadc5ee..4fa3adf8 100644
--- a/src/app/data/store.rs
+++ b/src/app/data/store.rs
@@ -11,7 +11,7 @@ use crate::{
collection::{
Data,
cpu::{CpuHarvest, LoadAvgHarvest},
- disks,
+ disks::{DiskHarvest, IoHarvest},
memory::MemData,
network::NetworkHarvest,
},
@@ -153,6 +153,11 @@ impl StoredData {
if let Some(disks) = data.disks {
if let Some(io) = data.io {
self.eat_disks(disks, io, harvested_time);
+
+ if used_widgets.use_disk_io_graph {
+ self.time_series_data
+ .update_disk_io(&self.disk_harvest, &filters.disk_io_graph_filter);
+ }
}
}
@@ -171,9 +176,8 @@ impl StoredData {
self.last_update_time = harvested_time;
}
- fn eat_disks(
- &mut self, disks: Vec, io: disks::IoHarvest, harvested_time: Instant,
- ) {
+ // TODO: There's a spike on the first hit. We should probably fix this and the index issue.
+ fn eat_disks(&mut self, disks: Vec, io: IoHarvest, harvested_time: Instant) {
let time_since_last_harvest = harvested_time
.duration_since(self.last_update_time)
.as_secs_f64();
@@ -184,6 +188,8 @@ impl StoredData {
self.prev_io.reserve(prev_io_diff);
self.prev_io.extend((0..prev_io_diff).map(|_| (0, 0)));
+ // FIXME: prev_io is indexed by position (itx), not by device name, which might cause problems
+ // if the order changes or something.
for (itx, device) in disks.into_iter().enumerate() {
let Some(checked_name) = ({
#[cfg(target_os = "windows")]
diff --git a/src/app/data/time_series.rs b/src/app/data/time_series.rs
index d1a5c27d..9b3fe66d 100644
--- a/src/app/data/time_series.rs
+++ b/src/app/data/time_series.rs
@@ -12,6 +12,7 @@ use timeless::data::ChunkedData;
use crate::{
app::{AppConfigFields, DataFilters, filter::Filter, layout_manager::UsedWidgets},
collection::Data,
+ widgets::DiskWidgetData,
};
/// Values corresponding to a time slice.
@@ -65,6 +66,12 @@ pub struct TimeSeriesData {
///
/// TODO: Maybe make this use TypedTemperature?
pub temperature: HashMap>,
+
+ /// Disk I/O read rate data, keyed by device name.
+ pub disk_io_read: HashMap>,
+
+ /// Disk I/O write rate data, keyed by device name.
+ pub disk_io_write: HashMap>,
}
impl TimeSeriesData {
@@ -226,6 +233,42 @@ impl TimeSeriesData {
}
}
+ /// Update disk I/O time series using already-calculated rates from
+ /// [`DiskWidgetData`]. Devices not present in `disks` get a break inserted
+ /// so the legend keeps them around for the window's remaining lifetime.
+ pub fn update_disk_io(&mut self, disks: &[DiskWidgetData], filter: &Option) {
+ let mut not_visited: HashSet = self.disk_io_read.keys().cloned().collect();
+
+ for disk in disks {
+ if !Filter::optional_should_keep(filter, &disk.name) {
+ continue;
+ }
+
+ not_visited.remove(&disk.name);
+
+ let read_entry = self.disk_io_read.entry(disk.name.clone()).or_default();
+ match disk.io_read_rate_bytes {
+ Some(v) => read_entry.push(v as f64),
+ None => read_entry.insert_break(),
+ }
+
+ let write_entry = self.disk_io_write.entry(disk.name.clone()).or_default();
+ match disk.io_write_rate_bytes {
+ Some(v) => write_entry.push(v as f64),
+ None => write_entry.insert_break(),
+ }
+ }
+
+ for name in not_visited {
+ if let Some(entry) = self.disk_io_read.get_mut(&name) {
+ entry.insert_break();
+ }
+ if let Some(entry) = self.disk_io_write.get_mut(&name) {
+ entry.insert_break();
+ }
+ }
+ }
+
/// Prune any data older than the given duration.
pub fn prune(&mut self, max_age: Duration) {
if self.time.is_empty() {
@@ -297,5 +340,27 @@ impl TimeSeriesData {
true
}
});
+
+ self.disk_io_read.retain(|_, data| {
+ let _ = data.prune(end);
+
+ if data.no_elements() {
+ false
+ } else {
+ data.shrink_to_fit();
+ true
+ }
+ });
+
+ self.disk_io_write.retain(|_, data| {
+ let _ = data.prune(end);
+
+ if data.no_elements() {
+ false
+ } else {
+ data.shrink_to_fit();
+ true
+ }
+ });
}
}
diff --git a/src/app/layout_manager.rs b/src/app/layout_manager.rs
index 73efce5b..9f7cbafe 100644
--- a/src/app/layout_manager.rs
+++ b/src/app/layout_manager.rs
@@ -943,6 +943,7 @@ pub enum BottomWidgetType {
Temp,
TempGraph,
Disk,
+ DiskIoGraph,
BasicCpu,
BasicMem,
BasicNet,
@@ -958,7 +959,7 @@ impl BottomWidgetType {
pub fn is_widget_graph(&self) -> bool {
use BottomWidgetType::*;
- matches!(self, Cpu | Net | Mem | TempGraph)
+ matches!(self, Cpu | Net | Mem | TempGraph | DiskIoGraph)
}
pub fn get_pretty_name(&self) -> &str {
@@ -990,6 +991,7 @@ impl std::str::FromStr for BottomWidgetType {
"temp" | "temperature" => Ok(BottomWidgetType::Temp),
"temp_graph" | "temperature_graph" => Ok(BottomWidgetType::TempGraph),
"disk" => Ok(BottomWidgetType::Disk),
+ "disk_io_graph" => Ok(BottomWidgetType::DiskIoGraph),
"empty" => Ok(BottomWidgetType::Empty),
#[cfg(feature = "battery")]
"battery" | "batt" => Ok(BottomWidgetType::Battery),
@@ -1015,6 +1017,8 @@ Supported widget names:
+--------------------------------+
| disk |
+--------------------------------+
+| disk_io_graph |
++--------------------------------+
| batt, battery |
+--------------------------------+
| empty |
@@ -1043,6 +1047,8 @@ Supported widget names:
+--------------------------------+
| disk |
+--------------------------------+
+| disk_io_graph |
++--------------------------------+
| empty |
+--------------------------------+
",
@@ -1064,5 +1070,6 @@ pub struct UsedWidgets {
pub use_disk: bool,
pub use_temp: bool,
pub use_temp_graph: bool,
+ pub use_disk_io_graph: bool,
pub use_battery: bool,
}
diff --git a/src/app/states.rs b/src/app/states.rs
index 32881d65..49555979 100644
--- a/src/app/states.rs
+++ b/src/app/states.rs
@@ -5,8 +5,9 @@ use crate::{
constants,
utils::input::InputFieldState,
widgets::{
- BatteryWidgetState, CpuWidgetState, DiskTableWidget, MemWidgetState, NetWidgetState,
- ProcWidgetState, TempGraphWidgetState, TempWidgetState, query::ProcessQuery,
+ BatteryWidgetState, CpuWidgetState, DiskIoGraphWidgetState, DiskTableWidget,
+ MemWidgetState, NetWidgetState, ProcWidgetState, TempGraphWidgetState, TempWidgetState,
+ query::ProcessQuery,
},
};
@@ -18,6 +19,7 @@ pub struct AppWidgetStates {
pub temp_state: TempState,
pub temp_graph_state: TempGraphStates,
pub disk_state: DiskState,
+ pub disk_io_graph_state: DiskIoGraphStates,
pub battery_state: AppBatteryState,
pub basic_table_widget_state: Option,
}
@@ -192,6 +194,21 @@ impl TempGraphStates {
}
}
+/// Holds per-widget state for all disk I/O graph instances in the layout.
+pub struct DiskIoGraphStates {
+ pub widget_states: HashMap,
+}
+
+impl DiskIoGraphStates {
+ pub fn init(widget_states: HashMap) -> Self {
+ DiskIoGraphStates { widget_states }
+ }
+
+ pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut DiskIoGraphWidgetState> {
+ self.widget_states.get_mut(&widget_id)
+ }
+}
+
pub struct DiskState {
pub widget_states: HashMap,
}
diff --git a/src/canvas.rs b/src/canvas.rs
index 21a8e681..d382866d 100644
--- a/src/canvas.rs
+++ b/src/canvas.rs
@@ -281,6 +281,12 @@ impl Painter {
rect[0],
app_state.current_widget.widget_id,
),
+ DiskIoGraph => self.draw_disk_io_graph(
+ f,
+ app_state,
+ rect[0],
+ app_state.current_widget.widget_id,
+ ),
_ => {}
}
} else if app_state.app_config_fields.use_basic_mode {
@@ -401,6 +407,9 @@ impl Painter {
vertical_chunks[3],
widget_id,
),
+ DiskIoGraph => {
+ self.draw_disk_io_graph(f, app_state, vertical_chunks[3], widget_id)
+ }
_ => {}
}
}
@@ -482,6 +491,9 @@ impl Painter {
TempGraph => {
self.draw_temperature_graph(f, app_state, *draw_loc, widget.widget_id)
}
+ DiskIoGraph => {
+ self.draw_disk_io_graph(f, app_state, *draw_loc, widget.widget_id)
+ }
_ => {}
}
}
diff --git a/src/canvas/widgets/disk_io_graph.rs b/src/canvas/widgets/disk_io_graph.rs
new file mode 100644
index 00000000..d27105be
--- /dev/null
+++ b/src/canvas/widgets/disk_io_graph.rs
@@ -0,0 +1,314 @@
+use std::{
+ borrow::Cow,
+ time::{Duration, Instant},
+};
+
+use rustc_hash::FxHashMap;
+use timeless::data::ChunkedData;
+use tui::{
+ Frame,
+ layout::{Constraint, Rect},
+};
+
+use crate::{
+ app::App,
+ canvas::{
+ Painter,
+ components::time_series::{AxisBound, ChartScaling, GraphData, LegendConstraints},
+ drawing_utils::should_hide_x_label,
+ },
+ components::time_series::GraphDrawCtx,
+ options::config::disk_io_graph::DiskGraphLegend,
+ utils::data_units::*,
+};
+
+impl Painter {
+ pub fn draw_disk_io_graph(
+ &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
+ ) {
+ if let Some(widget_state) = app_state
+ .states
+ .disk_io_graph_state
+ .get_mut_widget_state(widget_id)
+ {
+ let shared_data = app_state.data_store.get_data();
+ let read_data = &shared_data.time_series_data.disk_io_read;
+ let write_data = &shared_data.time_series_data.disk_io_write;
+ let times = &shared_data.time_series_data.time;
+
+ let mount_map: FxHashMap<&str, &str> = shared_data
+ .disk_harvest
+ .iter()
+ .map(|d| (d.name.as_str(), d.mount_point.as_str()))
+ .collect();
+
+ let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id);
+ let graph_state = widget_state.graph.state_mut();
+ let hide_x_labels = should_hide_x_label(
+ app_state.app_config_fields.hide_time,
+ app_state.app_config_fields.autohide_time,
+ graph_state.autohide_timer_mut(),
+ draw_loc,
+ );
+ let current_display_time = graph_state.current_display_time();
+
+ let show_read = widget_state.show_read;
+ let show_write = widget_state.show_write;
+ let use_log = widget_state.use_log;
+ let legend_type = &widget_state.legend;
+
+ let mut device_names: Vec<&String> = read_data
+ .keys()
+ .filter(|name| {
+ mount_map.contains_key(name.as_str())
+ || read_data
+ .get(*name)
+ .is_some_and(|d| has_data_in_window(d, times, current_display_time))
+ })
+ .collect();
+ device_names.sort_unstable();
+
+ // Compute y_max across all visible series.
+ let mut visible_sources: Vec<&_> = Vec::new();
+ for name in &device_names {
+ if show_read {
+ if let Some(data) = read_data.get(*name) {
+ visible_sources.push(data);
+ }
+ }
+ if show_write {
+ if let Some(data) = write_data.get(*name) {
+ visible_sources.push(data);
+ }
+ }
+ }
+ let y_max = widget_state.graph.y_max(visible_sources.into_iter(), times);
+
+ let (adjusted_y_max, y_labels) = if use_log {
+ adjust_disk_io_log(y_max)
+ } else {
+ adjust_disk_io_linear(y_max)
+ };
+
+ let legend_constraints = LegendConstraints {
+ width: Constraint::Ratio(9, 10),
+ height: Constraint::Ratio(1, 2),
+ };
+
+ // Removed devices still visible in the window show "N/A".
+ // TODO: Maybe should make it so the colour is based on entry name? As then it may shift.
+ let read_colours = &self.styles.disk_io_read_colour_styles;
+ let write_colours = &self.styles.disk_io_write_colour_styles;
+
+ let mut graph_data: Vec> =
+ Vec::with_capacity(device_names.len() * 2);
+ for (idx, name) in device_names.iter().enumerate() {
+ let display_name = match legend_type {
+ DiskGraphLegend::Disk => name.as_str(),
+ DiskGraphLegend::Mount => mount_map
+ .get(name.as_str())
+ .copied()
+ .unwrap_or(name.as_str()),
+ };
+
+ let is_active = mount_map.contains_key(name.as_str());
+
+ let read_values = show_read.then(|| read_data.get(*name)).flatten();
+ let write_values = show_write.then(|| write_data.get(*name)).flatten();
+
+ let read_style = if read_colours.is_empty() {
+ tui::style::Style::default()
+ } else {
+ read_colours[idx % read_colours.len()]
+ };
+ let write_style = if write_colours.is_empty() {
+ tui::style::Style::default()
+ } else {
+ write_colours[idx % write_colours.len()]
+ };
+
+ // TODO: Combine into one line; probably need to add some kind of multi-styled GraphData.
+ if let Some(values) = read_values {
+ let rate = if is_active {
+ format_rate_fixed(values.last().copied().unwrap_or(0.0))
+ } else {
+ " N/A".to_string()
+ };
+ graph_data.push(
+ GraphData::default()
+ .name(format!("{display_name} R:{rate}").into())
+ .style(read_style)
+ .time(times)
+ .values(values),
+ );
+ }
+
+ if let Some(values) = write_values {
+ let rate = if is_active {
+ format_rate_fixed(values.last().copied().unwrap_or(0.0))
+ } else {
+ " N/A".to_string()
+ };
+ graph_data.push(
+ GraphData::default()
+ .name(format!("{display_name} W:{rate}").into())
+ .style(write_style)
+ .time(times)
+ .values(values),
+ );
+ }
+ }
+
+ let marker = self.get_marker(app_state.app_config_fields.use_dot);
+ let y_bounds = AxisBound::Max(adjusted_y_max);
+ let scaling = if use_log {
+ ChartScaling::Log2
+ } else {
+ ChartScaling::Linear
+ };
+ let y_labels: Vec> = y_labels.into_iter().map(Into::into).collect();
+
+ widget_state.graph.draw(
+ f,
+ draw_loc,
+ GraphDrawCtx {
+ title: " Disk I/O ".into(),
+ border_style,
+ title_style: self.styles.widget_title_style,
+ graph_style: self.styles.graph_style,
+ general_widget_style: self.styles.general_widget_style,
+ border_type: self.styles.border_type,
+ marker,
+ hide_x_labels,
+ is_selected: app_state.current_widget.widget_id == widget_id,
+ is_expanded: app_state.is_expanded,
+ legend_position: app_state.app_config_fields.disk_io_legend_position,
+ legend_constraints: Some(legend_constraints),
+ },
+ y_bounds,
+ &y_labels,
+ scaling,
+ graph_data,
+ );
+ }
+
+ if app_state.should_get_widget_bounds() {
+ if let Some(widget) = app_state.widget_map.get_mut(&widget_id) {
+ widget.top_left_corner = Some((draw_loc.x, draw_loc.y));
+ widget.bottom_right_corner =
+ Some((draw_loc.x + draw_loc.width, draw_loc.y + draw_loc.height));
+ }
+ }
+ }
+}
+
+/// Returns true if `data` has at least one real (non-gap) data point within the
+/// visible time window defined by `current_display_time` milliseconds from the end
+/// of `times`.
+fn has_data_in_window>(
+ data: &ChunkedData, times: &[Instant], current_display_time: u64,
+) -> bool {
+ let Some(&last_time) = times.last() else {
+ return false;
+ };
+ let display_duration = Duration::from_millis(current_display_time);
+ let oldest = last_time.checked_sub(display_duration).unwrap_or(last_time);
+ data.iter_along_base(times)
+ .next_back()
+ .is_some_and(|(t, _)| *t >= oldest)
+}
+
+/// Format a byte/s rate as a fixed-width string (always 11 chars, right-aligned)
+/// to keep legend labels a stable width and prevent legend box shifting between frames.
+fn format_rate_fixed(bytes_per_sec: f64) -> String {
+ let (val, unit) = if bytes_per_sec < KIBI_LIMIT_F64 {
+ (bytes_per_sec, "B/s")
+ } else if bytes_per_sec < MEBI_LIMIT_F64 {
+ (bytes_per_sec / KIBI_LIMIT_F64, "KiB/s")
+ } else if bytes_per_sec < GIBI_LIMIT_F64 {
+ (bytes_per_sec / MEBI_LIMIT_F64, "MiB/s")
+ } else if bytes_per_sec < TEBI_LIMIT_F64 {
+ (bytes_per_sec / GIBI_LIMIT_F64, "GiB/s")
+ } else {
+ (bytes_per_sec / TEBI_LIMIT_F64, "TiB/s")
+ };
+ format!("{:>11}", format!("{val:.1}{unit}"))
+}
+
+fn adjust_disk_io_linear(max_entry: f64) -> (f64, Vec) {
+ let max_entry_upper = if max_entry == 0.0 {
+ 1.0
+ } else {
+ max_entry * 1.5
+ };
+
+ let (max_value_scaled, unit) = if max_entry_upper < KIBI_LIMIT_F64 {
+ (max_entry, "B/s")
+ } else if max_entry_upper < MEBI_LIMIT_F64 {
+ (max_entry / KIBI_LIMIT_F64, "KiB/s")
+ } else if max_entry_upper < GIBI_LIMIT_F64 {
+ (max_entry / MEBI_LIMIT_F64, "MiB/s")
+ } else if max_entry_upper < TEBI_LIMIT_F64 {
+ (max_entry / GIBI_LIMIT_F64, "GiB/s")
+ } else {
+ (max_entry / TEBI_LIMIT_F64, "TiB/s")
+ };
+
+ let labels = vec![
+ format!("0{unit}"),
+ format!("{:.1}", max_value_scaled * 0.5),
+ format!("{:.1}", max_value_scaled),
+ format!("{:.1}", max_value_scaled * 1.5),
+ ]
+ .into_iter()
+ .map(|s| format!("{s:>8}"))
+ .collect();
+
+ (max_entry_upper, labels)
+}
+
+fn adjust_disk_io_log(max_entry: f64) -> (f64, Vec) {
+ use crate::utils::general::saturating_log2;
+
+ let log_max = saturating_log2(max_entry);
+
+ if log_max < LOG_MEBI_LIMIT {
+ (
+ LOG_MEBI_LIMIT,
+ vec![" 0B/s".into(), "1KiB/s".into(), "1MiB/s".into()],
+ )
+ } else if log_max < LOG_GIBI_LIMIT {
+ (
+ LOG_GIBI_LIMIT,
+ vec![
+ " 0B/s".into(),
+ "1KiB/s".into(),
+ "1MiB/s".into(),
+ "1GiB/s".into(),
+ ],
+ )
+ } else if log_max < LOG_TEBI_LIMIT {
+ (
+ LOG_TEBI_LIMIT,
+ vec![
+ " 0B/s".into(),
+ "1KiB/s".into(),
+ "1MiB/s".into(),
+ "1GiB/s".into(),
+ "1TiB/s".into(),
+ ],
+ )
+ } else {
+ (
+ LOG_PEBI_LIMIT,
+ vec![
+ " 0B/s".into(),
+ "1KiB/s".into(),
+ "1MiB/s".into(),
+ "1GiB/s".into(),
+ "1TiB/s".into(),
+ "1PiB/s".into(),
+ ],
+ )
+ }
+}
diff --git a/src/canvas/widgets/mod.rs b/src/canvas/widgets/mod.rs
index ef9a6156..37dfcd6e 100644
--- a/src/canvas/widgets/mod.rs
+++ b/src/canvas/widgets/mod.rs
@@ -2,6 +2,7 @@ use crate::{collection::network::NetworkHarvest, utils::data_units::convert_byte
pub mod cpu_basic;
pub mod cpu_graph;
+pub mod disk_io_graph;
pub mod disk_table;
pub mod mem_basic;
pub mod mem_graph;
diff --git a/src/collection.rs b/src/collection.rs
index 87119847..175a6789 100644
--- a/src/collection.rs
+++ b/src/collection.rs
@@ -627,7 +627,7 @@ impl DataCollector {
#[inline]
fn update_disks(&mut self) {
- if self.widgets_to_harvest.use_disk {
+ if self.widgets_to_harvest.use_disk || self.widgets_to_harvest.use_disk_io_graph {
self.data.disks = disks::get_disk_usage(self).ok();
self.data.io = disks::get_io_usage().ok();
}
@@ -669,6 +669,7 @@ mod tests {
mount_filter: None,
temp_filter: None,
temp_graph_filter: None,
+ disk_io_graph_filter: None,
net_filter: None,
});
diff --git a/src/components/time_series.rs b/src/components/time_series.rs
index 340f7196..c1f4d0e9 100644
--- a/src/components/time_series.rs
+++ b/src/components/time_series.rs
@@ -49,7 +49,7 @@ impl TimeseriesState {
}
/// Get the current display time.
- fn current_display_time(&self) -> u64 {
+ pub(crate) fn current_display_time(&self) -> u64 {
self.current_display_time
}
diff --git a/src/options.rs b/src/options.rs
index 2ddd76c2..dd33cb8d 100644
--- a/src/options.rs
+++ b/src/options.rs
@@ -360,6 +360,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
let mut temp_state_map: FxHashMap = FxHashMap::default();
let mut temp_graph_state_map: FxHashMap = FxHashMap::default();
let mut disk_state_map: FxHashMap = FxHashMap::default();
+ let mut disk_io_graph_state_map: FxHashMap = FxHashMap::default();
let mut battery_state_map: FxHashMap = FxHashMap::default();
let autohide_timer = if autohide_time {
@@ -402,6 +403,12 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
let network_legend_position = get_network_legend_position(args, config)?;
let memory_legend_position = get_memory_legend_position(args, config)?;
let temperature_legend_position = get_temperature_legend_position(config)?;
+ let disk_io_legend_position = get_disk_io_legend_position(config)?;
+ let disk_io_name_filter = match &config.disk_io_graph {
+ Some(cfg) => get_ignore_list(&cfg.name_filter)
+ .context("Update 'disk_io_graph.name_filter' in your config file")?,
+ None => None,
+ };
// TODO: Can probably just reuse the options struct.
let app_config_fields = AppConfigFields {
@@ -478,6 +485,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
.as_ref()
.and_then(|cfg| cfg.default_sort.to_owned()),
temperature_legend_position,
+ disk_io_legend_position,
};
let process_default_sort = match &args.process.process_default_sort {
@@ -614,6 +622,43 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
TempGraphWidgetState::new(ts_config, autohide_timer, upper_limit),
);
}
+ DiskIoGraph => {
+ let show_read = config
+ .disk_io_graph
+ .as_ref()
+ .and_then(|c| c.show_read)
+ .unwrap_or(true);
+
+ let show_write = config
+ .disk_io_graph
+ .as_ref()
+ .and_then(|c| c.show_write)
+ .unwrap_or(true);
+
+ let legend = config
+ .disk_io_graph
+ .as_ref()
+ .and_then(|c| c.legend.clone())
+ .unwrap_or_default();
+
+ let use_log = config
+ .disk_io_graph
+ .as_ref()
+ .and_then(|c| c.use_log)
+ .unwrap_or(false);
+
+ disk_io_graph_state_map.insert(
+ widget.widget_id,
+ DiskIoGraphWidgetState::new(
+ ts_config,
+ autohide_timer,
+ show_read,
+ show_write,
+ legend,
+ use_log,
+ ),
+ );
+ }
Battery => {
battery_state_map
.insert(widget.widget_id, BatteryWidgetState::default());
@@ -661,6 +706,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
use_disk: used_widget_set.contains(&Disk),
use_temp: used_widget_set.contains(&Temp),
use_temp_graph: used_widget_set.contains(&TempGraph),
+ use_disk_io_graph: used_widget_set.contains(&DiskIoGraph),
use_battery: used_widget_set.contains(&Battery),
};
@@ -701,6 +747,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
temp_state: TempState::init(temp_state_map),
temp_graph_state: TempGraphStates::init(temp_graph_state_map),
disk_state: DiskState::init(disk_state_map),
+ disk_io_graph_state: DiskIoGraphStates::init(disk_io_graph_state_map),
battery_state: AppBatteryState::init(battery_state_map),
basic_table_widget_state,
};
@@ -714,6 +761,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
mount_filter: disk_mount_filter,
temp_filter: temp_sensor_filter,
temp_graph_filter: temp_graph_sensor_filter,
+ disk_io_graph_filter: disk_io_name_filter,
net_filter: net_interface_filter,
};
let is_expanded = expanded && !use_basic_mode;
@@ -1284,6 +1332,18 @@ fn get_temperature_legend_position(config: &Config) -> OptionResult