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 @@ ![platform](https://img.shields.io/badge/platform-Windows%2010%2F11%20x64-0078D6) ![rust](https://img.shields.io/badge/built%20with-Rust-orange) ![rust](https://img.shields.io/github/downloads/FrancescoCoding/Throttle/total) +[![buy me a coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-ffdd00?logo=buymeacoffee&logoColor=black)](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 -Throttle demo +Live rates per process, with the selected process on the right and a button +that opens its folder: -Throttle download limit +Throttle main window + +The Themes menu, with editor and community palettes: + +Themes menu + +Customise: corners, density, scale, fonts and accents: + +Customise window + +The filter box and the Active chip narrow the table to what matters: + +Filtered process table ## 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)); + } +}