diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 011cbf1..9c239ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,12 @@
name: CI
-on: [push, pull_request]
+# Pushes to a branch with an open pull request would run every job twice,
+# once for the push and once for the pull request, so pushes only count on
+# main; the pull request run covers every other branch.
+on:
+ push:
+ branches: [main]
+ pull_request:
env:
CARGO_TERM_COLOR: always
diff --git a/Cargo.lock b/Cargo.lock
index e47e8fc..4d52975 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -24,6 +24,8 @@ version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
dependencies = [
+ "enumn",
+ "serde",
"uuid",
]
@@ -146,6 +148,7 @@ dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
+ "serde",
"version_check",
"zerocopy",
]
@@ -464,6 +467,9 @@ name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "block-buffer"
@@ -804,6 +810,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
+ "block2 0.6.2",
+ "libc",
"objc2 0.6.4",
]
@@ -856,6 +864,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
dependencies = [
"bytemuck",
"emath",
+ "serde",
]
[[package]]
@@ -884,6 +893,8 @@ dependencies = [
"pollster",
"profiling",
"raw-window-handle",
+ "ron",
+ "serde",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -908,6 +919,8 @@ dependencies = [
"log",
"nohash-hasher",
"profiling",
+ "ron",
+ "serde",
"smallvec",
"unicode-segmentation",
"web-sys",
@@ -949,6 +962,7 @@ dependencies = [
"objc2-ui-kit 0.3.2",
"profiling",
"raw-window-handle",
+ "serde",
"smithay-clipboard",
"web-time",
"webbrowser",
@@ -1008,6 +1022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
dependencies = [
"bytemuck",
+ "serde",
]
[[package]]
@@ -1063,6 +1078,17 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "enumn"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "epaint"
version = "0.36.2"
@@ -1081,6 +1107,7 @@ dependencies = [
"parking_lot",
"profiling",
"self_cell",
+ "serde",
"skrifa",
"smallvec",
"unicode-general-category",
@@ -1211,6 +1238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
dependencies = [
"bytemuck",
+ "serde",
]
[[package]]
@@ -2157,10 +2185,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
+ "block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
+ "objc2-uniform-type-identifiers 0.3.2",
]
[[package]]
@@ -2379,7 +2409,7 @@ dependencies = [
"objc2-link-presentation",
"objc2-quartz-core 0.2.2",
"objc2-symbols",
- "objc2-uniform-type-identifiers",
+ "objc2-uniform-type-identifiers 0.2.2",
"objc2-user-notifications",
]
@@ -2406,6 +2436,16 @@ dependencies = [
"objc2-foundation 0.2.2",
]
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4"
+dependencies = [
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
[[package]]
name = "objc2-user-notifications"
version = "0.2.2"
@@ -2868,6 +2908,30 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+[[package]]
+name = "rfd"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4b9d4857b10297ffb0f64cae711e983a7aeaee561848d56d429d3858bd8ed93"
+dependencies = [
+ "block2 0.6.2",
+ "dispatch2",
+ "js-sys",
+ "libc",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-uniform-type-identifiers 0.3.2",
+ "percent-encoding",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -2882,6 +2946,19 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "ron"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
+dependencies = [
+ "bitflags 2.13.2",
+ "serde",
+ "serde_derive",
+ "typeid",
+ "unicode-ident",
+]
+
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3173,6 +3250,9 @@ name = "smallvec"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
+dependencies = [
+ "serde",
+]
[[package]]
name = "smithay-client-toolkit"
@@ -3385,7 +3465,7 @@ dependencies = [
[[package]]
name = "throttle"
-version = "0.1.3"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossbeam-channel",
@@ -3393,6 +3473,7 @@ dependencies = [
"egui_extras",
"egui_plot",
"embed-manifest",
+ "rfd",
"serde",
"serde_json",
"sha2",
@@ -3599,6 +3680,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
[[package]]
name = "typenum"
version = "1.20.1"
diff --git a/Cargo.toml b/Cargo.toml
index aa4d0e1..2d9c3f0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "throttle"
-version = "0.1.3"
+version = "0.2.0"
edition = "2024"
description = "A free, open-source per-process bandwidth monitor and limiter for Windows"
repository = "https://github.com/FrancescoCoding/Throttle"
@@ -9,7 +9,7 @@ license = "MIT"
[dependencies]
anyhow = "1.0.103"
crossbeam-channel = "0.5.16"
-eframe = "0.36.2"
+eframe = { version = "0.36.2", features = ["persistence"] }
egui_extras = "0.36.2"
egui_plot = "0.37.0"
serde = { version = "1.0.228", features = ["derive"] }
@@ -17,6 +17,7 @@ serde_json = "1.0.150"
sha2 = "0.10"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
+rfd = { version = "0.18", default-features = false }
tray-icon = { version = "0.26.1", default-features = false }
ureq = "3"
windivert = "0.6.0"
diff --git a/README.md b/README.md
index e55f6a3..17c071f 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@



+[](https://buymeacoffee.com/franwbu)
A free, open-source **NetLimiter-style bandwidth monitor and limiter for Windows**,
@@ -40,13 +41,37 @@ https://github.com/user-attachments/assets/632312e7-4096-4957-b2f7-f2277aa96bb3
minimised, and launch at Windows startup (Settings menu). Startup uses a
Task Scheduler logon task, since Windows skips Run-key entries that need
elevation.
+- Optional colour themes (Themes menu): editor and community palettes, import
+ of any editor colour theme JSON, Customise (corners, density, scale, fonts,
+ accents) and View (columns, plot, always on top, compact). Stock by default.
+- Filter box above the table (Ctrl+F) and an Active chip that hides idle
+ processes. The window size and position, panel
+ sizes, column widths and sort order are remembered across restarts.
+- Plot navigation: scroll zooms the time axis, drag pans it, double click
+ resets the view.
+- Rules can be switched off and back on without losing them, from the
+ right-click menu or the tray menu, which lists every rule with a check
+ mark. The tray tooltip shows the live download and upload totals.
- Runs as a single elevated process; engine and GUI communicate over channels.
## Screenshots
-
+Live rates per process, with the selected process on the right and a button
+that opens its folder:
-
+
+
+The Themes menu, with editor and community palettes:
+
+
+
+Customise: corners, density, scale, fonts and accents:
+
+
+
+The filter box and the Active chip narrow the table to what matters:
+
+
## Download
@@ -115,6 +140,13 @@ internet, so Throttle shows the browser's traffic under the proxy's process
itself has no effect; set it on the proxy process instead. Throttle does not
capture loopback traffic, so the interception itself keeps working.
+## Support
+
+Throttle is free, with no ads and no telemetry, and is built in my spare time.
+If it saved you some frustration, you can
+[buy me a coffee](https://buymeacoffee.com/franwbu). Stars, bug reports and
+pull requests are just as welcome.
+
## License
- Throttle's own source code is licensed under the MIT License (see `LICENSE`).
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..006893a
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,141 @@
+# Third party notices
+
+Throttle's optional colour themes (the named palettes in the Themes menu) use
+colour values taken from the theme files listed below. Only colour values are
+used; no other part of these projects is included. Each source is distributed
+under the MIT License, reproduced here as required.
+
+## Visual Studio Code default themes (Dark Modern, Dark+, Light Modern, Light+, High Contrast Dark, High Contrast Light)
+
+Source: https://github.com/microsoft/vscode
+- `extensions/theme-defaults/themes/dark_modern.json`
+- `extensions/theme-defaults/themes/dark_plus.json`
+- `extensions/theme-defaults/themes/dark_vs.json`
+- `extensions/theme-defaults/themes/light_modern.json`
+- `extensions/theme-defaults/themes/light_plus.json`
+- `extensions/theme-defaults/themes/light_vs.json`
+- `extensions/theme-defaults/themes/hc_black.json`
+- `extensions/theme-defaults/themes/hc_light.json`
+
+Where a theme file does not define a colour, the editor's built-in default for
+that colour is used.
+
+```
+MIT License
+
+Copyright (c) 2015 - present Microsoft Corporation
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## Colorsublime themes, as bundled with Visual Studio Code (Monokai, Solarized Dark, Solarized Light, Abyss, Kimbie Dark, Quiet Light, Red, Tomorrow Night Blue)
+
+Source: https://github.com/Colorsublime/Colorsublime-Themes, as bundled in
+https://github.com/microsoft/vscode, which registers each of these theme
+extensions as coming from Colorsublime-Themes. The files below are covered by
+the Microsoft notice above.
+- `extensions/theme-monokai/themes/monokai-color-theme.json`
+- `extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json`
+- `extensions/theme-solarized-light/themes/solarized-light-color-theme.json`
+- `extensions/theme-abyss/themes/abyss-color-theme.json`
+- `extensions/theme-kimbie-dark/themes/kimbie-dark-color-theme.json`
+- `extensions/theme-quietlight/themes/quietlight-color-theme.json`
+- `extensions/theme-red/themes/Red-color-theme.json`
+- `extensions/theme-tomorrow-night-blue/themes/tomorrow-night-blue-color-theme.json`
+
+Solarized is a colour scheme by Ethan Schoonover (https://github.com/altercation/solarized), MIT License.
+Tomorrow is a colour scheme by Chris Kempson (https://github.com/chriskempson/tomorrow-theme), MIT License.
+
+```
+MIT License
+
+Copyright (c) 2015 Colorsublime.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## Community colour themes
+
+The palettes in the Themes menu's Dark and Light submenus take their colour
+values from the following third party colour themes, each distributed under
+the MIT License:
+
+- Dracula: Copyright (c) 2016 Dracula Theme, https://github.com/dracula/visual-studio-code, MIT License
+- One Dark Pro: Copyright (c) 2013-2022 Binaryify, https://github.com/Binaryify/OneDark-Pro, MIT License
+- Catppuccin Mocha: Copyright (c) 2021 Catppuccin, https://github.com/catppuccin/vscode, MIT License
+- Catppuccin Macchiato: Copyright (c) 2021 Catppuccin, https://github.com/catppuccin/vscode, MIT License
+- Catppuccin Frappe: Copyright (c) 2021 Catppuccin, https://github.com/catppuccin/vscode, MIT License
+- Catppuccin Latte: Copyright (c) 2021 Catppuccin, https://github.com/catppuccin/vscode, MIT License
+- Nord: Copyright (c) 2016-present Sven Greb, https://github.com/nordtheme/visual-studio-code, MIT License
+- Tokyo Night: Copyright (c) 2018-present Enkia, https://github.com/tokyo-night/tokyo-night-vscode-theme, MIT License
+- Tokyo Night Storm: Copyright (c) 2018-present Enkia, https://github.com/tokyo-night/tokyo-night-vscode-theme, MIT License
+- Tokyo Night Light: Copyright (c) 2018-present Enkia, https://github.com/tokyo-night/tokyo-night-vscode-theme, MIT License
+- GitHub Dark: Copyright (c) 2020 Primer, https://github.com/primer/github-vscode-theme, MIT License
+- GitHub Light: Copyright (c) 2020 Primer, https://github.com/primer/github-vscode-theme, MIT License
+- GitHub Dark and GitHub Light (Primer Primitives colours): Copyright (c) 2018 GitHub Inc., https://github.com/primer/primitives, MIT License
+- Gruvbox Dark: Copyright (c) 2017 JD, https://github.com/jdinhify/vscode-theme-gruvbox, MIT License
+- Gruvbox Light: Copyright (c) 2017 JD, https://github.com/jdinhify/vscode-theme-gruvbox, MIT License
+- Ayu Dark: Copyright (c) 2016 Ike Kurghinyan, https://github.com/ayu-theme/vscode-ayu, MIT License
+- Ayu Mirage: Copyright (c) 2016 Ike Kurghinyan, https://github.com/ayu-theme/vscode-ayu, MIT License
+- Ayu Light: Copyright (c) 2016 Ike Kurghinyan, https://github.com/ayu-theme/vscode-ayu, MIT License
+- Night Owl: Copyright (c) 2018 Sarah Drasner, https://github.com/sdras/night-owl-vscode-theme, MIT License
+- Rose Pine: Copyright (c) 2021 Rose Pine, https://github.com/rose-pine/vscode, MIT License
+- Everforest Dark: Copyright (c) 2020 sainnhe, https://github.com/sainnhe/everforest-vscode, MIT License
+- Kanagawa: Copyright (c) 2025 Pierre-Alain Castella, https://github.com/paccodes/kanagawa-vscode-theme, MIT License
+- Kanagawa (original palette): Copyright (c) 2021 Tommaso Laurenzi, https://github.com/rebelot/kanagawa.nvim, MIT License
+- Cobalt2: Copyright (c) 2018 Wes Bos, Roberto Achar, https://github.com/wesbos/cobalt2-vscode, MIT License
+- SynthWave '84: Copyright (c) 2019 Robb Owen, https://github.com/robb0wen/synthwave-vscode, MIT License
+
+The following permission notice applies to all of the above:
+
+```
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
diff --git a/docs/screenshots/customise.png b/docs/screenshots/customise.png
new file mode 100644
index 0000000..0c2fb4c
Binary files /dev/null and b/docs/screenshots/customise.png differ
diff --git a/docs/screenshots/filter.png b/docs/screenshots/filter.png
new file mode 100644
index 0000000..97145c4
Binary files /dev/null and b/docs/screenshots/filter.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
new file mode 100644
index 0000000..b0c70e9
Binary files /dev/null and b/docs/screenshots/overview.png differ
diff --git a/docs/screenshots/themes.png b/docs/screenshots/themes.png
new file mode 100644
index 0000000..0fa316d
Binary files /dev/null and b/docs/screenshots/themes.png differ
diff --git a/src/appearance.rs b/src/appearance.rs
new file mode 100644
index 0000000..63e7ede
--- /dev/null
+++ b/src/appearance.rs
@@ -0,0 +1,574 @@
+//! Appearance options layered on top of the active theme: corner rounding,
+//! density, UI scale, shadows, button frames, font and accent overrides.
+//!
+//! Every default is a no-op, so `Appearance::default()` leaves egui's stock
+//! style exactly as it is. `apply` must run after `theme::apply`, which
+//! resets both stock visuals.
+
+use eframe::egui::{
+ self, CornerRadius, FontDefinitions, FontFamily, Id, Shadow, Spacing, Ui, vec2,
+};
+use serde::{Deserialize, Serialize};
+
+use crate::theme::{self, Accents};
+
+/// Corner rounding of widgets, windows and menus.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Corners {
+ /// Square corners.
+ None,
+ /// Clearly rounded.
+ Round,
+ /// Fully rounded buttons and inputs.
+ Pill,
+ /// egui's stock radii (no change). Last so unknown values land here.
+ #[default]
+ #[serde(other)]
+ Subtle,
+}
+
+impl Corners {
+ /// Menu order.
+ pub const ALL: [Corners; 4] = [
+ Corners::None,
+ Corners::Subtle,
+ Corners::Round,
+ Corners::Pill,
+ ];
+}
+
+/// Spacing and row height preset.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Density {
+ /// Tighter padding and shorter rows.
+ Compact,
+ /// Looser padding and taller rows.
+ Spacious,
+ /// egui's stock spacing (no change). Last so unknown values land here.
+ #[default]
+ #[serde(other)]
+ Normal,
+}
+
+impl Density {
+ /// Menu order.
+ pub const ALL: [Density; 3] = [Density::Compact, Density::Normal, Density::Spacious];
+
+ /// Height of a table row and of the table header.
+ pub fn row_height(self) -> f32 {
+ match self {
+ Density::Compact => 16.0,
+ Density::Normal => 20.0,
+ Density::Spacious => 26.0,
+ }
+ }
+}
+
+/// Which font family the whole UI uses.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Font {
+ /// The bundled monospace font everywhere.
+ Monospace,
+ /// egui's stock proportional font (no change). Last so unknown values
+ /// land here.
+ #[default]
+ #[serde(other)]
+ Default,
+}
+
+impl Font {
+ /// Menu order.
+ pub const ALL: [Font; 2] = [Font::Default, Font::Monospace];
+}
+
+/// Custom accent colours as `#rrggbb` strings, overriding the theme's.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct AccentOverride {
+ /// Download traffic colour.
+ pub down: String,
+ /// Upload traffic colour.
+ pub up: String,
+ /// Blocked traffic and error colour.
+ pub block: String,
+}
+
+/// The scale steps offered in the Customise window, as zoom factors.
+pub const SCALE_STEPS: [f32; 6] = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5];
+
+/// User appearance preferences, persisted in `Settings::appearance`.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Appearance {
+ /// Corner rounding of widgets, windows and menus.
+ pub corners: Corners,
+ /// Spacing and table row height preset.
+ pub density: Density,
+ /// UI zoom factor; 1.0 is stock. Clamped to `0.8..=1.5` on apply.
+ pub scale: f32,
+ /// Shadows under menus, popups and windows.
+ pub shadows: bool,
+ /// Alternating table row fill.
+ pub stripes: bool,
+ /// Framed buttons (false draws flat text buttons).
+ pub button_frames: bool,
+ /// Font family for the whole UI.
+ pub font: Font,
+ /// Draw the rate and total columns in the monospace font.
+ pub numbers_monospace: bool,
+ /// Custom accents; `None` uses the theme's.
+ pub accent_override: Option,
+}
+
+impl Default for Appearance {
+ fn default() -> Self {
+ Self {
+ corners: Corners::Subtle,
+ density: Density::Normal,
+ scale: 1.0,
+ shadows: true,
+ stripes: true,
+ button_frames: true,
+ font: Font::Default,
+ numbers_monospace: false,
+ accent_override: None,
+ }
+ }
+}
+
+impl Appearance {
+ /// The accent override as colours, if set and fully parseable.
+ pub fn accents(&self) -> Option {
+ let o = self.accent_override.as_ref()?;
+ Some(Accents {
+ down: theme::parse_hex(&o.down)?,
+ up: theme::parse_hex(&o.up)?,
+ block: theme::parse_hex(&o.block)?,
+ })
+ }
+}
+
+/// Apply `a` on top of the current visuals of both egui themes and set the
+/// zoom factor. With `Appearance::default()` this changes nothing.
+pub fn apply(ctx: &egui::Context, a: &Appearance) {
+ // `theme::apply` resets the visuals but not the spacing, so `Normal`
+ // writes egui's stock spacing back. When nothing was changed before,
+ // that rewrites the same values and stays a no-op.
+ ctx.all_styles_mut(|style| {
+ let v = &mut style.visuals;
+ let radii = match a.corners {
+ Corners::None => Some((CornerRadius::ZERO, CornerRadius::ZERO)),
+ Corners::Subtle => None,
+ Corners::Round => Some((CornerRadius::same(6), CornerRadius::same(10))),
+ Corners::Pill => Some((CornerRadius::same(12), CornerRadius::same(12))),
+ };
+ if let Some((widget, window)) = radii {
+ let w = &mut v.widgets;
+ for state in [
+ &mut w.noninteractive,
+ &mut w.inactive,
+ &mut w.hovered,
+ &mut w.active,
+ &mut w.open,
+ ] {
+ state.corner_radius = widget;
+ }
+ v.window_corner_radius = window;
+ v.menu_corner_radius = window;
+ }
+ if !a.shadows {
+ v.window_shadow = Shadow::NONE;
+ v.popup_shadow = Shadow::NONE;
+ }
+ if !a.button_frames {
+ v.button_frame = false;
+ }
+
+ let stock = Spacing::default();
+ let (item, button, row, menu) = match a.density {
+ Density::Compact => (vec2(6.0, 2.0), vec2(3.0, 0.0), 16.0, 1.0),
+ Density::Normal => (
+ stock.item_spacing,
+ stock.button_padding,
+ stock.interact_size.y,
+ stock.menu_spacing,
+ ),
+ Density::Spacious => (vec2(10.0, 6.0), vec2(8.0, 4.0), 26.0, 4.0),
+ };
+ let sp = &mut style.spacing;
+ sp.item_spacing = item;
+ sp.button_padding = button;
+ sp.interact_size.y = row;
+ sp.menu_spacing = menu;
+ });
+
+ apply_font(ctx, a.font);
+
+ let zoom = a.scale.clamp(0.8, 1.5);
+ if (zoom - ctx.zoom_factor()).abs() > f32::EPSILON {
+ ctx.set_zoom_factor(zoom);
+ }
+}
+
+/// Memory id remembering which `Font` is installed.
+fn font_id() -> Id {
+ Id::new("throttle_appearance_font")
+}
+
+/// Switch the UI font family, remembering what was installed so that the
+/// default never calls `set_fonts` (which compares the font data).
+fn apply_font(ctx: &egui::Context, font: Font) {
+ let current = ctx
+ .data(|d| d.get_temp::(font_id()))
+ .unwrap_or(Font::Default);
+ if current == font {
+ return;
+ }
+ let mut fonts = FontDefinitions::default();
+ if font == Font::Monospace {
+ let mono = fonts
+ .families
+ .get(&FontFamily::Monospace)
+ .cloned()
+ .unwrap_or_default();
+ fonts.families.insert(FontFamily::Proportional, mono);
+ }
+ ctx.set_fonts(fonts);
+ ctx.data_mut(|d| d.insert_temp(font_id(), font));
+}
+
+/// The stock accents offered when custom accents are switched on.
+const STOCK_ACCENTS: [&str; 3] = ["#4caf50", "#2196f3", "#e53935"];
+
+/// The body of the Customise window. Returns true when anything changed,
+/// so the caller can re-apply and save.
+pub fn customise_ui(ui: &mut Ui, a: &mut Appearance) -> bool {
+ let mut changed = false;
+
+ egui::Grid::new("customise_grid")
+ .num_columns(2)
+ .spacing([12.0, 8.0])
+ .show(ui, |ui| {
+ ui.label("Corners")
+ .on_hover_text("How rounded buttons, menus and windows are.");
+ ui.horizontal(|ui| {
+ for c in Corners::ALL {
+ changed |= ui
+ .radio_value(&mut a.corners, c, corners_label(c))
+ .changed();
+ }
+ });
+ ui.end_row();
+
+ ui.label("Density")
+ .on_hover_text("Spacing between widgets and the height of table rows.");
+ ui.horizontal(|ui| {
+ for d in Density::ALL {
+ changed |= ui
+ .radio_value(&mut a.density, d, density_label(d))
+ .changed();
+ }
+ });
+ ui.end_row();
+
+ ui.label("Scale")
+ .on_hover_text("Size of everything in the window.");
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ for step in SCALE_STEPS {
+ let selected = (a.scale - step).abs() < 0.001;
+ let label = format!("{}%", (step * 100.0).round() as i32);
+ if ui.selectable_label(selected, label).clicked() && !selected {
+ a.scale = step;
+ changed = true;
+ }
+ }
+ });
+ ui.weak("Ctrl + and Ctrl - also zoom; the setting is remembered.");
+ });
+ ui.end_row();
+
+ ui.label("Font")
+ .on_hover_text("The typeface used for the whole window.");
+ ui.horizontal(|ui| {
+ for f in Font::ALL {
+ changed |= ui.radio_value(&mut a.font, f, font_label(f)).changed();
+ }
+ });
+ ui.end_row();
+
+ ui.label("Details");
+ ui.vertical(|ui| {
+ changed |= ui
+ .checkbox(&mut a.shadows, "Shadows")
+ .on_hover_text("Drop shadows under menus, popups and windows.")
+ .changed();
+ changed |= ui
+ .checkbox(&mut a.stripes, "Table stripes")
+ .on_hover_text("Shade every other row of the process table.")
+ .changed();
+ changed |= ui
+ .checkbox(&mut a.button_frames, "Button frames")
+ .on_hover_text("Draw a frame around buttons; off shows flat text buttons.")
+ .changed();
+ changed |= ui
+ .checkbox(&mut a.numbers_monospace, "Monospace numbers")
+ .on_hover_text("Line up the rate and total columns in a fixed width font.")
+ .changed();
+ });
+ ui.end_row();
+
+ ui.label("Accents");
+ ui.vertical(|ui| {
+ let mut custom = a.accent_override.is_some();
+ if ui
+ .checkbox(&mut custom, "Custom accents")
+ .on_hover_text("Pick your own download, upload and blocked colours.")
+ .changed()
+ {
+ a.accent_override = custom.then(|| {
+ let [down, up, block] = STOCK_ACCENTS.map(String::from);
+ AccentOverride { down, up, block }
+ });
+ changed = true;
+ }
+ if let Some(o) = a.accent_override.as_mut() {
+ for ((label, value), stock) in [
+ ("Download", &mut o.down),
+ ("Upload", &mut o.up),
+ ("Blocked", &mut o.block),
+ ]
+ .into_iter()
+ .zip(STOCK_ACCENTS)
+ {
+ ui.horizontal(|ui| {
+ let mut colour = theme::parse_hex(value)
+ .or_else(|| theme::parse_hex(stock))
+ .unwrap_or_default();
+ if ui.color_edit_button_srgba(&mut colour).changed() {
+ *value = theme::to_hex(colour);
+ changed = true;
+ }
+ ui.label(label);
+ });
+ }
+ }
+ });
+ ui.end_row();
+ });
+
+ ui.separator();
+ if ui
+ .button("Reset to defaults")
+ .on_hover_text("Go back to the stock look.")
+ .clicked()
+ && *a != Appearance::default()
+ {
+ *a = Appearance::default();
+ changed = true;
+ }
+ changed
+}
+
+fn corners_label(c: Corners) -> &'static str {
+ match c {
+ Corners::None => "Square",
+ Corners::Subtle => "Subtle",
+ Corners::Round => "Round",
+ Corners::Pill => "Pill",
+ }
+}
+
+fn density_label(d: Density) -> &'static str {
+ match d {
+ Density::Compact => "Compact",
+ Density::Normal => "Normal",
+ Density::Spacious => "Spacious",
+ }
+}
+
+fn font_label(f: Font) -> &'static str {
+ match f {
+ Font::Default => "Default",
+ Font::Monospace => "Monospace",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use eframe::egui::Theme;
+
+ #[test]
+ fn default_apply_is_a_no_op() {
+ let ctx = egui::Context::default();
+ apply(&ctx, &Appearance::default());
+ // `Style` as a whole never compares equal (its number formatter is a
+ // function pointer), so compare the parts the options touch.
+ for theme in [Theme::Dark, Theme::Light] {
+ let got = ctx.style_of(theme);
+ let want = theme.default_style();
+ assert_eq!(got.visuals, want.visuals);
+ assert_eq!(got.spacing, want.spacing);
+ assert_eq!(got.text_styles, want.text_styles);
+ }
+ assert_eq!(ctx.zoom_factor(), 1.0);
+ }
+
+ #[test]
+ fn options_reach_both_themes_and_normal_restores_spacing() {
+ let ctx = egui::Context::default();
+ let a = Appearance {
+ corners: Corners::None,
+ density: Density::Compact,
+ shadows: false,
+ button_frames: false,
+ ..Appearance::default()
+ };
+ apply(&ctx, &a);
+ for theme in [Theme::Dark, Theme::Light] {
+ let s = ctx.style_of(theme);
+ assert_eq!(s.visuals.widgets.inactive.corner_radius, CornerRadius::ZERO);
+ assert_eq!(s.visuals.window_corner_radius, CornerRadius::ZERO);
+ assert_eq!(s.visuals.window_shadow, Shadow::NONE);
+ assert_eq!(s.visuals.popup_shadow, Shadow::NONE);
+ assert!(!s.visuals.button_frame);
+ assert_eq!(s.spacing.item_spacing, vec2(6.0, 2.0));
+ assert_eq!(s.spacing.interact_size.y, 16.0);
+ }
+ // Switching back: theme::apply resets the visuals, apply the spacing.
+ crate::theme::apply(&ctx, crate::theme::DEFAULT_ID, &[]);
+ apply(&ctx, &Appearance::default());
+ for theme in [Theme::Dark, Theme::Light] {
+ let got = ctx.style_of(theme);
+ let want = theme.default_style();
+ assert_eq!(got.visuals, want.visuals);
+ assert_eq!(got.spacing, want.spacing);
+ }
+ }
+
+ /// A palette plus corner, shadow and font changes, then back to the
+ /// default theme and appearance: both styles must be stock again.
+ #[test]
+ fn default_theme_and_appearance_restore_stock_after_a_palette() {
+ use eframe::egui::{ThemePreference, Visuals};
+ let ctx = egui::Context::default();
+ let palette = crate::theme::builtin()
+ .next()
+ .expect("bundled palettes exist");
+ crate::theme::apply(&ctx, &palette.id, &[]);
+ let a = Appearance {
+ corners: Corners::Pill,
+ shadows: false,
+ font: Font::Monospace,
+ ..Appearance::default()
+ };
+ apply(&ctx, &a);
+ let changed = if palette.dark {
+ Theme::Dark
+ } else {
+ Theme::Light
+ };
+ assert_ne!(
+ ctx.style_of(changed).visuals,
+ changed.default_style().visuals
+ );
+ assert_ne!(ctx.style_of(Theme::Light).visuals, Visuals::light());
+
+ crate::theme::apply(&ctx, crate::theme::DEFAULT_ID, &[]);
+ apply(&ctx, &Appearance::default());
+ for theme in [Theme::Dark, Theme::Light] {
+ let got = ctx.style_of(theme);
+ let want = theme.default_style();
+ assert_eq!(got.visuals, want.visuals);
+ assert_eq!(got.spacing, want.spacing);
+ assert_eq!(got.text_styles, want.text_styles);
+ }
+ assert_eq!(ctx.options(|o| o.theme_preference), ThemePreference::System);
+ assert_eq!(
+ ctx.data(|d| d.get_temp::(font_id())),
+ Some(Font::Default),
+ "the stock font family is installed again"
+ );
+ }
+
+ #[test]
+ fn pill_corners_and_spacious_density() {
+ let ctx = egui::Context::default();
+ let a = Appearance {
+ corners: Corners::Pill,
+ density: Density::Spacious,
+ ..Appearance::default()
+ };
+ apply(&ctx, &a);
+ for theme in [Theme::Dark, Theme::Light] {
+ let s = ctx.style_of(theme);
+ assert_eq!(
+ s.visuals.widgets.hovered.corner_radius,
+ CornerRadius::same(12)
+ );
+ assert_eq!(s.visuals.menu_corner_radius, CornerRadius::same(12));
+ assert_eq!(s.spacing.button_padding, vec2(8.0, 4.0));
+ assert_eq!(s.spacing.menu_spacing, 4.0);
+ }
+ }
+
+ #[test]
+ fn font_flag_tracks_the_installed_family() {
+ let ctx = egui::Context::default();
+ let flag = |ctx: &egui::Context| ctx.data(|d| d.get_temp::(font_id()));
+ apply_font(&ctx, Font::Default);
+ assert_eq!(flag(&ctx), None);
+ apply_font(&ctx, Font::Monospace);
+ assert_eq!(flag(&ctx), Some(Font::Monospace));
+ apply_font(&ctx, Font::Default);
+ assert_eq!(flag(&ctx), Some(Font::Default));
+ }
+
+ #[test]
+ fn scale_is_clamped() {
+ let ctx = egui::Context::default();
+ let a = Appearance {
+ scale: 3.0,
+ ..Appearance::default()
+ };
+ apply(&ctx, &a);
+ // The new zoom factor takes effect at the start of the next pass.
+ let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
+ out.textures_delta.clear();
+ assert_eq!(ctx.zoom_factor(), 1.5);
+ }
+
+ #[test]
+ fn unknown_enum_values_fall_back_to_defaults() {
+ let a: Appearance =
+ serde_json::from_str(r#"{"corners":"hexagonal","density":"huge","font":"comic"}"#)
+ .unwrap();
+ assert_eq!(a, Appearance::default());
+ }
+
+ #[test]
+ fn partial_object_keeps_other_defaults() {
+ let a: Appearance = serde_json::from_str(r#"{"shadows":false}"#).unwrap();
+ assert!(!a.shadows);
+ assert_eq!(a.scale, 1.0);
+ assert!(a.stripes);
+ }
+
+ #[test]
+ fn accent_override_parses_or_is_none() {
+ let mut a = Appearance::default();
+ assert!(a.accents().is_none());
+ a.accent_override = Some(AccentOverride {
+ down: "#00ff00".into(),
+ up: "#0000ff".into(),
+ block: "#ff0000".into(),
+ });
+ assert!(a.accents().is_some());
+ a.accent_override.as_mut().unwrap().up = "blue".into();
+ assert!(a.accents().is_none());
+ }
+}
diff --git a/src/backend/engine.rs b/src/backend/engine.rs
index a1aefd4..355a435 100644
--- a/src/backend/engine.rs
+++ b/src/backend/engine.rs
@@ -275,11 +275,13 @@ fn handle_packet(
}
// Look up the rule (cloned so we don't hold the rules lock). The "Unknown"
- // row is never shaped: see `synthetic::is_shapable`.
+ // row is never shaped: see `synthetic::is_shapable`. A disabled rule is
+ // listed but not applied, so it counts as no rule here.
let rule = exe
.as_ref()
.filter(|e| synthetic::is_shapable(e))
- .and_then(|e| shared.rules.lock().unwrap().get(e).cloned());
+ .and_then(|e| shared.rules.lock().unwrap().get(e).cloned())
+ .filter(|r| !r.disabled);
match rule {
// No process match, or no rule: pass through untouched.
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 0e35239..9148780 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -218,11 +218,21 @@ fn run_aggregator(shared: Arc, cmd_rx: Receiver, snapshot_tx: S
Ok(Command::SetRule(mut rule)) => {
rule.exe_path = rule.exe_path.to_lowercase();
let key = rule.exe_path.clone();
+ let disabled = rule.disabled;
let snapshot = {
let mut rules = shared.rules.lock().unwrap();
- rules.insert(key, rule);
+ rules.insert(key.clone(), rule);
rules.clone()
};
+ // A disabled rule must stop shaping at once: drop its
+ // buckets so held packets are released, as for a removal.
+ if disabled {
+ shared
+ .buckets
+ .lock()
+ .unwrap()
+ .retain(|(exe, _), _| exe != &key);
+ }
if let Err(e) = rules::save(&snapshot) {
tracing::warn!("failed to persist rules: {e}");
}
diff --git a/src/backend/rules.rs b/src/backend/rules.rs
index 04e52ac..a6ac12d 100644
--- a/src/backend/rules.rs
+++ b/src/backend/rules.rs
@@ -42,7 +42,8 @@ pub fn load() -> HashMap {
}
};
- match serde_json::from_str::>(&data) {
+ // A UTF-8 byte order mark from an editor must not empty the rule set.
+ match serde_json::from_str::>(data.trim_start_matches('\u{feff}')) {
Ok(mut map) => {
// Defensively normalise keys to lowercase so lookups from the engine
// (which always lowercases) match regardless of how the file was written.
@@ -89,6 +90,7 @@ mod tests {
down_limit: Some(2 * 1024 * 1024),
up_limit: None,
blocked: false,
+ disabled: false,
},
);
rules.insert(
@@ -98,6 +100,7 @@ mod tests {
down_limit: None,
up_limit: Some(512 * 1024),
blocked: true,
+ disabled: true,
},
);
@@ -121,5 +124,6 @@ mod tests {
assert!(r.down_limit.is_none());
assert!(r.up_limit.is_none());
assert!(!r.blocked);
+ assert!(!r.disabled);
}
}
diff --git a/src/gui.rs b/src/gui.rs
index 3ab3184..17c8618 100644
--- a/src/gui.rs
+++ b/src/gui.rs
@@ -4,27 +4,43 @@
//! This file is built against the shared contract in `crate::types` and must
//! not depend on any backend internals beyond the `snapshot_rx` receiver.
-use std::time::{Duration, Instant};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use crate::plot::{self, PlotHint};
use crossbeam_channel::{Receiver, Sender};
use eframe::egui;
use eframe::egui::Widget;
-use egui_plot::{Legend, Line, Plot, PlotPoints};
-use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
-use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
+use tray_icon::{Icon, TrayIcon};
+
+use crate::tray::{self, TrayAction, TrayRule};
use crate::backend::rules;
use crate::backend::synthetic::{is_shapable, is_system};
-use crate::settings::{self, Settings};
+use crate::settings::{self, Settings, SortColumn, View};
use crate::types::{Command, ProcessStats, Rule, Snapshot};
+use crate::units::{GB, KB, MB, humanize_bytes, humanize_rate, settle};
use crate::update_check::{self, UpdateInfo};
-use crate::{autostart, icon, single_instance};
+use crate::{appearance, autostart, icon, single_instance, theme};
-/// How many total-rate samples we keep for the header graph.
+/// How many total-rate samples we keep for the header graph by default; the
+/// View menu changes it through `View::history_secs`.
const TOTAL_HISTORY_LEN: usize = 120;
+/// Header plot history choices in the View menu: `(seconds, label)`.
+const HISTORY_CHOICES: [(usize, &str); 4] = [
+ (60, "1 minute"),
+ (120, "2 minutes"),
+ (300, "5 minutes"),
+ (600, "10 minutes"),
+];
+
+/// Plot line width choices in the View menu: `(points, label)`.
+const LINE_WIDTH_CHOICES: [(f32, &str); 3] = [(1.0, "1"), (2.0, "2"), (3.0, "3")];
+
/// Project links used by the Help menu and the About dialog.
const REPO_URL: &str = env!("CARGO_PKG_REPOSITORY");
+/// Where to support the project, linked from Help and About.
+const SUPPORT_URL: &str = "https://buymeacoffee.com/franwbu";
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Version of the bundled WinDivert driver (see `vendor/`).
const WINDIVERT_VERSION: &str = "2.2.2";
@@ -32,49 +48,13 @@ const WINDIVERT_VERSION: &str = "2.2.2";
/// How long a start in the tray waits for the taskbar before staying visible.
const START_HIDDEN_WAIT: Duration = Duration::from_secs(60);
-const COLOR_DOWN: egui::Color32 = egui::Color32::from_rgb(0x4c, 0xaf, 0x50);
-const COLOR_UP: egui::Color32 = egui::Color32::from_rgb(0x21, 0x96, 0xf3);
-const COLOR_BLOCK: egui::Color32 = egui::Color32::from_rgb(0xe5, 0x39, 0x35);
+/// Width reserved for the folder button in the detail panel header.
+const FOLDER_BUTTON_WIDTH: f32 = 44.0;
// ---------------------------------------------------------------------------
// Formatting / parsing helpers
// ---------------------------------------------------------------------------
-const KB: f64 = 1024.0;
-const MB: f64 = 1024.0 * 1024.0;
-const GB: f64 = 1024.0 * 1024.0 * 1024.0;
-const TB: f64 = 1024.0 * 1024.0 * 1024.0 * 1024.0;
-
-/// Humanize a byte-per-second rate using base-1024 units.
-fn humanize_rate(bps: u64) -> String {
- let b = bps as f64;
- if b >= GB {
- format!("{:.2} GB/s", b / GB)
- } else if b >= MB {
- format!("{:.2} MB/s", b / MB)
- } else if b >= KB {
- format!("{:.1} KB/s", b / KB)
- } else {
- format!("{bps} B/s")
- }
-}
-
-/// Humanize a plain byte total using base-1024 units.
-fn humanize_bytes(bytes: u64) -> String {
- let b = bytes as f64;
- if b >= TB {
- format!("{:.2} TB", b / TB)
- } else if b >= GB {
- format!("{:.2} GB", b / GB)
- } else if b >= MB {
- format!("{:.2} MB", b / MB)
- } else if b >= KB {
- format!("{:.1} KB", b / KB)
- } else {
- format!("{bytes} B")
- }
-}
-
/// Rate unit for the limit dialog's dropdown (base 1024).
#[derive(Clone, Copy, PartialEq, Eq)]
enum RateUnit {
@@ -125,40 +105,173 @@ impl RateUnit {
}
}
-/// One-line summary of a rule for the table column.
-fn rule_summary(rule: Option<&Rule>) -> String {
- match rule {
- None => "-".to_string(),
- Some(r) if r.blocked => "BLOCKED".to_string(),
- Some(r) => {
- let mut parts = Vec::new();
- if let Some(d) = r.down_limit {
- parts.push(format!("Dn {}", humanize_rate(d)));
- }
- if let Some(u) = r.up_limit {
- parts.push(format!("Up {}", humanize_rate(u)));
- }
- if parts.is_empty() {
- "-".to_string()
- } else {
- parts.join(" ")
- }
+/// What may stay in the limit field: digits and one decimal separator (a
+/// dot or a comma), so letters typed or pasted in disappear at once.
+fn numeric_input(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ let mut separator = false;
+ for c in text.chars() {
+ if c.is_ascii_digit() {
+ out.push(c);
+ } else if (c == '.' || c == ',') && !separator {
+ separator = true;
+ out.push(c);
+ }
+ }
+ out
+}
+
+/// One-line summary of a rule for the table column. A disabled rule reads
+/// "Off: " plus what it would do when switched back on.
+pub(crate) fn rule_summary(rule: Option<&Rule>) -> String {
+ let Some(r) = rule else {
+ return "-".to_string();
+ };
+ let summary = if r.blocked {
+ "BLOCKED".to_string()
+ } else {
+ let mut parts = Vec::new();
+ if let Some(d) = r.down_limit {
+ parts.push(format!("Dn {}", humanize_rate(d)));
+ }
+ if let Some(u) = r.up_limit {
+ parts.push(format!("Up {}", humanize_rate(u)));
+ }
+ if parts.is_empty() {
+ return "-".to_string();
}
+ parts.join(" ")
+ };
+ if r.disabled {
+ format!("Off: {summary}")
+ } else {
+ summary
}
}
+/// Whether the process moved any data in the last second.
+fn is_active(stats: &ProcessStats) -> bool {
+ stats.down_rate > 0 || stats.up_rate > 0
+}
+
+/// Whether `stats` matches the filter box text: a case-insensitive
+/// substring of the process name or its path. Blank matches everything.
+fn matches_filter(stats: &ProcessStats, filter: &str) -> bool {
+ let needle = filter.trim().to_lowercase();
+ needle.is_empty()
+ || stats.name.to_lowercase().contains(&needle)
+ || stats.exe_path.to_lowercase().contains(&needle)
+}
+
// ---------------------------------------------------------------------------
// Sorting / modal state
// ---------------------------------------------------------------------------
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum SortColumn {
+/// A column of the process table, in display order.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum TableColumn {
Name,
Flows,
Down,
Up,
DownTotal,
UpTotal,
+ Rule,
+}
+
+impl TableColumn {
+ const ALL: [TableColumn; 7] = [
+ TableColumn::Name,
+ TableColumn::Flows,
+ TableColumn::Down,
+ TableColumn::Up,
+ TableColumn::DownTotal,
+ TableColumn::UpTotal,
+ TableColumn::Rule,
+ ];
+
+ fn label(self) -> &'static str {
+ match self {
+ TableColumn::Name => "Process",
+ TableColumn::Flows => "Flows",
+ TableColumn::Down => "Down",
+ TableColumn::Up => "Up",
+ TableColumn::DownTotal => "Total down",
+ TableColumn::UpTotal => "Total up",
+ TableColumn::Rule => "Rule",
+ }
+ }
+
+ /// Stable key for the column's saved width in the settings file.
+ fn id(self) -> &'static str {
+ match self {
+ TableColumn::Name => "name",
+ TableColumn::Flows => "flows",
+ TableColumn::Down => "down",
+ TableColumn::Up => "up",
+ TableColumn::DownTotal => "down_total",
+ TableColumn::UpTotal => "up_total",
+ TableColumn::Rule => "rule",
+ }
+ }
+
+ /// The sort key behind the header, if the column sorts.
+ fn sort(self) -> Option {
+ match self {
+ TableColumn::Name => Some(SortColumn::Name),
+ TableColumn::Flows => Some(SortColumn::Flows),
+ TableColumn::Down => Some(SortColumn::Down),
+ TableColumn::Up => Some(SortColumn::Up),
+ TableColumn::DownTotal => Some(SortColumn::DownTotal),
+ TableColumn::UpTotal => Some(SortColumn::UpTotal),
+ TableColumn::Rule => None,
+ }
+ }
+
+ /// Whether the View menu shows the column. The process name always shows.
+ fn visible(self, view: &View) -> bool {
+ match self {
+ TableColumn::Name => true,
+ TableColumn::Flows => view.show_flows,
+ TableColumn::Down => view.show_down,
+ TableColumn::Up => view.show_up,
+ TableColumn::DownTotal => view.show_down_total,
+ TableColumn::UpTotal => view.show_up_total,
+ TableColumn::Rule => view.show_rule,
+ }
+ }
+}
+
+/// Plot options shared by the View menu and the plots' right click menus:
+/// filled area, line width and, for the header plot, its history length.
+/// Returns whether anything changed.
+fn plot_options(ui: &mut egui::Ui, view: &mut View, header: bool) -> bool {
+ let mut changed = ui.checkbox(&mut view.plot_fill, "Filled area").changed();
+ ui.menu_button("Line width", |ui| {
+ for (width, label) in LINE_WIDTH_CHOICES {
+ changed |= ui
+ .radio_value(&mut view.plot_line_width, width, label)
+ .changed();
+ }
+ });
+ if header {
+ ui.menu_button("Header plot history", |ui| {
+ for (secs, label) in HISTORY_CHOICES {
+ changed |= ui
+ .radio_value(&mut view.history_secs, secs, label)
+ .changed();
+ }
+ });
+ }
+ changed
+}
+
+/// The table columns `view` shows, in display order.
+fn visible_columns(view: &View) -> Vec {
+ TableColumn::ALL
+ .into_iter()
+ .filter(|c| c.visible(view))
+ .collect()
}
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -190,15 +303,31 @@ pub struct ThrottleApp {
total_down_hist: Vec,
total_up_hist: Vec,
- sort_column: SortColumn,
- sort_desc: bool,
selected: Option,
+ /// Text of the filter box above the table. Not persisted.
+ filter: String,
+ /// Ctrl+F was pressed: focus the filter box when it is drawn.
+ focus_filter: bool,
limit_modal: Option,
about_open: bool,
shutdown_sent: bool,
settings: Settings,
+ /// Accent colours of the active theme (plots, rates, blocked rules).
+ accents: theme::Accents,
+ /// Palettes imported from colour theme files (`settings.custom_themes`).
+ custom_palettes: Vec,
+ /// The Customise window is open.
+ customise_open: bool,
+ /// Why the last theme import failed; shown until dismissed.
+ import_error: Option,
+ /// No frame has run yet, so the window level is still to be applied.
+ first_frame: bool,
+ /// Last frame's state of the header plot, for the hint in the totals line.
+ total_plot_hint: PlotHint,
+ /// Last frame's state of the detail plot, for the hint by its heading.
+ detail_plot_hint: PlotHint,
/// `None` until the background check of the startup task reports back.
autostart: Option,
autostart_rx: Receiver,
@@ -207,6 +336,10 @@ pub struct ThrottleApp {
/// `None` if the tray icon could not be created; the window then never hides.
tray: Option,
tray_rx: Receiver,
+ /// The rule toggles the tray menu currently shows, to rebuild it on change.
+ tray_rules: Vec,
+ /// The tray tooltip currently set, to update it only when the totals change.
+ tray_tooltip: String,
/// The window is hidden to the tray (no taskbar button).
hidden: bool,
/// Whether the window was minimised last frame, to catch the moment it is.
@@ -223,12 +356,6 @@ pub struct ThrottleApp {
update_install: UpdateInstall,
}
-/// What the tray icon (or a second launch) asks the app to do.
-enum TrayAction {
- Show,
- Exit,
-}
-
/// Progress of an in-app update started from the banner.
enum UpdateInstall {
Idle,
@@ -245,30 +372,46 @@ impl ThrottleApp {
start_hidden: bool,
) -> Self {
let (tray_tx, tray_rx) = crossbeam_channel::unbounded();
- let tray = create_tray(&cc.egui_ctx, tray_tx.clone());
+ // The tray starts with the stock icon; `restyle` below recolours it
+ // when the theme's accents differ from the stock ones.
+ let tray = tray::create_tray(&cc.egui_ctx, tray_tx.clone());
let ctx = cc.egui_ctx.clone();
single_instance::on_show_request(move || {
let _ = tray_tx.send(TrayAction::Show);
ctx.request_repaint();
});
- Self {
+ let custom_palettes = settings
+ .custom_themes
+ .iter()
+ .map(theme::CustomTheme::to_palette)
+ .collect();
+ let mut app = Self {
snapshot_rx,
cmd_tx,
last_snapshot: Snapshot::default(),
total_down_hist: Vec::with_capacity(TOTAL_HISTORY_LEN),
total_up_hist: Vec::with_capacity(TOTAL_HISTORY_LEN),
- sort_column: SortColumn::Down,
- sort_desc: true,
selected: None,
+ filter: String::new(),
+ focus_filter: false,
limit_modal: None,
about_open: false,
shutdown_sent: false,
settings,
+ accents: theme::Accents::DEFAULT,
+ custom_palettes,
+ customise_open: false,
+ import_error: None,
+ first_frame: true,
+ total_plot_hint: PlotHint::default(),
+ detail_plot_hint: PlotHint::default(),
autostart: None,
autostart_rx: spawn_autostart_check(),
autostart_error: None,
tray,
tray_rx,
+ tray_rules: Vec::new(),
+ tray_tooltip: String::new(),
hidden: false,
was_minimized: false,
start_hidden: start_hidden.then(|| Instant::now() + START_HIDDEN_WAIT),
@@ -277,9 +420,77 @@ impl ThrottleApp {
update: None,
update_dismissed: false,
update_install: UpdateInstall::Idle,
+ };
+ app.restyle(&cc.egui_ctx);
+ app
+ }
+
+ /// Apply the theme and appearance settings to `ctx`, take the accents
+ /// they give and recolour the tray icon to match. Runs at startup and
+ /// after every change from the Themes menu, the Customise window or an
+ /// import.
+ fn restyle(&mut self, ctx: &egui::Context) {
+ let mut accents = theme::apply(ctx, &self.settings.theme, &self.custom_palettes);
+ // Always after `theme::apply`, which resets both stock visuals.
+ appearance::apply(ctx, &self.settings.appearance);
+ if let Some(o) = self.settings.appearance.accents() {
+ accents = o;
+ }
+ let icon_changed = (accents.down, accents.up) != (self.accents.down, self.accents.up);
+ self.accents = accents;
+ if icon_changed {
+ self.refresh_tray_icon();
+ }
+ }
+
+ /// Redraw the tray icon in the current accents. Best effort: a failure
+ /// is logged and the old icon stays.
+ fn refresh_tray_icon(&self) {
+ let Some(tray) = &self.tray else {
+ return;
+ };
+ let result = Icon::from_rgba(tray::icon_rgba(self.accents), icon::SIZE, icon::SIZE)
+ .map_err(|e| e.to_string())
+ .and_then(|icon| tray.set_icon(Some(icon)).map_err(|e| e.to_string()));
+ if let Err(e) = result {
+ tracing::warn!("failed to recolour the tray icon: {e}");
+ }
+ }
+
+ /// After a new snapshot: put the live totals in the tray tooltip and
+ /// rebuild the tray menu when the rules it lists changed. Runs while
+ /// hidden too, which is when the tray is what the user sees.
+ fn refresh_tray(&mut self, ctx: &egui::Context) {
+ let Some(tray) = &self.tray else {
+ return;
+ };
+ let snap = &self.last_snapshot;
+ let tooltip = tray::tooltip(snap);
+ if tooltip != self.tray_tooltip {
+ if let Err(e) = tray.set_tooltip(Some(&tooltip)) {
+ tracing::warn!("failed to update the tray tooltip: {e}");
+ }
+ self.tray_tooltip = tooltip;
+ }
+
+ let rules = tray::rules(snap);
+ if rules != self.tray_rules {
+ tray.set_menu(Some(Box::new(tray::menu(&rules))));
+ self.tray_rules = rules;
+ ctx.request_repaint();
}
}
+ /// Send the window level the View menu asks for.
+ fn send_window_level(&self, ctx: &egui::Context) {
+ let level = if self.settings.view.always_on_top {
+ egui::WindowLevel::AlwaysOnTop
+ } else {
+ egui::WindowLevel::Normal
+ };
+ ctx.send_viewport_cmd(egui::ViewportCommand::WindowLevel(level));
+ }
+
fn send_shutdown(&mut self) {
if !self.shutdown_sent {
let _ = self.cmd_tx.send(Command::Shutdown);
@@ -305,6 +516,10 @@ impl ThrottleApp {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
+ // Showing a hidden window can drop it back to the normal level.
+ if self.settings.view.always_on_top {
+ self.send_window_level(ctx);
+ }
self.hidden = false;
}
@@ -350,13 +565,25 @@ impl ThrottleApp {
let _ = self.cmd_tx.send(Command::SetRule(rule));
}
+ /// Block or unblock; either switches a disabled rule back on, since the
+ /// user just asked for it to do something.
fn set_blocked(&mut self, key: &str, blocked: bool) {
let mut rule = self.base_rule(key);
rule.exe_path = key.to_string();
rule.blocked = blocked;
+ rule.disabled = false;
self.send_rule(rule);
}
+ /// Switch a rule off (kept, not applied) or back on. No rule: nothing.
+ fn set_rule_disabled(&mut self, key: &str, disabled: bool) {
+ if let Some(rule) = self.last_snapshot.rules.get(key) {
+ let mut rule = rule.clone();
+ rule.disabled = disabled;
+ self.send_rule(rule);
+ }
+ }
+
fn open_limit_modal(&mut self, key: &str, name: &str, direction: Direction) {
let existing = self.last_snapshot.rules.get(key);
let current = existing.and_then(|r| match direction {
@@ -410,14 +637,14 @@ impl ThrottleApp {
fn sort_header(&mut self, ui: &mut egui::Ui, label: &str, col: SortColumn) {
let text = egui::RichText::new(label).strong();
let grow = egui::Atom::grow();
- let col_selected = self.sort_column == col;
+ let col_selected = self.settings.table.sort == col;
let direction_text = if !col_selected {
""
- } else if self.sort_desc {
- "▼"
+ } else if self.settings.table.descending {
+ "\u{25bc}"
} else {
- "▲"
+ "\u{25b2}"
};
// The default font is missing the arrows, use the bundled monospace font (Hack) instead
let direction = egui::RichText::new(direction_text).monospace();
@@ -429,12 +656,14 @@ impl ThrottleApp {
.corner_radius(0);
if ui.add(button).clicked() {
+ let table = &mut self.settings.table;
if col_selected {
- self.sort_desc = !self.sort_desc;
+ table.descending = !table.descending;
} else {
- self.sort_column = col;
- self.sort_desc = true;
+ table.sort = col;
+ table.descending = true;
}
+ self.save_settings();
}
}
@@ -442,6 +671,11 @@ impl ThrottleApp {
egui::Panel::top("menu_bar").show(ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
+ if ui.button("Import theme...").clicked() {
+ ui.close();
+ self.import_theme(ui.ctx());
+ }
+ ui.separator();
if ui.button("Open rules file").clicked() {
open_path(&rules::rules_path());
ui.close();
@@ -461,6 +695,15 @@ impl ThrottleApp {
}
});
ui.menu_button("Settings", |ui| self.draw_settings_menu(ui));
+ // Menus close on any click by default; this one stays open
+ // so palettes can be cycled through, and its submenus inherit
+ // that. Surprise me and Customise close it explicitly.
+ let stay_open = egui::containers::menu::MenuConfig::new()
+ .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
+ egui::containers::menu::MenuButton::new("Themes")
+ .config(stay_open)
+ .ui(ui, |ui| self.draw_themes_menu(ui));
+ ui.menu_button("View", |ui| self.draw_view_menu(ui));
ui.menu_button("Help", |ui| {
if ui.button("Documentation").clicked() {
open_url(ui.ctx(), &format!("{REPO_URL}#readme"));
@@ -471,8 +714,8 @@ impl ThrottleApp {
ui.close();
}
let update_label = match &self.update {
- Some(u) => format!("Update available: v{}…", u.version),
- None => "Check for updates…".to_string(),
+ Some(u) => format!("Update available: v{}\u{2026}", u.version),
+ None => "Check for updates\u{2026}".to_string(),
};
if ui.button(update_label).clicked() {
open_url(ui.ctx(), &format!("{REPO_URL}/releases/latest"));
@@ -483,7 +726,18 @@ impl ThrottleApp {
open_url(ui.ctx(), &format!("{REPO_URL}/blob/main/LICENSE"));
ui.close();
}
+ if ui.button("Third party notices").clicked() {
+ open_url(
+ ui.ctx(),
+ &format!("{REPO_URL}/blob/main/THIRD_PARTY_NOTICES.md"),
+ );
+ ui.close();
+ }
ui.separator();
+ if ui.button("Buy me a coffee").clicked() {
+ open_url(ui.ctx(), SUPPORT_URL);
+ ui.close();
+ }
if ui.button("About Throttle").clicked() {
self.about_open = true;
ui.close();
@@ -528,7 +782,248 @@ impl ThrottleApp {
self.set_autostart(autostart);
}
if let Some(e) = &self.autostart_error {
- ui.label(egui::RichText::new(e).small().color(COLOR_BLOCK));
+ ui.label(egui::RichText::new(e).small().color(self.accents.block));
+ }
+ }
+
+ fn draw_themes_menu(&mut self, ui: &mut egui::Ui) {
+ for (id, label) in theme::BUILTIN {
+ self.theme_row(ui, id, label, None);
+ }
+ ui.separator();
+ type InGroup = fn(&theme::Palette) -> bool;
+ let groups: [(&str, InGroup); 3] = [
+ ("Editor", |p| p.source == theme::Source::Editor),
+ ("Dark", |p| p.source == theme::Source::Community && p.dark),
+ ("Light", |p| p.source == theme::Source::Community && !p.dark),
+ ];
+ for (title, in_group) in groups {
+ ui.menu_button(title, |ui| {
+ for palette in theme::builtin().filter(|p| in_group(p)) {
+ self.theme_row(ui, &palette.id, &palette.label, Some(palette));
+ }
+ });
+ }
+ if !self.custom_palettes.is_empty() {
+ ui.menu_button("Imported", |ui| self.draw_imported_menu(ui));
+ }
+ ui.separator();
+ if ui.button("Surprise me").clicked() {
+ self.surprise_me(ui.ctx());
+ ui.close();
+ }
+ if ui.button("Customise...").clicked() {
+ self.customise_open = true;
+ ui.close();
+ }
+ }
+
+ /// The Imported submenu: one row per imported palette, then a Remove
+ /// submenu listing them again.
+ fn draw_imported_menu(&mut self, ui: &mut egui::Ui) {
+ // Cloned so the rows can borrow `self` mutably; the list is short.
+ let custom = self.custom_palettes.clone();
+ for palette in &custom {
+ self.theme_row(ui, &palette.id, &palette.label, Some(palette));
+ }
+ ui.separator();
+ ui.menu_button("Remove", |ui| {
+ for palette in &custom {
+ if ui.button(palette.label.as_ref()).clicked() {
+ self.remove_custom_theme(ui.ctx(), &palette.id);
+ ui.close();
+ }
+ }
+ });
+ }
+
+ /// One Themes menu row: a swatch for palettes, then the radio label. An
+ /// unknown stored id simply leaves no row checked. Clicking applies the
+ /// theme but leaves the menu open.
+ fn theme_row(
+ &mut self,
+ ui: &mut egui::Ui,
+ id: &str,
+ label: &str,
+ swatch: Option<&theme::Palette>,
+ ) {
+ let selected = self.settings.theme == id;
+ let clicked = match swatch {
+ Some(palette) => {
+ ui.horizontal(|ui| {
+ draw_swatch(ui, palette);
+ ui.radio(selected, label).clicked()
+ })
+ .inner
+ }
+ None => ui.radio(selected, label).clicked(),
+ };
+ // The Themes menu is set to stay open (see `draw_menu_bar`), so
+ // palettes can be cycled through in one go.
+ if clicked && !selected {
+ self.settings.theme = id.to_string();
+ self.restyle(ui.ctx());
+ self.save_settings();
+ }
+ }
+
+ /// Switch to a random bundled palette other than the current theme.
+ fn surprise_me(&mut self, ctx: &egui::Context) {
+ let others: Vec<&theme::Palette> = theme::builtin()
+ .filter(|p| p.id != self.settings.theme)
+ .collect();
+ if others.is_empty() {
+ return;
+ }
+ let palette = others[random_index(others.len(), time_seed())];
+ self.settings.theme = palette.id.to_string();
+ self.restyle(ctx);
+ self.save_settings();
+ }
+
+ /// File > Import theme...: pick a colour theme JSON file, add it to the
+ /// imported palettes and switch to it. A failure opens the error window.
+ fn import_theme(&mut self, ctx: &egui::Context) {
+ let Some(path) = rfd::FileDialog::new()
+ .add_filter("Colour theme", &["json"])
+ .pick_file()
+ else {
+ return;
+ };
+ match theme::import::import(&path) {
+ Ok(palette) => self.add_custom_theme(ctx, &palette),
+ Err(e) => {
+ tracing::warn!("failed to import {}: {e:#}", path.display());
+ self.import_error = Some(format!("{e:#}"));
+ }
+ }
+ }
+
+ /// Store `palette` as an imported theme (replacing one with the same id)
+ /// and make it the active theme.
+ fn add_custom_theme(&mut self, ctx: &egui::Context, palette: &theme::Palette) {
+ let mut stored = theme::CustomTheme::from(palette);
+ stored.id = custom_theme_id(&stored.id);
+ // Round trip through the stored form, so this session shows exactly
+ // what the next start will load.
+ let palette = stored.to_palette();
+ self.settings.custom_themes.retain(|t| t.id != stored.id);
+ self.custom_palettes.retain(|p| p.id != palette.id);
+ self.settings.theme = stored.id.clone();
+ self.settings.custom_themes.push(stored);
+ self.custom_palettes.push(palette);
+ self.import_error = None;
+ self.restyle(ctx);
+ self.save_settings();
+ }
+
+ /// Forget the imported theme `id`, falling back to the default theme if
+ /// it was the active one.
+ fn remove_custom_theme(&mut self, ctx: &egui::Context, id: &str) {
+ self.settings.custom_themes.retain(|t| t.id != id);
+ self.custom_palettes.retain(|p| p.id != id);
+ if self.settings.theme == id {
+ self.settings.theme = theme::DEFAULT_ID.to_string();
+ self.restyle(ctx);
+ }
+ self.save_settings();
+ }
+
+ fn draw_view_menu(&mut self, ui: &mut egui::Ui) {
+ let mut changed = false;
+ let view = &mut self.settings.view;
+ ui.menu_button("Columns", |ui| {
+ for (show, label) in [
+ (&mut view.show_flows, "Flows"),
+ (&mut view.show_down, "Down"),
+ (&mut view.show_up, "Up"),
+ (&mut view.show_down_total, "Total down"),
+ (&mut view.show_up_total, "Total up"),
+ (&mut view.show_rule, "Rule"),
+ ] {
+ changed |= ui.checkbox(show, label).changed();
+ }
+ });
+ ui.menu_button("Plot", |ui| {
+ changed |= plot_options(ui, view, true);
+ });
+ ui.separator();
+ let on_top_changed = ui
+ .checkbox(&mut view.always_on_top, "Always on top")
+ .changed();
+ changed |= on_top_changed;
+ changed |= ui
+ .checkbox(&mut view.compact, "Compact (hide header plot)")
+ .changed();
+ changed |= ui.checkbox(&mut view.show_filter, "Filter box").changed();
+ if on_top_changed {
+ self.send_window_level(ui.ctx());
+ }
+ if changed {
+ self.save_settings();
+ }
+ }
+
+ /// Right click menu of a plot built with `plot::rate_plot(id)`: reset
+ /// the view, then the same plot options as the View menu. The header
+ /// plot also offers its history length; the detail plot's is fixed.
+ fn plot_context_menu(&mut self, response: &egui::Response, id: &'static str, header: bool) {
+ response.context_menu(|ui| {
+ if ui.button("Reset view").clicked() {
+ plot::reset_view(ui.ctx(), id);
+ ui.close();
+ }
+ ui.separator();
+ if plot_options(ui, &mut self.settings.view, header) {
+ self.save_settings();
+ }
+ });
+ }
+
+ /// The Customise window: appearance options on top of the theme.
+ fn draw_customise_window(&mut self, ctx: &egui::Context) {
+ if !self.customise_open {
+ return;
+ }
+ let mut open = true;
+ let changed = egui::Window::new("Customise")
+ .open(&mut open)
+ .collapsible(false)
+ .resizable(false)
+ .show(ctx, |ui| {
+ appearance::customise_ui(ui, &mut self.settings.appearance)
+ })
+ .and_then(|r| r.inner)
+ .unwrap_or(false);
+ self.customise_open = open;
+ if changed {
+ self.restyle(ctx);
+ self.save_settings();
+ }
+ }
+
+ /// The "Import failed" window, shown while `import_error` is set.
+ fn draw_import_error(&mut self, ctx: &egui::Context) {
+ let Some(message) = &self.import_error else {
+ return;
+ };
+ let mut open = true;
+ let mut close = false;
+ egui::Window::new("Import failed")
+ .collapsible(false)
+ .resizable(false)
+ .open(&mut open)
+ .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
+ .show(ctx, |ui| {
+ ui.label("The colour theme could not be imported.");
+ ui.label(egui::RichText::new(message).color(self.accents.block));
+ ui.add_space(4.0);
+ if ui.button("Close").clicked() {
+ close = true;
+ }
+ });
+ if close || !open {
+ self.import_error = None;
}
}
@@ -580,11 +1075,14 @@ impl ThrottleApp {
);
if let UpdateInstall::Running(_) = self.update_install {
ui.spinner();
- ui.label("Downloading update…");
+ ui.label("Downloading update\u{2026}");
return;
}
if let UpdateInstall::Failed(e) = &self.update_install {
- ui.label(egui::RichText::new(format!("Update failed: {e}")).color(COLOR_BLOCK));
+ ui.label(
+ egui::RichText::new(format!("Update failed: {e}"))
+ .color(self.accents.block),
+ );
}
match &update.asset_url {
Some(asset_url) => {
@@ -658,10 +1156,11 @@ impl ThrottleApp {
ui.hyperlink_to("GitHub", REPO_URL);
ui.hyperlink_to("Releases", format!("{REPO_URL}/releases"));
ui.hyperlink_to("Report issue", format!("{REPO_URL}/issues"));
+ ui.hyperlink_to("Buy me a coffee", SUPPORT_URL);
});
ui.add_space(4.0);
ui.label(
- egui::RichText::new("Copyright © 2026 Francesco Gruosso")
+ egui::RichText::new("Copyright \u{a9} 2026 Francesco Gruosso")
.weak()
.small(),
);
@@ -669,32 +1168,61 @@ impl ThrottleApp {
self.about_open = open;
}
+ /// Monospace `text` when the Customise window asks for monospace numbers.
+ fn numeric(&self, text: egui::RichText) -> egui::RichText {
+ if self.settings.appearance.numbers_monospace {
+ text.monospace()
+ } else {
+ text
+ }
+ }
+
+ /// The header line: app name, total Down / Up rates and process count.
+ fn draw_totals_line(&self, ui: &mut egui::Ui, snap: &Snapshot) {
+ ui.horizontal(|ui| {
+ ui.heading("Throttle");
+ ui.separator();
+ ui.label(
+ self.numeric(
+ egui::RichText::new(format!("Down {}", humanize_rate(snap.total_down_rate)))
+ .color(self.accents.down)
+ .strong(),
+ ),
+ );
+ ui.label(
+ self.numeric(
+ egui::RichText::new(format!("Up {}", humanize_rate(snap.total_up_rate)))
+ .color(self.accents.up)
+ .strong(),
+ ),
+ );
+ ui.separator();
+ ui.label(format!("{} processes", snap.processes.len()));
+ if let Some(text) = self.total_plot_hint.text() {
+ ui.separator();
+ ui.weak(text);
+ }
+ });
+ }
+
fn draw_top_panel(&mut self, ui: &mut egui::Ui, snap: &Snapshot) {
+ if self.settings.view.compact {
+ // Only the totals line. Its own id keeps the full panel's
+ // resized height for when compact mode is turned off again.
+ egui::Panel::top("top_panel_compact").show(ui, |ui| {
+ ui.add_space(4.0);
+ self.draw_totals_line(ui, snap);
+ ui.add_space(4.0);
+ });
+ return;
+ }
egui::Panel::top("top_panel")
.resizable(true)
- .default_size(120.0)
+ .default_size(self.settings.panels.plot_height.unwrap_or(120.0))
.min_size(70.0)
.show(ui, |ui| {
ui.add_space(4.0);
- ui.horizontal(|ui| {
- ui.heading("Throttle");
- ui.separator();
- ui.label(
- egui::RichText::new(format!(
- "Down {}",
- humanize_rate(snap.total_down_rate)
- ))
- .color(COLOR_DOWN)
- .strong(),
- );
- ui.label(
- egui::RichText::new(format!("Up {}", humanize_rate(snap.total_up_rate)))
- .color(COLOR_UP)
- .strong(),
- );
- ui.separator();
- ui.label(format!("{} processes", snap.processes.len()));
- });
+ self.draw_totals_line(ui, snap);
// Let the plot fill the panel's remaining height rather than
// requesting an explicit one. An explicit height derived from
@@ -702,24 +1230,25 @@ impl ThrottleApp {
// with the drag target each frame, which egui persists as the new
// panel size and turns into a resize feedback loop. Filling avoids
// that.
- Plot::new("total_plot")
- .show_x(false)
- .allow_zoom(false)
- .allow_drag(false)
- .allow_scroll(false)
- .allow_boxed_zoom(false)
- .legend(Legend::default())
- .include_y(0.0)
- .show(ui, |pui| {
- pui.line(
- Line::new("Down", PlotPoints::from_ys_f64(&self.total_down_hist))
- .color(COLOR_DOWN),
- );
- pui.line(
- Line::new("Up", PlotPoints::from_ys_f64(&self.total_up_hist))
- .color(COLOR_UP),
- );
- });
+ let plot = plot::rate_plot("total_plot").show(ui, |pui| {
+ let view = &self.settings.view;
+ pui.line(plot::plot_line(
+ view,
+ "Down",
+ &self.total_down_hist,
+ self.accents.down,
+ ));
+ pui.line(plot::plot_line(
+ view,
+ "Up",
+ &self.total_up_hist,
+ self.accents.up,
+ ));
+ plot::plot_navigation(pui);
+ pui.auto_bounds()
+ });
+ self.total_plot_hint = PlotHint::from(&plot);
+ self.plot_context_menu(&plot.response, "total_plot", true);
});
}
@@ -732,7 +1261,7 @@ impl ThrottleApp {
egui::Panel::right("detail_panel")
.resizable(true)
- .default_size(300.0)
+ .default_size(self.settings.panels.detail_width.unwrap_or(300.0))
.min_size(220.0)
.show(ui, |ui| {
ui.add_space(4.0);
@@ -748,8 +1277,7 @@ impl ThrottleApp {
return;
};
let key = stats.exe_path.clone();
- ui.heading(&stats.name);
- ui.label(egui::RichText::new(&stats.exe_path).weak().small());
+ self.draw_detail_header(ui, &stats);
ui.separator();
egui::Grid::new("detail_grid")
@@ -763,11 +1291,15 @@ impl ThrottleApp {
ui.end_row();
ui.label("Download");
ui.label(
- egui::RichText::new(humanize_rate(stats.down_rate)).color(COLOR_DOWN),
+ egui::RichText::new(humanize_rate(stats.down_rate))
+ .color(self.accents.down),
);
ui.end_row();
ui.label("Upload");
- ui.label(egui::RichText::new(humanize_rate(stats.up_rate)).color(COLOR_UP));
+ ui.label(
+ egui::RichText::new(humanize_rate(stats.up_rate))
+ .color(self.accents.up),
+ );
ui.end_row();
ui.label("Total down");
ui.label(humanize_bytes(stats.down_total));
@@ -781,39 +1313,95 @@ impl ThrottleApp {
});
ui.separator();
- ui.label("History (per second)");
+ ui.horizontal(|ui| {
+ ui.label("History (per second)");
+ // Truncated: a side panel grows to fit its content, and
+ // the hint must not widen it whenever the plot is hovered.
+ if let Some(text) = self.detail_plot_hint.text() {
+ ui.add(egui::Label::new(egui::RichText::new(text).weak()).truncate());
+ }
+ });
let down: Vec = stats.down_history.iter().map(|&v| v as f64).collect();
let up: Vec = stats.up_history.iter().map(|&v| v as f64).collect();
- Plot::new("detail_plot")
+ let plot = plot::rate_plot("detail_plot")
.height(160.0)
- .show_x(false)
- .allow_zoom(false)
- .allow_drag(false)
- .allow_scroll(false)
- .allow_boxed_zoom(false)
- .legend(Legend::default())
- .include_y(0.0)
.show(ui, |pui| {
- pui.line(
- Line::new("Down", PlotPoints::from_ys_f64(&down)).color(COLOR_DOWN),
- );
- pui.line(Line::new("Up", PlotPoints::from_ys_f64(&up)).color(COLOR_UP));
+ let view = &self.settings.view;
+ pui.line(plot::plot_line(view, "Down", &down, self.accents.down));
+ pui.line(plot::plot_line(view, "Up", &up, self.accents.up));
+ plot::plot_navigation(pui);
+ pui.auto_bounds()
});
+ self.detail_plot_hint = PlotHint::from(&plot);
+ self.plot_context_menu(&plot.response, "detail_plot", false);
});
}
+ /// Process name and path, with a folder button on the right that opens
+ /// the executable's folder. The button is as tall as the two text lines.
+ fn draw_detail_header(&self, ui: &mut egui::Ui, stats: &ProcessStats) {
+ let folder = std::path::Path::new(&stats.exe_path)
+ .parent()
+ .filter(|p| !p.as_os_str().is_empty() && p.is_dir());
+ ui.horizontal(|ui| {
+ let button_width = if folder.is_some() {
+ FOLDER_BUTTON_WIDTH
+ } else {
+ 0.0
+ };
+ let text_width = (ui.available_width() - button_width).max(0.0);
+ let text = ui
+ .allocate_ui_with_layout(
+ egui::vec2(text_width, 0.0),
+ egui::Layout::top_down(egui::Align::Min),
+ |ui| {
+ ui.set_width(text_width);
+ ui.heading(&stats.name);
+ ui.label(egui::RichText::new(&stats.exe_path).weak().small());
+ },
+ )
+ .response
+ .rect;
+ if let Some(folder) = folder {
+ let side = text.height().clamp(24.0, FOLDER_BUTTON_WIDTH);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let response =
+ folder_button(ui, side).on_hover_text(format!("Open {}", folder.display()));
+ if response.clicked() {
+ open_path(folder);
+ }
+ });
+ }
+ });
+ }
+
fn draw_table(&mut self, ui: &mut egui::Ui, snap: &Snapshot) {
// Owned, sorted list of (lowercase key, stats). The key is used for all
// rule commands per the shared contract.
+ let total = snap.processes.len();
let mut rows: Vec<(String, ProcessStats)> = snap
.processes
.iter()
+ .filter(|(_, v)| matches_filter(v, &self.filter))
+ .filter(|(_, v)| !self.settings.table.active_only || is_active(v))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
- let sort_column = self.sort_column;
- let sort_desc = self.sort_desc;
+ let columns = visible_columns(&self.settings.view);
+ // A hidden column cannot be clicked to change the order, so fall back
+ // to sorting by name.
+ if !columns
+ .iter()
+ .any(|c| c.sort() == Some(self.settings.table.sort))
+ {
+ self.settings.table.sort = SortColumn::Name;
+ self.settings.table.descending = false;
+ self.save_settings();
+ }
+
+ let sort_column = self.settings.table.sort;
+ let sort_desc = self.settings.table.descending;
rows.sort_by(|a, b| {
let (a, b) = (&a.1, &b.1);
let ord = match sort_column {
@@ -827,45 +1415,173 @@ impl ThrottleApp {
if sort_desc { ord.reverse() } else { ord }
});
- const SORT_COLUMN_LABELS: [(SortColumn, &str); 6] = [
- (SortColumn::Name, "Process"),
- (SortColumn::Flows, "Flows"),
- (SortColumn::Down, "Down"),
- (SortColumn::Up, "Up"),
- (SortColumn::DownTotal, "Total down"),
- (SortColumn::UpTotal, "Total up"),
- ];
+ let row_height = self.settings.appearance.density.row_height();
egui::CentralPanel::default().show(ui, |ui| {
- egui_extras::TableBuilder::new(ui)
+ if self.settings.view.show_filter {
+ self.draw_filter_row(ui, rows.len(), total);
+ }
+
+ // Saved widths are only usable as a full set: with any column
+ // left to auto size, the table's sizing pass resizes every column
+ // to its content and the saved widths would be lost anyway.
+ let saved = &self.settings.table.widths;
+ let restore = columns.iter().all(|c| saved.contains_key(c.id()));
+ let last = columns.len() - 1;
+ let mut table = egui_extras::TableBuilder::new(ui)
.resizable(true)
.auto_shrink(false)
- .striped(true)
+ .striped(self.settings.appearance.stripes)
.sense(egui::Sense::CLICK | egui::Sense::HOVER)
- .column(egui_extras::Column::remainder().at_least(200.)) // Name
- .columns(egui_extras::Column::auto(), 5) // Numeric
- .column(egui_extras::Column::remainder()) // Rule
- .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
- .header(20., |mut row| {
- for (sort_column, label) in SORT_COLUMN_LABELS {
- row.col(|ui| self.sort_header(ui, label, sort_column));
+ .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
+ for (i, column) in columns.iter().enumerate() {
+ table = table.column(match column {
+ // The last column fills the table, so it stays remainder.
+ _ if restore && i == last => egui_extras::Column::remainder(),
+ TableColumn::Name if restore => {
+ egui_extras::Column::initial(saved[column.id()]).at_least(200.)
+ }
+ _ if restore => egui_extras::Column::initial(saved[column.id()]),
+ TableColumn::Name => egui_extras::Column::remainder().at_least(200.),
+ TableColumn::Rule => egui_extras::Column::remainder(),
+ _ => egui_extras::Column::auto(), // Numeric
+ });
+ }
+ // Column widths as laid out this frame, from the header cells. The
+ // table sizes auto columns in a sizing pass of its own child ui,
+ // where the widths are provisional, so the cells report that too.
+ let mut widths: Vec = Vec::with_capacity(columns.len());
+ let mut sizing_pass = false;
+ table
+ .header(row_height, |mut row| {
+ for &column in &columns {
+ match column.sort() {
+ Some(sort_column) => {
+ row.col(|ui| {
+ sizing_pass |= ui.is_sizing_pass();
+ widths.push(ui.max_rect().width());
+ self.sort_header(ui, column.label(), sort_column);
+ });
+ }
+ None => {
+ row.col(|ui| {
+ sizing_pass |= ui.is_sizing_pass();
+ widths.push(ui.max_rect().width());
+ ui.label(egui::RichText::new(column.label()).strong());
+ });
+ }
+ }
}
- row.col(|ui| {
- ui.label(egui::RichText::new("Rule").strong());
- });
})
.body(|body| {
- body.rows(20., rows.len(), |row| {
+ body.rows(row_height, rows.len(), |row| {
let (key, stats) = rows.get(row.index()).expect("row index in bounds");
- let resp = self.draw_table_row(row, key, stats, snap);
+ let resp = self.draw_table_row(row, key, stats, snap, &columns);
if resp.clicked() || resp.secondary_clicked() {
self.selected = Some(key.clone());
}
self.row_context_menu(&resp, key, &stats.name);
});
});
+ // Not during a sizing pass nor while a drag may be under way.
+ if !sizing_pass && !ui.input(|i| i.pointer.any_down()) {
+ self.remember_widths(&columns, &widths);
+ }
+ });
+ }
+
+ /// The filter box above the table, with the match count and a clear
+ /// button once something is typed. Ctrl+F focuses it, Escape clears it.
+ fn draw_filter_row(&mut self, ui: &mut egui::Ui, shown: usize, total: usize) {
+ ui.add_space(2.0);
+ ui.horizontal(|ui| {
+ // The text edit drops focus and swallows Escape itself, so the
+ // key is read before it runs.
+ let id = ui.make_persistent_id("process_filter");
+ let escape =
+ ui.memory(|m| m.has_focus(id)) && ui.input(|i| i.key_pressed(egui::Key::Escape));
+ if escape {
+ self.filter.clear();
+ }
+ let response = ui.add(
+ egui::TextEdit::singleline(&mut self.filter)
+ .id(id)
+ .hint_text("Filter processes (Ctrl+F)")
+ .margin(egui::Margin::symmetric(8, 5))
+ .desired_width(220.0),
+ );
+ if self.focus_filter {
+ self.focus_filter = false;
+ response.request_focus();
+ }
+ if !self.filter.is_empty() && ui.small_button("Clear").clicked() {
+ self.filter.clear();
+ }
+ // Framed in every state: an unframed idle chip grows by its
+ // stroke on hover and shifts the row by a pixel.
+ let active = self.settings.table.active_only;
+ if ui
+ .add(egui::Button::selectable(active, "Active").frame_when_inactive(true))
+ .on_hover_text("Only processes moving data right now")
+ .clicked()
+ {
+ self.settings.table.active_only = !active;
+ self.save_settings();
+ }
+ if shown != total {
+ ui.weak(format!("{shown} of {total}"));
+ }
});
+ ui.add_space(2.0);
+ }
+
+ /// Keep the sizes of the two resizable panels in the settings, once a
+ /// drag has ended and only when one of them moved by more than a hair.
+ fn remember_panels(&mut self, ctx: &egui::Context) {
+ if ctx.input(|i| i.pointer.any_down()) {
+ return;
+ }
+ let size = |id: &str| {
+ egui::containers::panel::PanelState::load(ctx, egui::Id::new(id)).map(|s| s.size())
+ };
+ let plot_height = size("top_panel").map(|s| s.y);
+ let detail_width = size("detail_panel").map(|s| s.x);
+ let mut changed = false;
+ for (saved, seen) in [
+ (&mut self.settings.panels.plot_height, plot_height),
+ (&mut self.settings.panels.detail_width, detail_width),
+ ] {
+ if let Some(v) = seen.and_then(|seen| settle(*saved, seen)) {
+ *saved = Some(v);
+ changed = true;
+ }
+ }
+ if changed {
+ self.save_settings();
+ }
+ }
+
+ /// Keep the table's column widths in the settings. Only widths that
+ /// moved by more than a hair are written, so a drag ends in one save.
+ /// The last column takes whatever is left, so it follows every window
+ /// resize; it is recorded but never a reason to write the file.
+ fn remember_widths(&mut self, columns: &[TableColumn], widths: &[f32]) {
+ if widths.len() != columns.len() {
+ return;
+ }
+ let saved = &mut self.settings.table.widths;
+ let mut changed = false;
+ let last = columns.len() - 1;
+ for (i, (column, &width)) in columns.iter().zip(widths).enumerate() {
+ if let Some(width) = settle(saved.get(column.id()).copied(), width) {
+ let first_time = saved.insert(column.id().to_string(), width).is_none();
+ changed |= i != last || first_time;
+ }
+ }
+ if changed {
+ self.save_settings();
+ }
}
fn draw_table_row(
@@ -874,28 +1590,41 @@ impl ThrottleApp {
key: &String,
stats: &ProcessStats,
snap: &Snapshot,
+ columns: &[TableColumn],
) -> egui::Response {
let selected = self.selected.as_deref() == Some(key.as_str());
- let rule = snap.rules.get(key);
- let summary = rule_summary(rule);
- let rule_text = if rule.map(|r| r.blocked).unwrap_or(false) {
- egui::RichText::new(summary).color(COLOR_BLOCK).strong()
- } else {
- egui::RichText::new(summary)
- };
-
- let cells: [egui::RichText; 7] = [
- egui::RichText::new(&stats.name),
- egui::RichText::new(stats.flow_count.to_string()),
- egui::RichText::new(humanize_rate(stats.down_rate)).color(COLOR_DOWN),
- egui::RichText::new(humanize_rate(stats.up_rate)).color(COLOR_UP),
- egui::RichText::new(humanize_bytes(stats.down_total)),
- egui::RichText::new(humanize_bytes(stats.up_total)),
- rule_text,
- ];
row.set_selected(selected);
- for content in cells {
+ for &column in columns {
+ let content = match column {
+ TableColumn::Name => egui::RichText::new(&stats.name),
+ TableColumn::Flows => egui::RichText::new(stats.flow_count.to_string()),
+ TableColumn::Down => self.numeric(
+ egui::RichText::new(humanize_rate(stats.down_rate)).color(self.accents.down),
+ ),
+ TableColumn::Up => self.numeric(
+ egui::RichText::new(humanize_rate(stats.up_rate)).color(self.accents.up),
+ ),
+ TableColumn::DownTotal => {
+ self.numeric(egui::RichText::new(humanize_bytes(stats.down_total)))
+ }
+ TableColumn::UpTotal => {
+ self.numeric(egui::RichText::new(humanize_bytes(stats.up_total)))
+ }
+ TableColumn::Rule => {
+ let rule = snap.rules.get(key);
+ let summary = rule_summary(rule);
+ if rule.is_some_and(|r| r.disabled) {
+ egui::RichText::new(summary).weak()
+ } else if rule.is_some_and(|r| r.blocked) {
+ egui::RichText::new(summary)
+ .color(self.accents.block)
+ .strong()
+ } else {
+ egui::RichText::new(summary)
+ }
+ }
+ };
row.col(|ui| {
egui::Label::new(content)
.wrap_mode(egui::TextWrapMode::Truncate)
@@ -930,11 +1659,11 @@ impl ThrottleApp {
);
ui.separator();
}
- if ui.button("Set download limit…").clicked() {
+ if ui.button("Set download limit\u{2026}").clicked() {
self.open_limit_modal(key, name, Direction::Down);
ui.close();
}
- if ui.button("Set upload limit…").clicked() {
+ if ui.button("Set upload limit\u{2026}").clicked() {
self.open_limit_modal(key, name, Direction::Up);
ui.close();
}
@@ -955,6 +1684,17 @@ impl ThrottleApp {
ui.close();
}
ui.separator();
+ if let Some(rule) = self.last_snapshot.rules.get(key) {
+ let (label, disable) = if rule.disabled {
+ ("Enable rule", false)
+ } else {
+ ("Disable rule", true)
+ };
+ if ui.button(label).clicked() {
+ self.set_rule_disabled(key, disable);
+ ui.close();
+ }
+ }
if ui.button("Remove rule").clicked() {
let _ = self.cmd_tx.send(Command::RemoveRule(key.to_string()));
ui.close();
@@ -989,11 +1729,23 @@ impl ThrottleApp {
.small(),
);
ui.horizontal(|ui| {
+ // As tall as the unit box beside it, which is a button:
+ // the interact height, or its text plus padding if taller.
+ let spacing = ui.spacing();
+ let button_height = spacing.interact_size.y.max(
+ ui.text_style_height(&egui::TextStyle::Button)
+ + 2.0 * spacing.button_padding.y,
+ );
let resp = ui.add(
egui::TextEdit::singleline(&mut modal.value)
.desired_width(80.0)
+ .min_size(egui::vec2(0.0, button_height))
+ .vertical_align(egui::Align::Center)
.hint_text("e.g. 2"),
);
+ if resp.changed() {
+ modal.value = numeric_input(&modal.value);
+ }
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
apply = true;
}
@@ -1006,7 +1758,7 @@ impl ThrottleApp {
});
});
if let Some(err) = &modal.error {
- ui.colored_label(COLOR_BLOCK, err);
+ ui.colored_label(self.accents.block, err);
}
ui.add_space(4.0);
ui.horizontal(|ui| {
@@ -1045,6 +1797,8 @@ impl ThrottleApp {
Direction::Down => rule.down_limit = limit,
Direction::Up => rule.up_limit = limit,
}
+ // A new limit is meant to apply, so a disabled rule comes back on.
+ rule.disabled = false;
self.send_rule(rule);
return; // done, modal closes
}
@@ -1060,18 +1814,27 @@ impl eframe::App for ThrottleApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Drain pending snapshots even while hidden: the channel is unbounded.
let incoming: Vec = self.snapshot_rx.try_iter().collect();
+ let keep = self.settings.view.history_secs.max(1);
+ let mut fresh = false;
for snap in incoming {
self.total_down_hist.push(snap.total_down_rate as f64);
self.total_up_hist.push(snap.total_up_rate as f64);
- if self.total_down_hist.len() > TOTAL_HISTORY_LEN {
- let excess = self.total_down_hist.len() - TOTAL_HISTORY_LEN;
- self.total_down_hist.drain(0..excess);
- }
- if self.total_up_hist.len() > TOTAL_HISTORY_LEN {
- let excess = self.total_up_hist.len() - TOTAL_HISTORY_LEN;
- self.total_up_hist.drain(0..excess);
- }
+ trim_front(&mut self.total_down_hist, keep);
+ trim_front(&mut self.total_up_hist, keep);
self.last_snapshot = snap;
+ fresh = true;
+ }
+ if fresh {
+ self.refresh_tray(ctx);
+ }
+
+ // The window exists by the first frame; only a non default level is
+ // sent, so the stock window is left alone.
+ if self.first_frame {
+ self.first_frame = false;
+ if self.settings.view.always_on_top {
+ self.send_window_level(ctx);
+ }
}
// At logon the startup task can run before Explorer has created the
@@ -1079,7 +1842,7 @@ impl eframe::App for ThrottleApp {
// it rather than hide with no icon to click; give up and stay visible if
// it takes too long.
if let Some(deadline) = self.start_hidden {
- if taskbar_ready() {
+ if tray::taskbar_ready() {
self.start_hidden = None;
self.hide_to_tray(ctx);
} else if Instant::now() >= deadline {
@@ -1096,6 +1859,12 @@ impl eframe::App for ThrottleApp {
self.show_window(ctx);
}
TrayAction::Exit => self.exit(ctx),
+ TrayAction::ToggleRule(key) => {
+ let disabled = self.last_snapshot.rules.get(&key).map(|r| r.disabled);
+ if let Some(disabled) = disabled {
+ self.set_rule_disabled(&key, !disabled);
+ }
+ }
}
}
@@ -1122,9 +1891,23 @@ impl eframe::App for ThrottleApp {
ctx.request_repaint_after(Duration::from_millis(500));
}
+ /// Only the window geometry is persisted by eframe; the rest of the
+ /// layout is in settings.json, so a stale egui state cannot shadow it.
+ fn persist_egui_memory(&self) -> bool {
+ false
+ }
+
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
+ // Ctrl plus / minus zoomed the UI: keep the new factor as the scale.
+ // A scale change from the Customise window lands here already equal.
+ let zoom = ctx.zoom_factor();
+ if (zoom - self.settings.appearance.scale).abs() > 0.001 {
+ self.settings.appearance.scale = zoom;
+ self.save_settings();
+ }
+
if let Ok(enabled) = self.autostart_rx.try_recv() {
self.autostart = Some(enabled);
}
@@ -1142,6 +1925,12 @@ impl eframe::App for ThrottleApp {
self.update = Some(update);
}
+ if self.settings.view.show_filter
+ && ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::F))
+ {
+ self.focus_filter = true;
+ }
+
self.draw_menu_bar(ui);
self.draw_update_banner(ui);
self.draw_top_panel(ui, &snap);
@@ -1149,6 +1938,9 @@ impl eframe::App for ThrottleApp {
self.draw_table(ui, &snap);
self.draw_limit_modal(&ctx);
self.draw_about(&ctx);
+ self.draw_customise_window(&ctx);
+ self.draw_import_error(&ctx);
+ self.remember_panels(&ctx);
}
}
@@ -1159,80 +1951,9 @@ impl Drop for ThrottleApp {
}
// ---------------------------------------------------------------------------
-// Tray and startup helpers
+// Startup helpers
// ---------------------------------------------------------------------------
-/// Create the tray icon: left click shows the window, right click opens a
-/// Show / Exit menu. Returns `None` (logged) if it cannot be created. If the
-/// taskbar does not exist yet, tray-icon still succeeds and adds the icon when
-/// Explorer announces the taskbar (see `taskbar_ready`).
-fn create_tray(ctx: &egui::Context, tx: Sender) -> Option {
- let show = MenuItem::new("Show Throttle", true, None);
- let exit = MenuItem::new("Exit", true, None);
- let menu = Menu::new();
- if let Err(e) = menu.append_items(&[&show, &PredefinedMenuItem::separator(), &exit]) {
- tracing::warn!("tray menu unavailable: {e}");
- return None;
- }
-
- // Hovering fires Move/Enter/Leave constantly: only clicks wake the app.
- let (click_tx, click_ctx) = (tx.clone(), ctx.clone());
- TrayIconEvent::set_event_handler(Some(move |event| {
- if let TrayIconEvent::Click {
- button: MouseButton::Left,
- button_state: MouseButtonState::Up,
- ..
- }
- | TrayIconEvent::DoubleClick {
- button: MouseButton::Left,
- ..
- } = event
- {
- let _ = click_tx.send(TrayAction::Show);
- click_ctx.request_repaint();
- }
- }));
- let (show_id, exit_id, ctx) = (show.id().clone(), exit.id().clone(), ctx.clone());
- MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
- let action = if event.id == show_id {
- TrayAction::Show
- } else if event.id == exit_id {
- TrayAction::Exit
- } else {
- return;
- };
- let _ = tx.send(action);
- ctx.request_repaint();
- }));
-
- let built = Icon::from_rgba(icon::rgba(), icon::SIZE, icon::SIZE)
- .map_err(|e| e.to_string())
- .and_then(|icon| {
- TrayIconBuilder::new()
- .with_icon(icon)
- .with_tooltip("Throttle")
- .with_menu(Box::new(menu))
- .with_menu_on_left_click(false)
- .build()
- .map_err(|e| e.to_string())
- });
- match built {
- Ok(tray) => Some(tray),
- Err(e) => {
- tracing::warn!("tray icon unavailable: {e}");
- None
- }
- }
-}
-
-/// Whether Explorer's taskbar (and so the notification area) exists.
-fn taskbar_ready() -> bool {
- use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
- use windows::core::w;
- // SAFETY: FindWindowW with a valid static class name; a miss is an error.
- unsafe { FindWindowW(w!("Shell_TrayWnd"), None) }.is_ok_and(|h| !h.is_invalid())
-}
-
/// Check the startup task off the UI thread (`schtasks` takes a moment). The
/// task is re-pointed at this exe only if the exe it starts no longer exists
/// (Throttle was moved): running another copy once must not take it over.
@@ -1252,6 +1973,76 @@ fn spawn_autostart_check() -> Receiver {
rx
}
+// ---------------------------------------------------------------------------
+// Theme and plot helpers
+// ---------------------------------------------------------------------------
+
+/// Paint a palette preview before its menu label: the background with a
+/// border and one dot per accent (down, up, block).
+fn draw_swatch(ui: &mut egui::Ui, palette: &theme::Palette) {
+ let (rect, _) = ui.allocate_exact_size(egui::vec2(14.0, 10.0), egui::Sense::hover());
+ let painter = ui.painter();
+ painter.rect_filled(rect, 0.0, palette.bg);
+ painter.rect_stroke(
+ rect,
+ 0.0,
+ egui::Stroke::new(1.0, palette.border),
+ egui::StrokeKind::Inside,
+ );
+ let accents = [
+ palette.accents.down,
+ palette.accents.up,
+ palette.accents.block,
+ ];
+ for (i, colour) in accents.into_iter().enumerate() {
+ let centre = egui::pos2(rect.left() + 3.5 * (i + 1) as f32, rect.center().y);
+ painter.circle_filled(centre, 1.5, colour);
+ }
+}
+
+/// Drop the oldest samples so at most `keep` remain.
+fn trim_front(hist: &mut Vec, keep: usize) {
+ if hist.len() > keep {
+ let excess = hist.len() - keep;
+ hist.drain(0..excess);
+ }
+}
+
+/// The id to store an imported palette under: its own, unless it is empty
+/// or would be shadowed by a bundled theme, which `theme::find` prefers.
+fn custom_theme_id(id: &str) -> String {
+ let id = id.trim();
+ if id.is_empty() {
+ return "imported".to_string();
+ }
+ let taken =
+ theme::BUILTIN.iter().any(|(b, _)| *b == id) || theme::builtin().any(|p| p.id == id);
+ if taken {
+ format!("{id}-imported")
+ } else {
+ id.to_string()
+ }
+}
+
+/// A seed for "Surprise me" from the clock; no need for a random crate.
+fn time_seed() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
+ .unwrap_or(0)
+}
+
+/// Map `seed` to an index below `len` (which must be non zero). The seed is
+/// mixed first: Windows clocks tick in 100 ns steps, so the raw nanoseconds
+/// share factors with many list lengths.
+fn random_index(len: usize, seed: u64) -> usize {
+ let mut x = seed;
+ x ^= x >> 33;
+ x = x.wrapping_mul(0xff51_afd7_ed55_8ccd);
+ x ^= x >> 33;
+ (x % len as u64) as usize
+}
+
// ---------------------------------------------------------------------------
// Shell helpers
// ---------------------------------------------------------------------------
@@ -1263,6 +2054,36 @@ fn open_url(ctx: &egui::Context, url: &str) {
/// Open a file with its default application, or a folder in Explorer.
/// Best-effort: failures are logged, never surfaced as errors.
+/// A square folder icon button of `side` points. It uses the theme's text
+/// colours, which are chosen to read against the panel in every palette:
+/// the weak text colour at rest, the full text colour under the pointer,
+/// over a faint translucent background.
+fn folder_button(ui: &mut egui::Ui, side: f32) -> egui::Response {
+ let (rect, response) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::click());
+ let visuals = ui.visuals();
+ let colour = if response.hovered() {
+ visuals.text_color()
+ } else {
+ visuals.weak_text_color()
+ };
+ // The front flap is a step towards the panel colour, for a little depth.
+ let flap_colour = colour.lerp_to_gamma(visuals.panel_fill, 0.3);
+ let painter = ui.painter();
+ if response.hovered() {
+ let bg = visuals.text_color().gamma_multiply(0.1);
+ painter.rect_filled(rect, egui::CornerRadius::same(4), bg);
+ }
+ // A tab on the top left, the body under it and the front flap.
+ let r = rect.shrink(side * 0.22);
+ let tab = egui::Rect::from_min_size(r.min, egui::vec2(r.width() * 0.42, r.height() * 0.3));
+ let body = egui::Rect::from_min_max(egui::pos2(r.min.x, r.min.y + r.height() * 0.16), r.max);
+ let flap = egui::Rect::from_min_max(egui::pos2(r.min.x, r.min.y + r.height() * 0.45), r.max);
+ painter.rect_filled(tab, egui::CornerRadius::same(2), colour);
+ painter.rect_filled(body, egui::CornerRadius::same(2), colour);
+ painter.rect_filled(flap, egui::CornerRadius::same(2), flap_colour);
+ response.on_hover_cursor(egui::CursorIcon::PointingHand)
+}
+
fn open_path(path: &std::path::Path) {
if let Err(e) = std::process::Command::new("explorer").arg(path).spawn() {
tracing::warn!("failed to open {}: {e}", path.display());
@@ -1305,7 +2126,7 @@ impl eframe::App for ErrorApp {
ui.label(
egui::RichText::new(&self.message)
.monospace()
- .color(COLOR_BLOCK),
+ .color(theme::Accents::DEFAULT.block),
);
});
ui.add_space(12.0);
@@ -1316,3 +2137,146 @@ impl eframe::App for ErrorApp {
});
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_history_matches_view_default() {
+ assert_eq!(View::default().history_secs, TOTAL_HISTORY_LEN);
+ assert!(HISTORY_CHOICES.iter().any(|(s, _)| *s == TOTAL_HISTORY_LEN));
+ let width = View::default().plot_line_width;
+ assert!(LINE_WIDTH_CHOICES.iter().any(|(w, _)| *w == width));
+ }
+
+ #[test]
+ fn default_view_shows_every_column() {
+ assert_eq!(visible_columns(&View::default()), TableColumn::ALL.to_vec());
+ }
+
+ #[test]
+ fn hidden_columns_keep_the_name() {
+ let view = View {
+ show_flows: false,
+ show_down: false,
+ show_up: false,
+ show_down_total: false,
+ show_up_total: false,
+ show_rule: false,
+ ..View::default()
+ };
+ assert_eq!(visible_columns(&view), vec![TableColumn::Name]);
+ let view = View {
+ show_up: false,
+ ..View::default()
+ };
+ let columns = visible_columns(&view);
+ assert!(!columns.contains(&TableColumn::Up));
+ assert_eq!(columns.len(), 6);
+ }
+
+ #[test]
+ fn limit_field_keeps_only_a_number() {
+ assert_eq!(numeric_input("dsadsa"), "");
+ assert_eq!(numeric_input("2"), "2");
+ assert_eq!(numeric_input("0.5"), "0.5");
+ assert_eq!(numeric_input("1,5"), "1,5");
+ assert_eq!(numeric_input("1.2.3"), "1.23");
+ assert_eq!(numeric_input("12 MB/s"), "12");
+ assert_eq!(numeric_input("-3e5"), "35");
+ }
+
+ #[test]
+ fn rule_summary_marks_disabled_rules() {
+ let rule = Rule {
+ exe_path: "c:\\x\\a.exe".to_string(),
+ down_limit: Some(2 * 1024 * 1024),
+ up_limit: None,
+ blocked: false,
+ disabled: true,
+ };
+ assert_eq!(rule_summary(Some(&rule)), "Off: Dn 2.00 MB/s");
+ let active = Rule {
+ disabled: false,
+ ..rule.clone()
+ };
+ assert_eq!(rule_summary(Some(&active)), "Dn 2.00 MB/s");
+ let blocked = Rule {
+ blocked: true,
+ ..rule
+ };
+ assert_eq!(rule_summary(Some(&blocked)), "Off: BLOCKED");
+ assert_eq!(rule_summary(None), "-");
+ }
+
+ #[test]
+ fn active_means_moving_data_in_either_direction() {
+ let idle = ProcessStats::default();
+ assert!(!is_active(&idle));
+ assert!(is_active(&ProcessStats {
+ up_rate: 1,
+ ..ProcessStats::default()
+ }));
+ assert!(is_active(&ProcessStats {
+ down_rate: 1,
+ ..ProcessStats::default()
+ }));
+ }
+
+ #[test]
+ fn filter_matches_name_or_path_ignoring_case() {
+ let stats = ProcessStats {
+ name: "Chrome.exe".to_string(),
+ exe_path: "c:\\program files\\google\\chrome.exe".to_string(),
+ ..ProcessStats::default()
+ };
+ assert!(matches_filter(&stats, ""));
+ assert!(matches_filter(&stats, " "));
+ assert!(matches_filter(&stats, "CHROME"));
+ assert!(matches_filter(&stats, "google"));
+ assert!(!matches_filter(&stats, "firefox"));
+ }
+
+ #[test]
+ fn column_ids_are_unique() {
+ let mut ids: Vec<&str> = TableColumn::ALL.iter().map(|c| c.id()).collect();
+ ids.sort_unstable();
+ ids.dedup();
+ assert_eq!(ids.len(), TableColumn::ALL.len());
+ }
+
+ #[test]
+ fn trim_front_keeps_the_newest() {
+ let mut hist: Vec = (0..10).map(f64::from).collect();
+ trim_front(&mut hist, 3);
+ assert_eq!(hist, vec![7.0, 8.0, 9.0]);
+ trim_front(&mut hist, 5);
+ assert_eq!(hist.len(), 3);
+ }
+
+ #[test]
+ fn random_index_stays_in_range_and_varies() {
+ let mut seen = std::collections::HashSet::new();
+ // Seeds 100 ns apart, as a Windows clock gives them.
+ for i in 0..200u64 {
+ let idx = random_index(20, 1_700_000_000 ^ (i * 100));
+ assert!(idx < 20);
+ seen.insert(idx);
+ }
+ assert!(seen.len() > 10, "only {} distinct picks", seen.len());
+ assert_eq!(random_index(1, 12345), 0);
+ }
+
+ #[test]
+ fn custom_ids_never_shadow_bundled_themes() {
+ assert_eq!(custom_theme_id("my-theme"), "my-theme");
+ assert_eq!(custom_theme_id(" "), "imported");
+ assert_eq!(custom_theme_id(theme::DEFAULT_ID), "system-imported");
+ if let Some(p) = theme::builtin().next() {
+ let id = custom_theme_id(&p.id);
+ assert_ne!(id, p.id);
+ assert!(theme::find(&id, &[]).is_none());
+ }
+ }
+}
diff --git a/src/icon.rs b/src/icon.rs
index 632706d..ee53f4f 100644
--- a/src/icon.rs
+++ b/src/icon.rs
@@ -8,7 +8,7 @@
/// Icon edge length in pixels.
pub const SIZE: u32 = 64;
-// Palette (matches gui.rs COLOR_DOWN / COLOR_UP and the dark panel background).
+// Palette (matches theme::Accents::DEFAULT down / up and the dark panel background).
const BG: [u8; 3] = [0x1e, 0x24, 0x30];
const BG_EDGE: [u8; 3] = [0x2b, 0x33, 0x42];
const DOWN: [u8; 3] = [0x4c, 0xc2, 0x5c]; // green
@@ -16,6 +16,12 @@ const UP: [u8; 3] = [0x4a, 0x9e, 0xff]; // blue
/// Generate the icon as `SIZE x SIZE` RGBA8 pixels (row-major, top-left origin).
pub fn rgba() -> Vec {
+ rgba_with(DOWN, UP)
+}
+
+/// Icon pixels with custom `down` and `up` arrow colours, for a tray icon
+/// that follows the active theme accents.
+pub fn rgba_with(down: [u8; 3], up: [u8; 3]) -> Vec {
let s = SIZE as f32;
let mut px = vec![0u8; (SIZE * SIZE * 4) as usize];
@@ -41,9 +47,9 @@ pub fn rgba() -> Vec {
// Left glyph: download arrow (points down). Right glyph: upload
// arrow (points up). Each occupies its half of the badge.
if in_arrow(fx, fy, cx_left, s, true) {
- rgb = DOWN;
+ rgb = down;
} else if in_arrow(fx, fy, cx_right, s, false) {
- rgb = UP;
+ rgb = up;
}
let idx = ((y * SIZE + x) * 4) as usize;
@@ -162,3 +168,25 @@ pub fn ico_bytes() -> Vec {
ico.extend(std::iter::repeat_n(0u8, h * and_stride));
ico
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_colours_keep_the_stock_icon() {
+ assert_eq!(rgba(), rgba_with(DOWN, UP));
+ assert_eq!(rgba().len(), (SIZE * SIZE * 4) as usize);
+ }
+
+ #[test]
+ fn custom_colours_change_the_arrows() {
+ let custom = rgba_with([255, 0, 0], [0, 0, 255]);
+ assert_ne!(custom, rgba());
+ // The badge outline (alpha) is unchanged; only arrow pixels differ.
+ let stock = rgba();
+ for (a, b) in custom.chunks(4).zip(stock.chunks(4)) {
+ assert_eq!(a[3], b[3]);
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index ad25907..d595b94 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,13 +10,18 @@
windows_subsystem = "windows"
)]
+mod appearance;
mod autostart;
mod backend;
mod gui;
mod icon;
+mod plot;
mod settings;
mod single_instance;
+mod theme;
+mod tray;
mod types;
+mod units;
mod update_check;
use crossbeam_channel::unbounded;
@@ -142,10 +147,14 @@ fn main() -> eframe::Result {
};
let native_options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
- .with_inner_size([1100.0, 720.0])
+ .with_inner_size([1280.0, 800.0])
.with_min_inner_size([720.0, 480.0])
.with_icon(app_icon)
.with_title("Throttle"),
+ // With the `persistence` feature eframe keeps the window size and
+ // position (clamped to the monitors present) in `%APPDATA%\Throttle\data`.
+ // The app opts out of saving egui's other state (see
+ // `ThrottleApp::persist_egui_memory`): the layout lives in settings.json.
..Default::default()
};
diff --git a/src/plot.rs b/src/plot.rs
new file mode 100644
index 0000000..e1ee8c5
--- /dev/null
+++ b/src/plot.rs
@@ -0,0 +1,230 @@
+//! Rate plots: the shared plot builder, line style, axis grid and navigation.
+
+use eframe::egui;
+use egui_plot::{Legend, Line, Plot, PlotPoints};
+
+use crate::settings::View;
+use crate::units::{GB, KB, MB, TB, axis_rate};
+
+/// Fill opacity under the plot lines when "Filled area" is on.
+const PLOT_FILL_ALPHA: f32 = 0.15;
+
+/// The plot both rate graphs share: no x axis labels, binary rate grid and
+/// labels on y, time axis zoom and pan only (the rate axis keeps fitting
+/// the data), a legend, and zero always at the bottom.
+pub fn rate_plot(id: &'static str) -> Plot<'static> {
+ // An explicit id, so `reset_view` can find the plot's memory.
+ Plot::new(id)
+ .id(egui::Id::new(id))
+ .show_x(false)
+ .y_axis_formatter(rate_axis_label)
+ .y_grid_spacer(rate_grid)
+ .allow_scroll(false)
+ .allow_zoom([true, false])
+ .allow_boxed_zoom(false)
+ .allow_drag([true, false])
+ .legend(Legend::default())
+ .include_y(0.0)
+ .include_y(1.0)
+}
+
+/// Put the plot built by `rate_plot(id)` back on auto bounds, as a double
+/// click on it would. Used by the plots' context menus.
+pub fn reset_view(ctx: &egui::Context, id: &str) {
+ let id = egui::Id::new(id);
+ if let Some(mut memory) = egui_plot::PlotMemory::load(ctx, id) {
+ memory.auto_bounds = egui::Vec2b::TRUE;
+ memory.store(ctx, id);
+ }
+}
+
+/// A plot line in the style the View menu asks for. Samples are one second
+/// apart, plotted against their index from zero.
+pub fn plot_line(view: &View, name: &str, ys: &[f64], colour: egui::Color32) -> Line<'static> {
+ let mut line = Line::new(name, PlotPoints::from_ys_f64(ys)).color(colour);
+ // The default width keeps egui_plot's own stroke (1.5), so the default
+ // plot looks as before; only a thicker choice sets the width.
+ if view.plot_line_width > View::default().plot_line_width {
+ line = line.width(view.plot_line_width);
+ }
+ if view.plot_fill {
+ line = line.fill(0.0).fill_alpha(PLOT_FILL_ALPHA);
+ }
+ line
+}
+
+/// Grid steps for a rate axis: 1, 2 and 5 of a binary unit (bytes, KB, MB,
+/// GB, TB) and their tens and hundreds, so the axis reads "512 KB/s, 1 MB/s,
+/// 2 MB/s" rather than the powers of ten egui_plot would pick ("976.6 KB/s"),
+/// and a labelled step always fits the plot height: with powers of ten a
+/// short plot whose range passes 3 MB/s lost every label but zero. Halves of
+/// the step are minor lines, labelled too when there is room.
+fn rate_grid(input: egui_plot::GridInput) -> Vec {
+ let (min, max) = input.bounds;
+ // egui_plot labels a step once it spans about 20 points and its base
+ // step is 8 points, so three base steps leave room for a label.
+ let wanted = input.base_step_size * 3.0;
+ if !(wanted.is_finite() && wanted > 0.0 && max > min) {
+ return Vec::new();
+ }
+ let major = nice_rate_step(wanted);
+ let minor = major / 2.0;
+ let first = (min / minor).floor() as i64;
+ let last = (max / minor).ceil() as i64;
+ if last - first > 10_000 {
+ return Vec::new();
+ }
+ // A label is centred on its mark, so one within half a text height of
+ // the top edge would spill out of the plot over whatever sits above it.
+ // The base step spans 8 points, which gives the distance in points.
+ let points_from_top = |value: f64| (max - value) / input.base_step_size * 8.0;
+ (first..=last)
+ .map(|i| egui_plot::GridMark {
+ value: i as f64 * minor,
+ step_size: if i % 2 == 0 { major } else { minor },
+ })
+ .filter(|mark| points_from_top(mark.value) >= 8.0)
+ .collect()
+}
+
+/// The smallest of 1, 2, 5, 10, 20, 50, 100, 200 or 500 bytes, KB, MB, GB
+/// or TB per second that is at least `wanted` bytes per second.
+fn nice_rate_step(wanted: f64) -> f64 {
+ for unit in [1.0, KB, MB, GB, TB] {
+ for scale in [1.0, 10.0, 100.0] {
+ for m in [1.0, 2.0, 5.0] {
+ let step = m * scale * unit;
+ if step >= wanted {
+ return step;
+ }
+ }
+ }
+ }
+ wanted
+}
+
+/// Y axis formatter for the rate plots.
+fn rate_axis_label(mark: egui_plot::GridMark, _range: &std::ops::RangeInclusive) -> String {
+ axis_rate(mark.value)
+}
+
+/// Extra plot navigation on top of egui_plot's own (left drag pans the time
+/// axis, double click resets): a plain mouse wheel zooms the time axis
+/// around the pointer, like Ctrl + wheel does by default, and a middle
+/// button drag pans it too. The wheel is swallowed so nothing else scrolls.
+/// The rate axis keeps fitting the data, so zero stays at the bottom. The
+/// plot's own scroll-to-pan is off (`allow_scroll(false)`).
+pub fn plot_navigation(pui: &mut egui_plot::PlotUi<'_>) {
+ let response = pui.response().clone();
+ if response.dragged_by(egui::PointerButton::Middle) {
+ // Screen pixels to plot units along x; dragging right moves the
+ // view left, like the built in left button pan.
+ let per_pixel = pui.transform().dvalue_dpos()[0];
+ let dx = -f64::from(response.drag_delta().x) * per_pixel;
+ pui.translate_bounds(egui::vec2(dx as f32, 0.0));
+ pui.set_auto_bounds([false, true]);
+ }
+
+ if !response.contains_pointer() {
+ return;
+ }
+ let ctx = pui.ctx().clone();
+ let scroll = ctx.input(|i| i.smooth_scroll_delta.y);
+ if scroll == 0.0 {
+ return;
+ }
+ // Same curve as egui's own wheel zoom: wheel up zooms in.
+ let factor = (scroll / 200.0).exp();
+ pui.zoom_bounds_around_hovered(egui::vec2(factor, 1.0));
+ // A zoom switches auto bounds off on both axes; put the rate axis back.
+ pui.set_auto_bounds([false, true]);
+ ctx.input_mut(|i| {
+ i.smooth_scroll_delta.y = 0.0;
+ });
+}
+
+/// What a plot looked like last frame, to show a usage hint next to it.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct PlotHint {
+ /// The pointer was over the plot.
+ pub hovered: bool,
+ /// The view is zoomed or panned (auto bounds are off on some axis).
+ pub moved: bool,
+}
+
+impl PlotHint {
+ /// The hint to show: how to use the plot while hovered, and the way back
+ /// for as long as the view is zoomed or panned.
+ pub fn text(self) -> Option<&'static str> {
+ if self.moved {
+ Some("Double click the plot to reset the view")
+ } else if self.hovered {
+ Some(
+ "Scroll to zoom the time axis, drag to pan, double click to reset, right click for options",
+ )
+ } else {
+ None
+ }
+ }
+}
+
+impl From<&egui_plot::PlotResponse> for PlotHint {
+ /// `inner` is the plot's auto bounds flags.
+ fn from(plot: &egui_plot::PlotResponse) -> Self {
+ PlotHint {
+ hovered: plot.response.hovered(),
+ moved: !(plot.inner.x && plot.inner.y),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rate_grid_uses_binary_steps_that_fit_the_plot() {
+ // A 62 point plot showing up to 2.1 MB/s: 8 points are 271 KB, so
+ // the labelled step is 1 MB and the halves are minor lines.
+ let marks = rate_grid(egui_plot::GridInput {
+ bounds: (0.0, 2.1 * MB),
+ base_step_size: 2.1 * MB / 62.0 * 8.0,
+ });
+ let major: Vec = marks
+ .iter()
+ .filter(|m| m.step_size == MB)
+ .map(|m| m.value)
+ .collect();
+ // 2 MB/s is 3 points under the top edge, so it is dropped.
+ assert_eq!(major, vec![0.0, MB]);
+ assert_eq!(axis_rate(major[1]), "1 MB/s");
+ let minor: Vec = marks
+ .iter()
+ .filter(|m| m.step_size == MB / 2.0)
+ .map(|m| axis_rate(m.value))
+ .collect();
+ assert_eq!(minor, vec!["512 KB/s", "1.5 MB/s"]);
+
+ // Steps climb through 1, 2, 5 and their tens within a unit.
+ assert_eq!(nice_rate_step(3.0 * KB), 5.0 * KB);
+ assert_eq!(nice_rate_step(60.0 * KB), 100.0 * KB);
+ assert_eq!(nice_rate_step(600.0 * KB), MB);
+ assert_eq!(nice_rate_step(1.0), 1.0);
+ assert!(
+ rate_grid(egui_plot::GridInput {
+ bounds: (0.0, 0.0),
+ base_step_size: 0.0,
+ })
+ .is_empty()
+ );
+
+ // A mark right at the top edge is dropped so its label cannot spill
+ // out of the plot: 2 MB/s sits 2 points under the top here.
+ let marks = rate_grid(egui_plot::GridInput {
+ bounds: (0.0, 2.05 * MB),
+ base_step_size: 2.05 * MB / 62.0 * 8.0,
+ });
+ let top = marks.iter().map(|m| m.value).fold(0.0, f64::max);
+ assert_eq!(top, 1.5 * MB);
+ }
+}
diff --git a/src/settings.rs b/src/settings.rs
index 2d64b83..af14868 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -6,15 +6,19 @@
//! startup is not stored here: the scheduled task's existence is the source of
//! truth (see `autostart`).
+use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
+use crate::appearance::Appearance;
use crate::backend::rules::config_dir;
+use crate::theme::{self, CustomTheme};
-/// User preferences for window and tray behaviour. Every field defaults to
-/// `false`, so an upgrade keeps the old "taskbar window, X quits" behaviour.
-#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
+/// User preferences for window, tray and theme. The tray flags default to
+/// `false` and the theme to `theme::DEFAULT_ID`, so an upgrade keeps the old
+/// "taskbar window, X quits, stock look" behaviour.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
/// Minimising the window hides it to the tray instead of the taskbar.
@@ -23,6 +27,135 @@ pub struct Settings {
pub close_to_tray: bool,
/// Start hidden in the tray when launched by hand.
pub start_minimized: bool,
+ /// Colour theme id (see `theme`). Kept as stored even if unknown, so a
+ /// newer build's palette survives a downgrade; `theme::apply` falls back.
+ pub theme: String,
+ /// Corner rounding, density, scale, fonts and accent overrides.
+ pub appearance: Appearance,
+ /// Table columns, plot style, always on top and compact mode.
+ pub view: View,
+ /// Sort order and column widths of the process table.
+ pub table: Table,
+ /// Sizes of the resizable panels.
+ pub panels: Panels,
+ /// Palettes imported from colour theme files.
+ pub custom_themes: Vec,
+}
+
+/// The process table's sort key. `Down` is the fallback for an unknown
+/// value in the file, so it must stay the last variant (`serde(other)`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SortColumn {
+ Name,
+ Flows,
+ Up,
+ DownTotal,
+ UpTotal,
+ #[serde(other)]
+ Down,
+}
+
+/// Panel sizes in points, as last dragged. `None` is the built in default.
+#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Panels {
+ /// Height of the header plot panel.
+ pub plot_height: Option,
+ /// Width of the detail panel on the right.
+ pub detail_width: Option,
+}
+
+/// Process table state kept across restarts. The defaults are today's
+/// start-up state: sorted by download rate, widest first, columns auto sized.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Table {
+ /// Column the rows are sorted by.
+ pub sort: SortColumn,
+ /// Sort largest (or Z) first.
+ pub descending: bool,
+ /// Column widths in points, keyed by column id, as last seen on screen.
+ /// Only used when every visible column has one (see `gui`).
+ pub widths: BTreeMap,
+ /// The Active chip: only list processes moving data right now.
+ pub active_only: bool,
+}
+
+impl Default for Table {
+ fn default() -> Self {
+ Self {
+ sort: SortColumn::Down,
+ descending: true,
+ widths: BTreeMap::new(),
+ active_only: false,
+ }
+ }
+}
+
+/// Layout preferences from the View menu. Every default is today's look.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct View {
+ /// Show the Flows column.
+ pub show_flows: bool,
+ /// Show the Down column.
+ pub show_down: bool,
+ /// Show the Up column.
+ pub show_up: bool,
+ /// Show the Total down column.
+ pub show_down_total: bool,
+ /// Show the Total up column.
+ pub show_up_total: bool,
+ /// Show the Rule column.
+ pub show_rule: bool,
+ /// Fill the area under the plot lines.
+ pub plot_fill: bool,
+ /// Plot line width in points.
+ pub plot_line_width: f32,
+ /// Header plot history in seconds (one sample per second).
+ pub history_secs: usize,
+ /// Keep the window above other windows.
+ pub always_on_top: bool,
+ /// Hide the header plot.
+ pub compact: bool,
+ /// Show the filter box above the process table.
+ pub show_filter: bool,
+}
+
+impl Default for View {
+ fn default() -> Self {
+ Self {
+ show_flows: true,
+ show_down: true,
+ show_up: true,
+ show_down_total: true,
+ show_up_total: true,
+ show_rule: true,
+ plot_fill: false,
+ plot_line_width: 1.0,
+ history_secs: 120,
+ always_on_top: false,
+ compact: false,
+ show_filter: true,
+ }
+ }
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ Self {
+ minimize_to_tray: false,
+ close_to_tray: false,
+ start_minimized: false,
+ theme: theme::DEFAULT_ID.to_string(),
+ appearance: Appearance::default(),
+ view: View::default(),
+ table: Table::default(),
+ panels: Panels::default(),
+ custom_themes: Vec::new(),
+ }
+ }
}
/// Full path to the settings file.
@@ -41,7 +174,8 @@ pub fn load() -> Settings {
return Settings::default();
}
};
- serde_json::from_str(&data).unwrap_or_else(|e| {
+ // Editors and shells often write a UTF-8 byte order mark; it is not JSON.
+ serde_json::from_str(data.trim_start_matches('\u{feff}')).unwrap_or_else(|e| {
tracing::warn!("failed to parse {}: {e}; using defaults", path.display());
Settings::default()
})
@@ -65,6 +199,8 @@ mod tests {
minimize_to_tray: true,
close_to_tray: false,
start_minimized: true,
+ theme: "dark".to_string(),
+ ..Settings::default()
};
let json = serde_json::to_string(&settings).unwrap();
let back: Settings = serde_json::from_str(&json).unwrap();
@@ -82,4 +218,101 @@ mod tests {
}
);
}
+
+ #[test]
+ fn missing_theme_defaults_to_system() {
+ let back: Settings =
+ serde_json::from_str(r#"{"minimize_to_tray": true, "close_to_tray": false}"#).unwrap();
+ assert_eq!(back.theme, theme::DEFAULT_ID);
+ assert!(back.minimize_to_tray);
+ }
+
+ #[test]
+ fn unknown_theme_is_kept_as_stored() {
+ let back: Settings = serde_json::from_str(r#"{"theme": "no-such-theme"}"#).unwrap();
+ assert_eq!(back.theme, "no-such-theme");
+ }
+
+ /// Francesco's live file: tray flags on, a palette picked. A partial or
+ /// odd `appearance` object must never reset those.
+ #[test]
+ fn bad_nested_values_keep_other_settings() {
+ let json = r#"{
+ "minimize_to_tray": true, "close_to_tray": true, "theme": "dark-plus",
+ "appearance": {"corners": "weird", "scale": 1.25},
+ "view": {"show_flows": false},
+ "custom_themes": [{"id": "x", "label": "X", "bg": "oops"}]
+ }"#;
+ let back: Settings = serde_json::from_str(json).unwrap();
+ assert!(back.minimize_to_tray && back.close_to_tray);
+ assert_eq!(back.theme, "dark-plus");
+ assert_eq!(back.appearance.scale, 1.25);
+ assert!(!back.view.show_flows);
+ assert!(back.view.show_down);
+ assert_eq!(back.custom_themes.len(), 1);
+ }
+
+ #[test]
+ fn custom_themes_round_trip_through_json() {
+ let palette = theme::builtin().next().expect("bundled palettes exist");
+ let mut custom = CustomTheme::from(palette);
+ custom.id = "mine".to_string();
+ custom.label = "Mine".to_string();
+ let settings = Settings {
+ theme: "mine".to_string(),
+ custom_themes: vec![custom],
+ ..Settings::default()
+ };
+ let json = serde_json::to_string_pretty(&settings).unwrap();
+ let back: Settings = serde_json::from_str(&json).unwrap();
+ assert_eq!(settings, back);
+ let restored = back.custom_themes[0].to_palette();
+ assert_eq!(restored.id, "mine");
+ assert_eq!(restored.source, theme::Source::Custom);
+ assert_eq!(restored.accents, palette.accents);
+ assert_eq!(restored.bg, palette.bg);
+ }
+
+ #[test]
+ fn view_defaults_show_everything() {
+ let v = View::default();
+ assert!(v.show_flows && v.show_down && v.show_up);
+ assert!(v.show_down_total && v.show_up_total && v.show_rule);
+ assert!(!v.plot_fill && !v.always_on_top && !v.compact);
+ assert!(v.show_filter);
+ assert_eq!(v.plot_line_width, 1.0);
+ assert_eq!(v.history_secs, 120);
+ // An empty object is the same as the defaults.
+ let back: View = serde_json::from_str("{}").unwrap();
+ assert_eq!(back, v);
+ }
+
+ #[test]
+ fn byte_order_mark_is_ignored() {
+ let json = "\u{feff}{\"close_to_tray\": true}";
+ let back: Settings = serde_json::from_str(json.trim_start_matches('\u{feff}')).unwrap();
+ assert!(back.close_to_tray);
+ }
+
+ #[test]
+ fn table_state_round_trips_and_tolerates_unknown_sort() {
+ let mut table = Table {
+ sort: SortColumn::UpTotal,
+ descending: false,
+ ..Table::default()
+ };
+ table.widths.insert("name".to_string(), 240.5);
+ let json = serde_json::to_string(&table).unwrap();
+ assert!(json.contains("\"up_total\""));
+ let back: Table = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, table);
+
+ // A sort key from a newer build falls back to the default, and the
+ // rest of the settings survive.
+ let back: Settings =
+ serde_json::from_str(r#"{"theme": "nord", "table": {"sort": "latency"}}"#).unwrap();
+ assert_eq!(back.table.sort, SortColumn::Down);
+ assert_eq!(back.theme, "nord");
+ assert!(back.table.descending);
+ }
}
diff --git a/src/theme/data_community.rs b/src/theme/data_community.rs
new file mode 100644
index 0000000..672e4f3
--- /dev/null
+++ b/src/theme/data_community.rs
@@ -0,0 +1,596 @@
+//! Palettes taken from popular third party colour themes, each MIT licensed;
+//! see `THIRD_PARTY_NOTICES.md` for the copyright lines. Every entry uses
+//! `Source::Community`; `dark` decides whether it appears in the Dark or the
+//! Light submenu.
+//!
+//! Sources (repository, file, commit or tag):
+//! Dracula: github.com/dracula/visual-studio-code, src/dracula.yml @ b50e62e32090
+//! One Dark Pro: github.com/Binaryify/OneDark-Pro, themes/OneDark-Pro.json @ 75c80ab18d90
+//! Catppuccin Mocha, Macchiato, Frappe, Latte: github.com/catppuccin/vscode,
+//! release catppuccin-vsc-v3.19.0, themes/{mocha,macchiato,frappe,latte}.json
+//! in the published package (the repository generates them)
+//! Nord: github.com/nordtheme/visual-studio-code, themes/nord-color-theme.json
+//! @ 8ead09822c02
+//! Tokyo Night, Storm, Light: github.com/tokyo-night/tokyo-night-vscode-theme,
+//! themes/tokyo-night{,-storm,-light}-color-theme.json @ 7c0f11eaef32
+//! GitHub Dark, Light: github.com/primer/github-vscode-theme, src/theme.js and
+//! src/colors.js @ cd78e5e4e7bc, evaluated by hand against
+//! dist/json/colors/{dark,light}.json of the @primer/primitives 7.10.0 npm
+//! package (github.com/primer/primitives), the version that commit pins
+//! Gruvbox Dark, Light: github.com/jdinhify/vscode-theme-gruvbox, src/shared.ts
+//! and src/colors/base.ts @ 5538f16b660b, medium contrast
+//! Ayu Dark, Mirage, Light: github.com/ayu-theme/vscode-ayu,
+//! ayu-{dark,mirage,light}.json @ 605c9c850468
+//! Night Owl: github.com/sdras/night-owl-vscode-theme,
+//! themes/Night Owl-color-theme.json @ cc291eba7976
+//! Rose Pine: github.com/rose-pine/vscode, themes/rose-pine-color-theme.json
+//! @ d8f5ebe8e096
+//! Everforest Dark: github.com/sainnhe/everforest-vscode (archived),
+//! themes/everforest-dark.json @ b17f8affe909, medium contrast
+//! Kanagawa: github.com/paccodes/kanagawa-vscode-theme,
+//! themes/kanagawa-wave-color-theme.json @ 60f23d7520c8, a port of
+//! github.com/rebelot/kanagawa.nvim (Wave variant)
+//! Cobalt2: github.com/wesbos/cobalt2-vscode, theme/cobalt2.json @ c4e9574372b8
+//! SynthWave '84: github.com/robb0wen/synthwave-vscode,
+//! themes/synthwave-color-theme.json @ ecfa2fe1279f
+//!
+//! Each field takes the workbench key named in its `Palette` doc. Unless a line
+//! says otherwise, `selection` is list.activeSelectionBackground, `selection_fg`
+//! is list.activeSelectionForeground, and light palettes hover with
+//! list.hoverBackground and black text. Translucent values are composited onto
+//! editor.background. Missing keys use the workbench default, marked
+//! (default); (blend) is the foreground mixed 70% into the background, used
+//! when descriptionForeground is missing, equals the foreground or reads below
+//! 3:1. Lines marked (adjusted) were changed so that widget text reaches 4:1 on
+//! its fill and on the panel (see `widget_text_reads_on_fill_and_panel`).
+//! Notes:
+//! Accents are the theme's ANSI green, blue and red unless noted. Dracula:
+//! cyan stands in for blue (its ANSI blue is purple). Nord: frost blue
+//! (textLink). Rose Pine: pine, foam and love, its ANSI green, blue and red.
+//! Tokyo Night (all three): the string token green. Gruvbox and Ayu: the
+//! editorGutter added, modified and deleted colours.
+//! Dark palettes whose primary button is a bright accent with dark text
+//! (Catppuccin, Nord, Ayu, Rose Pine, Everforest) hover with their
+//! selection.background tint and the editor foreground instead, since the
+//! dark button text cannot read on the panel. The Frappe, Nord and Cobalt2
+//! hover fills and the Nord selection are darkened toward the background
+//! until their text reaches 4:1.
+//! Catppuccin: `widget_bg` is checkbox.background because Frappe's
+//! button.secondaryBackground misses 4:1; `input_border` is panel.border
+//! because input.border and checkbox.border are transparent.
+//! Nord: its selection is a bright frost fill meant for dark text, so
+//! `selection` is selection.background darkened and `selection_fg` is
+//! list.hoverForeground.
+//! Tokyo Night (all three): errorForeground is a grey, so `error` is
+//! editorError.foreground. Tokyo Night Light: its file says "dark" but is a
+//! light theme; `widget_bg` is list.hoverBackground (its secondary button
+//! misses 4:1), and list.activeSelectionBackground equals the background,
+//! so `selection` is editor.selectionBackground and the hover uses
+//! selection.background.
+//! Borders: Dracula uses tab.border and Cobalt2 sideBar.border, because their
+//! panel.border is a saturated accent; Rose Pine (transparent panel.border)
+//! and Everforest (panel.border equal to the background) use
+//! editorWidget.border.
+//! Everforest: input.background and focusBorder are transparent, so
+//! `input_bg` is the background and `focus` is its green.
+//! Cobalt2: list.activeSelectionBackground equals the background, so
+//! `selection` is editor.selectionBackground with menu.selectionForeground.
+//! GitHub: built from the primitives with the overrides in src/colors.js;
+//! editor.selectionBackground ends up unset for both palettes.
+//! Dracula (button.secondaryBackground), Gruvbox, Night Owl and Cobalt2
+//! (dropdown.background) draw idle buttons in the background colour,
+//! outlined by `input_border`, as the themes do.
+
+use std::borrow::Cow;
+
+use super::{Palette, Source, accents, rgb};
+
+/// Community palettes, in menu order: dark ones first, then light.
+pub const PALETTES: &[Palette] = &[
+ Palette {
+ id: Cow::Borrowed("dracula"),
+ label: Cow::Borrowed("Dracula"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x50FA7B, 0x8BE9FD, 0xFF5555),
+ bg: rgb(0x282A36),
+ faint_bg: rgb(0x21222C),
+ input_bg: rgb(0x282A36),
+ widget_bg: rgb(0x282A36),
+ border: rgb(0x191A21), // tab.border
+ input_border: rgb(0x191A21),
+ hover_bg: rgb(0x44475A),
+ hover_fg: rgb(0xF8F8F2),
+ focus: rgb(0x6272A4),
+ selection: rgb(0x44475A),
+ selection_fg: rgb(0xF8F8F2),
+ fg: rgb(0xF8F8F2),
+ weak_fg: rgb(0xBABABA), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xFF5555),
+ },
+ Palette {
+ id: Cow::Borrowed("one-dark-pro"),
+ label: Cow::Borrowed("One Dark Pro"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x8CC265, 0x4AA5F0, 0xE05561),
+ bg: rgb(0x282C34),
+ faint_bg: rgb(0x21252B),
+ input_bg: rgb(0x1D1F23),
+ widget_bg: rgb(0x30333D),
+ border: rgb(0x3E4452),
+ input_border: rgb(0x404754),
+ hover_bg: rgb(0x404754),
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0x3E4452),
+ selection: rgb(0x2C313A),
+ selection_fg: rgb(0xD7DAE0),
+ fg: rgb(0xABB2BF),
+ weak_fg: rgb(0x848A95), // (blend)
+ link: rgb(0x61AFEF),
+ error: rgb(0xF48771), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("catppuccin-mocha"),
+ label: Cow::Borrowed("Catppuccin Mocha"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0xA6E3A1, 0x89B4FA, 0xF38BA8),
+ bg: rgb(0x1E1E2E),
+ faint_bg: rgb(0x181825),
+ input_bg: rgb(0x313244),
+ widget_bg: rgb(0x45475A), // checkbox.background
+ border: rgb(0x585B70),
+ input_border: rgb(0x585B70), // panel.border
+ hover_bg: rgb(0x63547E), // selection.background (adjusted)
+ hover_fg: rgb(0xCDD6F4), // editor.foreground (adjusted)
+ focus: rgb(0xCBA6F7),
+ selection: rgb(0x313244),
+ selection_fg: rgb(0xCDD6F4),
+ fg: rgb(0xCDD6F4),
+ weak_fg: rgb(0x989FB9), // (blend)
+ link: rgb(0x89B4FA),
+ error: rgb(0xF38BA8),
+ },
+ Palette {
+ id: Cow::Borrowed("catppuccin-macchiato"),
+ label: Cow::Borrowed("Catppuccin Macchiato"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0xA6DA95, 0x8AADF4, 0xED8796),
+ bg: rgb(0x24273A),
+ faint_bg: rgb(0x1E2030),
+ input_bg: rgb(0x363A4F),
+ widget_bg: rgb(0x494D64), // checkbox.background
+ border: rgb(0x5B6078),
+ input_border: rgb(0x5B6078), // panel.border
+ hover_bg: rgb(0x655785), // selection.background (adjusted)
+ hover_fg: rgb(0xCAD3F5), // editor.foreground (adjusted)
+ focus: rgb(0xC6A0F6),
+ selection: rgb(0x363A4F),
+ selection_fg: rgb(0xCAD3F5),
+ fg: rgb(0xCAD3F5),
+ weak_fg: rgb(0x989FBD), // (blend)
+ link: rgb(0x8AADF4),
+ error: rgb(0xED8796),
+ },
+ Palette {
+ id: Cow::Borrowed("catppuccin-frappe"),
+ label: Cow::Borrowed("Catppuccin Frappe"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0xA6D189, 0x8CAAEE, 0xE78284),
+ bg: rgb(0x303446),
+ faint_bg: rgb(0x292C3C),
+ input_bg: rgb(0x414559),
+ widget_bg: rgb(0x51576D), // checkbox.background
+ border: rgb(0x626880),
+ input_border: rgb(0x626880), // panel.border
+ hover_bg: rgb(0x685A80), // selection.background, darkened (adjusted)
+ hover_fg: rgb(0xC6D0F5), // editor.foreground (adjusted)
+ focus: rgb(0xCA9EE6),
+ selection: rgb(0x414559),
+ selection_fg: rgb(0xC6D0F5),
+ fg: rgb(0xC6D0F5),
+ weak_fg: rgb(0x99A1C0), // (blend)
+ link: rgb(0x8CAAEE),
+ error: rgb(0xE78284),
+ },
+ Palette {
+ id: Cow::Borrowed("nord"),
+ label: Cow::Borrowed("Nord"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0xA3BE8C, 0x88C0D0, 0xBF616A),
+ bg: rgb(0x2E3440),
+ faint_bg: rgb(0x2E3440),
+ input_bg: rgb(0x3B4252),
+ widget_bg: rgb(0x434C5E),
+ border: rgb(0x3B4252),
+ input_border: rgb(0x3B4252),
+ hover_bg: rgb(0x5A7886), // selection.background, darkened (adjusted)
+ hover_fg: rgb(0xECEFF4), // list.hoverForeground (adjusted)
+ focus: rgb(0x3B4252),
+ selection: rgb(0x5A7886), // selection.background, darkened (adjusted)
+ selection_fg: rgb(0xECEFF4), // list.hoverForeground (adjusted)
+ fg: rgb(0xD8DEE9),
+ weak_fg: rgb(0xC7CDD8),
+ link: rgb(0x88C0D0),
+ error: rgb(0xBF616A),
+ },
+ Palette {
+ id: Cow::Borrowed("tokyo-night"),
+ label: Cow::Borrowed("Tokyo Night"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x9ECE6A, 0x7AA2F7, 0xF7768E),
+ bg: rgb(0x1A1B26),
+ faint_bg: rgb(0x16161E),
+ input_bg: rgb(0x14141B),
+ widget_bg: rgb(0x3B3E52),
+ border: rgb(0x101014),
+ input_border: rgb(0x0F0F14),
+ hover_bg: rgb(0x385191),
+ hover_fg: rgb(0xFFFFFF),
+ focus: rgb(0x262838),
+ selection: rgb(0x202330),
+ selection_fg: rgb(0xA9B1D6),
+ fg: rgb(0xA9B1D6),
+ weak_fg: rgb(0x7E84A1), // (blend)
+ link: rgb(0x6183BB),
+ error: rgb(0xDB4B4B), // editorError.foreground
+ },
+ Palette {
+ id: Cow::Borrowed("tokyo-night-storm"),
+ label: Cow::Borrowed("Tokyo Night Storm"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x9ECE6A, 0x7AA2F7, 0xF7768E),
+ bg: rgb(0x24283B),
+ faint_bg: rgb(0x1F2335),
+ input_bg: rgb(0x1B1E2E),
+ widget_bg: rgb(0x41496B),
+ border: rgb(0x1B1E2E),
+ input_border: rgb(0x282E44),
+ hover_bg: rgb(0x3A5293),
+ hover_fg: rgb(0xFFFFFF),
+ focus: rgb(0x2E3248),
+ selection: rgb(0x2C324A),
+ selection_fg: rgb(0xA9B1D6),
+ fg: rgb(0xA9B1D6),
+ weak_fg: rgb(0x8188A8), // (blend)
+ link: rgb(0x668AC4),
+ error: rgb(0xDB4B4B), // editorError.foreground
+ },
+ Palette {
+ id: Cow::Borrowed("github-dark"),
+ label: Cow::Borrowed("GitHub Dark"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x3FB950, 0x58A6FF, 0xFF7B72),
+ bg: rgb(0x0D1117),
+ faint_bg: rgb(0x010409),
+ input_bg: rgb(0x0D1117),
+ widget_bg: rgb(0x282E33),
+ border: rgb(0x30363D),
+ input_border: rgb(0x30363D),
+ hover_bg: rgb(0x238636),
+ hover_fg: rgb(0xFFFFFF),
+ focus: rgb(0x1F6FEB),
+ selection: rgb(0x343941),
+ selection_fg: rgb(0xE6EDF3),
+ fg: rgb(0xE6EDF3),
+ weak_fg: rgb(0x7D8590),
+ link: rgb(0x2F81F7),
+ error: rgb(0xF85149),
+ },
+ Palette {
+ id: Cow::Borrowed("gruvbox-dark"),
+ label: Cow::Borrowed("Gruvbox Dark"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0xB8BB26, 0x83A598, 0xFB4934),
+ bg: rgb(0x282828),
+ faint_bg: rgb(0x282828),
+ input_bg: rgb(0x282828),
+ widget_bg: rgb(0x282828), // dropdown.background
+ border: rgb(0x3C3836),
+ input_border: rgb(0x3C3836),
+ hover_bg: rgb(0x375758),
+ hover_fg: rgb(0xEBDBB2),
+ focus: rgb(0x3C3836),
+ selection: rgb(0x32302F),
+ selection_fg: rgb(0x8EC07C),
+ fg: rgb(0xEBDBB2),
+ weak_fg: rgb(0xB0A589), // (blend)
+ link: rgb(0x83A598),
+ error: rgb(0xFB4934),
+ },
+ Palette {
+ id: Cow::Borrowed("ayu-dark"),
+ label: Cow::Borrowed("Ayu Dark"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x70BF56, 0x73B8FF, 0xF26D78),
+ bg: rgb(0x10141C),
+ faint_bg: rgb(0x0D1017),
+ input_bg: rgb(0x10141C),
+ widget_bg: rgb(0x1F242F),
+ border: rgb(0x1B1F29),
+ input_border: rgb(0x1F242F),
+ hover_bg: rgb(0x193155), // selection.background (adjusted)
+ hover_fg: rgb(0xBFBDB6), // editor.foreground (adjusted)
+ focus: rgb(0xE6B450),
+ selection: rgb(0x1E242F),
+ selection_fg: rgb(0xBFBDB6),
+ fg: rgb(0xBFBDB6),
+ weak_fg: rgb(0x5C657B),
+ link: rgb(0xE6B450),
+ error: rgb(0xD95757),
+ },
+ Palette {
+ id: Cow::Borrowed("ayu-mirage"),
+ label: Cow::Borrowed("Ayu Mirage"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x87D96C, 0x80BFFF, 0xF27983),
+ bg: rgb(0x242936),
+ faint_bg: rgb(0x1F2430),
+ input_bg: rgb(0x242936),
+ widget_bg: rgb(0x343A48),
+ border: rgb(0x171B24),
+ input_border: rgb(0x343A48),
+ hover_bg: rgb(0x2B4768), // selection.background (adjusted)
+ hover_fg: rgb(0xCCCAC2), // editor.foreground (adjusted)
+ focus: rgb(0xFFCC66),
+ selection: rgb(0x2D3445),
+ selection_fg: rgb(0xCCCAC2),
+ fg: rgb(0xCCCAC2),
+ weak_fg: rgb(0x737D8F),
+ link: rgb(0xFFCC66),
+ error: rgb(0xFF6666),
+ },
+ Palette {
+ id: Cow::Borrowed("night-owl"),
+ label: Cow::Borrowed("Night Owl"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x22DA6E, 0x82AAFF, 0xEF5350),
+ bg: rgb(0x011627),
+ faint_bg: rgb(0x011627),
+ input_bg: rgb(0x0B253A),
+ widget_bg: rgb(0x011627), // dropdown.background
+ border: rgb(0x5F7E97),
+ input_border: rgb(0x5F7E97),
+ hover_bg: rgb(0x654AA3),
+ hover_fg: rgb(0xCCD0D4),
+ focus: rgb(0x122D42),
+ selection: rgb(0x14344F),
+ selection_fg: rgb(0xFFFFFF),
+ fg: rgb(0xD6DEEB),
+ weak_fg: rgb(0x96A2B0), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xEF5350),
+ },
+ Palette {
+ id: Cow::Borrowed("rose-pine"),
+ label: Cow::Borrowed("Rose Pine"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x31748F, 0x9CCFD8, 0xEB6F92),
+ bg: rgb(0x191724),
+ faint_bg: rgb(0x191724),
+ input_bg: rgb(0x232034),
+ widget_bg: rgb(0x1F1D2E),
+ border: rgb(0x26233A), // editorWidget.border
+ input_border: rgb(0x2A2838),
+ hover_bg: rgb(0x3B384B), // selection.background (adjusted)
+ hover_fg: rgb(0xE0DEF4), // editor.foreground (adjusted)
+ focus: rgb(0x2A2838),
+ selection: rgb(0x2A2838),
+ selection_fg: rgb(0xE0DEF4),
+ fg: rgb(0xE0DEF4),
+ weak_fg: rgb(0x908CAA),
+ link: rgb(0xC4A7E7),
+ error: rgb(0xEB6F92),
+ },
+ Palette {
+ id: Cow::Borrowed("everforest-dark"),
+ label: Cow::Borrowed("Everforest Dark"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0xA7C080, 0x7FBBB3, 0xE67E80),
+ bg: rgb(0x2D353B),
+ faint_bg: rgb(0x2D353B),
+ input_bg: rgb(0x2D353B), // (transparent)
+ widget_bg: rgb(0x3D484D),
+ border: rgb(0x4F585E), // editorWidget.border
+ input_border: rgb(0x4F585E),
+ hover_bg: rgb(0x444E54), // selection.background (adjusted)
+ hover_fg: rgb(0xD3C6AA), // editor.foreground (adjusted)
+ focus: rgb(0xA7C080), // textLink.foreground (adjusted)
+ selection: rgb(0x3A444A),
+ selection_fg: rgb(0xD3C6AA),
+ fg: rgb(0xD3C6AA),
+ weak_fg: rgb(0x859289),
+ link: rgb(0xA7C080),
+ error: rgb(0xE67E80),
+ },
+ Palette {
+ id: Cow::Borrowed("kanagawa"),
+ label: Cow::Borrowed("Kanagawa"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x76946A, 0x7E9CD8, 0xC34043),
+ bg: rgb(0x1F1F28),
+ faint_bg: rgb(0x1F1F28),
+ input_bg: rgb(0x16161D),
+ widget_bg: rgb(0x223249),
+ border: rgb(0x16161D),
+ input_border: rgb(0x223249),
+ hover_bg: rgb(0x2A2A37),
+ hover_fg: rgb(0xC8C093),
+ focus: rgb(0x223249),
+ selection: rgb(0x363646),
+ selection_fg: rgb(0xDCD7BA),
+ fg: rgb(0xDCD7BA),
+ weak_fg: rgb(0xA3A08E), // (blend)
+ link: rgb(0x6A9589),
+ error: rgb(0xF48771), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("cobalt2"),
+ label: Cow::Borrowed("Cobalt2"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x3AD900, 0x0088FF, 0xFF628C),
+ bg: rgb(0x193549),
+ faint_bg: rgb(0x15232D),
+ input_bg: rgb(0x193549),
+ widget_bg: rgb(0x193549), // dropdown.background
+ border: rgb(0x0D3A58), // sideBar.border
+ input_border: rgb(0x0D3A58),
+ hover_bg: rgb(0x037CE5), // button.background, darkened (adjusted)
+ hover_fg: rgb(0xFFFFFF),
+ focus: rgb(0x0D3A58),
+ selection: rgb(0x0050A4), // editor.selectionBackground (adjusted)
+ selection_fg: rgb(0xFFFFFF), // menu.selectionForeground (adjusted)
+ fg: rgb(0xFFFFFF),
+ weak_fg: rgb(0xAAAAAA),
+ link: rgb(0x0088FF),
+ error: rgb(0xA22929),
+ },
+ Palette {
+ id: Cow::Borrowed("synthwave-84"),
+ label: Cow::Borrowed("SynthWave '84"),
+ source: Source::Community,
+ dark: true,
+ accents: accents(0x72F1B8, 0x03EDF9, 0xFE4450),
+ bg: rgb(0x262335),
+ faint_bg: rgb(0x241B2F),
+ input_bg: rgb(0x2A2139),
+ widget_bg: rgb(0x232530), // dropdown.background
+ border: rgb(0x45434F), // (default)
+ input_border: rgb(0x3C3C3C), // (default)
+ hover_bg: rgb(0x614D85),
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0x1F212B),
+ selection: rgb(0x413F4E),
+ selection_fg: rgb(0xFFFFFF),
+ fg: rgb(0xFFFFFF), // foreground
+ weak_fg: rgb(0xBEBDC2), // (blend)
+ link: rgb(0xF97E72),
+ error: rgb(0xFE4450),
+ },
+ Palette {
+ id: Cow::Borrowed("catppuccin-latte"),
+ label: Cow::Borrowed("Catppuccin Latte"),
+ source: Source::Community,
+ dark: false,
+ accents: accents(0x40A02B, 0x1E66F5, 0xD20F39),
+ bg: rgb(0xEFF1F5),
+ faint_bg: rgb(0xE6E9EF),
+ input_bg: rgb(0xCCD0DA),
+ widget_bg: rgb(0xBCC0CC), // checkbox.background
+ border: rgb(0xACB0BE),
+ input_border: rgb(0xACB0BE), // panel.border
+ hover_bg: rgb(0xDDE0E7),
+ hover_fg: rgb(0x000000),
+ focus: rgb(0x8839EF),
+ selection: rgb(0xCCD0DA),
+ selection_fg: rgb(0x4C4F69),
+ fg: rgb(0x4C4F69),
+ weak_fg: rgb(0x7D8093), // (blend)
+ link: rgb(0x1E66F5),
+ error: rgb(0xD20F39),
+ },
+ Palette {
+ id: Cow::Borrowed("tokyo-night-light"),
+ label: Cow::Borrowed("Tokyo Night Light"),
+ source: Source::Community,
+ dark: false,
+ accents: accents(0x385F0D, 0x2959AA, 0x8C4351),
+ bg: rgb(0xE6E7ED),
+ faint_bg: rgb(0xD6D8DF),
+ input_bg: rgb(0xE6E7ED),
+ widget_bg: rgb(0xE1E2E8), // list.hoverBackground
+ border: rgb(0xC1C2C7),
+ input_border: rgb(0xC1C2C7),
+ hover_bg: rgb(0xD3D5DE), // selection.background (adjusted)
+ hover_fg: rgb(0x000000),
+ focus: rgb(0xCED0D7),
+ selection: rgb(0xD7D9E1), // editor.selectionBackground (adjusted)
+ selection_fg: rgb(0x363C4D),
+ fg: rgb(0x343B59),
+ weak_fg: rgb(0x707280),
+ link: rgb(0x2959AA),
+ error: rgb(0xBD4040), // editorError.foreground
+ },
+ Palette {
+ id: Cow::Borrowed("github-light"),
+ label: Cow::Borrowed("GitHub Light"),
+ source: Source::Community,
+ dark: false,
+ accents: accents(0x116329, 0x0969DA, 0xCF222E),
+ bg: rgb(0xFFFFFF),
+ faint_bg: rgb(0xF6F8FA),
+ input_bg: rgb(0xFFFFFF),
+ widget_bg: rgb(0xEBECF0),
+ border: rgb(0xD0D7DE),
+ input_border: rgb(0xD0D7DE),
+ hover_bg: rgb(0xF4F6F8),
+ hover_fg: rgb(0x000000),
+ focus: rgb(0x0969DA),
+ selection: rgb(0xEFF1F3),
+ selection_fg: rgb(0x1F2328),
+ fg: rgb(0x1F2328),
+ weak_fg: rgb(0x656D76),
+ link: rgb(0x0969DA),
+ error: rgb(0xCF222E),
+ },
+ Palette {
+ id: Cow::Borrowed("gruvbox-light"),
+ label: Cow::Borrowed("Gruvbox Light"),
+ source: Source::Community,
+ dark: false,
+ accents: accents(0x79740E, 0x076678, 0x9D0006),
+ bg: rgb(0xFBF1C7),
+ faint_bg: rgb(0xFBF1C7),
+ input_bg: rgb(0xFBF1C7),
+ widget_bg: rgb(0xFBF1C7), // dropdown.background
+ border: rgb(0xEBDBB2),
+ input_border: rgb(0xEBDBB2),
+ hover_bg: rgb(0xF3E6BC),
+ hover_fg: rgb(0x000000),
+ focus: rgb(0xEBDBB2),
+ selection: rgb(0xF3E6BC),
+ selection_fg: rgb(0x427B58),
+ fg: rgb(0x3C3836),
+ weak_fg: rgb(0x757062), // (blend)
+ link: rgb(0x076678),
+ error: rgb(0x9D0006),
+ },
+ Palette {
+ id: Cow::Borrowed("ayu-light"),
+ label: Cow::Borrowed("Ayu Light"),
+ source: Source::Community,
+ dark: false,
+ accents: accents(0x6CBF43, 0x478ACC, 0xFF7383),
+ bg: rgb(0xFCFCFC),
+ faint_bg: rgb(0xF8F9FA),
+ input_bg: rgb(0xFCFCFC),
+ widget_bg: rgb(0xE4E7EA),
+ border: rgb(0xEAEDEF),
+ input_border: rgb(0xE4E7EA),
+ hover_bg: rgb(0xE8EAED),
+ hover_fg: rgb(0x000000),
+ focus: rgb(0xF29718),
+ selection: rgb(0xE8EAED),
+ selection_fg: rgb(0x5C6166),
+ fg: rgb(0x5C6166),
+ weak_fg: rgb(0x8490A0),
+ link: rgb(0xF29718),
+ error: rgb(0xE65050),
+ },
+];
diff --git a/src/theme/data_editor.rs b/src/theme/data_editor.rs
new file mode 100644
index 0000000..d369b87
--- /dev/null
+++ b/src/theme/data_editor.rs
@@ -0,0 +1,422 @@
+//! Palettes taken from the editor's bundled colour themes.
+//!
+//! Sources, from github.com/microsoft/vscode (MIT):
+//! Dark Modern: extensions/theme-defaults/themes/dark_modern.json
+//! (includes dark_plus.json, which includes dark_vs.json)
+//! Light Modern: extensions/theme-defaults/themes/light_modern.json
+//! (includes light_plus.json, which includes light_vs.json)
+//! Dark+: extensions/theme-defaults/themes/dark_plus.json (+ dark_vs.json)
+//! Light+: extensions/theme-defaults/themes/light_plus.json (+ light_vs.json)
+//! Monokai: extensions/theme-monokai/themes/monokai-color-theme.json
+//! (from Colorsublime-Themes, MIT)
+//! Solarized Dark: extensions/theme-solarized-dark/themes/
+//! solarized-dark-color-theme.json (from Colorsublime-Themes,
+//! MIT; Solarized by Ethan Schoonover, MIT)
+//! Solarized Light: extensions/theme-solarized-light/themes/
+//! solarized-light-color-theme.json (from Colorsublime-Themes,
+//! MIT; Solarized by Ethan Schoonover, MIT)
+//! Abyss: extensions/theme-abyss/themes/abyss-color-theme.json
+//! (from Colorsublime-Themes, MIT)
+//! Kimbie Dark: extensions/theme-kimbie-dark/themes/kimbie-dark-color-theme.json
+//! (from Colorsublime-Themes, MIT)
+//! Quiet Light: extensions/theme-quietlight/themes/quietlight-color-theme.json
+//! (from Colorsublime-Themes, MIT)
+//! Red: extensions/theme-red/themes/Red-color-theme.json
+//! (from Colorsublime-Themes, MIT)
+//! Tomorrow Night Blue: extensions/theme-tomorrow-night-blue/themes/
+//! tomorrow-night-blue-color-theme.json (from Colorsublime-Themes,
+//! MIT; Tomorrow by Chris Kempson, MIT)
+//! High Contrast Dark: extensions/theme-defaults/themes/hc_black.json
+//! High Contrast Light: extensions/theme-defaults/themes/hc_light.json
+//! None of the files from Solarized Dark on has an include chain.
+//! Keys missing from a file and its include chain use the workbench's built-in
+//! default for that key, marked (default); the high contrast files define few
+//! workbench colours, so most of their values are the workbench's high
+//! contrast defaults. Values marked (blend) are the foreground mixed 70% into
+//! the background, as the workbench derives `descriptionForeground`. Values
+//! marked (alpha) come from a `#rrggbbaa` key composited over
+//! editor.background, as the workbench paints it. Notes:
+//! Dark Modern: `border` is panel.border because widget.border equals
+//! input.background; `widget_bg` is checkbox.background (its secondary
+//! button is transparent); `selection` is menu.selectionBackground;
+//! accents are editorGutter added / textLink.foreground / gutter deleted.
+//! Light Modern: `selection` is the default editor selection (ADD6FF);
+//! `weak_fg` is a blend because its descriptionForeground equals the
+//! editor foreground; accents are editorGutter added / modified / deleted.
+//! Light palettes hover with the selection tint and black text instead of
+//! button.background, since hovered text also sits on the white panel.
+//! Dark+: `selection` is menu.selectionBackground from dark_vs.json; accents
+//! are the type, keyword and invalid token colours.
+//! Light+: `widget_bg` is list.hoverBackground; `selection_fg` is the
+//! 0451A5 token colour; accents are the comment, constant and invalid
+//! token colours.
+//! Monokai: `widget_bg` is dropdown.background, `border` is panel.border,
+//! `input_border`, `hover_bg` and `selection` are its 75715E focus colour,
+//! `hover_fg` is the editor foreground, `weak_fg` is
+//! editorLineNumber.foreground, `link` is its cyan and `error` is
+//! inputValidation.errorBorder; accents are its green, cyan and pink.
+//! Where a dark theme leaves checkbox.border unset it defaults to
+//! dropdown.background, which would hide the outline of idle widgets;
+//! those palettes take `input_border` from agentsChatInput.border.
+//! Solarized Dark: `widget_bg` is dropdown.background, `input_border` is
+//! checkbox.border (its dropdown.border), `selection` is
+//! list.activeSelectionBackground (via menu.selectionBackground);
+//! accents are terminal.ansiGreen / ansiBlue / ansiRed.
+//! Solarized Light: `widget_bg` is peekViewEditor.background because
+//! dropdown.background (EEE8D5) gives the editor foreground under 4:1;
+//! `hover_bg` and `selection` are list.activeSelectionBackground since
+//! its editor selection equals sideBar.background; `selection_fg` is
+//! 073642 (terminal.ansiBlack) because list.activeSelectionForeground
+//! (6C6C6C) reads under 4:1 on it; accents are terminal.ansiGreen /
+//! ansiBlue / ansiRed.
+//! Abyss: `widget_bg` is dropdown.background, `selection` is
+//! list.activeSelectionBackground; its terminal colours are pale, so
+//! accents are the string, function argument and diff deleted token
+//! colours.
+//! Kimbie Dark: `widget_bg` is dropdown.background, `border` is
+//! panelSection.border (no panel.border), `selection` is
+//! list.activeSelectionBackground; accents are the string, function and
+//! invalid token colours.
+//! Quiet Light: `fg` is the light default, which its token foreground
+//! repeats; `widget_bg` is list.hoverBackground, `border` is
+//! panelSection.border, `hover_bg` and `selection` are its editor
+//! selection; `selection_fg` is the editor foreground because
+//! list.activeSelectionForeground (6C6C6C) reads under 4:1 on the tint;
+//! accents are the string, keyword and invalid token colours.
+//! Red: `widget_bg` is dropdown.background, `border` is panelSection.border,
+//! `hover_bg` expands its #833 button.background, `selection` is
+//! list.activeSelectionBackground; accents are the diff inserted and
+//! keyword token colours and, as the theme has no blue, the default
+//! editorGutter.modifiedBackground.
+//! Tomorrow Night Blue: `widget_bg` is dropdown.background, `border` is
+//! panelSection.border, `selection` is list.activeSelectionBackground;
+//! accents are terminal.ansiGreen / ansiBlue / ansiRed.
+//! High Contrast Dark: `selection` is its selection.background (008000),
+//! since its white editor selection would hide the white text of pressed
+//! widgets; `selection_fg` is white because editor.selectionForeground
+//! (black) would hide the focus ring on the black input; hovered widgets
+//! keep the black button fill and show the focus outline, as the
+//! workbench does; accents are the type, keyword and invalid token
+//! colours.
+//! High Contrast Light: `hover_bg` and `selection` are the default
+//! list.activeSelectionBackground (0F4A85 at 10%); `selection_fg` is
+//! 0F4A85 because editor.selectionForeground (white) cannot read on that
+//! tint; accents are the constant, diff changed and invalid token
+//! colours.
+
+use std::borrow::Cow;
+
+use super::{Palette, Source, accents, rgb};
+
+/// Editor built-in palettes, in menu order. The first dark and the first
+/// light entry also serve as fallbacks for malformed imported themes.
+pub const PALETTES: &[Palette] = &[
+ Palette {
+ id: Cow::Borrowed("dark-modern"),
+ label: Cow::Borrowed("Dark Modern"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0x2EA043, 0x4DAAFC, 0xF85149),
+ bg: rgb(0x1F1F1F),
+ faint_bg: rgb(0x181818),
+ input_bg: rgb(0x313131),
+ widget_bg: rgb(0x313131),
+ border: rgb(0x2B2B2B),
+ input_border: rgb(0x3C3C3C),
+ hover_bg: rgb(0x0078D4),
+ hover_fg: rgb(0xFFFFFF),
+ focus: rgb(0x0078D4),
+ selection: rgb(0x0078D4),
+ selection_fg: rgb(0xFFFFFF),
+ fg: rgb(0xCCCCCC),
+ weak_fg: rgb(0x9D9D9D),
+ link: rgb(0x4DAAFC),
+ error: rgb(0xF85149),
+ },
+ Palette {
+ id: Cow::Borrowed("light-modern"),
+ label: Cow::Borrowed("Light Modern"),
+ source: Source::Editor,
+ dark: false,
+ accents: accents(0x2EA043, 0x005FB8, 0xF85149),
+ bg: rgb(0xFFFFFF),
+ faint_bg: rgb(0xF8F8F8),
+ input_bg: rgb(0xFFFFFF),
+ widget_bg: rgb(0xE5E5E5),
+ border: rgb(0xE5E5E5),
+ input_border: rgb(0xCECECE),
+ hover_bg: rgb(0xADD6FF), // (default)
+ hover_fg: rgb(0x000000),
+ focus: rgb(0x005FB8),
+ selection: rgb(0xADD6FF), // (default)
+ selection_fg: rgb(0x005FB8),
+ fg: rgb(0x3B3B3B),
+ weak_fg: rgb(0x757575), // (blend)
+ link: rgb(0x005FB8),
+ error: rgb(0xF85149),
+ },
+ Palette {
+ id: Cow::Borrowed("dark-plus"),
+ label: Cow::Borrowed("Dark+"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0x4EC9B0, 0x569CD6, 0xF44747),
+ bg: rgb(0x1E1E1E),
+ faint_bg: rgb(0x252526), // (default)
+ input_bg: rgb(0x3C3C3C), // (default)
+ widget_bg: rgb(0x3C3C3C), // (default) dropdown.background
+ border: rgb(0x303031),
+ input_border: rgb(0x6B6B6B), // checkbox.border
+ hover_bg: rgb(0x0E639C), // (default)
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0x007FD4), // (default)
+ selection: rgb(0x0078D4),
+ selection_fg: rgb(0xFFFFFF),
+ fg: rgb(0xD4D4D4),
+ weak_fg: rgb(0x9E9E9E), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xF48771), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("light-plus"),
+ label: Cow::Borrowed("Light+"),
+ source: Source::Editor,
+ dark: false,
+ accents: accents(0x008000, 0x0070C1, 0xCD3131),
+ bg: rgb(0xFFFFFF),
+ faint_bg: rgb(0xF3F3F3), // (default)
+ input_bg: rgb(0xFFFFFF), // (default)
+ widget_bg: rgb(0xE8E8E8),
+ border: rgb(0xD4D4D4),
+ input_border: rgb(0x919191), // checkbox.border
+ hover_bg: rgb(0xADD6FF), // (default)
+ hover_fg: rgb(0x000000),
+ focus: rgb(0x0090F1), // (default)
+ selection: rgb(0xADD6FF), // (default)
+ selection_fg: rgb(0x0451A5),
+ fg: rgb(0x000000),
+ weak_fg: rgb(0x717171), // (default)
+ link: rgb(0x006AB1), // (default)
+ error: rgb(0xA1260D), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("monokai"),
+ label: Cow::Borrowed("Monokai"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0xA6E22E, 0x66D9EF, 0xF92672),
+ bg: rgb(0x272822),
+ faint_bg: rgb(0x1E1F1C),
+ input_bg: rgb(0x414339),
+ widget_bg: rgb(0x414339),
+ border: rgb(0x414339),
+ input_border: rgb(0x75715E),
+ hover_bg: rgb(0x75715E),
+ hover_fg: rgb(0xF8F8F2),
+ focus: rgb(0x99947C),
+ selection: rgb(0x75715E),
+ selection_fg: rgb(0xF8F8F2),
+ fg: rgb(0xF8F8F2),
+ weak_fg: rgb(0x90908A),
+ link: rgb(0x66D9EF),
+ error: rgb(0xF92672),
+ },
+ Palette {
+ id: Cow::Borrowed("solarized-dark"),
+ label: Cow::Borrowed("Solarized Dark"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0x859900, 0x268BD2, 0xDC322F),
+ bg: rgb(0x002B36),
+ faint_bg: rgb(0x00212B),
+ input_bg: rgb(0x003847),
+ widget_bg: rgb(0x00212B),
+ border: rgb(0x2B2B4A),
+ input_border: rgb(0x197271), // (alpha) checkbox.border
+ hover_bg: rgb(0x197271), // (alpha)
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0x197271), // (alpha)
+ selection: rgb(0x005A6F),
+ selection_fg: rgb(0xFFFFFF), // (default)
+ fg: rgb(0x839496),
+ weak_fg: rgb(0x5C7579), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xFFEAEA),
+ },
+ Palette {
+ id: Cow::Borrowed("solarized-light"),
+ label: Cow::Borrowed("Solarized Light"),
+ source: Source::Editor,
+ dark: false,
+ accents: accents(0x859900, 0x268BD2, 0xDC322F),
+ bg: rgb(0xFDF6E3),
+ faint_bg: rgb(0xEEE8D5),
+ input_bg: rgb(0xDDD6C1),
+ widget_bg: rgb(0xFFFBF2), // peekViewEditor.background, for contrast
+ border: rgb(0xDDD6C1),
+ input_border: rgb(0xD3AF86), // checkbox.border
+ hover_bg: rgb(0xDFCA88),
+ hover_fg: rgb(0x000000),
+ focus: rgb(0xB49471),
+ selection: rgb(0xDFCA88),
+ selection_fg: rgb(0x073642), // terminal.ansiBlack, for contrast
+ fg: rgb(0x657B83),
+ weak_fg: rgb(0x717171), // (default)
+ link: rgb(0x006AB1), // (default)
+ error: rgb(0xA1260D), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("abyss"),
+ label: Cow::Borrowed("Abyss"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0x22AA44, 0x2277FF, 0xDC322F),
+ bg: rgb(0x000C18),
+ faint_bg: rgb(0x060621),
+ input_bg: rgb(0x181F2F),
+ widget_bg: rgb(0x181F2F),
+ border: rgb(0x2B2B4A),
+ input_border: rgb(0x2B2B4A), // agentsChatInput.border
+ hover_bg: rgb(0x2B3C5D),
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0x596F99),
+ selection: rgb(0x08286B),
+ selection_fg: rgb(0xFFFFFF), // (default)
+ fg: rgb(0x6688CC),
+ weak_fg: rgb(0x486396), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xF48771), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("kimbie-dark"),
+ label: Cow::Borrowed("Kimbie Dark"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0x889B4A, 0x8AB1B0, 0xDC3958),
+ bg: rgb(0x221A0F),
+ faint_bg: rgb(0x362712),
+ input_bg: rgb(0x51412C),
+ widget_bg: rgb(0x51412C),
+ border: rgb(0x5E452B),
+ input_border: rgb(0x5E452B), // agentsChatInput.border
+ hover_bg: rgb(0x6E583B),
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0xA57A4C),
+ selection: rgb(0x7C5021),
+ selection_fg: rgb(0xFFFFFF), // (default)
+ fg: rgb(0xD3AF86),
+ weak_fg: rgb(0x9E8363), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xF48771), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("quiet-light"),
+ label: Cow::Borrowed("Quiet Light"),
+ source: Source::Editor,
+ dark: false,
+ accents: accents(0x448C27, 0x4B69C6, 0xCD3131),
+ bg: rgb(0xF5F5F5),
+ faint_bg: rgb(0xF2F2F2),
+ input_bg: rgb(0xFFFFFF), // (default)
+ widget_bg: rgb(0xE0E0E0),
+ border: rgb(0xC9D0D9),
+ input_border: rgb(0xCECECE), // (default) checkbox.border
+ hover_bg: rgb(0xC9D0D9),
+ hover_fg: rgb(0x000000),
+ focus: rgb(0x9769DC),
+ selection: rgb(0xC9D0D9),
+ selection_fg: rgb(0x333333), // editor foreground, for contrast
+ fg: rgb(0x333333), // (default)
+ weak_fg: rgb(0x717171), // (default)
+ link: rgb(0x006AB1), // (default)
+ error: rgb(0xF1897F),
+ },
+ Palette {
+ id: Cow::Borrowed("red"),
+ label: Cow::Borrowed("Red"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0x41A83E, 0x1B81A8, 0xF12727),
+ bg: rgb(0x390000),
+ faint_bg: rgb(0x330000),
+ input_bg: rgb(0x580000),
+ widget_bg: rgb(0x580000),
+ border: rgb(0x580000),
+ input_border: rgb(0x611414), // (alpha) agentsChatInput.border
+ hover_bg: rgb(0x883333),
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0xBD4444), // (alpha)
+ selection: rgb(0x880000),
+ selection_fg: rgb(0xFFFFFF), // (default)
+ fg: rgb(0xF8F8F8),
+ weak_fg: rgb(0xBFAEAE), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xFFEAEA),
+ },
+ Palette {
+ id: Cow::Borrowed("tomorrow-night-blue"),
+ label: Cow::Borrowed("Tomorrow Night Blue"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0xD1F1A9, 0xBBDAFF, 0xFF9DA4),
+ bg: rgb(0x002451),
+ faint_bg: rgb(0x001C40),
+ input_bg: rgb(0x001733),
+ widget_bg: rgb(0x001733),
+ border: rgb(0x404F7D),
+ input_border: rgb(0x404F7D), // agentsChatInput.border
+ hover_bg: rgb(0x0E639C), // (default)
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0xBBDAFF),
+ selection: rgb(0x607693), // (alpha)
+ selection_fg: rgb(0xFFFFFF), // (default)
+ fg: rgb(0xFFFFFF),
+ weak_fg: rgb(0xB3BECB), // (blend)
+ link: rgb(0x3794FF), // (default)
+ error: rgb(0xA92049),
+ },
+ Palette {
+ id: Cow::Borrowed("hc-dark"),
+ label: Cow::Borrowed("High Contrast Dark"),
+ source: Source::Editor,
+ dark: true,
+ accents: accents(0x4EC9B0, 0x569CD6, 0xF44747),
+ bg: rgb(0x000000),
+ faint_bg: rgb(0x000000), // (default)
+ input_bg: rgb(0x000000), // (default)
+ widget_bg: rgb(0x000000), // (default) dropdown.background
+ border: rgb(0x6FC3DF), // (default) contrastBorder
+ input_border: rgb(0x6FC3DF), // (default) contrastBorder
+ hover_bg: rgb(0x000000), // (default)
+ hover_fg: rgb(0xFFFFFF), // (default)
+ focus: rgb(0xF38518), // (default)
+ selection: rgb(0x008000),
+ selection_fg: rgb(0xFFFFFF), // white, not black, for the focus ring
+ fg: rgb(0xFFFFFF),
+ weak_fg: rgb(0xB3B3B3), // (blend)
+ link: rgb(0x21A6FF), // (default)
+ error: rgb(0xF48771), // (default)
+ },
+ Palette {
+ id: Cow::Borrowed("hc-light"),
+ label: Cow::Borrowed("High Contrast Light"),
+ source: Source::Editor,
+ dark: false,
+ accents: accents(0x096D48, 0x0451A5, 0xB5200D),
+ bg: rgb(0xFFFFFF), // (default)
+ faint_bg: rgb(0xFFFFFF), // (default)
+ input_bg: rgb(0xFFFFFF), // (default)
+ widget_bg: rgb(0xFFFFFF), // (default)
+ border: rgb(0x0F4A85), // (default) contrastBorder
+ input_border: rgb(0x0F4A85), // (default) contrastBorder
+ hover_bg: rgb(0xE7EDF3), // (default) (alpha)
+ hover_fg: rgb(0x000000),
+ focus: rgb(0x006BBD), // (default)
+ selection: rgb(0xE7EDF3), // (default) (alpha)
+ selection_fg: rgb(0x0F4A85), // contrastBorder, for contrast
+ fg: rgb(0x292929), // (default)
+ weak_fg: rgb(0x696969), // (blend)
+ link: rgb(0x0F4A85), // (default)
+ error: rgb(0xB5200D), // (default)
+ },
+];
diff --git a/src/theme/import.rs b/src/theme/import.rs
new file mode 100644
index 0000000..db115de
--- /dev/null
+++ b/src/theme/import.rs
@@ -0,0 +1,611 @@
+//! Import a colour theme JSON file (the editor's `color-theme` format) as a
+//! `Palette`, using the same key mapping as the bundled data files.
+//!
+//! The file is untrusted data: it is size capped, parsed with serde_json and
+//! never executed. An `include` entry is resolved relative to the file and
+//! silently skipped when the included file is missing; missing keys fall
+//! back to the workbench defaults for the theme's `type` (`dark`, `light`,
+//! `hc` or `hcLight`), so an import never fails over an absent colour.
+
+use std::borrow::Cow;
+use std::collections::HashMap;
+use std::path::Path;
+
+use anyhow::Context as _;
+use eframe::egui::Color32;
+use serde_json::Value;
+
+use super::{Accents, Palette, Source, data_editor};
+
+/// Largest theme file we are willing to read, in bytes.
+pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024;
+
+/// How many `include` hops are followed before the chain is cut off.
+const MAX_INCLUDE_DEPTH: u32 = 3;
+
+/// Minimum WCAG contrast of an accent against the background.
+const MIN_ACCENT_CONTRAST: f32 = 3.0;
+
+/// Parse `path` into a palette with `Source::Custom`. The id is derived
+/// from the theme's `name` (or the file stem), lowercased, with runs of
+/// non-alphanumerics replaced by `-` and prefixed with `custom-`.
+pub fn import(path: &Path) -> anyhow::Result {
+ let top = read_json(path)?;
+ let theme = load(path, &top, 0)?;
+
+ let stem = path
+ .file_stem()
+ .map(|s| s.to_string_lossy().into_owned())
+ .unwrap_or_default();
+ let label = top
+ .get("name")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+ .unwrap_or(stem);
+ let label = if label.is_empty() {
+ "Imported theme".to_string()
+ } else {
+ label
+ };
+
+ Ok(theme.to_palette(format!("custom-{}", slug(&label)), label))
+}
+
+/// Read and parse one theme file, enforcing the size cap. Comments and
+/// trailing commas are allowed, as the editor's own theme files use them.
+fn read_json(path: &Path) -> anyhow::Result {
+ let size = std::fs::metadata(path)
+ .with_context(|| format!("cannot read {}", path.display()))?
+ .len();
+ if size > MAX_FILE_SIZE {
+ anyhow::bail!(
+ "{} is {size} bytes; theme files larger than {MAX_FILE_SIZE} bytes are not imported",
+ path.display()
+ );
+ }
+ let text =
+ std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
+ let text = text.trim_start_matches('\u{feff}');
+ let value: Value = serde_json::from_str(&strip_jsonc(text))
+ .with_context(|| format!("{} is not a colour theme JSON file", path.display()))?;
+ if !value.is_object() {
+ anyhow::bail!("{} is not a colour theme JSON file", path.display());
+ }
+ Ok(value)
+}
+
+/// Colours and token rules gathered from a theme file and its includes.
+#[derive(Default)]
+struct Theme {
+ /// Workbench colours; the including file's keys win.
+ colors: HashMap,
+ /// `(scopes, foreground)` token rules, the including file's first.
+ tokens: Vec<(Vec, String)>,
+ /// The `type` entry, from the nearest file that has one.
+ kind: Option,
+}
+
+/// Gather `value` (read from `path`) and its include chain. The included
+/// file is loaded first so that this file's colours override its colours
+/// and this file's token rules are searched before its rules.
+fn load(path: &Path, value: &Value, depth: u32) -> anyhow::Result {
+ let mut theme = Theme::default();
+ if let Some(include) = value.get("include").and_then(Value::as_str)
+ && depth < MAX_INCLUDE_DEPTH
+ && Path::new(include).is_relative()
+ {
+ let included = path
+ .parent()
+ .unwrap_or_else(|| Path::new("."))
+ .join(include);
+ if included.is_file() {
+ let inner = read_json(&included)?;
+ theme = load(&included, &inner, depth + 1)?;
+ }
+ }
+
+ if let Some(colors) = value.get("colors").and_then(Value::as_object) {
+ for (key, v) in colors {
+ if let Some(s) = v.as_str() {
+ theme.colors.insert(key.clone(), s.to_string());
+ }
+ }
+ }
+ let mut tokens = Vec::new();
+ if let Some(rules) = value.get("tokenColors").and_then(Value::as_array) {
+ for rule in rules {
+ let fg = rule
+ .get("settings")
+ .and_then(|s| s.get("foreground"))
+ .and_then(Value::as_str);
+ let Some(fg) = fg else { continue };
+ let scopes = match rule.get("scope") {
+ Some(Value::String(s)) => split_scopes(s),
+ Some(Value::Array(list)) => list
+ .iter()
+ .filter_map(Value::as_str)
+ .flat_map(split_scopes)
+ .collect(),
+ _ => continue,
+ };
+ tokens.push((scopes, fg.to_string()));
+ }
+ }
+ tokens.append(&mut theme.tokens);
+ theme.tokens = tokens;
+ if let Some(kind) = value.get("type").and_then(Value::as_str) {
+ theme.kind = Some(kind.to_string());
+ }
+ Ok(theme)
+}
+
+/// Split a comma separated scope selector into trimmed scopes.
+fn split_scopes(s: &str) -> Vec {
+ s.split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+ .collect()
+}
+
+impl Theme {
+ /// Map the gathered keys onto a palette, filling gaps from the stock
+ /// dark or light editor palette.
+ fn to_palette(&self, id: String, label: String) -> Palette {
+ let dark = match self.kind.as_deref() {
+ Some("dark" | "hc") => true,
+ Some("light" | "hcLight") => false,
+ _ => self
+ .colors
+ .get("editor.background")
+ .and_then(|s| parse_rgba(s))
+ .is_none_or(|[r, g, b, _]| luminance(Color32::from_rgb(r, g, b)) < 0.5),
+ };
+ let base = base_palette(dark);
+
+ let bg = self
+ .colors
+ .get("editor.background")
+ .and_then(|s| parse_rgba(s))
+ .filter(|c| c[3] > 0)
+ .map_or(base.bg, |c| over(c, base.bg));
+ // The first of `keys` that is set and not fully transparent,
+ // composited onto the background.
+ let pick = |keys: &[&str]| {
+ keys.iter().find_map(|k| {
+ let c = parse_rgba(self.colors.get(*k)?)?;
+ (c[3] > 0).then(|| over(c, bg))
+ })
+ };
+ let token = |matches: &dyn Fn(&str) -> bool| {
+ self.tokens.iter().find_map(|(scopes, fg)| {
+ if !scopes.iter().any(|s| matches(s)) {
+ return None;
+ }
+ let c = parse_rgba(fg)?;
+ (c[3] > 0).then(|| over(c, bg))
+ })
+ };
+ let scope_is = |name: &'static str| {
+ move |s: &str| {
+ s == name
+ || s.strip_prefix(name)
+ .is_some_and(|rest| rest.starts_with('.'))
+ }
+ };
+
+ let fg = pick(&["editor.foreground", "foreground"]).unwrap_or(base.fg);
+ let (contrast_text, hover_bg, hover_fg) = if dark {
+ (
+ Color32::WHITE,
+ pick(&["button.hoverBackground", "button.background"]).unwrap_or(base.hover_bg),
+ pick(&["button.foreground"]).unwrap_or(base.hover_fg),
+ )
+ } else {
+ (
+ Color32::BLACK,
+ pick(&["list.hoverBackground"]).unwrap_or(base.hover_bg),
+ Color32::BLACK,
+ )
+ };
+
+ let down = pick(&["editorGutter.addedBackground", "terminal.ansiGreen"])
+ .or_else(|| token(&|s: &str| s.contains("string")))
+ .unwrap_or(base.accents.down);
+ let up = pick(&["textLink.foreground", "terminal.ansiBlue"])
+ .or_else(|| token(&scope_is("keyword")))
+ .unwrap_or(base.accents.up);
+ let block = pick(&["errorForeground", "terminal.ansiRed"])
+ .or_else(|| token(&scope_is("invalid")))
+ .unwrap_or(base.accents.block);
+
+ Palette {
+ id: Cow::Owned(id),
+ label: Cow::Owned(label),
+ source: Source::Custom,
+ dark,
+ accents: Accents {
+ down: readable(down, bg, dark),
+ up: readable(up, bg, dark),
+ block: readable(block, bg, dark),
+ },
+ bg,
+ faint_bg: pick(&["sideBar.background"]).unwrap_or(base.faint_bg),
+ input_bg: pick(&["input.background"]).unwrap_or(base.input_bg),
+ widget_bg: pick(&[
+ "button.secondaryBackground",
+ "checkbox.background",
+ "dropdown.background",
+ ])
+ .unwrap_or(base.widget_bg),
+ border: pick(&["panel.border", "widget.border"]).unwrap_or(base.border),
+ input_border: pick(&["input.border", "checkbox.border"]).unwrap_or(base.input_border),
+ hover_bg,
+ hover_fg,
+ focus: pick(&["focusBorder"]).unwrap_or(base.focus),
+ selection: pick(&[
+ "list.activeSelectionBackground",
+ "editor.selectionBackground",
+ ])
+ .unwrap_or(base.selection),
+ selection_fg: pick(&["list.activeSelectionForeground"]).unwrap_or(contrast_text),
+ fg,
+ weak_fg: pick(&["descriptionForeground"]).unwrap_or_else(|| blend(fg, bg, 0.7)),
+ link: pick(&["textLink.foreground"]).unwrap_or(base.link),
+ error: pick(&["errorForeground"]).unwrap_or(base.error),
+ }
+ }
+}
+
+/// The bundled palette whose values stand in for missing keys: Dark+ or
+/// Light+, whose unset keys hold the workbench defaults.
+fn base_palette(dark: bool) -> &'static Palette {
+ let id = if dark { "dark-plus" } else { "light-plus" };
+ data_editor::PALETTES
+ .iter()
+ .find(|p| p.id == id)
+ .or_else(|| data_editor::PALETTES.iter().find(|p| p.dark == dark))
+ .expect("bundled dark and light palettes exist")
+}
+
+/// Parse `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` into `[r, g, b, a]`.
+fn parse_rgba(s: &str) -> Option<[u8; 4]> {
+ let hex = s.trim().strip_prefix('#')?;
+ if !hex.is_ascii() {
+ return None;
+ }
+ let nibble = |i: usize| u8::from_str_radix(&hex[i..=i], 16).ok().map(|v| v * 17);
+ let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
+ match hex.len() {
+ 3 => Some([nibble(0)?, nibble(1)?, nibble(2)?, 255]),
+ 4 => Some([nibble(0)?, nibble(1)?, nibble(2)?, nibble(3)?]),
+ 6 => Some([byte(0)?, byte(2)?, byte(4)?, 255]),
+ 8 => Some([byte(0)?, byte(2)?, byte(4)?, byte(6)?]),
+ _ => None,
+ }
+}
+
+/// Composite a possibly translucent colour onto an opaque background.
+fn over([r, g, b, a]: [u8; 4], bg: Color32) -> Color32 {
+ blend(Color32::from_rgb(r, g, b), bg, f32::from(a) / 255.0)
+}
+
+/// `t` of `fg` mixed into `bg` (1.0 is pure `fg`).
+fn blend(fg: Color32, bg: Color32, t: f32) -> Color32 {
+ let mix = |f: u8, b: u8| (f32::from(b) + (f32::from(f) - f32::from(b)) * t).round() as u8;
+ Color32::from_rgb(
+ mix(fg.r(), bg.r()),
+ mix(fg.g(), bg.g()),
+ mix(fg.b(), bg.b()),
+ )
+}
+
+/// WCAG relative luminance of an opaque colour.
+fn luminance(c: Color32) -> f32 {
+ let lin = |v: u8| {
+ let v = f32::from(v) / 255.0;
+ if v <= 0.03928 {
+ v / 12.92
+ } else {
+ ((v + 0.055) / 1.055).powf(2.4)
+ }
+ };
+ 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
+}
+
+/// WCAG contrast ratio between two opaque colours.
+fn contrast(a: Color32, b: Color32) -> f32 {
+ let (x, y) = (luminance(a), luminance(b));
+ (x.max(y) + 0.05) / (x.min(y) + 0.05)
+}
+
+/// Lighten (dark themes) or darken (light themes) `c` in 10% steps until it
+/// reaches `MIN_ACCENT_CONTRAST` on `bg`, or white or black is reached.
+fn readable(mut c: Color32, bg: Color32, dark: bool) -> Color32 {
+ let target = if dark { Color32::WHITE } else { Color32::BLACK };
+ for _ in 0..30 {
+ if contrast(c, bg) >= MIN_ACCENT_CONTRAST || c == target {
+ break;
+ }
+ let next = blend(target, c, 0.1);
+ // Rounding can stall a channel one step short of the target.
+ c = if next == c { target } else { next };
+ }
+ c
+}
+
+/// Lowercase ASCII slug: runs of anything else become a single `-`.
+fn slug(s: &str) -> String {
+ let mut out = String::new();
+ for ch in s.chars() {
+ if ch.is_ascii_alphanumeric() {
+ out.push(ch.to_ascii_lowercase());
+ } else if !out.is_empty() && !out.ends_with('-') {
+ out.push('-');
+ }
+ }
+ let out = out.trim_end_matches('-');
+ if out.is_empty() {
+ "theme".to_string()
+ } else {
+ out.to_string()
+ }
+}
+
+/// Remove `//` and `/* */` comments and trailing commas outside strings,
+/// so the JSON-with-comments theme files parse with serde_json.
+fn strip_jsonc(text: &str) -> String {
+ let chars: Vec = text.chars().collect();
+ let mut no_comments = Vec::with_capacity(chars.len());
+ let mut i = 0;
+ let mut in_string = false;
+ while i < chars.len() {
+ let ch = chars[i];
+ if in_string {
+ no_comments.push(ch);
+ if ch == '\\' {
+ if let Some(&next) = chars.get(i + 1) {
+ no_comments.push(next);
+ i += 1;
+ }
+ } else if ch == '"' {
+ in_string = false;
+ }
+ i += 1;
+ continue;
+ }
+ match (ch, chars.get(i + 1)) {
+ ('"', _) => {
+ in_string = true;
+ no_comments.push(ch);
+ i += 1;
+ }
+ ('/', Some('/')) => {
+ while i < chars.len() && chars[i] != '\n' {
+ i += 1;
+ }
+ }
+ ('/', Some('*')) => {
+ i += 2;
+ while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
+ i += 1;
+ }
+ i += 2;
+ no_comments.push(' ');
+ }
+ _ => {
+ no_comments.push(ch);
+ i += 1;
+ }
+ }
+ }
+
+ let mut out = String::with_capacity(no_comments.len());
+ let mut in_string = false;
+ let mut escaped = false;
+ for (i, &ch) in no_comments.iter().enumerate() {
+ if in_string {
+ if escaped {
+ escaped = false;
+ } else if ch == '\\' {
+ escaped = true;
+ } else if ch == '"' {
+ in_string = false;
+ }
+ } else if ch == '"' {
+ in_string = true;
+ } else if ch == ',' {
+ let next = no_comments[i + 1..].iter().find(|c| !c.is_whitespace());
+ if matches!(next, Some('}' | ']')) {
+ continue;
+ }
+ }
+ out.push(ch);
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::theme::to_hex;
+ use std::path::PathBuf;
+
+ /// A fresh directory per test, since tests run in parallel.
+ fn temp_dir(name: &str) -> PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("throttle-import-{}-{name}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ const DARK: &str = r##"{
+ // Comments and trailing commas appear in real theme files.
+ "name": "My Theme (v2)!",
+ "type": "dark",
+ "include": "./this-file-does-not-exist.json",
+ "colors": {
+ "editor.background": "#101010",
+ "editor.foreground": "#e0e0e0",
+ "button.background": "#0a64a0",
+ "button.secondaryBackground": "#00000000",
+ "dropdown.background": "#303030",
+ "list.activeSelectionBackground": "#ffffff80", /* half white */
+ "focusBorder": 42,
+ },
+ "tokenColors": [
+ { "settings": { "foreground": "#abcdef" } },
+ { "scope": "comment", "settings": { "foreground": "#6a9955" } },
+ { "scope": ["string.quoted", "string"], "settings": { "foreground": "#ce9178" } },
+ { "scope": "keyword.control, storage", "settings": { "foreground": "#569cd6" } },
+ { "scope": "keywordish", "settings": { "foreground": "#ff00ff" } },
+ { "scope": "invalid", "settings": { "foreground": "#101010" } },
+ ],
+ }"##;
+
+ const LIGHT: &str = r##"{
+ "colors": {
+ "editor.background": "#fafafa",
+ "editor.foreground": "#333333",
+ "textLink.foreground": "#0066cc",
+ "terminal.ansiGreen": "#e0ffe0"
+ }
+ }"##;
+
+ #[test]
+ fn dark_theme_maps_keys_tokens_and_defaults() {
+ let dir = temp_dir("dark");
+ let path = dir.join("mine.json");
+ std::fs::write(&path, DARK).unwrap();
+ let p = import(&path).unwrap();
+ let base = base_palette(true);
+
+ assert_eq!(p.id, "custom-my-theme-v2");
+ assert_eq!(p.label, "My Theme (v2)!");
+ assert_eq!(p.source, Source::Custom);
+ assert!(p.dark);
+ assert_eq!(to_hex(p.bg), "#101010");
+ assert_eq!(to_hex(p.fg), "#e0e0e0");
+ // No hoverBackground: button.background stands in.
+ assert_eq!(to_hex(p.hover_bg), "#0a64a0");
+ // A transparent secondary button is skipped for the next key.
+ assert_eq!(to_hex(p.widget_bg), "#303030");
+ // Translucent colours are composited onto the background.
+ assert_eq!(to_hex(p.selection), "#888888");
+ assert_eq!(p.selection_fg, Color32::WHITE);
+ // Missing or malformed keys use the workbench defaults.
+ assert_eq!(p.faint_bg, base.faint_bg);
+ assert_eq!(p.focus, base.focus);
+ assert_eq!(p.link, base.link);
+ assert_eq!(p.hover_fg, base.hover_fg);
+ assert_eq!(p.weak_fg, blend(p.fg, p.bg, 0.7));
+ // Accents from the token rules.
+ assert_eq!(to_hex(p.accents.down), "#ce9178");
+ assert_eq!(to_hex(p.accents.up), "#569cd6");
+ // The invalid token equals the background, so it gets lightened.
+ assert!(contrast(p.accents.block, p.bg) >= MIN_ACCENT_CONTRAST);
+ assert_ne!(p.accents.block, p.bg);
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn light_theme_without_type_or_name() {
+ let dir = temp_dir("light");
+ let path = dir.join("Solar Flare.json");
+ std::fs::write(&path, LIGHT).unwrap();
+ let p = import(&path).unwrap();
+ let base = base_palette(false);
+
+ assert_eq!(p.id, "custom-solar-flare");
+ assert_eq!(p.label, "Solar Flare");
+ assert!(!p.dark, "luminance of editor.background decides");
+ assert_eq!(to_hex(p.bg), "#fafafa");
+ assert_eq!(to_hex(p.link), "#0066cc");
+ assert_eq!(p.hover_fg, Color32::BLACK);
+ assert_eq!(p.selection_fg, Color32::BLACK);
+ assert_eq!(p.hover_bg, base.hover_bg);
+ assert_eq!(p.faint_bg, base.faint_bg);
+ assert_eq!(p.error, base.error);
+ assert_eq!(to_hex(p.accents.up), "#0066cc");
+ assert_eq!(p.accents.block, base.accents.block);
+ // The pale green is darkened until it reads on the background.
+ assert_ne!(to_hex(p.accents.down), "#e0ffe0");
+ assert!(contrast(p.accents.down, p.bg) >= MIN_ACCENT_CONTRAST);
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn including_file_wins_over_included() {
+ let dir = temp_dir("include");
+ std::fs::write(
+ dir.join("base.json"),
+ r##"{ "type": "light", "colors": { "editor.background": "#202020", "focusBorder": "#123456" },
+ "tokenColors": [{ "scope": "string", "settings": { "foreground": "#00aa00" } }] }"##,
+ )
+ .unwrap();
+ let path = dir.join("top.json");
+ std::fs::write(
+ &path,
+ r##"{ "name": "Top", "include": "base.json", "colors": { "editor.background": "#ffffff" },
+ "tokenColors": [{ "scope": "string.quoted", "settings": { "foreground": "#008800" } }] }"##,
+ )
+ .unwrap();
+ let p = import(&path).unwrap();
+ assert!(!p.dark, "type comes from the included file");
+ assert_eq!(to_hex(p.bg), "#ffffff");
+ assert_eq!(to_hex(p.focus), "#123456");
+ assert_eq!(to_hex(p.accents.down), "#008800");
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn oversized_or_invalid_files_error() {
+ let dir = temp_dir("bad");
+ let big = dir.join("big.json");
+ std::fs::File::create(&big)
+ .unwrap()
+ .set_len(MAX_FILE_SIZE + 1)
+ .unwrap();
+ assert!(import(&big).is_err());
+
+ let junk = dir.join("junk.json");
+ std::fs::write(&junk, "this is not json").unwrap();
+ assert!(import(&junk).is_err());
+
+ let array = dir.join("array.json");
+ std::fs::write(&array, "[1, 2, 3]").unwrap();
+ assert!(import(&array).is_err());
+
+ assert!(import(&dir.join("missing.json")).is_err());
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn slugs_and_hex_forms() {
+ assert_eq!(slug("My Theme (v2)!"), "my-theme-v2");
+ assert_eq!(slug(" -- "), "theme");
+ assert_eq!(slug("Ros\u{e9} Pine"), "ros-pine");
+ assert_eq!(parse_rgba("#fff"), Some([255, 255, 255, 255]));
+ assert_eq!(parse_rgba("#0008"), Some([0, 0, 0, 0x88]));
+ assert_eq!(parse_rgba("#12345678"), Some([0x12, 0x34, 0x56, 0x78]));
+ assert_eq!(parse_rgba("red"), None);
+ assert_eq!(parse_rgba("#12345"), None);
+ }
+
+ #[test]
+ fn jsonc_keeps_strings_intact() {
+ let src = r#"{ "a": "http://x/*y*/", "b": [1, 2,], // tail
+ "c": "q\"//", }"#;
+ let v: Value = serde_json::from_str(&strip_jsonc(src)).unwrap();
+ assert_eq!(v["a"], "http://x/*y*/");
+ assert_eq!(v["b"], serde_json::json!([1, 2]));
+ assert_eq!(v["c"], "q\"//");
+ }
+}
diff --git a/src/theme/mod.rs b/src/theme/mod.rs
new file mode 100644
index 0000000..9a5cc87
--- /dev/null
+++ b/src/theme/mod.rs
@@ -0,0 +1,535 @@
+//! Colour themes for the GUI.
+//!
+//! The default (`system`) keeps egui's stock visuals and follows the OS light
+//! or dark preference, so an existing install looks exactly as before. Named
+//! palettes are opt-in via the Themes menu and persisted in `Settings::theme`.
+//! Palettes come in three groups: the editor built-ins in `data_editor`, the
+//! community themes in `data_community`, and palettes the user imported from
+//! a colour theme JSON file (`import`), which are stored in `Settings` as
+//! `CustomTheme`s.
+//!
+//! Bundled palette colours come from MIT licensed colour theme files; see
+//! `THIRD_PARTY_NOTICES.md` for attribution.
+
+mod data_community;
+mod data_editor;
+/// Importing colour theme JSON files as palettes.
+pub mod import;
+
+use std::borrow::Cow;
+
+use eframe::egui::{self, Color32, Theme, ThemePreference, Visuals};
+use serde::{Deserialize, Serialize};
+
+/// Theme id used when `Settings::theme` is missing or unknown.
+pub const DEFAULT_ID: &str = "system";
+
+/// Built-in entries that are not palettes: `(id, menu label)`.
+/// `system` follows the OS, `light` and `dark` pin egui's stock visuals.
+pub const BUILTIN: [(&str, &str); 3] = [
+ (DEFAULT_ID, "Default (follow system)"),
+ ("light", "Light"),
+ ("dark", "Dark"),
+];
+
+/// Accent colours used by the plots and the table, per theme.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Accents {
+ /// Download traffic.
+ pub down: Color32,
+ /// Upload traffic.
+ pub up: Color32,
+ /// Blocked traffic and error text.
+ pub block: Color32,
+}
+
+impl Accents {
+ /// The accents of the stock look (Material green, blue and red).
+ pub const DEFAULT: Accents = Accents {
+ down: Color32::from_rgb(0x4c, 0xaf, 0x50),
+ up: Color32::from_rgb(0x21, 0x96, 0xf3),
+ block: Color32::from_rgb(0xe5, 0x39, 0x35),
+ };
+}
+
+/// Where a palette comes from; decides which Themes submenu lists it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Source {
+ /// Shipped with the editor the palettes were taken from (`data_editor`).
+ Editor,
+ /// A popular third party theme (`data_community`).
+ Community,
+ /// Imported by the user from a colour theme JSON file.
+ Custom,
+}
+
+/// A named colour palette. Each colour names the workbench key it was taken
+/// from; see the comments above the palette data for keys a theme lacks.
+/// The field set is a frozen contract shared by the data files, the importer
+/// and `CustomTheme`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Palette {
+ /// Stable id stored in `settings.json`; lowercase, no spaces.
+ pub id: Cow<'static, str>,
+ /// Label shown in the Themes menu.
+ pub label: Cow<'static, str>,
+ /// Which Themes submenu lists the palette.
+ pub source: Source,
+ /// Whether the palette is dark (light text on a dark background).
+ pub dark: bool,
+ /// Plot and table accents, picked from the theme's own colours.
+ pub accents: Accents,
+ /// `editor.background`: panel and window fill.
+ pub bg: Color32,
+ /// `sideBar.background`: faint fill (table stripes).
+ pub faint_bg: Color32,
+ /// `input.background`: text edits, plot and code backgrounds.
+ pub input_bg: Color32,
+ /// `button.secondaryBackground` or similar: idle buttons and checkboxes.
+ pub widget_bg: Color32,
+ /// `widget.border` or `panel.border`: separators and window outline.
+ pub border: Color32,
+ /// `input.border` or `checkbox.border`: outline of idle widgets.
+ pub input_border: Color32,
+ /// Fill of hovered widgets: `button.background` in dark palettes, a
+ /// light selection tint in light ones.
+ pub hover_bg: Color32,
+ /// Text of hovered widgets. egui paints it on `hover_bg` (buttons) and on
+ /// the bare panel (checkbox and radio labels), so it must read on both.
+ pub hover_fg: Color32,
+ /// `focusBorder`: outline of hovered and pressed widgets.
+ pub focus: Color32,
+ /// Selection fill (selected text, selected items, pressed widgets).
+ pub selection: Color32,
+ /// Text on `selection`; also the focused text edit outline.
+ pub selection_fg: Color32,
+ /// `editor.foreground`: normal text.
+ pub fg: Color32,
+ /// `descriptionForeground` or a blend of `fg` into `bg`: weak text.
+ pub weak_fg: Color32,
+ /// `textLink.foreground`: hyperlinks.
+ pub link: Color32,
+ /// `errorForeground`: error text.
+ pub error: Color32,
+}
+
+/// `0xRRGGBB` to an opaque colour.
+pub(crate) const fn rgb(hex: u32) -> Color32 {
+ Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
+}
+
+/// Three `0xRRGGBB` values to the down, up and block accents.
+pub(crate) const fn accents(down: u32, up: u32, block: u32) -> Accents {
+ Accents {
+ down: rgb(down),
+ up: rgb(up),
+ block: rgb(block),
+ }
+}
+
+/// Parse `#rrggbb`, `#rrggbbaa`, `rrggbb` or `rrggbbaa` (alpha ignored).
+pub fn parse_hex(s: &str) -> Option {
+ let s = s.trim().trim_start_matches('#');
+ if s.len() != 6 && s.len() != 8 {
+ return None;
+ }
+ let v = u32::from_str_radix(&s[..6], 16).ok()?;
+ if s.len() == 8 {
+ u8::from_str_radix(&s[6..], 16).ok()?;
+ }
+ Some(rgb(v))
+}
+
+/// Format a colour as `#rrggbb` (alpha dropped).
+pub fn to_hex(c: Color32) -> String {
+ format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())
+}
+
+/// A user imported palette as stored in `settings.json`. Every colour is a
+/// `#rrggbb` string; a missing or malformed one falls back to the matching
+/// colour of the stock dark or light palette so an edited file never fails
+/// to load.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct CustomTheme {
+ /// Stable id, as `Palette::id`.
+ pub id: String,
+ /// Menu label, as `Palette::label`.
+ pub label: String,
+ /// Whether the palette is dark, as `Palette::dark`.
+ pub dark: bool,
+ /// Download accent (`Accents::down`).
+ pub accent_down: String,
+ /// Upload accent (`Accents::up`).
+ pub accent_up: String,
+ /// Blocked accent (`Accents::block`).
+ pub accent_block: String,
+ /// Panel and window fill (`Palette::bg`).
+ pub bg: String,
+ /// Faint fill for table stripes (`Palette::faint_bg`).
+ pub faint_bg: String,
+ /// Text edit and plot background (`Palette::input_bg`).
+ pub input_bg: String,
+ /// Idle button and checkbox fill (`Palette::widget_bg`).
+ pub widget_bg: String,
+ /// Separators and window outline (`Palette::border`).
+ pub border: String,
+ /// Outline of idle widgets (`Palette::input_border`).
+ pub input_border: String,
+ /// Fill of hovered widgets (`Palette::hover_bg`).
+ pub hover_bg: String,
+ /// Text of hovered widgets (`Palette::hover_fg`).
+ pub hover_fg: String,
+ /// Outline of hovered and pressed widgets (`Palette::focus`).
+ pub focus: String,
+ /// Selection fill (`Palette::selection`).
+ pub selection: String,
+ /// Text on the selection fill (`Palette::selection_fg`).
+ pub selection_fg: String,
+ /// Normal text (`Palette::fg`).
+ pub fg: String,
+ /// Weak text (`Palette::weak_fg`).
+ pub weak_fg: String,
+ /// Hyperlinks (`Palette::link`).
+ pub link: String,
+ /// Error text (`Palette::error`).
+ pub error: String,
+}
+
+impl From<&Palette> for CustomTheme {
+ fn from(p: &Palette) -> Self {
+ CustomTheme {
+ id: p.id.to_string(),
+ label: p.label.to_string(),
+ dark: p.dark,
+ accent_down: to_hex(p.accents.down),
+ accent_up: to_hex(p.accents.up),
+ accent_block: to_hex(p.accents.block),
+ bg: to_hex(p.bg),
+ faint_bg: to_hex(p.faint_bg),
+ input_bg: to_hex(p.input_bg),
+ widget_bg: to_hex(p.widget_bg),
+ border: to_hex(p.border),
+ input_border: to_hex(p.input_border),
+ hover_bg: to_hex(p.hover_bg),
+ hover_fg: to_hex(p.hover_fg),
+ focus: to_hex(p.focus),
+ selection: to_hex(p.selection),
+ selection_fg: to_hex(p.selection_fg),
+ fg: to_hex(p.fg),
+ weak_fg: to_hex(p.weak_fg),
+ link: to_hex(p.link),
+ error: to_hex(p.error),
+ }
+ }
+}
+
+impl CustomTheme {
+ /// Convert to a runtime palette with `Source::Custom`. Unparseable
+ /// colours fall back to the first bundled dark or light editor palette.
+ pub fn to_palette(&self) -> Palette {
+ let base = data_editor::PALETTES
+ .iter()
+ .find(|p| p.dark == self.dark)
+ .expect("bundled dark and light palettes exist");
+ let c = |s: &str, fallback: Color32| parse_hex(s).unwrap_or(fallback);
+ Palette {
+ id: Cow::Owned(self.id.clone()),
+ label: Cow::Owned(self.label.clone()),
+ source: Source::Custom,
+ dark: self.dark,
+ accents: Accents {
+ down: c(&self.accent_down, base.accents.down),
+ up: c(&self.accent_up, base.accents.up),
+ block: c(&self.accent_block, base.accents.block),
+ },
+ bg: c(&self.bg, base.bg),
+ faint_bg: c(&self.faint_bg, base.faint_bg),
+ input_bg: c(&self.input_bg, base.input_bg),
+ widget_bg: c(&self.widget_bg, base.widget_bg),
+ border: c(&self.border, base.border),
+ input_border: c(&self.input_border, base.input_border),
+ hover_bg: c(&self.hover_bg, base.hover_bg),
+ hover_fg: c(&self.hover_fg, base.hover_fg),
+ focus: c(&self.focus, base.focus),
+ selection: c(&self.selection, base.selection),
+ selection_fg: c(&self.selection_fg, base.selection_fg),
+ fg: c(&self.fg, base.fg),
+ weak_fg: c(&self.weak_fg, base.weak_fg),
+ link: c(&self.link, base.link),
+ error: c(&self.error, base.error),
+ }
+ }
+}
+
+/// All bundled palettes: editor built-ins first, then community themes.
+pub fn builtin() -> impl Iterator- {
+ data_editor::PALETTES
+ .iter()
+ .chain(data_community::PALETTES.iter())
+}
+
+/// Look up a palette by id among the bundled ones and `custom`.
+pub fn find<'a>(id: &str, custom: &'a [Palette]) -> Option<&'a Palette> {
+ builtin()
+ .find(|p| p.id == id)
+ .or_else(|| custom.iter().find(|p| p.id == id))
+}
+
+/// Apply the theme `id` to `ctx` and return its accents. Handles the
+/// `BUILTIN` ids, every bundled palette id and the ids in `custom`; an
+/// unknown id applies the default. Applying `system` must restore egui's
+/// stock dark and light visuals, so switching away from a palette leaves
+/// nothing behind. `appearance::apply` must run after this, since this
+/// resets both stock visuals.
+pub fn apply(ctx: &egui::Context, id: &str, custom: &[Palette]) -> Accents {
+ // Start every switch from stock visuals for both modes, so whatever the
+ // previous theme changed is undone before the new one is applied.
+ ctx.set_visuals_of(Theme::Dark, Visuals::dark());
+ ctx.set_visuals_of(Theme::Light, Visuals::light());
+
+ match id {
+ "light" => ctx.set_theme(Theme::Light),
+ "dark" => ctx.set_theme(Theme::Dark),
+ _ => match find(id, custom) {
+ Some(palette) => {
+ let theme = if palette.dark {
+ Theme::Dark
+ } else {
+ Theme::Light
+ };
+ ctx.set_visuals_of(theme, visuals(palette));
+ ctx.set_theme(theme);
+ return palette.accents;
+ }
+ // `system` and unknown ids.
+ None => ctx.set_theme(ThemePreference::System),
+ },
+ }
+ Accents::DEFAULT
+}
+
+/// Build the egui visuals for `p` on top of the stock dark or light visuals.
+/// Only colours change; stroke widths and corner radii stay stock.
+pub(crate) fn visuals(p: &Palette) -> Visuals {
+ let mut v = if p.dark {
+ Visuals::dark()
+ } else {
+ Visuals::light()
+ };
+ // Strong text (`RichText::strong`) uses the pressed widget's text colour.
+ let strong = if p.dark {
+ Color32::WHITE
+ } else {
+ Color32::BLACK
+ };
+
+ v.panel_fill = p.bg;
+ v.window_fill = p.bg;
+ v.window_stroke.color = p.border;
+ v.faint_bg_color = p.faint_bg;
+ v.extreme_bg_color = p.input_bg;
+ v.text_edit_bg_color = Some(p.input_bg);
+ v.code_bg_color = p.input_bg;
+ v.hyperlink_color = p.link;
+ v.error_fg_color = p.error;
+ // Text colour comes from the widget states below; `override_text_color`
+ // would also override the text on hovered and selected widgets.
+ v.weak_text_color = Some(p.weak_fg);
+ v.selection.bg_fill = p.selection;
+ v.selection.stroke.color = p.selection_fg;
+
+ let w = &mut v.widgets;
+ w.noninteractive.bg_fill = p.bg;
+ w.noninteractive.weak_bg_fill = p.bg;
+ w.noninteractive.bg_stroke.color = p.border;
+ w.noninteractive.fg_stroke.color = p.fg;
+
+ w.inactive.bg_fill = p.widget_bg;
+ w.inactive.weak_bg_fill = p.widget_bg;
+ w.inactive.bg_stroke = egui::Stroke::new(1.0, p.input_border);
+ w.inactive.fg_stroke.color = p.fg;
+
+ w.hovered.bg_fill = p.hover_bg;
+ w.hovered.weak_bg_fill = p.hover_bg;
+ w.hovered.bg_stroke.color = p.focus;
+ w.hovered.fg_stroke.color = p.hover_fg;
+
+ w.active.bg_fill = p.selection;
+ w.active.weak_bg_fill = p.selection;
+ w.active.bg_stroke.color = p.focus;
+ w.active.fg_stroke.color = strong;
+
+ w.open.bg_fill = p.bg;
+ w.open.weak_bg_fill = p.widget_bg;
+ w.open.bg_stroke.color = p.border;
+ w.open.fg_stroke.color = p.fg;
+
+ v
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ids_are_unique_lowercase_without_spaces() {
+ let ids: Vec<&str> = builtin().map(|p| p.id.as_ref()).collect();
+ for (i, id) in ids.iter().enumerate() {
+ assert!(!id.is_empty());
+ assert_eq!(*id, id.to_lowercase(), "{id} is not lowercase");
+ assert!(!id.contains(char::is_whitespace), "{id} has whitespace");
+ assert!(
+ id.chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
+ "{id} is not a slug"
+ );
+ assert!(!ids[i + 1..].contains(id), "{id} is duplicated");
+ }
+ }
+
+ #[test]
+ fn find_every_palette() {
+ for p in builtin() {
+ assert_eq!(find(&p.id, &[]).map(|f| f.id.as_ref()), Some(p.id.as_ref()));
+ }
+ assert!(find("nope", &[]).is_none());
+ }
+
+ #[test]
+ fn find_custom_palette() {
+ let mut custom = data_editor::PALETTES[0].clone();
+ custom.id = Cow::Owned("mine".to_string());
+ custom.source = Source::Custom;
+ let list = vec![custom];
+ assert_eq!(find("mine", &list).map(|p| p.source), Some(Source::Custom));
+ }
+
+ #[test]
+ fn builtin_ids_do_not_collide_with_palettes() {
+ for (id, _) in BUILTIN {
+ assert!(find(id, &[]).is_none(), "{id} is both builtin and palette");
+ }
+ }
+
+ #[test]
+ fn sources_match_data_file() {
+ assert!(
+ data_editor::PALETTES
+ .iter()
+ .all(|p| p.source == Source::Editor)
+ );
+ assert!(
+ data_community::PALETTES
+ .iter()
+ .all(|p| p.source == Source::Community)
+ );
+ }
+
+ #[test]
+ fn accents_differ_from_panel_fill() {
+ for p in builtin() {
+ let Accents { down, up, block } = p.accents;
+ for c in [down, up, block] {
+ assert_ne!(c, p.bg, "{} accent equals its background", p.id);
+ }
+ assert!(down != up && up != block && down != block, "{}", p.id);
+ }
+ }
+
+ #[test]
+ fn custom_theme_round_trip() {
+ let p = &data_editor::PALETTES[0];
+ let custom = CustomTheme::from(p);
+ let back = custom.to_palette();
+ assert_eq!(back.source, Source::Custom);
+ assert_eq!(back.id, p.id);
+ assert_eq!(back.accents, p.accents);
+ assert_eq!(back.bg, p.bg);
+ assert_eq!(back.error, p.error);
+ }
+
+ #[test]
+ fn custom_theme_bad_colours_fall_back() {
+ let custom = CustomTheme {
+ id: "x".into(),
+ dark: true,
+ bg: "not a colour".into(),
+ ..CustomTheme::default()
+ };
+ let p = custom.to_palette();
+ let base = data_editor::PALETTES.iter().find(|p| p.dark).unwrap();
+ assert_eq!(p.bg, base.bg);
+ }
+
+ #[test]
+ fn hex_round_trip() {
+ assert_eq!(parse_hex("#1F1F1F"), Some(rgb(0x1F1F1F)));
+ assert_eq!(parse_hex("1f1f1f"), Some(rgb(0x1F1F1F)));
+ assert_eq!(parse_hex("#1F1F1F80"), Some(rgb(0x1F1F1F)));
+ assert_eq!(parse_hex("#fff"), None);
+ assert_eq!(to_hex(rgb(0x0A0B0C)), "#0a0b0c");
+ }
+
+ /// WCAG contrast ratio between two opaque colours.
+ fn contrast(a: Color32, b: Color32) -> f32 {
+ let lum = |c: Color32| {
+ let lin = |v: u8| {
+ let v = f32::from(v) / 255.0;
+ if v <= 0.03928 {
+ v / 12.92
+ } else {
+ ((v + 0.055) / 1.055).powf(2.4)
+ }
+ };
+ 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
+ };
+ let (x, y) = (lum(a), lum(b));
+ (x.max(y) + 0.05) / (x.min(y) + 0.05)
+ }
+
+ #[test]
+ fn widget_text_reads_on_fill_and_panel() {
+ for p in builtin() {
+ let v = visuals(p);
+ let w = &v.widgets;
+ for (state, s) in [
+ ("noninteractive", w.noninteractive),
+ ("inactive", w.inactive),
+ ("hovered", w.hovered),
+ ("active", w.active),
+ ("open", w.open),
+ ] {
+ let fg = s.fg_stroke.color;
+ // Checkbox and radio labels sit on the bare panel in every state.
+ for (what, bg) in [("fill", s.weak_bg_fill), ("panel", v.panel_fill)] {
+ let c = contrast(fg, bg);
+ assert!(c >= 4.0, "{} {state} text on {what}: {c:.2}", p.id);
+ }
+ }
+ let sel = contrast(v.selection.stroke.color, v.selection.bg_fill);
+ assert!(sel >= 4.0, "{} selection: {sel:.2}", p.id);
+ let ring = contrast(v.selection.stroke.color, v.text_edit_bg_color());
+ assert!(ring >= 3.0, "{} focus ring: {ring:.2}", p.id);
+ }
+ }
+
+ #[test]
+ fn apply_every_id() {
+ let ctx = egui::Context::default();
+ assert_eq!(apply(&ctx, "nope", &[]), Accents::DEFAULT);
+ assert_eq!(ctx.options(|o| o.theme_preference), ThemePreference::System);
+ for (id, _) in BUILTIN {
+ assert_eq!(apply(&ctx, id, &[]), Accents::DEFAULT);
+ }
+ for p in builtin() {
+ assert_eq!(apply(&ctx, &p.id, &[]), p.accents);
+ let theme = if p.dark { Theme::Dark } else { Theme::Light };
+ assert_eq!(ctx.style_of(theme).visuals.panel_fill, p.bg);
+ }
+ // Back to the default restores stock visuals for both modes.
+ apply(&ctx, DEFAULT_ID, &[]);
+ assert_eq!(ctx.style_of(Theme::Dark).visuals, Visuals::dark());
+ assert_eq!(ctx.style_of(Theme::Light).visuals, Visuals::light());
+ }
+}
diff --git a/src/tray.rs b/src/tray.rs
new file mode 100644
index 0000000..4f97fdc
--- /dev/null
+++ b/src/tray.rs
@@ -0,0 +1,253 @@
+//! Tray icon: menu with rule toggles, tooltip and icon colours.
+
+use crossbeam_channel::Sender;
+use eframe::egui;
+use tray_icon::menu::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem};
+use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
+
+use crate::gui::rule_summary;
+use crate::types::Snapshot;
+use crate::units::humanize_rate;
+use crate::{icon, theme};
+
+/// Ids of the fixed tray menu entries; rule entries use `TRAY_RULE_PREFIX`.
+const TRAY_SHOW_ID: &str = "show";
+const TRAY_EXIT_ID: &str = "exit";
+/// Tray menu id prefix for a rule toggle; the rest is the rule's exe key.
+const TRAY_RULE_PREFIX: &str = "rule:";
+/// Most rule toggles the tray menu lists before it points to the window.
+const TRAY_RULE_LIMIT: usize = 15;
+
+/// What the tray icon (or a second launch) asks the app to do.
+pub enum TrayAction {
+ Show,
+ Exit,
+ /// Switch the rule with this exe key off, or back on.
+ ToggleRule(String),
+}
+
+/// One rule toggle in the tray menu.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TrayRule {
+ /// Lowercase exe path, the rule's key.
+ pub key: String,
+ /// Process name and what the rule does.
+ pub label: String,
+ /// The rule is active (the entry shows a check mark).
+ pub active: bool,
+}
+
+/// Create the tray icon: left click shows the window, right click opens a
+/// Show / Exit menu. Returns `None` (logged) if it cannot be created. If the
+/// taskbar does not exist yet, tray-icon still succeeds and adds the icon when
+/// Explorer announces the taskbar (see `taskbar_ready`).
+pub fn create_tray(ctx: &egui::Context, tx: Sender) -> Option {
+ let menu = menu(&[]);
+
+ // Hovering fires Move/Enter/Leave constantly: only clicks wake the app.
+ let (click_tx, click_ctx) = (tx.clone(), ctx.clone());
+ TrayIconEvent::set_event_handler(Some(move |event| {
+ if let TrayIconEvent::Click {
+ button: MouseButton::Left,
+ button_state: MouseButtonState::Up,
+ ..
+ }
+ | TrayIconEvent::DoubleClick {
+ button: MouseButton::Left,
+ ..
+ } = event
+ {
+ let _ = click_tx.send(TrayAction::Show);
+ click_ctx.request_repaint();
+ }
+ }));
+ // Entries are known by their string ids, so the menu can be rebuilt
+ // without touching this handler.
+ let ctx = ctx.clone();
+ MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
+ let id = event.id.0.as_str();
+ let action = if id == TRAY_SHOW_ID {
+ TrayAction::Show
+ } else if id == TRAY_EXIT_ID {
+ TrayAction::Exit
+ } else if let Some(key) = id.strip_prefix(TRAY_RULE_PREFIX) {
+ TrayAction::ToggleRule(key.to_string())
+ } else {
+ return;
+ };
+ let _ = tx.send(action);
+ ctx.request_repaint();
+ }));
+
+ let built = Icon::from_rgba(icon::rgba(), icon::SIZE, icon::SIZE)
+ .map_err(|e| e.to_string())
+ .and_then(|icon| {
+ TrayIconBuilder::new()
+ .with_icon(icon)
+ .with_tooltip("Throttle")
+ .with_menu(Box::new(menu))
+ .with_menu_on_left_click(false)
+ .build()
+ .map_err(|e| e.to_string())
+ });
+ match built {
+ Ok(tray) => Some(tray),
+ Err(e) => {
+ tracing::warn!("tray icon unavailable: {e}");
+ None
+ }
+ }
+}
+
+/// The tray menu: Show, one check entry per rule (checked while the rule is
+/// active; clicking switches it off or on), Exit. With no rules the menu is
+/// the two entries it always had.
+pub fn menu(rules: &[TrayRule]) -> Menu {
+ let menu = Menu::new();
+ let show = MenuItem::with_id(TRAY_SHOW_ID, "Show Throttle", true, None);
+ let exit = MenuItem::with_id(TRAY_EXIT_ID, "Exit", true, None);
+ let mut result = menu.append_items(&[&show, &PredefinedMenuItem::separator()]);
+ if !rules.is_empty() {
+ for rule in rules.iter().take(TRAY_RULE_LIMIT) {
+ let id = format!("{TRAY_RULE_PREFIX}{}", rule.key);
+ let item = CheckMenuItem::with_id(id, &rule.label, true, rule.active, None);
+ result = result.and(menu.append(&item));
+ }
+ if rules.len() > TRAY_RULE_LIMIT {
+ let more = rules.len() - TRAY_RULE_LIMIT;
+ let note = MenuItem::new(format!("{more} more in the window"), false, None);
+ result = result.and(menu.append(¬e));
+ }
+ result = result.and(menu.append(&PredefinedMenuItem::separator()));
+ }
+ result = result.and(menu.append(&exit));
+ if let Err(e) = result {
+ tracing::warn!("tray menu incomplete: {e}");
+ }
+ menu
+}
+
+/// The rule toggles for the tray menu, by process name. A rule whose
+/// process is not running is named after its executable; one that neither
+/// limits nor blocks has nothing to switch and is left out.
+pub fn rules(snap: &Snapshot) -> Vec {
+ let mut rules: Vec = snap
+ .rules
+ .values()
+ .filter(|r| r.blocked || r.down_limit.is_some() || r.up_limit.is_some())
+ .map(|rule| {
+ let name = snap
+ .processes
+ .get(&rule.exe_path)
+ .map(|p| p.name.clone())
+ .unwrap_or_else(|| exe_name(&rule.exe_path));
+ let mut active = rule.clone();
+ active.disabled = false;
+ TrayRule {
+ key: rule.exe_path.clone(),
+ label: format!("{name}: {}", rule_summary(Some(&active))),
+ active: !rule.disabled,
+ }
+ })
+ .collect();
+ rules.sort_by_key(|r| r.label.to_lowercase());
+ rules
+}
+
+/// The tray tooltip: the live download and upload totals.
+pub fn tooltip(snap: &Snapshot) -> String {
+ format!(
+ "Throttle\nDown {}\nUp {}",
+ humanize_rate(snap.total_down_rate),
+ humanize_rate(snap.total_up_rate)
+ )
+}
+
+/// The file name of an exe path, or the path itself if it has none.
+fn exe_name(path: &str) -> String {
+ std::path::Path::new(path)
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .filter(|n| !n.is_empty())
+ .unwrap_or_else(|| path.to_string())
+}
+
+/// Tray icon pixels for `accents`. The stock accents keep the stock icon,
+/// whose arrow colours predate the theme accents, so the default look is
+/// unchanged.
+pub fn icon_rgba(accents: theme::Accents) -> Vec {
+ let stock = theme::Accents::DEFAULT;
+ if (accents.down, accents.up) == (stock.down, stock.up) {
+ icon::rgba()
+ } else {
+ let rgb = |c: egui::Color32| [c.r(), c.g(), c.b()];
+ icon::rgba_with(rgb(accents.down), rgb(accents.up))
+ }
+}
+
+/// Whether Explorer's taskbar (and so the notification area) exists.
+pub fn taskbar_ready() -> bool {
+ use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
+ use windows::core::w;
+ // SAFETY: FindWindowW with a valid static class name; a miss is an error.
+ unsafe { FindWindowW(w!("Shell_TrayWnd"), None) }.is_ok_and(|h| !h.is_invalid())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::{ProcessStats, Rule};
+
+ #[test]
+ fn rules_name_running_processes_and_sort_by_label() {
+ let mut snap = Snapshot::default();
+ let steam = "c:\\steam\\steam.exe".to_string();
+ let curl = "c:\\tools\\curl.exe".to_string();
+ snap.processes.insert(
+ steam.clone(),
+ ProcessStats {
+ name: "Steam.exe".to_string(),
+ exe_path: steam.clone(),
+ ..ProcessStats::default()
+ },
+ );
+ snap.rules.insert(
+ steam.clone(),
+ Rule {
+ exe_path: steam.clone(),
+ down_limit: Some(1024 * 1024),
+ disabled: true,
+ ..Rule::default()
+ },
+ );
+ snap.rules.insert(
+ curl.clone(),
+ Rule {
+ exe_path: curl.clone(),
+ blocked: true,
+ ..Rule::default()
+ },
+ );
+ // A rule with nothing to apply is not worth a toggle.
+ snap.rules.insert(
+ r"c:\idle.exe".to_string(),
+ Rule {
+ exe_path: r"c:\idle.exe".to_string(),
+ ..Rule::default()
+ },
+ );
+ let rules = rules(&snap);
+ assert_eq!(rules.len(), 2);
+ assert_eq!(rules[0].label, "curl.exe: BLOCKED");
+ assert!(rules[0].active);
+ // Disabled rules still show what they would do, unchecked.
+ assert_eq!(rules[1].label, "Steam.exe: Dn 1.00 MB/s");
+ assert!(!rules[1].active);
+ assert_eq!(rules[1].key, steam);
+ }
+
+ #[test]
+ fn stock_accents_keep_the_stock_tray_icon() {
+ assert_eq!(icon_rgba(theme::Accents::DEFAULT), icon::rgba());
+ }
+}
diff --git a/src/types.rs b/src/types.rs
index 4502e1d..0f28469 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -17,6 +17,10 @@ pub struct Rule {
pub up_limit: Option,
/// If true, all traffic for this process is dropped.
pub blocked: bool,
+ /// The rule is switched off: kept in the list but not applied. Missing in
+ /// files from older builds, so it defaults to active.
+ #[serde(default)]
+ pub disabled: bool,
}
/// Live stats for one process, sent to the GUI once per second.
diff --git a/src/units.rs b/src/units.rs
new file mode 100644
index 0000000..f99a7ad
--- /dev/null
+++ b/src/units.rs
@@ -0,0 +1,139 @@
+//! Byte and rate formatting in base 1024, and small layout helpers.
+
+pub const KB: f64 = 1024.0;
+pub const MB: f64 = 1024.0 * 1024.0;
+pub const GB: f64 = 1024.0 * 1024.0 * 1024.0;
+pub const TB: f64 = 1024.0 * 1024.0 * 1024.0 * 1024.0;
+
+/// Humanize a byte-per-second rate using base-1024 units.
+pub fn humanize_rate(bps: u64) -> String {
+ let b = bps as f64;
+ if b >= GB {
+ format!("{:.2} GB/s", b / GB)
+ } else if b >= MB {
+ format!("{:.2} MB/s", b / MB)
+ } else if b >= KB {
+ format!("{:.1} KB/s", b / KB)
+ } else {
+ format!("{bps} B/s")
+ }
+}
+
+/// Humanize a plain byte total using base-1024 units.
+pub fn humanize_bytes(bytes: u64) -> String {
+ let b = bytes as f64;
+ if b >= TB {
+ format!("{:.2} TB", b / TB)
+ } else if b >= GB {
+ format!("{:.2} GB", b / GB)
+ } else if b >= MB {
+ format!("{:.2} MB", b / MB)
+ } else if b >= KB {
+ format!("{:.1} KB", b / KB)
+ } else {
+ format!("{bytes} B")
+ }
+}
+
+/// Compact rate for a plot axis mark: the unit is picked per mark and
+/// trailing zeros are dropped, so the axis reads "1.5 MB/s" not "1572864".
+pub fn axis_rate(bps: f64) -> String {
+ // Marks below zero come from the plot's margin and get no label, so an
+ // idle process does not show a column of zeros.
+ if bps < 0.0 {
+ return String::new();
+ }
+ if bps == 0.0 {
+ return "0".to_string();
+ }
+ let (value, unit) = if bps >= GB {
+ (bps / GB, "GB/s")
+ } else if bps >= MB {
+ (bps / MB, "MB/s")
+ } else if bps >= KB {
+ (bps / KB, "KB/s")
+ } else {
+ (bps, "B/s")
+ };
+ let text = format!("{value:.1}");
+ let text = text.trim_end_matches('0').trim_end_matches('.');
+ format!("{text} {unit}")
+}
+
+/// A remembered size (a column width or a panel size, in points) settled to
+/// the nearest half point. Returns the value to store when `seen` is a real
+/// size and differs from `saved` by at least half a point, or when nothing
+/// was saved yet; otherwise `None`, so a drag that barely moves writes
+/// nothing.
+pub fn settle(saved: Option, seen: f32) -> Option {
+ if !seen.is_finite() || seen <= 0.0 {
+ return None;
+ }
+ let seen = (seen * 2.0).round() / 2.0;
+ saved
+ .is_none_or(|v| (v - seen).abs() >= 0.5)
+ .then_some(seen)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn negative_axis_marks_get_no_label() {
+ assert_eq!(axis_rate(-512.0), "");
+ assert_eq!(axis_rate(0.0), "0");
+ }
+
+ #[test]
+ fn axis_rates_pick_a_unit_and_trim_zeros() {
+ assert_eq!(axis_rate(0.0), "0");
+ assert_eq!(axis_rate(512.0), "512 B/s");
+ assert_eq!(axis_rate(1536.0), "1.5 KB/s");
+ assert_eq!(axis_rate(2.0 * MB), "2 MB/s");
+ assert_eq!(axis_rate(1.75 * GB), "1.8 GB/s");
+ }
+
+ #[test]
+ fn rates_and_bytes_pick_a_unit() {
+ assert_eq!(humanize_rate(512), "512 B/s");
+ assert_eq!(humanize_rate(1536), "1.5 KB/s");
+ assert_eq!(humanize_rate(2 * 1024 * 1024), "2.00 MB/s");
+ assert_eq!(humanize_bytes(1023), "1023 B");
+ assert_eq!(humanize_bytes(3 * 1024 * 1024 * 1024), "3.00 GB");
+ assert_eq!(humanize_bytes(1024u64.pow(4)), "1.00 TB");
+ }
+
+ #[test]
+ fn settle_records_the_first_value() {
+ assert_eq!(settle(None, 120.0), Some(120.0));
+ }
+
+ #[test]
+ fn settle_records_a_changed_value() {
+ assert_eq!(settle(Some(120.0), 140.0), Some(140.0));
+ assert_eq!(settle(Some(120.0), 120.5), Some(120.5));
+ }
+
+ #[test]
+ fn settle_ignores_a_change_within_the_tolerance() {
+ assert_eq!(settle(Some(120.0), 120.0), None);
+ assert_eq!(settle(Some(120.0), 120.2), None);
+ assert_eq!(settle(Some(120.0), 119.8), None);
+ }
+
+ #[test]
+ fn settle_rejects_zero_negative_and_nan() {
+ assert_eq!(settle(None, 0.0), None);
+ assert_eq!(settle(None, -4.0), None);
+ assert_eq!(settle(None, f32::NAN), None);
+ assert_eq!(settle(None, f32::INFINITY), None);
+ }
+
+ #[test]
+ fn settle_rounds_to_halves() {
+ assert_eq!(settle(None, 100.3), Some(100.5));
+ assert_eq!(settle(None, 100.2), Some(100.0));
+ assert_eq!(settle(None, 100.8), Some(101.0));
+ }
+}