feature: add temperature graph (#2048)

Add support for a temperature graph widget. Quick-and-dirty implementation for now, will clean this up with other widgets later (maybe after adding a disk graph widget).
This commit is contained in:
Clement Tsang
2026-05-03 03:19:49 -04:00
committed by GitHub
parent 735543f1bf
commit 797129156d
42 changed files with 1250 additions and 348 deletions
+1
View File
@@ -36,6 +36,7 @@ That said, these are more guidelines rather than hard rules, though the project
- [#1948](https://github.com/ClementTsang/bottom/pull/1948): Add support for both an `!=` operator and `!` negation prefixes in query searches.
- [#2045](https://github.com/ClementTsang/bottom/pull/2045): Add support for showing a decimal place for CPU usage
- [#2046](https://github.com/ClementTsang/bottom/pull/2046): Add a `show_table_scroll_bar` config option to show a scroll bar on table widgets.
- [#2048](https://github.com/ClementTsang/bottom/pull/2048): Add support for a temperature graph to show sensor temperature change over time.
### Changes
@@ -48,7 +48,7 @@ regex = true
# Whether to be case-sensitive. Defaults to false.
case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
whole_word = false
```
@@ -39,16 +39,17 @@ represents a _widget_. A widget is represented by having a `type` field set to a
The following `type` values are supported:
| | |
| -------------------------------- | ------------------------ |
| `"cpu"` | CPU chart and legend |
| `"mem", "memory"` | Memory chart |
| `"net", "network"` | Network chart and legend |
| `"proc", "process", "processes"` | Process table and search |
| `"temp", "temperature"` | Temperature table |
| `"disk"` | Disk table |
| `"empty"` | An empty space |
| `"batt", "battery"` | Battery statistics |
| | |
| ----------------------------------- | ------------------------ |
| `"cpu"` | CPU chart and legend |
| `"mem", "memory"` | Memory chart |
| `"net", "network"` | Network chart and legend |
| `"proc", "process", "processes"` | Process table and search |
| `"temp", "temperature"` | Temperature table |
| `"temp_graph", "temperature_graph"` | Temperature graph |
| `"disk"` | Disk table |
| `"empty"` | An empty space |
| `"batt", "battery"` | Battery statistics |
Each component of the layout accepts a `ratio` value. If this is not set, it defaults to 1.
@@ -29,6 +29,6 @@ regex = true
# Whether to be case-sensitive. Defaults to false.
case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
whole_word = false
```
@@ -103,6 +103,14 @@ These can be set under `[styles.cpu]`:
| `avg_entry_color` | The colour of the average CPU label and graph line | `avg_entry_color = "255, 0, 255"` |
| `cpu_core_colors` | Colour of each CPU threads' label and graph line. Read in order. | `cpu_core_colors = ["Red", "Blue", "Green"]` |
#### Temperature Graph
These can be set under `[styles.temp_graph]`:
| Config field | Details | Examples |
| ------------------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
| `temp_graph_color_styles` | Colour of each temperature sensor's graph line. Read in order. | `temp_graph_color_styles = ["Red", "Blue", "Green"]` |
#### Memory
These can be set under `[styles.memory]`:
@@ -0,0 +1,49 @@
# Temperature Graph
The temperature graph widget is configured under `[temperature_graph]`.
## 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
[temperature_graph]
legend_position = "top-right"
```
## Upper Limit
By default, the y-axis is bounded at 100°C (or the equivalent in the configured `temperature_type`) and grows
automatically if a reading exceeds that. An explicit upper bound can be set with `max_temp` (uses the same unit as
`temperature_type`). Sensor readings above this value will be drawn off the chart.
```toml
[temperature_graph]
max_temp = 90.0
```
## Filtering Entries
You can filter which sensors to plot by configuring `[temperature_graph.sensor_filter]`. This works the same way as
the temperature table's filter.
For example, here we are ignoring any sensor that has "cpu" or "wifi" in its name:
```toml
[temperature_graph.sensor_filter]
# Whether to ignore any matches. Defaults to true.
is_list_ignored = true
# A list of filters to try and match.
list = ["cpu", "wifi"]
# Whether to use regex. Defaults to false.
regex = false
# Whether to be case-sensitive. Defaults to false.
case_sensitive = false
# Whether to require matching the whole word. Defaults to false.
whole_word = false
```
@@ -1,5 +1,7 @@
# Temperature Table
The temperature table widget is configured under `[temperature]`.
## Default Sort Order
You can customize the default sort order (by default, it sorts by temperature sensor name). For example, to sort by temperature:
@@ -29,6 +31,6 @@ regex = false
# Whether to be case-sensitive. Defaults to false.
case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
whole_word = false
```
@@ -0,0 +1,28 @@
# Temperature Graph Widget
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 `temperature_type` flag).
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.
## 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 |
+5 -2
View File
@@ -3,7 +3,8 @@ site_name: bottom
site_author: Clement Tsang
site_url: https://bottom.pages.dev
site_description: >-
A customizable cross-platform graphical process/system monitor for the terminal. Supports Linux, macOS, and Windows.
A customizable cross-platform graphical process/system monitor for the
terminal. Supports Linux, macOS, and Windows.
docs_dir: "content/"
# Project information
repo_name: ClementTsang/bottom
@@ -165,7 +166,8 @@ nav:
- "Network Widget": usage/widgets/network.md
- "Process Widget": usage/widgets/process.md
- "Disk Widget": usage/widgets/disk.md
- "Temperature Widget": usage/widgets/temperature.md
- "Temperature Widget": usage/widgets/temperature-table.md
- "Temperature Graph Widget": usage/widgets/temperature-graph.md
- "Battery Widget": usage/widgets/battery.md
- "Auto-Complete": usage/autocomplete.md
- "Configuration":
@@ -177,6 +179,7 @@ nav:
- "Network Widget": configuration/config-file/network.md
- "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
- "Flags": configuration/config-file/flags.md
- "Layout": configuration/config-file/layout.md
- "Styling": configuration/config-file/styling.md
+36 -5
View File
@@ -182,7 +182,7 @@
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
# By default, there are no mount name filters enabled. An example use case is provided below.
@@ -199,7 +199,7 @@
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
@@ -226,7 +226,35 @@
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
# Temperature graph widget configuration
#[temperature_graph]
# Where to place the legend for the temperature graph widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right".
#legend_position = "top-right"
# An upper temperature value for the graph; entries higher than this will be hidden. If not set,
# there is no limit. Is in the unit of `temperature_type`.
#max_temp = 100.0
# By default, there are no temperature sensor filters enabled. An example use case is provided below.
#[temperature_graph.sensor_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["cpu", "wifi"]
# Whether to use regex. Defaults to false.
#regex = false
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
@@ -246,7 +274,7 @@
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
@@ -270,6 +298,9 @@
#avg_entry_color = "red"
#cpu_core_colors = ["light magenta", "light yellow", "light cyan", "light green", "light blue", "cyan", "green", "blue"]
#[styles.temp_graph]
#temp_graph_color_styles = ["light magenta", "light yellow", "light cyan", "light green", "light blue", "cyan", "green", "blue"]
#[styles.memory]
#ram_color = "light magenta"
#cache_color = "light red"
@@ -312,7 +343,7 @@
# [[row.child]] represents either a widget or a column.
# [[row.child.child]] represents a widget.
#
# All widgets must have the type value set to one of ["cpu", "mem", "proc", "net", "temp", "disk", "empty"].
# All widgets must have the type value set to one of ["cpu", "mem", "proc", "net", "temp", "temp_graph", "disk", "empty"].
# All layout components have a ratio value - if this is not set, then it defaults to 1.
# The default widget layout:
#[[row]]
+71
View File
@@ -83,6 +83,16 @@
"type": "null"
}
]
},
"temperature_graph": {
"anyOf": [
{
"$ref": "#/$defs/TempGraphConfig"
},
{
"type": "null"
}
]
}
},
"$defs": {
@@ -980,6 +990,17 @@
}
]
},
"temp_graph": {
"description": "Styling for the temperature graph widget.",
"anyOf": [
{
"$ref": "#/$defs/TempGraphStyle"
},
{
"type": "null"
}
]
},
"theme": {
"description": "A built-in theme.\n\nIf this is and a custom colour are both set, in the config file,\nthe custom colour scheme will be prioritized first. If a theme\nis set in the command-line args, however, it will always be\nprioritized first.",
"type": [
@@ -1053,6 +1074,56 @@
}
}
},
"TempGraphConfig": {
"description": "Temperature graph configuration.",
"type": "object",
"properties": {
"legend_position": {
"description": "The location of the graph's legend.",
"type": [
"string",
"null"
],
"default": null
},
"max_temp": {
"description": "An upper temperature value for the graph; entries higher than this will be hidden. If not set,\nthere is no limit.\n\nIs in the unit of `temperature_type`.",
"type": [
"number",
"null"
],
"format": "double",
"default": null
},
"sensor_filter": {
"description": "A filter over the sensor names.",
"anyOf": [
{
"$ref": "#/$defs/IgnoreList"
},
{
"type": "null"
}
]
}
}
},
"TempGraphStyle": {
"description": "Styling specific to the temperature graph widget.",
"type": "object",
"properties": {
"temp_graph_color_styles": {
"description": "Colour of each temperature sensor's graph line. Read in order.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/$defs/ColorStr"
}
}
}
},
"TempWidgetColumn": {
"type": "string",
"enum": [
+69 -2
View File
@@ -80,14 +80,16 @@ pub struct AppConfigFields {
pub default_tree_collapse: bool,
pub default_temp_sort_column: Option<TempWidgetColumn>,
pub default_disk_sort_column: Option<DiskWidgetColumn>,
pub temperature_legend_position: Option<LegendPosition>,
}
/// For filtering out information
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct DataFilters {
pub disk_filter: Option<Filter>,
pub mount_filter: Option<Filter>,
pub temp_filter: Option<Filter>,
pub temp_graph_filter: Option<Filter>,
pub net_filter: Option<Filter>,
}
@@ -117,10 +119,13 @@ impl App {
widget_map: HashMap<u64, BottomWidget>, current_widget: BottomWidget,
used_widgets: UsedWidgets, filters: DataFilters, is_expanded: bool,
) -> Self {
let mut data_store = DataStore::new(used_widgets);
data_store.set_filters(filters.clone());
Self {
awaiting_second_char: false,
second_char: None,
data_store: DataStore::default(),
data_store,
last_key_press: Instant::now(),
process_kill_dialog: ProcessKillDialog::default(),
help_dialog_state: AppHelpDialogState::default(),
@@ -2038,6 +2043,31 @@ impl App {
}
}
}
BottomWidgetType::TempGraph => {
if let Some(widget_state) = self
.states
.temp_graph_state
.get_mut_widget_state(self.current_widget.widget_id)
{
let new_time = widget_state
.current_display_time
.saturating_add(self.app_config_fields.time_interval);
if new_time <= self.app_config_fields.retention_ms {
widget_state.current_display_time = new_time;
if self.app_config_fields.autohide_time {
widget_state.autohide_timer = Some(Instant::now());
}
} else if widget_state.current_display_time
!= self.app_config_fields.retention_ms
{
widget_state.current_display_time = self.app_config_fields.retention_ms;
if self.app_config_fields.autohide_time {
widget_state.autohide_timer = Some(Instant::now());
}
}
}
}
_ => {}
}
}
@@ -2116,6 +2146,29 @@ impl App {
}
}
}
BottomWidgetType::TempGraph => {
if let Some(widget_state) = self
.states
.temp_graph_state
.get_mut_widget_state(self.current_widget.widget_id)
{
let new_time = widget_state
.current_display_time
.saturating_sub(self.app_config_fields.time_interval);
if new_time >= STALE_MIN_MILLISECONDS {
widget_state.current_display_time = new_time;
if self.app_config_fields.autohide_time {
widget_state.autohide_timer = Some(Instant::now());
}
} else if widget_state.current_display_time != STALE_MIN_MILLISECONDS {
widget_state.current_display_time = STALE_MIN_MILLISECONDS;
if self.app_config_fields.autohide_time {
widget_state.autohide_timer = Some(Instant::now());
}
}
}
}
_ => {}
}
}
@@ -2162,11 +2215,25 @@ impl App {
}
}
fn reset_temp_graph_zoom(&mut self) {
if let Some(widget_state) = self
.states
.temp_graph_state
.get_mut_widget_state(self.current_widget.widget_id)
{
widget_state.current_display_time = self.app_config_fields.default_time_value;
if self.app_config_fields.autohide_time {
widget_state.autohide_timer = Some(Instant::now());
}
}
}
fn reset_zoom(&mut self) {
match self.current_widget.widget_type {
BottomWidgetType::Cpu => self.reset_cpu_zoom(),
BottomWidgetType::Mem => self.reset_mem_zoom(),
BottomWidgetType::Net => self.reset_net_zoom(),
BottomWidgetType::TempGraph => self.reset_temp_graph_zoom(),
_ => {}
}
}
+39 -12
View File
@@ -7,8 +7,14 @@ use super::{ProcessData, TimeSeriesData};
#[cfg(feature = "battery")]
use crate::collection::batteries;
use crate::{
app::AppConfigFields,
collection::{Data, cpu, disks, memory::MemData, network},
app::{AppConfigFields, DataFilters, filter::Filter, layout_manager::UsedWidgets},
collection::{
Data,
cpu::{CpuHarvest, LoadAvgHarvest},
disks,
memory::MemData,
network::NetworkHarvest,
},
utils::data_units::DataUnit,
widgets::{DiskWidgetData, TempWidgetData},
};
@@ -22,7 +28,7 @@ pub struct StoredData {
// FIXME: (points_rework_v1) we could be able to remove this with some more refactoring.
pub last_update_time: Instant,
pub timeseries_data: TimeSeriesData,
pub network_harvest: network::NetworkHarvest,
pub network_harvest: NetworkHarvest,
pub ram_harvest: Option<MemData>,
pub swap_harvest: Option<MemData>,
#[cfg(not(target_os = "windows"))]
@@ -31,8 +37,8 @@ pub struct StoredData {
pub arc_harvest: Option<MemData>,
#[cfg(feature = "gpu")]
pub gpu_harvest: Vec<(String, MemData)>,
pub cpu_harvest: cpu::CpuHarvest,
pub load_avg_harvest: cpu::LoadAvgHarvest,
pub cpu_harvest: CpuHarvest,
pub load_avg_harvest: LoadAvgHarvest,
pub process_data: ProcessData,
/// TODO: (points_rework_v1) Might be a better way to do this without having
/// to store here?
@@ -48,13 +54,13 @@ impl Default for StoredData {
StoredData {
last_update_time: Instant::now(),
timeseries_data: TimeSeriesData::default(),
network_harvest: network::NetworkHarvest::default(),
network_harvest: NetworkHarvest::default(),
ram_harvest: None,
#[cfg(not(target_os = "windows"))]
cache_harvest: None,
swap_harvest: None,
cpu_harvest: cpu::CpuHarvest::default(),
load_avg_harvest: cpu::LoadAvgHarvest::default(),
cpu_harvest: CpuHarvest::default(),
load_avg_harvest: LoadAvgHarvest::default(),
process_data: Default::default(),
prev_io: Vec::default(),
disk_harvest: Vec::default(),
@@ -78,7 +84,10 @@ impl StoredData {
clippy::boxed_local,
reason = "This avoids warnings on certain platforms (e.g. 32-bit)."
)]
fn eat_data(&mut self, mut data: Box<Data>, settings: &AppConfigFields) {
fn eat_data(
&mut self, mut data: Box<Data>, settings: &AppConfigFields, used_widgets: &UsedWidgets,
filters: &DataFilters,
) {
let harvested_time = data.collection_time;
// We must adjust all the network values to their selected type (defaults to
@@ -91,7 +100,8 @@ impl StoredData {
}
if !settings.use_basic_mode {
self.timeseries_data.add(&data);
self.timeseries_data
.add(&data, used_widgets, settings, filters);
}
if let Some(network) = data.network {
@@ -129,6 +139,7 @@ impl StoredData {
.map(|sensors| {
sensors
.into_iter()
.filter(|temp| Filter::optional_should_keep(&filters.temp_filter, &temp.name))
.map(|temp| TempWidgetData {
sensor: temp.name,
temperature: temp
@@ -279,13 +290,24 @@ pub enum FrozenState {
}
/// What data to share to other parts of the application.
#[derive(Default)]
pub struct DataStore {
frozen_state: FrozenState,
main: StoredData,
used_widgets: UsedWidgets,
filters: DataFilters,
}
impl DataStore {
/// Create a new [`DataStore`]
pub fn new(used_widgets: UsedWidgets) -> Self {
Self {
frozen_state: FrozenState::default(),
main: StoredData::default(),
used_widgets,
filters: DataFilters::default(),
}
}
/// Toggle whether the [`DataState`] is frozen or not.
pub fn toggle_frozen(&mut self) {
match &self.frozen_state {
@@ -311,9 +333,14 @@ impl DataStore {
}
}
pub fn set_filters(&mut self, filters: DataFilters) {
self.filters = filters;
}
/// Eat data.
pub fn eat_data(&mut self, data: Box<Data>, settings: &AppConfigFields) {
self.main.eat_data(data, settings);
self.main
.eat_data(data, settings, &self.used_widgets, &self.filters);
}
/// Clean data.
+19
View File
@@ -26,6 +26,15 @@ impl FromStr for TemperatureType {
}
impl TemperatureType {
/// Return the unit string.
pub fn unit(&self) -> &'static str {
match self {
TemperatureType::Celsius => "°C",
TemperatureType::Kelvin => "K",
TemperatureType::Fahrenheit => "°F",
}
}
/// Given a temperature in Celsius, convert it if necessary for a different
/// unit.
pub fn convert_temp_unit(&self, celsius: f32) -> TypedTemperature {
@@ -37,6 +46,16 @@ impl TemperatureType {
}
}
}
/// Given a temperature in Celsius, convert it if necessary for a different
/// unit as a bare float.
pub fn convert_temp_unit_float(&self, celsius: f32) -> f32 {
match self {
TemperatureType::Celsius => celsius,
TemperatureType::Kelvin => celsius + 273.15,
TemperatureType::Fahrenheit => celsius * (9.0 / 5.0) + 32.0,
}
}
}
/// A temperature and its type.
+69 -3
View File
@@ -6,11 +6,13 @@ use std::{
vec::Vec,
};
#[cfg(feature = "gpu")]
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use timeless::data::ChunkedData;
use crate::collection::Data;
use crate::{
app::{AppConfigFields, DataFilters, filter::Filter, layout_manager::UsedWidgets},
collection::Data,
};
/// Values corresponding to a time slice.
pub type Values = ChunkedData<f64>;
@@ -58,11 +60,17 @@ pub struct TimeSeriesData {
#[cfg(feature = "gpu")]
/// GPU memory data.
pub gpu_mem: HashMap<String, Values>,
/// Temperature data.
pub temperature: HashMap<String, ChunkedData<f32>>,
}
impl TimeSeriesData {
/// Add a new data point.
pub fn add(&mut self, data: &Data) {
pub fn add(
&mut self, data: &Data, used_widgets: &UsedWidgets, settings: &AppConfigFields,
filters: &DataFilters,
) {
self.time.push(data.collection_time);
if let Some(network) = &data.network {
@@ -168,6 +176,52 @@ impl TimeSeriesData {
}
}
}
if used_widgets.use_temp_graph {
if let Some(temperature_sensors) = &data.temperature_sensors {
let mut not_visited = self
.temperature
.keys()
.map(String::to_owned)
.collect::<HashSet<_>>();
for sensor_data in temperature_sensors {
if !Filter::optional_should_keep(&filters.temp_graph_filter, &sensor_data.name)
{
continue;
}
if let Some(temperature) = sensor_data.temperature {
not_visited.remove(&sensor_data.name);
if !self.temperature.contains_key(&sensor_data.name) {
self.temperature
.insert(sensor_data.name.clone(), ChunkedData::default());
}
let curr = self
.temperature
.get_mut(&sensor_data.name)
.expect("entry must exist as it was created above");
let converted_temperature = settings
.temperature_type
.convert_temp_unit_float(temperature);
curr.push(converted_temperature);
}
}
for nv in not_visited {
if let Some(entry) = self.temperature.get_mut(&nv) {
entry.insert_break();
}
}
} else {
for g in self.temperature.values_mut() {
g.insert_break();
}
}
}
}
/// Prune any data older than the given duration.
@@ -229,5 +283,17 @@ impl TimeSeriesData {
}
});
}
self.temperature.retain(|_, data| {
let _ = data.prune(end);
// Remove the entry if it is empty. We can always add it again later.
if data.no_elements() {
false
} else {
data.shrink_to_fit();
true
}
});
}
}
+41 -33
View File
@@ -941,6 +941,7 @@ pub enum BottomWidgetType {
ProcSearch,
ProcSort,
Temp,
TempGraph,
Disk,
BasicCpu,
BasicMem,
@@ -957,7 +958,7 @@ impl BottomWidgetType {
pub fn is_widget_graph(&self) -> bool {
use BottomWidgetType::*;
matches!(self, Cpu | Net | Mem)
matches!(self, Cpu | Net | Mem | TempGraph)
}
pub fn get_pretty_name(&self) -> &str {
@@ -970,6 +971,7 @@ impl BottomWidgetType {
Temp => "Temperature",
Disk => "Disks",
Battery => "Battery",
TempGraph => "Temperature",
_ => "",
}
}
@@ -986,6 +988,7 @@ impl std::str::FromStr for BottomWidgetType {
"net" | "network" => Ok(BottomWidgetType::Net),
"proc" | "process" | "processes" => Ok(BottomWidgetType::Proc),
"temp" | "temperature" => Ok(BottomWidgetType::Temp),
"temp_graph" | "temperature_graph" => Ok(BottomWidgetType::TempGraph),
"disk" => Ok(BottomWidgetType::Disk),
"empty" => Ok(BottomWidgetType::Empty),
#[cfg(feature = "battery")]
@@ -997,23 +1000,25 @@ impl std::str::FromStr for BottomWidgetType {
"'{s}' is an invalid widget name.
Supported widget names:
+--------------------------+
| cpu |
+--------------------------+
| mem, memory |
+--------------------------+
| net, network |
+--------------------------+
| proc, process, processes |
+--------------------------+
| temp, temperature |
+--------------------------+
| disk |
+--------------------------+
| batt, battery |
+--------------------------+
| empty |
+--------------------------+
+--------------------------------+
| cpu |
+--------------------------------+
| mem, memory |
+--------------------------------+
| net, network |
+--------------------------------+
| proc, process, processes |
+--------------------------------+
| temp, temperature |
+--------------------------------+
| temp_graph, temperature_graph |
+--------------------------------+
| disk |
+--------------------------------+
| batt, battery |
+--------------------------------+
| empty |
+--------------------------------+
",
)))
}
@@ -1023,21 +1028,23 @@ Supported widget names:
"'{s}' is an invalid widget name.
Supported widget names:
+--------------------------+
| cpu |
+--------------------------+
| mem, memory |
+--------------------------+
| net, network |
+--------------------------+
| proc, process, processes |
+--------------------------+
| temp, temperature |
+--------------------------+
| disk |
+--------------------------+
| empty |
+--------------------------+
+--------------------------------+
| cpu |
+--------------------------------+
| mem, memory |
+--------------------------------+
| net, network |
+--------------------------------+
| proc, process, processes |
+--------------------------------+
| temp, temperature |
+--------------------------------+
| temp_graph, temperature_graph |
+--------------------------------+
| disk |
+--------------------------------+
| empty |
+--------------------------------+
",
)))
}
@@ -1056,5 +1063,6 @@ pub struct UsedWidgets {
pub use_proc: bool,
pub use_disk: bool,
pub use_temp: bool,
pub use_temp_graph: bool,
pub use_battery: bool,
}
+20 -1
View File
@@ -6,7 +6,7 @@ use crate::{
utils::input::InputFieldState,
widgets::{
BatteryWidgetState, CpuWidgetState, DiskTableWidget, MemWidgetState, NetWidgetState,
ProcWidgetState, TempWidgetState, query::ProcessQuery,
ProcWidgetState, TempGraphWidgetState, TempWidgetState, query::ProcessQuery,
},
};
@@ -16,6 +16,7 @@ pub struct AppWidgetStates {
pub net_state: NetState,
pub proc_state: ProcState,
pub temp_state: TempState,
pub temp_graph_state: TempGraphStates,
pub disk_state: DiskState,
pub battery_state: AppBatteryState,
pub basic_table_widget_state: Option<BasicTableWidgetState>,
@@ -148,6 +149,24 @@ impl TempState {
}
}
pub struct TempGraphStates {
pub widget_states: HashMap<u64, TempGraphWidgetState>,
}
impl TempGraphStates {
pub fn init(widget_states: HashMap<u64, TempGraphWidgetState>) -> Self {
TempGraphStates { widget_states }
}
pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut TempGraphWidgetState> {
self.widget_states.get_mut(&widget_id)
}
pub fn get_widget_state(&self, widget_id: u64) -> Option<&TempGraphWidgetState> {
self.widget_states.get(&widget_id)
}
}
pub struct DiskState {
pub widget_states: HashMap<u64, DiskTableWidget>,
}
+15
View File
@@ -266,6 +266,12 @@ impl Painter {
#[cfg(feature = "battery")]
self.draw_battery(f, app_state, rect[0], app_state.current_widget.widget_id)
}
TempGraph => self.draw_temperature_graph(
f,
app_state,
rect[0],
app_state.current_widget.widget_id,
),
_ => {}
}
} else if app_state.app_config_fields.use_basic_mode {
@@ -380,6 +386,12 @@ impl Painter {
#[cfg(feature = "battery")]
self.draw_battery(f, app_state, vertical_chunks[3], widget_id)
}
TempGraph => self.draw_temperature_graph(
f,
app_state,
vertical_chunks[3],
widget_id,
),
_ => {}
}
}
@@ -458,6 +470,9 @@ impl Painter {
#[cfg(feature = "battery")]
self.draw_battery(f, app_state, *draw_loc, widget.widget_id)
}
TempGraph => {
self.draw_temperature_graph(f, app_state, *draw_loc, widget.widget_id)
}
_ => {}
}
}
+10 -10
View File
@@ -1,6 +1,7 @@
use std::{borrow::Cow, time::Instant};
use concat_string::concat_string;
use timeless::data::ChunkedData;
use tui::{
Frame,
layout::{Constraint, Rect},
@@ -10,29 +11,26 @@ use tui::{
widgets::{BorderType, GraphType},
};
use crate::{
app::data::Values,
canvas::{components::time_graph::*, drawing_utils::widget_block},
};
use crate::canvas::{components::time_graph::*, drawing_utils::widget_block};
/// Represents the data required by the [`TimeGraph`].
///
/// TODO: We may be able to get rid of this intermediary data structure.
#[derive(Default)]
pub(crate) struct GraphData<'a> {
pub(crate) struct GraphData<'a, F = f64> {
time: &'a [Instant],
values: Option<&'a Values>,
values: Option<&'a ChunkedData<F>>,
style: Style,
name: Option<Cow<'a, str>>,
}
impl<'a> GraphData<'a> {
impl<'a, F> GraphData<'a, F> {
pub fn time(mut self, time: &'a [Instant]) -> Self {
self.time = time;
self
}
pub fn values(mut self, values: &'a Values) -> Self {
pub fn values(mut self, values: &'a ChunkedData<F>) -> Self {
self.values = Some(values);
self
}
@@ -154,7 +152,9 @@ impl TimeGraph<'_> {
/// graph.
/// - Expects `graph_data`, which represents *what* data to draw, and
/// various details like style and optional legends.
pub fn draw(&self, f: &mut Frame<'_>, draw_loc: Rect, graph_data: Vec<GraphData<'_>>) {
pub fn draw<F: Copy + Default + Into<f64>>(
&self, f: &mut Frame<'_>, draw_loc: Rect, graph_data: Vec<GraphData<'_, F>>,
) {
// TODO: (points_rework_v1) can we reduce allocations in the underlying graph by
// saving some sort of state?
@@ -202,7 +202,7 @@ impl TimeGraph<'_> {
}
/// Creates a new [`Dataset`].
fn create_dataset(data: GraphData<'_>) -> Dataset<'_> {
fn create_dataset<F: Copy + Default + Into<f64>>(data: GraphData<'_, F>) -> Dataset<'_, F> {
let GraphData {
time,
values,
+52 -46
View File
@@ -15,6 +15,7 @@ use std::{
};
use canvas::*;
use timeless::data::ChunkedData;
use tui::{
buffer::Buffer,
layout::{Alignment, Constraint, Flex, Layout, Rect},
@@ -26,7 +27,6 @@ use tui::{
use unicode_width::UnicodeWidthStr;
use crate::{
app::data::Values,
canvas::components::time_graph::LegendConstraints,
utils::general::{saturating_log2, saturating_log10},
};
@@ -268,10 +268,10 @@ impl FromStr for LegendPosition {
}
#[derive(Debug, Default, Clone)]
enum Data<'a> {
enum Data<'a, F> {
Some {
times: &'a [Instant],
values: &'a Values,
values: &'a ChunkedData<F>,
},
#[default]
None,
@@ -284,11 +284,11 @@ enum Data<'a> {
/// A dataset can be [named](Dataset::name). Only named datasets will be
/// rendered in the legend.
#[derive(Debug, Default, Clone)]
pub struct Dataset<'a> {
pub struct Dataset<'a, F: Copy + Default + Into<f64> = f64> {
/// Name of the dataset (used in the legend if shown)
name: Option<Line<'a>>,
/// A reference to data.
data: Data<'a>,
data: Data<'a, F>,
/// Symbol used for each points of this dataset
marker: symbols::Marker,
/// Determines graph type used for drawing points
@@ -297,10 +297,10 @@ pub struct Dataset<'a> {
style: Style,
}
impl<'a> Dataset<'a> {
impl<'a, F: Copy + Default + Into<f64>> Dataset<'a, F> {
/// Sets the name of the dataset.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn name<S>(mut self, name: S) -> Dataset<'a>
pub fn name<S>(mut self, name: S) -> Dataset<'a, F>
where
S: Into<Line<'a>>,
{
@@ -317,7 +317,7 @@ impl<'a> Dataset<'a> {
/// element being X and the second Y. It's also worth noting that,
/// unlike the [`Rect`], here the Y axis is bottom to top, as in math.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn data(mut self, times: &'a [Instant], values: &'a Values) -> Dataset<'a> {
pub fn data(mut self, times: &'a [Instant], values: &'a ChunkedData<F>) -> Dataset<'a, F> {
self.data = Data::Some { times, values };
self
}
@@ -332,7 +332,7 @@ impl<'a> Dataset<'a> {
/// Patterns.
#[must_use = "method moves the value of self and returns the modified value"]
#[expect(dead_code)]
pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a> {
pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a, F> {
self.marker = marker;
self
}
@@ -344,7 +344,7 @@ impl<'a> Dataset<'a> {
/// in the dataset while a line will also draw a line between them. See
/// [`GraphType`] for more details
#[must_use = "method moves the value of self and returns the modified value"]
pub fn graph_type(mut self, graph_type: GraphType) -> Dataset<'a> {
pub fn graph_type(mut self, graph_type: GraphType) -> Dataset<'a, F> {
self.graph_type = graph_type;
self
}
@@ -359,7 +359,7 @@ impl<'a> Dataset<'a> {
/// [`Style`], [`Color`], or your own type that implements
/// [`Into<Style>`]).
#[must_use = "method moves the value of self and returns the modified value"]
pub fn style<S: Into<Style>>(mut self, style: S) -> Dataset<'a> {
pub fn style<S: Into<Style>>(mut self, style: S) -> Dataset<'a, F> {
self.style = style.into();
self
}
@@ -419,7 +419,7 @@ impl ChartScaling {
/// - Automatically trimming out redundant draws in the x-bounds.
/// - Automatic interpolation to points that fall *just* outside of the screen.
#[derive(Debug, Default, Clone)]
pub(super) struct TimeChart<'a> {
pub(super) struct TimeChart<'a, F: Copy + Default + Into<f64> = f64> {
/// A block to display around the widget eventually
block: Option<Block<'a>>,
/// The horizontal axis
@@ -427,12 +427,12 @@ pub(super) struct TimeChart<'a> {
/// The vertical axis
y_axis: Axis<'a>,
/// A reference to the datasets
datasets: Vec<Dataset<'a>>,
datasets: Vec<Dataset<'a, F>>,
/// The widget base style
style: Style,
/// The legend's style.
legend_style: Style,
/// Constraints used to determine whether the legend should be shown or not
/// Constraints used to determine whether the legend should be shown or not.
hidden_legend_constraints: (Constraint, Constraint),
/// The position determining whether the length is shown or hidden,
/// regardless of `hidden_legend_constraints`
@@ -443,9 +443,9 @@ pub(super) struct TimeChart<'a> {
scaling: ChartScaling,
}
impl<'a> TimeChart<'a> {
impl<'a, F: Copy + Default + Into<f64>> TimeChart<'a, F> {
/// Creates a chart with the given [datasets](Dataset).
pub fn new(datasets: Vec<Dataset<'a>>) -> TimeChart<'a> {
pub fn new(datasets: Vec<Dataset<'a, F>>) -> TimeChart<'a, F> {
TimeChart {
block: None,
x_axis: Axis::default(),
@@ -462,7 +462,7 @@ impl<'a> TimeChart<'a> {
/// Wraps the chart with the given [`Block`]
#[must_use = "method moves the value of self and returns the modified value"]
pub fn block(mut self, block: Block<'a>) -> TimeChart<'a> {
pub fn block(mut self, block: Block<'a>) -> TimeChart<'a, F> {
self.block = Some(block);
self
}
@@ -475,14 +475,14 @@ impl<'a> TimeChart<'a> {
///
/// Styles of [`Axis`] and [`Dataset`] will have priority over this style.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn style<S: Into<Style>>(mut self, style: S) -> TimeChart<'a> {
pub fn style<S: Into<Style>>(mut self, style: S) -> TimeChart<'a, F> {
self.style = style.into();
self
}
/// Sets the legend's style.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn legend_style(mut self, legend_style: Style) -> TimeChart<'a> {
pub fn legend_style(mut self, legend_style: Style) -> TimeChart<'a, F> {
self.legend_style = legend_style;
self
}
@@ -491,7 +491,7 @@ impl<'a> TimeChart<'a> {
///
/// The default is an empty [`Axis`], i.e. only a line.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn x_axis(mut self, axis: Axis<'a>) -> TimeChart<'a> {
pub fn x_axis(mut self, axis: Axis<'a>) -> TimeChart<'a, F> {
self.x_axis = axis;
self
}
@@ -500,14 +500,14 @@ impl<'a> TimeChart<'a> {
///
/// The default is an empty [`Axis`], i.e. only a line.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn y_axis(mut self, axis: Axis<'a>) -> TimeChart<'a> {
pub fn y_axis(mut self, axis: Axis<'a>) -> TimeChart<'a, F> {
self.y_axis = axis;
self
}
/// Sets the marker type.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn marker(mut self, marker: Marker) -> TimeChart<'a> {
pub fn marker(mut self, marker: Marker) -> TimeChart<'a, F> {
self.marker = marker;
self
}
@@ -525,7 +525,7 @@ impl<'a> TimeChart<'a> {
#[must_use = "method moves the value of self and returns the modified value"]
pub fn hidden_legend_constraints(
mut self, constraints: (Constraint, Constraint),
) -> TimeChart<'a> {
) -> TimeChart<'a, F> {
self.hidden_legend_constraints = constraints;
self
}
@@ -543,14 +543,14 @@ impl<'a> TimeChart<'a> {
///
/// [`hidden_legend_constraints`]: Self::hidden_legend_constraints
#[must_use = "method moves the value of self and returns the modified value"]
pub fn legend_position(mut self, position: Option<LegendPosition>) -> TimeChart<'a> {
pub fn legend_position(mut self, position: Option<LegendPosition>) -> TimeChart<'a, F> {
self.legend_position = position;
self
}
/// Set chart scaling.
#[must_use = "method moves the value of self and returns the modified value"]
pub fn scaling(mut self, scaling: ChartScaling) -> TimeChart<'a> {
pub fn scaling(mut self, scaling: ChartScaling) -> TimeChart<'a, F> {
self.scaling = scaling;
self
}
@@ -793,7 +793,7 @@ impl<'a> TimeChart<'a> {
}
}
impl Widget for TimeChart<'_> {
impl<F: Copy + Default + Into<f64>> Widget for TimeChart<'_, F> {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
@@ -958,8 +958,8 @@ impl<'a> Styled for Axis<'a> {
}
}
impl<'a> Styled for Dataset<'a> {
type Item = Dataset<'a>;
impl<'a, F: Copy + Default + Into<f64>> Styled for Dataset<'a, F> {
type Item = Dataset<'a, F>;
fn style(&self) -> Style {
self.style
@@ -970,8 +970,8 @@ impl<'a> Styled for Dataset<'a> {
}
}
impl<'a> Styled for TimeChart<'a> {
type Item = TimeChart<'a>;
impl<'a, F: Copy + Default + Into<f64>> Styled for TimeChart<'a, F> {
type Item = TimeChart<'a, F>;
fn style(&self) -> Style {
self.style
@@ -1040,6 +1040,7 @@ mod tests {
use tui::style::{Modifier, Stylize};
use super::*;
use crate::app::data::Values;
struct LegendTestCase {
chart_area: Rect,
@@ -1103,7 +1104,12 @@ mod tests {
#[test]
fn dataset_can_be_stylized() {
assert_eq!(
Dataset::default().black().on_white().bold().not_dim().style,
Dataset::<f64>::default()
.black()
.on_white()
.bold()
.not_dim()
.style,
Style::default()
.fg(Color::Black)
.bg(Color::White)
@@ -1115,7 +1121,7 @@ mod tests {
#[test]
fn chart_can_be_stylized() {
assert_eq!(
TimeChart::new(vec![])
TimeChart::<f64>::new(vec![])
.black()
.on_white()
.bold()
@@ -1144,7 +1150,7 @@ mod tests {
#[test]
fn it_does_not_panic_if_title_is_wider_than_buffer() {
let widget = TimeChart::default()
let widget = TimeChart::<f64>::default()
.y_axis(Axis::default().title("xxxxxxxxxxxxxxxx"))
.x_axis(Axis::default().title("xxxxxxxxxxxxxxxx"));
let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 4));
@@ -1158,7 +1164,7 @@ mod tests {
let data_named_1 = Dataset::default().name("data1"); // must occupy a row in legend
let data_named_2 = Dataset::default().name(""); // must occupy a row in legend, even if name is empty
let data_unnamed = Dataset::default(); // must not occupy a row in legend
let widget = TimeChart::new(vec![data_named_1, data_unnamed, data_named_2]);
let widget = TimeChart::<f64>::new(vec![data_named_1, data_unnamed, data_named_2]);
let buffer = Buffer::empty(Rect::new(0, 0, 50, 25));
let layout = widget.layout(buffer.area);
@@ -1169,7 +1175,7 @@ mod tests {
#[test]
fn no_legend_if_no_named_datasets() {
let dataset = Dataset::default();
let dataset = Dataset::<f64>::default();
let widget = TimeChart::new(vec![dataset; 3]);
let buffer = Buffer::empty(Rect::new(0, 0, 50, 25));
let layout = widget.layout(buffer.area);
@@ -1179,7 +1185,7 @@ mod tests {
#[test]
fn dataset_legend_style_is_patched() {
let long_dataset_name = Dataset::default().name("Very long name");
let long_dataset_name = Dataset::<f64>::default().name("Very long name");
let short_dataset =
Dataset::default().name(Line::from("Short name").alignment(Alignment::Right));
let widget = TimeChart::new(vec![long_dataset_name, short_dataset])
@@ -1200,7 +1206,7 @@ mod tests {
#[test]
fn test_chart_have_a_topleft_legend() {
let chart = TimeChart::new(vec![Dataset::default().name("Ds1")])
let chart = TimeChart::<f64>::new(vec![Dataset::default().name("Ds1")])
.legend_position(Some(LegendPosition::TopLeft));
let area = Rect::new(0, 0, 30, 20);
@@ -1236,7 +1242,7 @@ mod tests {
#[test]
fn test_chart_have_a_long_y_axis_title_overlapping_legend() {
let chart = TimeChart::new(vec![Dataset::default().name("Ds1")])
let chart = TimeChart::<f64>::new(vec![Dataset::default().name("Ds1")])
.y_axis(Axis::default().title("The title overlap a legend."));
let area = Rect::new(0, 0, 30, 20);
@@ -1272,7 +1278,7 @@ mod tests {
#[test]
fn test_chart_have_overflowed_y_axis() {
let chart = TimeChart::new(vec![Dataset::default().name("Ds1")])
let chart = TimeChart::<f64>::new(vec![Dataset::default().name("Ds1")])
.y_axis(Axis::default().title("The title overlap a legend."));
let area = Rect::new(0, 0, 10, 10);
@@ -1299,7 +1305,7 @@ mod tests {
#[test]
fn test_legend_area_can_fit_same_chart_area() {
let name = "Data";
let chart = TimeChart::new(vec![Dataset::default().name(name)])
let chart = TimeChart::<f64>::new(vec![Dataset::default().name(name)])
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
let area = Rect::new(0, 0, name.len() as u16 + 2, 3);
@@ -1329,7 +1335,7 @@ mod tests {
#[test]
fn test_legend_of_chart_have_odd_margin_size() {
let name = "Data";
let base_chart = TimeChart::new(vec![Dataset::default().name(name)])
let base_chart = TimeChart::<f64>::new(vec![Dataset::default().name(name)])
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
let area = Rect::new(0, 0, name.len() as u16 + 2 + 3, 3 + 3);
@@ -1496,7 +1502,7 @@ mod tests {
let datasets: Vec<_> = (0..5)
.map(|i| Dataset::default().name(format!("D{i}")))
.collect();
let chart = TimeChart::new(datasets)
let chart = TimeChart::<f64>::new(datasets)
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
// Height 5 means room for 3 entries (5 - 2 borders).
@@ -1514,7 +1520,7 @@ mod tests {
let datasets: Vec<_> = (0..5)
.map(|i| Dataset::default().name(format!("D{i}")))
.collect();
let chart = TimeChart::new(datasets)
let chart = TimeChart::<f64>::new(datasets)
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
let area = Rect::new(0, 0, 20, 4); // 4 - 2 = 2 entries
@@ -1536,7 +1542,7 @@ mod tests {
Dataset::default().name("Short"),
Dataset::default().name("A very long dataset name"),
];
let chart = TimeChart::new(datasets)
let chart = TimeChart::<f64>::new(datasets)
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
// Width 12 means legend_width capped to 12, inner width = 10.
@@ -1556,7 +1562,7 @@ mod tests {
Dataset::default().name("AB"),
Dataset::default().name("Very long name here"),
];
let chart = TimeChart::new(datasets)
let chart = TimeChart::<f64>::new(datasets)
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
// Width 8, legend box capped to 8, inner is 6, long name truncated.
@@ -1584,7 +1590,7 @@ mod tests {
Dataset::default().name("CD"),
Dataset::default().name("A very long dataset name"),
];
let chart = TimeChart::new(datasets)
let chart = TimeChart::<f64>::new(datasets)
.hidden_legend_constraints((Constraint::Percentage(100), Constraint::Percentage(100)));
let area = Rect::new(0, 0, 30, 4); // height 4 → 2 visible entries
@@ -9,7 +9,7 @@ use tui::{
use super::{Context, Data, Point, TimeChart};
impl TimeChart<'_> {
impl<F: Copy + Default + Into<f64>> TimeChart<'_, F> {
pub(crate) fn draw_points(&self, ctx: &mut Context<'_>) {
// Idea is to:
// - Go over all datasets, determine *where* a point will be drawn.
@@ -52,7 +52,7 @@ impl TimeChart<'_> {
// XXX: Should this be generic over dataset.graph_type instead? That would allow
// us to move transformations behind a type - however, that
// also means that there's some complexity added.
(from_start, self.scaling.scale(val))
(from_start, self.scaling.scale(val.into()))
})
.tuple_windows()
{
+1
View File
@@ -8,6 +8,7 @@ pub mod mem_graph;
pub mod network_basic;
pub mod network_graph;
pub mod process_table;
pub mod temperature_graph;
pub mod temperature_table;
#[cfg(feature = "battery")]
+7 -89
View File
@@ -1,5 +1,3 @@
use std::time::Duration;
use tui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
@@ -22,7 +20,6 @@ use crate::{
data_units::*,
general::{saturating_log2, saturating_log10},
},
widgets::{NetWidgetHeightCache, NetWidgetState},
};
impl Painter {
@@ -81,68 +78,13 @@ impl Painter {
let y_max = {
if let Some(last_time) = times.last() {
let cached_network_height =
check_network_height_cache(network_widget_state, last_time);
let (mut biggest, mut biggest_time, oldest_to_check) = cached_network_height
.unwrap_or_else(|| {
let visible_duration =
Duration::from_millis(network_widget_state.current_display_time);
let visible_left_bound = match last_time.checked_sub(visible_duration) {
Some(v) => v,
None => {
// On some systems (like Windows) it can be possible that the
// current display time
// causes subtraction to fail if, for example, the uptime of the
// system is too low and current_display_time is too high. See https://github.com/ClementTsang/bottom/issues/1825.
//
// As such, we instead take the oldest visible time. This is a
// bit inefficient, but
// since it should only happen rarely, it should be fine.
times
.iter()
.take_while(|t| {
last_time.duration_since(**t) < visible_duration
})
.last()
.cloned()
.unwrap_or(*last_time)
}
};
(0.0, visible_left_bound, visible_left_bound)
});
for (&time, &v) in rx_points
.iter_along_base(times)
.rev()
.take_while(|&(&time, _)| time >= oldest_to_check)
{
if v > biggest {
biggest = v;
biggest_time = time;
}
}
for (&time, &v) in tx_points
.iter_along_base(times)
.rev()
.take_while(|&(&time, _)| time >= oldest_to_check)
{
if v > biggest {
biggest = v;
biggest_time = time;
}
}
network_widget_state.height_cache = Some(NetWidgetHeightCache {
best_point: (biggest_time, biggest),
right_edge: *last_time,
period: network_widget_state.current_display_time,
});
biggest
let cache = &mut network_widget_state.height_cache;
cache.get_or_update(
last_time,
network_widget_state.current_display_time,
[rx_points, tx_points].into_iter(),
times,
)
} else {
0.0
}
@@ -389,30 +331,6 @@ impl Painter {
}
}
/// Returns a cached max value, it's time, and what period it covers if it is
/// cached.
#[inline]
fn check_network_height_cache(
network_widget_state: &NetWidgetState, last_time: &std::time::Instant,
) -> Option<(f64, std::time::Instant, std::time::Instant)> {
let visible_duration = Duration::from_millis(network_widget_state.current_display_time);
if let Some(NetWidgetHeightCache {
best_point,
right_edge,
period,
}) = &network_widget_state.height_cache
{
if *period == network_widget_state.current_display_time
&& last_time.duration_since(best_point.0) < visible_duration
{
return Some((best_point.1, best_point.0, *right_edge));
}
}
None
}
/// Returns the required labels.
///
/// TODO: This is _really_ ugly... also there might be a bug with certain
+204
View File
@@ -0,0 +1,204 @@
use tui::{
Frame,
layout::{Constraint, Rect},
symbols::Marker,
};
use crate::{
app::{App, AppConfigFields},
canvas::{
Painter,
components::time_graph::{
AxisBound, ChartScaling, GraphData, LegendConstraints, TimeGraph,
},
drawing_utils::should_hide_x_label,
},
};
impl Painter {
pub fn draw_temperature_graph(
&self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
) {
if let Some(widget_state) = app_state
.states
.temp_graph_state
.get_mut_widget_state(widget_id)
{
let shared_data = app_state.data_store.get_data();
let points = &(shared_data.timeseries_data.temperature);
let times = &(shared_data.timeseries_data.time);
let time_start = -(widget_state.current_display_time as f64);
let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id);
let hide_x_labels = should_hide_x_label(
app_state.app_config_fields.hide_time,
app_state.app_config_fields.autohide_time,
&mut widget_state.autohide_timer,
draw_loc,
);
let y_max = {
if let Some(last_time) = times.last() {
let cache = &mut widget_state.height_cache;
cache.get_or_update(
last_time,
widget_state.current_display_time,
points.values(),
times,
)
} else {
0.0
}
};
let (adjusted_y_max, y_labels) =
adjust_temp_data_point(y_max, widget_state.max_temp, &app_state.app_config_fields);
let y_bounds = AxisBound::Max(adjusted_y_max);
// Hide the legend if the width is 90% of the total widget width
// or the height is greater than 50% of the total widget height.
let legend_constraints = LegendConstraints {
width: Constraint::Ratio(9, 10),
height: Constraint::Ratio(1, 2),
};
let unit = app_state.app_config_fields.temperature_type.unit();
let graph_data: Vec<GraphData<'_, f32>> = points
.iter()
.enumerate()
.map(|(itx, (source, values))| {
// TODO: Maybe align the value later.
let name = match values.last() {
Some(latest) => format!("{source}: {latest:.0}{unit}").into(),
None => source.as_str().into(),
};
GraphData::default()
.name(name)
.style(
self.styles.temp_graph_colour_styles
[itx % self.styles.temp_graph_colour_styles.len()],
)
.time(times)
.values(values)
})
.collect();
let marker = if app_state.app_config_fields.use_dot {
Marker::Dot
} else {
Marker::Braille
};
TimeGraph {
x_min: time_start,
hide_x_labels,
y_bounds,
y_labels: &(y_labels.into_iter().map(Into::into).collect::<Vec<_>>()),
graph_style: self.styles.graph_style,
general_widget_style: self.styles.general_widget_style,
border_style,
border_type: self.styles.border_type,
title: " Temperature ".into(),
is_selected: app_state.current_widget.widget_id == widget_id,
is_expanded: app_state.is_expanded,
title_style: self.styles.widget_title_style,
legend_position: app_state.app_config_fields.temperature_legend_position,
legend_constraints: Some(legend_constraints),
marker,
scaling: ChartScaling::Linear,
}
.draw(f, draw_loc, graph_data);
}
// Update draw loc in widget map.
if app_state.should_get_widget_bounds() {
if let Some(temperature_graph_widget) = app_state.widget_map.get_mut(&widget_id) {
temperature_graph_widget.top_left_corner = Some((draw_loc.x, draw_loc.y));
temperature_graph_widget.bottom_right_corner =
Some((draw_loc.x + draw_loc.width, draw_loc.y + draw_loc.height));
}
}
}
}
/// Returns the required labels.
fn adjust_temp_data_point(
max_entry: f64, upper_limit: Option<f32>, config: &AppConfigFields,
) -> (f64, [String; 3]) {
let default_upper: f64 = config
.temperature_type
.convert_temp_unit_float(100.0)
.into();
let unit = config.temperature_type.unit();
let max_entry = if let Some(limit) = upper_limit {
limit as f64
} else if max_entry < default_upper {
default_upper
} else {
max_entry
};
let halfway_label = (max_entry / 2.0).ceil() as u32;
let max_entry_label = max_entry.ceil() as u32;
let labels = [
format!("0{unit}"),
format!("{halfway_label}{unit}"),
format!("{max_entry_label}{unit}"),
];
(max_entry, labels)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::data::TemperatureType;
fn config(temperature_type: TemperatureType) -> AppConfigFields {
AppConfigFields {
temperature_type,
..Default::default()
}
}
#[test]
fn floors_to_default_upper_when_below() {
let cfg = config(TemperatureType::Celsius);
let (max, labels) = adjust_temp_data_point(40.0, None, &cfg);
assert_eq!(max, 100.0);
assert_eq!(labels, ["0°C", "50°C", "100°C"]);
}
#[test]
fn keeps_actual_max_when_above_default() {
let cfg = config(TemperatureType::Celsius);
let (max, labels) = adjust_temp_data_point(120.0, None, &cfg);
assert_eq!(max, 120.0);
assert_eq!(labels, ["0°C", "60°C", "120°C"]);
}
#[test]
fn upper_limit_overrides_actual_max() {
let cfg = config(TemperatureType::Celsius);
let (max, labels) = adjust_temp_data_point(150.0, Some(80.0), &cfg);
assert_eq!(max, 80.0);
assert_eq!(labels, ["0°C", "40°C", "80°C"]);
}
#[test]
fn fahrenheit_default_is_212() {
let cfg = config(TemperatureType::Fahrenheit);
let (max, labels) = adjust_temp_data_point(100.0, None, &cfg);
assert_eq!(max, 212.0);
assert_eq!(labels, ["0°F", "106°F", "212°F"]);
}
#[test]
fn kelvin_default_is_373() {
let cfg = config(TemperatureType::Kelvin);
let (max, labels) = adjust_temp_data_point(100.0, None, &cfg);
assert!((max - 373.15).abs() < 1e-3);
assert_eq!(labels, ["0K", "187K", "374K"]);
}
}
+17 -9
View File
@@ -328,7 +328,7 @@ impl DataCollector {
}
}
if self.widgets_to_harvest.use_temp {
if self.widgets_to_harvest.use_temp || self.widgets_to_harvest.use_temp_graph {
if self.should_run_less_routine_tasks {
self.sys.temps.refresh(true);
}
@@ -393,9 +393,11 @@ impl DataCollector {
let mut local_gpu_total_mem: u64 = 0;
#[cfg(feature = "nvidia")]
if let Some(data) =
nvidia::get_nvidia_vecs(&self.filters.temp_filter, &self.widgets_to_harvest)
{
if let Some(data) = nvidia::get_nvidia_vecs(
&self.filters.temp_filter,
&self.filters.temp_graph_filter,
&self.widgets_to_harvest,
) {
if let Some(mut temp) = data.temperature {
if let Some(sensors) = &mut self.data.temperature_sensors {
sensors.append(&mut temp);
@@ -458,16 +460,21 @@ impl DataCollector {
#[inline]
fn update_temps(&mut self) {
if self.widgets_to_harvest.use_temp {
if self.widgets_to_harvest.use_temp || self.widgets_to_harvest.use_temp_graph {
#[cfg(not(target_os = "linux"))]
if let Ok(data) =
temperature::get_temperature_data(&self.sys.temps, &self.filters.temp_filter)
{
if let Ok(data) = temperature::get_temperature_data(
&self.sys.temps,
&self.filters.temp_filter,
&self.filters.temp_graph_filter,
) {
self.data.temperature_sensors = data;
}
#[cfg(target_os = "linux")]
if let Ok(data) = temperature::get_temperature_data(&self.filters.temp_filter) {
if let Ok(data) = temperature::get_temperature_data(
&self.filters.temp_filter,
&self.filters.temp_graph_filter,
) {
self.data.temperature_sensors = data;
}
}
@@ -642,6 +649,7 @@ mod tests {
disk_filter: None,
mount_filter: None,
temp_filter: None,
temp_graph_filter: None,
net_filter: None,
});
+4 -3
View File
@@ -45,7 +45,7 @@ fn init_nvml() -> Result<Nvml, NvmlError> {
/// Returns the GPU data from NVIDIA cards.
#[inline]
pub fn get_nvidia_vecs(
filter: &Option<Filter>, widgets_to_harvest: &UsedWidgets,
filter: &Option<Filter>, graph_filter: &Option<Filter>, widgets_to_harvest: &UsedWidgets,
) -> Option<GpusData> {
if let Ok(nvml) = NVML_DATA.get_or_init(init_nvml) {
if let Ok(num_gpu) = nvml.device_count() {
@@ -71,8 +71,9 @@ pub fn get_nvidia_vecs(
}
}
if widgets_to_harvest.use_temp
&& Filter::optional_should_keep(filter, &name)
if (widgets_to_harvest.use_temp || widgets_to_harvest.use_temp_graph)
&& (Filter::optional_should_keep(filter, &name)
|| Filter::optional_should_keep(graph_filter, &name))
{
if let Ok(temperature) = device.temperature(TemperatureSensor::Gpu) {
temp_vec.push(TempSensorData {
+15 -7
View File
@@ -200,7 +200,7 @@ fn finalize_name(
/// the device is already in ACPI D0. This has the notable issue that
/// once this happens, the device will be *kept* on through the sensor
/// reading, and not be able to re-enter ACPI D3cold.
fn hwmon_temperatures(filter: &Option<Filter>) -> HwmonResults {
fn hwmon_temperatures(filter: &Option<Filter>, graph_filter: &Option<Filter>) -> HwmonResults {
let mut temperatures: Vec<TempSensorData> = vec![];
let mut seen_names: HashMap<String, u32> = HashMap::default();
@@ -326,7 +326,9 @@ fn hwmon_temperatures(filter: &Option<Filter>) -> HwmonResults {
// TODO: It's possible we may want to move the filter check further up to avoid
// probing hwmon if not needed?
if Filter::optional_should_keep(filter, &name) {
if Filter::optional_should_keep(filter, &name)
|| Filter::optional_should_keep(graph_filter, &name)
{
if let Ok(temp_celsius) = parse_temp(&temp_path) {
temperatures.push(TempSensorData {
name,
@@ -350,7 +352,9 @@ fn hwmon_temperatures(filter: &Option<Filter>) -> HwmonResults {
///
/// See [the Linux kernel documentation](https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-class-thermal)
/// for more details.
fn add_thermal_zone_temperatures(temperatures: &mut Vec<TempSensorData>, filter: &Option<Filter>) {
fn add_thermal_zone_temperatures(
temperatures: &mut Vec<TempSensorData>, filter: &Option<Filter>, graph_filter: &Option<Filter>,
) {
let path = Path::new("/sys/class/thermal");
let Ok(read_dir) = path.read_dir() else {
return;
@@ -374,7 +378,9 @@ fn add_thermal_zone_temperatures(temperatures: &mut Vec<TempSensorData>, filter:
name
};
if Filter::optional_should_keep(filter, &name) {
if Filter::optional_should_keep(filter, &name)
|| Filter::optional_should_keep(graph_filter, &name)
{
let temp_path = file_path.join("temp");
if let Ok(temp_celsius) = parse_temp(&temp_path) {
let name = counted_name(&mut seen_names, name);
@@ -391,11 +397,13 @@ fn add_thermal_zone_temperatures(temperatures: &mut Vec<TempSensorData>, filter:
}
/// Gets temperature sensors and data.
pub fn get_temperature_data(filter: &Option<Filter>) -> Result<Option<Vec<TempSensorData>>> {
let mut results = hwmon_temperatures(filter);
pub fn get_temperature_data(
filter: &Option<Filter>, graph_filter: &Option<Filter>,
) -> Result<Option<Vec<TempSensorData>>> {
let mut results = hwmon_temperatures(filter, graph_filter);
if results.num_hwmon == 0 {
add_thermal_zone_temperatures(&mut results.temperatures, filter);
add_thermal_zone_temperatures(&mut results.temperatures, filter, graph_filter);
}
Ok(Some(results.temperatures))
+4 -2
View File
@@ -6,14 +6,16 @@ use super::TempSensorData;
use crate::app::filter::Filter;
pub fn get_temperature_data(
components: &sysinfo::Components, filter: &Option<Filter>,
components: &sysinfo::Components, filter: &Option<Filter>, graph_filter: &Option<Filter>,
) -> Result<Option<Vec<TempSensorData>>> {
let mut temperatures: Vec<TempSensorData> = Vec::new();
for component in components {
let name = component.label().to_string();
if Filter::optional_should_keep(filter, &name) {
if Filter::optional_should_keep(filter, &name)
|| Filter::optional_should_keep(graph_filter, &name)
{
temperatures.push(TempSensorData {
name,
temperature: component.temperature(),
+36 -5
View File
@@ -430,7 +430,7 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
# By default, there are no mount name filters enabled. An example use case is provided below.
@@ -447,7 +447,7 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
@@ -474,7 +474,35 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
# Temperature graph widget configuration
#[temperature_graph]
# Where to place the legend for the temperature graph widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right".
#legend_position = "top-right"
# An upper temperature value for the graph; entries higher than this will be hidden. If not set,
# there is no limit. Is in the unit of `temperature_type`.
#max_temp = 100.0
# By default, there are no temperature sensor filters enabled. An example use case is provided below.
#[temperature_graph.sensor_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["cpu", "wifi"]
# Whether to use regex. Defaults to false.
#regex = false
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
@@ -494,7 +522,7 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
# Whether to require matching the whole word. Defaults to false.
#whole_word = false
@@ -518,6 +546,9 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
#avg_entry_color = "red"
#cpu_core_colors = ["light magenta", "light yellow", "light cyan", "light green", "light blue", "cyan", "green", "blue"]
#[styles.temp_graph]
#temp_graph_color_styles = ["light magenta", "light yellow", "light cyan", "light green", "light blue", "cyan", "green", "blue"]
#[styles.memory]
#ram_color = "light magenta"
#cache_color = "light red"
@@ -560,7 +591,7 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
# [[row.child]] represents either a widget or a column.
# [[row.child.child]] represents a widget.
#
# All widgets must have the type value set to one of ["cpu", "mem", "proc", "net", "temp", "disk", "empty"].
# All widgets must have the type value set to one of ["cpu", "mem", "proc", "net", "temp", "temp_graph", "disk", "empty"].
# All layout components have a ratio value - if this is not set, then it defaults to 1.
# The default widget layout:
#[[row]]
+52 -12
View File
@@ -22,7 +22,7 @@ use data::TemperatureType;
pub(crate) use error::{OptionError, OptionResult};
use indexmap::IndexSet;
use regex::Regex;
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use rustc_hash::{FxHashMap, FxHashSet};
#[cfg(feature = "battery")]
use starship_battery::Manager;
@@ -205,7 +205,7 @@ pub(crate) fn get_or_create_config(config_path: Option<&Path>) -> anyhow::Result
indoc::eprintdoc!(
"Note: bottom couldn't create a default config file at '{}', and the \
application has fallen back to the default configuration.
Caused by:
{err}
",
@@ -279,14 +279,15 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
// For CPU
let default_cpu_selection = get_default_cpu_selection(args, config);
let mut widget_map = HashMap::default();
let mut cpu_state_map: HashMap<u64, CpuWidgetState> = HashMap::default();
let mut mem_state_map: HashMap<u64, MemWidgetState> = HashMap::default();
let mut net_state_map: HashMap<u64, NetWidgetState> = HashMap::default();
let mut proc_state_map: HashMap<u64, ProcWidgetState> = HashMap::default();
let mut temp_state_map: HashMap<u64, TempWidgetState> = HashMap::default();
let mut disk_state_map: HashMap<u64, DiskTableWidget> = HashMap::default();
let mut battery_state_map: HashMap<u64, BatteryWidgetState> = HashMap::default();
let mut widget_map = FxHashMap::default();
let mut cpu_state_map: FxHashMap<u64, CpuWidgetState> = FxHashMap::default();
let mut mem_state_map: FxHashMap<u64, MemWidgetState> = FxHashMap::default();
let mut net_state_map: FxHashMap<u64, NetWidgetState> = FxHashMap::default();
let mut proc_state_map: FxHashMap<u64, ProcWidgetState> = FxHashMap::default();
let mut temp_state_map: FxHashMap<u64, TempWidgetState> = FxHashMap::default();
let mut temp_graph_state_map: FxHashMap<u64, TempGraphWidgetState> = FxHashMap::default();
let mut disk_state_map: FxHashMap<u64, DiskTableWidget> = FxHashMap::default();
let mut battery_state_map: FxHashMap<u64, BatteryWidgetState> = FxHashMap::default();
let autohide_timer = if autohide_time {
Some(Instant::now())
@@ -297,7 +298,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
let mut initial_widget_id: u64 = default_widget_id;
let mut initial_widget_type = Proc;
let is_custom_layout = config.row.is_some();
let mut used_widget_set = HashSet::default();
let mut used_widget_set = FxHashSet::default();
let network_unit_type = get_network_unit_type(args, config);
let network_scale_type = get_network_scale_type(args, config);
@@ -320,6 +321,7 @@ 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)?;
// TODO: Can probably just reuse the options struct.
let app_config_fields = AppConfigFields {
@@ -374,6 +376,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
.disk
.as_ref()
.and_then(|cfg| cfg.default_sort.to_owned()),
temperature_legend_position,
};
let table_config = ProcTableConfig {
@@ -483,11 +486,28 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
TempWidgetState::new(&app_config_fields, &styling),
);
}
TempGraph => {
let upper_limit = config
.temperature_graph
.as_ref()
.and_then(|cfg| cfg.max_temp)
.map(|v| v as f32);
temp_graph_state_map.insert(
widget.widget_id,
TempGraphWidgetState::new(
default_time_value,
autohide_timer,
upper_limit,
),
);
}
Battery => {
battery_state_map
.insert(widget.widget_id, BatteryWidgetState::default());
}
_ => {}
// FIXME: This is kind of a hack that we have these cases at all.
Empty | BasicCpu | BasicMem | BasicNet | BasicTables | CpuLegend
| ProcSort | ProcSearch => {}
}
}
}
@@ -527,6 +547,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
use_proc: used_widget_set.contains(&Proc),
use_disk: used_widget_set.contains(&Disk),
use_temp: used_widget_set.contains(&Temp),
use_temp_graph: used_widget_set.contains(&TempGraph),
use_battery: used_widget_set.contains(&Battery),
};
@@ -548,6 +569,11 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
.context("Update 'temperature.sensor_filter' in your config file")?,
None => None,
};
let temp_graph_sensor_filter = match &config.temperature_graph {
Some(cfg) => get_ignore_list(&cfg.sensor_filter)
.context("Update 'temperature_graph.sensor_filter' in your config file")?,
None => None,
};
let net_interface_filter = match &config.network {
Some(cfg) => get_ignore_list(&cfg.interface_filter)
.context("Update 'network.interface_filter' in your config file")?,
@@ -560,6 +586,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
net_state: NetState::init(net_state_map),
proc_state: ProcState::init(proc_state_map),
temp_state: TempState::init(temp_state_map),
temp_graph_state: TempGraphStates::init(temp_graph_state_map),
disk_state: DiskState::init(disk_state_map),
battery_state: AppBatteryState::init(battery_state_map),
basic_table_widget_state,
@@ -573,6 +600,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
disk_filter: disk_name_filter,
mount_filter: disk_mount_filter,
temp_filter: temp_sensor_filter,
temp_graph_filter: temp_graph_sensor_filter,
net_filter: net_interface_filter,
};
let is_expanded = expanded && !use_basic_mode;
@@ -1016,6 +1044,7 @@ fn get_retention(args: &BottomArgs, config: &Config) -> OptionResult<u64> {
)
}
#[inline]
fn parse_legend_position(
arg: Option<&String>, cfg: Option<&String>, setting: &'static str,
) -> OptionResult<Option<LegendPosition>> {
@@ -1064,6 +1093,17 @@ fn get_memory_legend_position(
)
}
fn get_temperature_legend_position(config: &Config) -> OptionResult<Option<LegendPosition>> {
parse_legend_position(
None,
config
.temperature_graph
.as_ref()
.and_then(|settings| settings.legend_position.as_ref()),
"legend_position",
)
}
#[cfg(test)]
mod test {
use clap::Parser;
+4 -1
View File
@@ -7,6 +7,7 @@ pub mod network;
pub mod process;
pub mod style;
pub mod temperature;
pub mod temperature_graph;
use disk::DiskConfig;
use flags::GeneralConfig;
@@ -14,6 +15,7 @@ use network::NetworkConfig;
use serde::{Deserialize, Serialize};
use style::StyleConfig;
use temperature::TempConfig;
use temperature_graph::TempGraphConfig;
pub use self::ignore_list::IgnoreList;
use self::{cpu::CpuConfig, layout::Row, process::ProcessesConfig};
@@ -21,7 +23,7 @@ use self::{cpu::CpuConfig, layout::Row, process::ProcessesConfig};
/// Overall config for `bottom`.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq))]
pub struct Config {
pub(crate) flags: Option<GeneralConfig>,
pub(crate) styles: Option<StyleConfig>,
@@ -29,6 +31,7 @@ pub struct Config {
pub(crate) processes: Option<ProcessesConfig>,
pub(crate) disk: Option<DiskConfig>,
pub(crate) temperature: Option<TempConfig>,
pub(crate) temperature_graph: Option<TempGraphConfig>,
pub(crate) network: Option<NetworkConfig>,
pub(crate) cpu: Option<CpuConfig>,
}
+13
View File
@@ -7,6 +7,7 @@ mod graphs;
mod memory;
mod network;
mod tables;
mod temp_graph;
mod themes;
mod utils;
mod widgets;
@@ -20,6 +21,7 @@ use memory::MemoryStyle;
use network::NetworkStyle;
use serde::{Deserialize, Serialize};
use tables::TableStyle;
use temp_graph::TempGraphStyle;
use tui::{style::Style, widgets::BorderType};
use utils::{opt, set_colour, set_colour_list, set_style};
use widgets::WidgetStyle;
@@ -82,6 +84,9 @@ pub(crate) struct StyleConfig {
/// Styling for the network widget.
pub(crate) network: Option<NetworkStyle>,
/// Styling for the temperature graph widget.
pub(crate) temp_graph: Option<TempGraphStyle>,
/// Styling for the battery widget.
pub(crate) battery: Option<BatteryStyle>,
@@ -113,6 +118,7 @@ pub struct Styles {
pub(crate) all_cpu_colour: Style,
pub(crate) avg_cpu_colour: Style,
pub(crate) cpu_colour_styles: Vec<Style>,
pub(crate) temp_graph_colour_styles: Vec<Style>,
pub(crate) border_style: Style,
pub(crate) highlighted_border_style: Style,
pub(crate) text_style: Style,
@@ -178,6 +184,13 @@ impl Styles {
set_colour!(self.all_cpu_colour, config.cpu, all_entry_color);
set_colour_list!(self.cpu_colour_styles, config.cpu, cpu_core_colors);
// Temperature graph
set_colour_list!(
self.temp_graph_colour_styles,
config.temp_graph,
temp_graph_color_styles
);
// Memory
set_colour!(self.ram_style, config.memory, ram_color);
set_colour!(self.swap_style, config.memory, swap_color);
+2
View File
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use super::ColorStr;
// TODO: Use Canadian spelling for colour with an alias for US later instead since internally I use Canadian spelling.
/// Styling specific to the CPU widget.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
+13
View File
@@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
use super::ColorStr;
/// Styling specific to the temperature graph widget.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
pub(crate) struct TempGraphStyle {
/// Colour of each temperature sensor's graph line. Read in order.
#[serde(alias = "temp_graph_colour_styles")]
pub(crate) temp_graph_color_styles: Option<Vec<ColorStr>>,
}
+25 -19
View File
@@ -20,6 +20,17 @@ impl Styles {
const DEFAULT_SELECTED_TEXT_STYLE: Style = color!(Color::Black).bg(HIGHLIGHT_COLOUR);
const TEXT_COLOUR: Color = Color::Gray;
let list_colours = vec![
color!(Color::LightMagenta),
color!(Color::LightYellow),
color!(Color::LightCyan),
color!(Color::LightGreen),
color!(Color::LightBlue),
color!(Color::Cyan),
color!(Color::Green),
color!(Color::Blue),
];
Self {
ram_style: color!(FIRST_COLOUR),
#[cfg(not(target_os = "windows"))]
@@ -43,16 +54,8 @@ impl Styles {
total_tx_style: color!(FOURTH_COLOUR),
all_cpu_colour: color!(ALL_COLOUR),
avg_cpu_colour: color!(AVG_COLOUR),
cpu_colour_styles: vec![
color!(Color::LightMagenta),
color!(Color::LightYellow),
color!(Color::LightCyan),
color!(Color::LightGreen),
color!(Color::LightBlue),
color!(Color::Cyan),
color!(Color::Green),
color!(Color::Blue),
],
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
border_style: color!(TEXT_COLOUR),
highlighted_border_style: color!(HIGHLIGHT_COLOUR),
text_style: color!(TEXT_COLOUR),
@@ -74,6 +77,16 @@ impl Styles {
}
pub fn default_light_palette() -> Self {
let list_colours = vec![
color!(Color::LightMagenta),
color!(Color::LightBlue),
color!(Color::LightRed),
color!(Color::Cyan),
color!(Color::Green),
color!(Color::Blue),
color!(Color::Red),
];
Self {
ram_style: color!(Color::Blue),
#[cfg(not(target_os = "windows"))]
@@ -95,15 +108,8 @@ impl Styles {
tx_style: color!(Color::Red),
total_rx_style: color!(Color::LightBlue),
total_tx_style: color!(Color::LightRed),
cpu_colour_styles: vec![
color!(Color::LightMagenta),
color!(Color::LightBlue),
color!(Color::LightRed),
color!(Color::Cyan),
color!(Color::Green),
color!(Color::Blue),
color!(Color::Red),
],
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
border_style: color!(Color::Black),
text_style: color!(Color::Black),
selected_text_style: color!(Color::White).bg(Color::LightBlue),
+50 -44
View File
@@ -8,6 +8,29 @@ use crate::options::config::style::{Styles, themes::hex_colour};
impl Styles {
pub(crate) fn gruvbox_palette() -> Self {
let list_colours = vec![
hex!("#cc241d"),
hex!("#98971a"),
hex!("#d79921"),
hex!("#458588"),
hex!("#b16286"),
hex!("#689d6a"),
hex!("#fe8019"),
hex!("#b8bb26"),
hex!("#fabd2f"),
hex!("#83a598"),
hex!("#d3869b"),
hex!("#d65d0e"),
hex!("#9d0006"),
hex!("#79740e"),
hex!("#b57614"),
hex!("#076678"),
hex!("#8f3f71"),
hex!("#427b58"),
hex!("#d65d03"),
hex!("#af3a03"),
];
Self {
ram_style: hex!("#8ec07c"),
#[cfg(not(target_os = "windows"))]
@@ -31,28 +54,8 @@ impl Styles {
total_tx_style: hex!("#d79921"),
all_cpu_colour: hex!("#8ec07c"),
avg_cpu_colour: hex!("#fb4934"),
cpu_colour_styles: vec![
hex!("#cc241d"),
hex!("#98971a"),
hex!("#d79921"),
hex!("#458588"),
hex!("#b16286"),
hex!("#689d6a"),
hex!("#fe8019"),
hex!("#b8bb26"),
hex!("#fabd2f"),
hex!("#83a598"),
hex!("#d3869b"),
hex!("#d65d0e"),
hex!("#9d0006"),
hex!("#79740e"),
hex!("#b57614"),
hex!("#076678"),
hex!("#8f3f71"),
hex!("#427b58"),
hex!("#d65d03"),
hex!("#af3a03"),
],
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
border_style: hex!("#ebdbb2"),
highlighted_border_style: hex!("#fe8019"),
text_style: hex!("#ebdbb2"),
@@ -74,6 +77,29 @@ impl Styles {
}
pub(crate) fn gruvbox_light_palette() -> Self {
let list_colours = vec![
hex!("#cc241d"),
hex!("#98971a"),
hex!("#d79921"),
hex!("#458588"),
hex!("#b16286"),
hex!("#689d6a"),
hex!("#fe8019"),
hex!("#b8bb26"),
hex!("#fabd2f"),
hex!("#83a598"),
hex!("#d3869b"),
hex!("#d65d0e"),
hex!("#9d0006"),
hex!("#79740e"),
hex!("#b57614"),
hex!("#076678"),
hex!("#8f3f71"),
hex!("#427b58"),
hex!("#d65d03"),
hex!("#af3a03"),
];
Self {
ram_style: hex!("#427b58"),
#[cfg(not(target_os = "windows"))]
@@ -97,28 +123,8 @@ impl Styles {
total_tx_style: hex!("#d79921"),
all_cpu_colour: hex!("#8ec07c"),
avg_cpu_colour: hex!("#fb4934"),
cpu_colour_styles: vec![
hex!("#cc241d"),
hex!("#98971a"),
hex!("#d79921"),
hex!("#458588"),
hex!("#b16286"),
hex!("#689d6a"),
hex!("#fe8019"),
hex!("#b8bb26"),
hex!("#fabd2f"),
hex!("#83a598"),
hex!("#d3869b"),
hex!("#d65d0e"),
hex!("#9d0006"),
hex!("#79740e"),
hex!("#b57614"),
hex!("#076678"),
hex!("#8f3f71"),
hex!("#427b58"),
hex!("#d65d03"),
hex!("#af3a03"),
],
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
border_style: hex!("#3c3836"),
highlighted_border_style: hex!("#af3a03"),
text_style: hex!("#3c3836"),
+26 -20
View File
@@ -8,6 +8,17 @@ use crate::options::config::style::{Styles, themes::hex_colour};
impl Styles {
pub(crate) fn nord_palette() -> Self {
let list_colours = vec![
hex!("#5e81ac"),
hex!("#81a1c1"),
hex!("#d8dee9"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#d08770"),
hex!("#bf616a"),
];
Self {
ram_style: hex!("#88c0d0"),
#[cfg(not(target_os = "windows"))]
@@ -31,16 +42,8 @@ impl Styles {
total_tx_style: hex!("#8fbcbb"),
all_cpu_colour: hex!("#88c0d0"),
avg_cpu_colour: hex!("#8fbcbb"),
cpu_colour_styles: vec![
hex!("#5e81ac"),
hex!("#81a1c1"),
hex!("#d8dee9"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#d08770"),
hex!("#bf616a"),
],
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
border_style: hex!("#88c0d0"),
highlighted_border_style: hex!("#5e81ac"),
text_style: hex!("#e5e9f0"),
@@ -62,6 +65,17 @@ impl Styles {
}
pub(crate) fn nord_light_palette() -> Self {
let list_colours = vec![
hex!("#5e81ac"),
hex!("#88c0d0"),
hex!("#4c566a"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#d08770"),
hex!("#bf616a"),
];
Self {
ram_style: hex!("#81a1c1"),
#[cfg(not(target_os = "windows"))]
@@ -85,16 +99,8 @@ impl Styles {
total_tx_style: hex!("#8fbcbb"),
all_cpu_colour: hex!("#81a1c1"),
avg_cpu_colour: hex!("#8fbcbb"),
cpu_colour_styles: vec![
hex!("#5e81ac"),
hex!("#88c0d0"),
hex!("#4c566a"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#d08770"),
hex!("#bf616a"),
],
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
border_style: hex!("#2e3440"),
highlighted_border_style: hex!("#5e81ac"),
text_style: hex!("#2e3440"),
+23
View File
@@ -0,0 +1,23 @@
use serde::Deserialize;
use super::IgnoreList;
/// Temperature graph configuration.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq))]
pub(crate) struct TempGraphConfig {
/// A filter over the sensor names.
pub(crate) sensor_filter: Option<IgnoreList>,
/// The location of the graph's legend.
#[serde(default)]
pub(crate) legend_position: Option<String>,
/// An upper temperature value for the graph; entries higher than this will be hidden. If not set,
/// there is no limit.
///
/// Is in the unit of `temperature_type`.
#[serde(default)]
pub(crate) max_temp: Option<f64>,
}
+174
View File
@@ -4,12 +4,186 @@ pub mod disk_table;
pub mod mem_graph;
pub mod network_graph;
pub mod process_table;
pub mod temperature_graph;
pub mod temperature_table;
use std::time::{Duration, Instant};
pub use battery_info::*;
pub use cpu_graph::*;
pub use disk_table::*;
pub use mem_graph::*;
pub use network_graph::*;
pub use process_table::*;
pub use temperature_graph::*;
pub use temperature_table::*;
use timeless::data::ChunkedData;
struct GraphHeightCacheInner {
best_point: (Instant, f64),
right_edge: Instant,
period: u64,
}
#[derive(Default)]
pub struct GraphHeightCache {
inner: Option<GraphHeightCacheInner>,
}
impl GraphHeightCache {
/// Get the cached height if it exists, or set it otherwise.
pub(crate) fn get_or_update<
'a,
F: Into<f64> + Clone + Copy + 'a,
S: Iterator<Item = &'a ChunkedData<F>>,
>(
&mut self, last_time: &Instant, current_display_time: u64, sources: S, times: &[Instant],
) -> f64 {
let visible_duration = Duration::from_millis(current_display_time);
let (mut biggest, mut biggest_time, oldest_to_check) = if let Some(GraphHeightCacheInner {
best_point,
right_edge,
period,
}) = self.inner.as_ref()
&& *period == current_display_time
&& last_time.duration_since(best_point.0) < visible_duration
{
(best_point.1, best_point.0, *right_edge)
} else {
let visible_duration = Duration::from_millis(current_display_time);
let visible_left_bound = match last_time.checked_sub(visible_duration) {
Some(v) => v,
None => {
// On some systems (like Windows) it can be possible that the
// current display time
// causes subtraction to fail if, for example, the uptime of the
// system is too low and current_display_time is too high. See https://github.com/ClementTsang/bottom/issues/1825.
//
// As such, we instead take the oldest visible time. This is a
// bit inefficient, but
// since it should only happen rarely, it should be fine.
times
.iter()
.take_while(|t| last_time.duration_since(**t) < visible_duration)
.last()
.cloned()
.unwrap_or(*last_time)
}
};
(0.0, visible_left_bound, visible_left_bound)
};
for source in sources {
for (&time, &v) in source
.iter_along_base(times)
.rev()
.take_while(|&(&time, _)| time >= oldest_to_check)
{
let v = v.into();
if v > biggest {
biggest = v;
biggest_time = time;
}
}
}
self.inner = Some(GraphHeightCacheInner {
best_point: (biggest_time, biggest),
right_edge: *last_time,
period: current_display_time,
});
biggest
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build(times: &[Instant], values: &[f64]) -> ChunkedData<f64> {
assert_eq!(times.len(), values.len());
let mut data = ChunkedData::default();
for &v in values {
data.push(v);
}
data
}
#[test]
fn empty_sources_returns_zero() {
let mut cache = GraphHeightCache::default();
let last_time = Instant::now();
let times: Vec<Instant> = vec![];
let sources: Vec<ChunkedData<f64>> = vec![];
let result = cache.get_or_update(&last_time, 1_000, sources.iter(), &times);
assert_eq!(result, 0.0);
assert!(cache.inner.is_some());
}
#[test]
fn picks_max_across_sources() {
let mut cache = GraphHeightCache::default();
let now = Instant::now();
let times = vec![
now - Duration::from_millis(300),
now - Duration::from_millis(200),
now - Duration::from_millis(100),
now,
];
let a = build(&times, &[1.0, 2.0, 3.0, 4.0]);
let b = build(&times, &[10.0, 5.0, 0.5, 0.25]);
let result = cache.get_or_update(&now, 1_000, [&a, &b].into_iter(), &times);
assert_eq!(result, 10.0);
}
#[test]
fn cache_hit_skips_older_points() {
// On a cache hit, only points after `right_edge` are rescanned. So if
// we pass a fresh source whose only large values are *older* than the
// previous `last_time`, the cached max should still win.
let mut cache = GraphHeightCache::default();
let now = Instant::now();
let times = vec![
now - Duration::from_millis(200),
now - Duration::from_millis(100),
now,
];
let first = build(&times, &[3.0, 5.0, 7.0]);
let first_result = cache.get_or_update(&now, 10_000, [&first].into_iter(), &times);
assert_eq!(first_result, 7.0);
// Older points carry huge values; newest is small. A full rescan would
// return 1000.0 — a true cache hit returns the cached 7.0.
let second = build(&times, &[1000.0, 999.0, 4.0]);
let second_result = cache.get_or_update(&now, 10_000, [&second].into_iter(), &times);
assert_eq!(second_result, 7.0);
}
#[test]
fn cache_invalidates_on_period_change() {
// First call uses a small window that excludes the older high value.
// Second call uses a larger window that should include it; a stale
// cache would miss it and return the previous max.
let mut cache = GraphHeightCache::default();
let now = Instant::now();
let times = vec![
now - Duration::from_millis(500),
now - Duration::from_millis(50),
now,
];
let data = build(&times, &[100.0, 5.0, 7.0]);
let first = cache.get_or_update(&now, 100, [&data].into_iter(), &times);
assert_eq!(first, 7.0);
let second = cache.get_or_update(&now, 1_000, [&data].into_iter(), &times);
assert_eq!(second, 100.0);
}
}
+4 -8
View File
@@ -1,15 +1,11 @@
use std::time::Instant;
use crate::widgets::GraphHeightCache;
pub struct NetWidgetState {
pub current_display_time: u64,
pub autohide_timer: Option<Instant>,
pub height_cache: Option<NetWidgetHeightCache>,
}
pub struct NetWidgetHeightCache {
pub best_point: (Instant, f64),
pub right_edge: Instant,
pub period: u64,
pub height_cache: GraphHeightCache,
}
impl NetWidgetState {
@@ -17,7 +13,7 @@ impl NetWidgetState {
NetWidgetState {
current_display_time,
autohide_timer,
height_cache: None,
height_cache: GraphHeightCache::default(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Code around a temperature graph widget.
use std::time::Instant;
use crate::widgets::GraphHeightCache;
/// A timeseries graph widget displaying temperature usage over time.
pub struct TempGraphWidgetState {
pub current_display_time: u64,
pub autohide_timer: Option<Instant>,
pub height_cache: GraphHeightCache,
pub max_temp: Option<f32>,
}
impl TempGraphWidgetState {
pub fn new(
current_display_time: u64, autohide_timer: Option<Instant>, max_temp: Option<f32>,
) -> Self {
TempGraphWidgetState {
current_display_time,
autohide_timer,
height_cache: GraphHeightCache::default(),
max_temp,
}
}
}