budctl6d1e074
budctl-gui: the Overview page with demo data (GUI step 1)
The owner chose a standalone GUI in iced, laid out like the reference screenshot: a sidebar with the device card and the pages, and an Overview with battery rings, the noise-control pill (with the ANC strength), the switches and rows into the other pages. It shows demo data for now; clicks change only that. Icons and rings are drawn as vector paths, in the palette's colors. Colors are built in (dark, red accent), or come from a matugen JSON file with the keys of the owner's Aurora palette, re-read when the window gets focus: --theme <file>, else ~/.config/budctl/theme.json. Rule 1 changes, by the owner (2026-10-27): network access is fine when a feature needs it, never telemetry. Nothing needs it yet; the HTTP crate bans stay until something does. The GUI adds one socket: the Wayland compositor's (built for Wayland only, so no X11/TCP display). guards.rs now checks the crates a Linux build uses (cargo tree for the two targets deny.toml checks) instead of Cargo.lock, which also lists crates only other platforms build (tokio, for wasm, via iced). New dependencies (gui): - iced 0.14, without default features: Wayland, the CPU renderer (tiny-skia), canvas. It builds 132 crates on Linux: no tokio, zbus, X11, wgpu or HTTP. - serde, serde_json (already in the workspace): the palette file. - iced_test (dev): renders pages to PNG without a display, so the look can be checked without the owner (target/gui-snapshots/).
+4271 −70
11 files changed
- M CLAUDE.md +18 −8
- M Cargo.lock +2717 −50
- M Cargo.toml +1 −1
- M crates/budctl-core/tests/guards.rs +37 −11
- A crates/budctl-gui/Cargo.toml +25 −0
- A crates/budctl-gui/src/demo.rs +46 −0
- A crates/budctl-gui/src/icons.rs +347 −0
- A crates/budctl-gui/src/main.rs +374 −0
- A crates/budctl-gui/src/overview.rs +171 −0
- A crates/budctl-gui/src/theme.rs +274 −0
- A crates/budctl-gui/src/ui.rs +261 −0
| @@ -1,13 +1,13 @@ | |||
| 1 | 1 | # budctl | |
| 2 | 2 | ||
| 3 | − | budctl is a Linux CLI (and later a GUI) for controlling wireless earbuds without the vendor app. It has no telemetry, needs no account and never makes a network call. | |
| 3 | + | budctl is a Linux CLI and GUI for controlling wireless earbuds without the vendor app. It has no telemetry and needs no account. | |
| 4 | 4 | ||
| 5 | 5 | The first supported device is the **realme Buds T310**. More devices are added as plugins when the owner gets new earbuds. | |
| 6 | 6 | ||
| 7 | 7 | ## Goals | |
| 8 | 8 | ||
| 9 | 9 | - Control earbud features from Linux: ANC / transparency / off, EQ presets, game mode, touch controls, battery readout, and anything else the buds offer. | |
| 10 | − | - Two front ends, one core: a `budctl` CLI and later a `budctl-gui`, both thin layers over the same library (`budctl-core`). The GUI is undecided. It will be either a Quickshell/Aurora integration (which can use `budctl --json`) or a standalone Rust GUI. | |
| 10 | + | - Two front ends, one core: the `budctl` CLI and the `budctl-gui` desktop app, both thin layers over the same library (`budctl-core`). Scripts and the owner's Quickshell (Aurora) can use `budctl --json`. | |
| 11 | 11 | - One small, readable codebase that the owner fully understands. | |
| 12 | 12 | - Device support is pluggable. Adding a new pair of buds means adding one module under `crates/budctl-core/src/devices/` and registering it in `devices/mod.rs`, without touching transport, CLI or GUI code. | |
| 13 | 13 | - Coexist with the phone app. Settings live on the buds, so budctl never stores device state locally beyond user config. | |
| @@ -15,15 +15,17 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 15 | 15 | ## Non-goals | |
| 16 | 16 | ||
| 17 | 17 | - No firmware dumping, flashing or modification. We only speak the buds' existing control protocol. | |
| 18 | − | - No network access of any kind: no update checks, crash reports or analytics. | |
| 18 | + | - No telemetry of any kind: no analytics, crash reports or usage data, and no update checks. | |
| 19 | 19 | - No support for models the owner doesn't have. Don't speculatively add devices. | |
| 20 | 20 | - No Windows or macOS support. | |
| 21 | 21 | ||
| 22 | 22 | ## Hard rules | |
| 23 | 23 | ||
| 24 | − | 1. **No network code.** Nothing in this workspace may open a socket except a Bluetooth socket (RFCOMM for the control link, L2CAP for SDP). | |
| 25 | − | - The one exception, approved by the owner: the local system D-Bus Unix socket, used through `zbus` only to talk to BlueZ (`org.bluez`). budctl opens that socket itself at the fixed path, so an environment variable can never redirect it to TCP. | |
| 26 | − | - No HTTP clients (reqwest, hyper, ureq, surf, etc.) may appear in the dependency tree, direct or transitive. This is enforced with `cargo deny` (`deny.toml`), clippy `disallowed-types`, and `crates/budctl-core/tests/guards.rs`. | |
| 24 | + | 1. **No telemetry; network only when a feature needs it.** The owner relaxed this rule on 2026-10-27: network access is fine when a feature truly needs it, but nothing may ever report on the user. No feature needs the network so far. The sockets budctl opens today: | |
| 25 | + | - Bluetooth sockets: RFCOMM for the control link, L2CAP for SDP. | |
| 26 | + | - The local system D-Bus Unix socket, used through `zbus` only to talk to BlueZ (`org.bluez`). budctl opens that socket itself at the fixed path, so an environment variable can never redirect it to TCP. | |
| 27 | + | - `budctl-gui` only: the Wayland compositor's Unix socket. The GUI is built for Wayland only (no X11), so `DISPLAY` can never point it at TCP. | |
| 28 | + | - HTTP clients (reqwest, hyper, ureq, surf, etc.) stay banned until a feature needs one. The bans live in `cargo deny` (`deny.toml`), clippy `disallowed-types` and `crates/budctl-core/tests/guards.rs`. Lifting one is fine then; say why in the commit. | |
| 27 | 29 | 2. **Clean-room protocol work.** Protocol facts (UUIDs, channel numbers, packet framing, command IDs, init sequences) may come from public references, but no other project's source code is ever translated or copied. Write findings into `docs/protocol/<device>.md` in our own words, then implement from that spec. | |
| 28 | 30 | - Committed files do not name or cite those references. Each fact carries an evidence level instead (rule 3). | |
| 29 | 31 | - Only the owner can change this rule, by editing it here. | |
| @@ -37,7 +39,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 37 | 39 | - Rust (edition 2024) in a cargo workspace: | |
| 38 | 40 | - `crates/budctl-core`: library with transport, BlueZ and device plugins. | |
| 39 | 41 | - `crates/budctl-cli`: package and binary `budctl`. | |
| 40 | − | - `crates/budctl-gui` will come later. | |
| 42 | + | - `crates/budctl-gui`: package and binary `budctl-gui`, built with iced 0.14 for Wayland only, with the CPU renderer (tiny-skia). | |
| 41 | 43 | - Bluetooth goes through BlueZ: | |
| 42 | 44 | - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record. | |
| 43 | 45 | - `zbus` (blocking API) only to list paired devices. | |
| @@ -47,6 +49,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 47 | 49 | - Keep dependencies minimal. Every new dependency needs a reason in the commit message. The current direct dependencies: | |
| 48 | 50 | - core: `libc`, `zbus`, `serde` | |
| 49 | 51 | - cli: `clap`, `serde_json` | |
| 52 | + | - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests | |
| 50 | 53 | ||
| 51 | 54 | ## Device plugin contract | |
| 52 | 55 | ||
| @@ -109,9 +112,16 @@ Exit codes: | |||
| 109 | 112 | 9. hold cycle | |
| 110 | 113 | 10. ~~fit test~~ (dropped: the T310 acks it but runs no test) | |
| 111 | 114 | 11. custom EQ | |
| 112 | − | 4. **GUI**: Quickshell/Aurora via `--json`, or a Rust GUI on top of the same core. | |
| 115 | + | 4. **GUI** (`budctl-gui`, see [GUI](#gui)): first the look, with demo data; then the keys; then the live connection; then the pages, one at a time. | |
| 113 | 116 | 5. **More devices**: as acquired. | |
| 114 | 117 | ||
| 118 | + | ## GUI | |
| 119 | + | ||
| 120 | + | - **Look:** a sidebar (device card, pages) and pages of cards: battery rings, a segmented noise-control pill, rows of switches. The owner's reference is a screenshot of another buds app; match the look, never its code (rule 2). | |
| 121 | + | - **Theme:** a built-in dark palette with a red accent. Optionally, a matugen JSON file with the same keys as the owner's Aurora palette (`~/.cache/matugen/aurora.json`: `bg`, `fg`, `primary`, `surface`…), mapped to the same roles as Aurora's `Theme.qml`. It is read again whenever the window gets focus. | |
| 122 | + | - **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top: `j`/`k` move a cursor, `h`/`l` change the value under it. The cursor shows only after a key press. | |
| 123 | + | - **Logic:** keys and clicks both become messages for one `update` function, which stays testable without a display. `iced_test` renders pages to PNG, so the look can be checked without the owner. | |
| 124 | + | ||
| 115 | 125 | ## Working with Claude | |
| 116 | 126 | ||
| 117 | 127 | - Claude has no access to the buds. Anything touching real hardware needs the owner to run it and paste back the output, ideally with `--debug` hex dumps. | |
[Diff too large] View file
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [workspace] | |
| 2 | 2 | resolver = "3" | |
| 3 | − | members = ["crates/budctl-core", "crates/budctl-cli"] | |
| 3 | + | members = ["crates/budctl-core", "crates/budctl-cli", "crates/budctl-gui"] | |
| 4 | 4 | ||
| 5 | 5 | [workspace.package] | |
| 6 | 6 | version = "0.1.0" | |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | //! Checks for the CLAUDE.md hard rules that the compiler and clippy cannot make on their own. | |
| 2 | 2 | //! | |
| 3 | − | //! - Rule 1: no network code in our sources, and no banned crate (deny.toml) in Cargo.lock. | |
| 3 | + | //! - Rule 1: no network code in our sources, and no banned crate (deny.toml) in a Linux build. | |
| 4 | 4 | //! - Rule 3: every `Spec: <file>.md#<anchor>` comment points at an anchor that exists. | |
| 5 | 5 | //! | |
| 6 | 6 | //! The CLI's exit codes are checked in crates/budctl-cli/tests/cli.rs. | |
| @@ -11,6 +11,7 @@ | |||
| 11 | 11 | use std::collections::BTreeSet; | |
| 12 | 12 | use std::fs; | |
| 13 | 13 | use std::path::{Path, PathBuf}; | |
| 14 | + | use std::process::Command; | |
| 14 | 15 | ||
| 15 | 16 | fn workspace() -> PathBuf { | |
| 16 | 17 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") | |
| @@ -94,25 +95,50 @@ fn banned_crates() -> BTreeSet<String> { | |||
| 94 | 95 | names | |
| 95 | 96 | } | |
| 96 | 97 | ||
| 97 | − | #[test] | |
| 98 | − | fn no_banned_crate_in_lockfile() { | |
| 99 | − | let lock = fs::read_to_string(workspace().join("Cargo.lock")).unwrap(); | |
| 100 | − | let locked: BTreeSet<&str> = lock | |
| 98 | + | /// The crates a Linux build of the workspace uses, tests included, for the targets deny.toml | |
| 99 | + | /// checks. Cargo.lock is no help here: it also lists crates that only other platforms build (iced's | |
| 100 | + | /// lock entries include tokio, for wasm). | |
| 101 | + | fn built_crates() -> BTreeSet<String> { | |
| 102 | + | let out = Command::new(env!("CARGO")) | |
| 103 | + | .current_dir(workspace()) | |
| 104 | + | .args(["tree", "--offline", "--locked", "--workspace", "--all-features"]) | |
| 105 | + | .args(["--edges", "normal,build,dev", "--prefix", "none", "--format", "{p}"]) | |
| 106 | + | .args([ | |
| 107 | + | "--target", | |
| 108 | + | "x86_64-unknown-linux-gnu", | |
| 109 | + | "--target", | |
| 110 | + | "aarch64-unknown-linux-gnu", | |
| 111 | + | ]) | |
| 112 | + | .output() | |
| 113 | + | .unwrap(); | |
| 114 | + | assert!( | |
| 115 | + | out.status.success(), | |
| 116 | + | "cargo tree failed: {}", | |
| 117 | + | String::from_utf8_lossy(&out.stderr) | |
| 118 | + | ); | |
| 119 | + | let names: BTreeSet<String> = String::from_utf8(out.stdout) | |
| 120 | + | .unwrap() | |
| 101 | 121 | .lines() | |
| 102 | − | .filter_map(|l| l.strip_prefix("name = \"")) | |
| 103 | − | .filter_map(|l| l.strip_suffix('"')) | |
| 122 | + | .filter_map(|line| line.split_whitespace().next()) | |
| 123 | + | .map(str::to_owned) | |
| 104 | 124 | .collect(); | |
| 105 | 125 | assert!( | |
| 106 | − | locked.contains("budctl-core"), | |
| 107 | − | "could not read crate names from Cargo.lock" | |
| 126 | + | names.contains("budctl-core") && names.contains("libc"), | |
| 127 | + | "could not read crate names from cargo tree" | |
| 108 | 128 | ); | |
| 129 | + | names | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | #[test] | |
| 133 | + | fn no_banned_crate_in_the_build() { | |
| 134 | + | let built = built_crates(); | |
| 109 | 135 | let found: Vec<String> = banned_crates() | |
| 110 | 136 | .into_iter() | |
| 111 | − | .filter(|b| locked.contains(b.as_str())) | |
| 137 | + | .filter(|banned| built.contains(banned)) | |
| 112 | 138 | .collect(); | |
| 113 | 139 | assert!( | |
| 114 | 140 | found.is_empty(), | |
| 115 | − | "banned crates in Cargo.lock (see deny.toml): {found:?}" | |
| 141 | + | "banned crates in the build (see deny.toml): {found:?}" | |
| 116 | 142 | ); | |
| 117 | 143 | } | |
| 118 | 144 | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "budctl-gui" | |
| 3 | + | description = "Graphical front end for budctl" | |
| 4 | + | version.workspace = true | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | license.workspace = true | |
| 8 | + | publish.workspace = true | |
| 9 | + | ||
| 10 | + | [[bin]] | |
| 11 | + | name = "budctl-gui" | |
| 12 | + | path = "src/main.rs" | |
| 13 | + | ||
| 14 | + | [dependencies] | |
| 15 | + | budctl-core = { path = "../budctl-core" } | |
| 16 | + | # Wayland only (no X11) and CPU rendering (no GPU stack): the smallest iced that draws this UI. | |
| 17 | + | iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "canvas", "crisp"] } | |
| 18 | + | serde = { version = "1.0.229", features = ["derive"] } | |
| 19 | + | serde_json = "1.0.154" | |
| 20 | + | ||
| 21 | + | [dev-dependencies] | |
| 22 | + | iced_test = "0.14.0" | |
| 23 | + | ||
| 24 | + | [lints] | |
| 25 | + | workspace = true | |
| @@ -0,0 +1,46 @@ | |||
| 1 | + | //! Demo data: roughly what the owner's T310 reported at the last checkpoint. The GUI shows it | |
| 2 | + | //! until it talks to real buds. | |
| 3 | + | ||
| 4 | + | use budctl_core::status::{Battery, Charge, NoiseControl, Status, Switch}; | |
| 5 | + | ||
| 6 | + | /// The buds the window shows. | |
| 7 | + | pub struct Buds { | |
| 8 | + | pub name: String, | |
| 9 | + | pub connected: bool, | |
| 10 | + | pub status: Status, | |
| 11 | + | /// The name to show for the EQ preset in use. | |
| 12 | + | pub eq_name: String, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | pub fn buds() -> Buds { | |
| 16 | + | let charge = |level| Some(Charge { level, charging: false }); | |
| 17 | + | let switch = |key, label, on| Switch { key, label, on }; | |
| 18 | + | Buds { | |
| 19 | + | name: "realme Buds T310".to_owned(), | |
| 20 | + | connected: true, | |
| 21 | + | status: Status { | |
| 22 | + | battery: Battery { | |
| 23 | + | left: charge(80), | |
| 24 | + | right: charge(80), | |
| 25 | + | case: None, | |
| 26 | + | }, | |
| 27 | + | noise_control: Some(NoiseControl { | |
| 28 | + | mode: "anc".to_owned(), | |
| 29 | + | level: Some("deep".to_owned()), | |
| 30 | + | }), | |
| 31 | + | eq: Some("custom-4".to_owned()), | |
| 32 | + | bass_level: None, | |
| 33 | + | switches: vec![ | |
| 34 | + | switch("game", "Game mode", false), | |
| 35 | + | switch("dual", "Dual connection", true), | |
| 36 | + | switch("wind", "Wind-noise reduction", true), | |
| 37 | + | switch("spatial", "3D sound", true), | |
| 38 | + | switch("bass-boost", "Bass boost", true), | |
| 39 | + | ], | |
| 40 | + | codec: Some("SBC".to_owned()), | |
| 41 | + | firmware: Some("1.1.0.43".to_owned()), | |
| 42 | + | warnings: Vec::new(), | |
| 43 | + | }, | |
| 44 | + | eq_name: "Custom1".to_owned(), | |
| 45 | + | } | |
| 46 | + | } | |
| @@ -0,0 +1,347 @@ | |||
| 1 | + | //! Icons and battery rings, drawn as vector paths so they take any theme color and stay sharp at | |
| 2 | + | //! any scale. Icons are designed on a 24 × 24 grid. | |
| 3 | + | ||
| 4 | + | use std::f32::consts::{FRAC_PI_2, TAU}; | |
| 5 | + | ||
| 6 | + | use iced::widget::canvas::{self, Frame, LineCap, LineJoin, Path, Stroke, path::Arc}; | |
| 7 | + | use iced::{Color, Element, Point, Radians, Rectangle, Renderer, Size, Theme, mouse}; | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 10 | + | pub enum Icon { | |
| 11 | + | Overview, | |
| 12 | + | Equalizer, | |
| 13 | + | Gestures, | |
| 14 | + | Earbud, | |
| 15 | + | Settings, | |
| 16 | + | NoiseOff, | |
| 17 | + | Anc, | |
| 18 | + | Transparency, | |
| 19 | + | Game, | |
| 20 | + | Spatial, | |
| 21 | + | Bass, | |
| 22 | + | Wind, | |
| 23 | + | Dual, | |
| 24 | + | Chevron, | |
| 25 | + | Bolt, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// `icon` at `size` pixels, in `color`. | |
| 29 | + | pub fn icon<'a, Message: 'a>(icon: Icon, size: f32, color: Color) -> Element<'a, Message> { | |
| 30 | + | iced::widget::canvas(Glyph { icon, color }) | |
| 31 | + | .width(size) | |
| 32 | + | .height(size) | |
| 33 | + | .into() | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | struct Glyph { | |
| 37 | + | icon: Icon, | |
| 38 | + | color: Color, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl<Message> canvas::Program<Message> for Glyph { | |
| 42 | + | type State = (); | |
| 43 | + | ||
| 44 | + | fn draw( | |
| 45 | + | &self, | |
| 46 | + | _state: &(), | |
| 47 | + | renderer: &Renderer, | |
| 48 | + | _theme: &Theme, | |
| 49 | + | bounds: Rectangle, | |
| 50 | + | _cursor: mouse::Cursor, | |
| 51 | + | ) -> Vec<canvas::Geometry> { | |
| 52 | + | let mut frame = Frame::new(renderer, bounds.size()); | |
| 53 | + | let mut pen = Pen::new(&mut frame, Point::ORIGIN, bounds.width, self.color); | |
| 54 | + | draw_icon(&mut pen, self.icon); | |
| 55 | + | vec![frame.into_geometry()] | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Draws on a 24 × 24 grid placed at `origin`, `size` pixels wide. | |
| 60 | + | struct Pen<'f> { | |
| 61 | + | frame: &'f mut Frame, | |
| 62 | + | origin: Point, | |
| 63 | + | scale: f32, | |
| 64 | + | color: Color, | |
| 65 | + | width: f32, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | impl<'f> Pen<'f> { | |
| 69 | + | fn new(frame: &'f mut Frame, origin: Point, size: f32, color: Color) -> Self { | |
| 70 | + | let scale = size / 24.0; | |
| 71 | + | Pen { | |
| 72 | + | frame, | |
| 73 | + | origin, | |
| 74 | + | scale, | |
| 75 | + | color, | |
| 76 | + | width: 1.9 * scale, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | fn at(&self, x: f32, y: f32) -> Point { | |
| 81 | + | Point::new(self.origin.x + x * self.scale, self.origin.y + y * self.scale) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn stroke(&mut self, path: &Path) { | |
| 85 | + | let stroke = Stroke::default() | |
| 86 | + | .with_color(self.color) | |
| 87 | + | .with_width(self.width) | |
| 88 | + | .with_line_cap(LineCap::Round) | |
| 89 | + | .with_line_join(LineJoin::Round); | |
| 90 | + | self.frame.stroke(path, stroke); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | fn polyline(&mut self, points: &[(f32, f32)], closed: bool) { | |
| 94 | + | let path = Path::new(|b| { | |
| 95 | + | for (n, &(x, y)) in points.iter().enumerate() { | |
| 96 | + | if n == 0 { | |
| 97 | + | b.move_to(self.at(x, y)); | |
| 98 | + | } else { | |
| 99 | + | b.line_to(self.at(x, y)); | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if closed { | |
| 103 | + | b.close(); | |
| 104 | + | } | |
| 105 | + | }); | |
| 106 | + | self.stroke(&path); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fn line(&mut self, from: (f32, f32), to: (f32, f32)) { | |
| 110 | + | self.polyline(&[from, to], false); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | fn circle(&mut self, x: f32, y: f32, r: f32) { | |
| 114 | + | let path = Path::circle(self.at(x, y), r * self.scale); | |
| 115 | + | self.stroke(&path); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | fn dot(&mut self, x: f32, y: f32, r: f32) { | |
| 119 | + | let path = Path::circle(self.at(x, y), r * self.scale); | |
| 120 | + | self.frame.fill(&path, self.color); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Degrees, clockwise from three o'clock. `to` may be below `from` to go the other way. | |
| 124 | + | fn arc(&mut self, x: f32, y: f32, r: f32, from: f32, to: f32) { | |
| 125 | + | let path = Path::new(|b| { | |
| 126 | + | b.arc(Arc { | |
| 127 | + | center: self.at(x, y), | |
| 128 | + | radius: r * self.scale, | |
| 129 | + | start_angle: Radians(from.to_radians()), | |
| 130 | + | end_angle: Radians(to.to_radians()), | |
| 131 | + | }); | |
| 132 | + | }); | |
| 133 | + | self.stroke(&path); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32) { | |
| 137 | + | let path = self.rounded(x, y, w, h, r); | |
| 138 | + | self.stroke(&path); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32) { | |
| 142 | + | let path = self.rounded(x, y, w, h, r); | |
| 143 | + | self.frame.fill(&path, self.color); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | fn rounded(&self, x: f32, y: f32, w: f32, h: f32, r: f32) -> Path { | |
| 147 | + | Path::rounded_rectangle( | |
| 148 | + | self.at(x, y), | |
| 149 | + | Size::new(w * self.scale, h * self.scale), | |
| 150 | + | (r * self.scale).into(), | |
| 151 | + | ) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | fn draw_icon(pen: &mut Pen, icon: Icon) { | |
| 156 | + | match icon { | |
| 157 | + | Icon::Overview => { | |
| 158 | + | for (x, y) in [(3.0, 3.0), (13.5, 3.0), (3.0, 13.5), (13.5, 13.5)] { | |
| 159 | + | pen.rect(x, y, 7.5, 7.5, 2.0); | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | Icon::Equalizer => { | |
| 163 | + | for (x, knob) in [(6.0, 14.0), (12.0, 8.0), (18.0, 16.0)] { | |
| 164 | + | pen.line((x, 3.5), (x, 20.5)); | |
| 165 | + | pen.line((x - 2.5, knob), (x + 2.5, knob)); | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | Icon::Gestures => { | |
| 169 | + | pen.rect(9.5, 11.0, 5.0, 11.0, 2.5); | |
| 170 | + | pen.arc(12.0, 11.5, 4.0, 200.0, 340.0); | |
| 171 | + | pen.arc(12.0, 11.5, 7.5, 210.0, 330.0); | |
| 172 | + | } | |
| 173 | + | Icon::Earbud => earbud(pen, false, None), | |
| 174 | + | Icon::Settings => { | |
| 175 | + | for (y, knob) in [(6.0, 15.0), (12.0, 8.0), (18.0, 13.0)] { | |
| 176 | + | pen.line((3.5, y), (20.5, y)); | |
| 177 | + | pen.dot(knob, y, 2.6); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | Icon::NoiseOff => { | |
| 181 | + | pen.circle(12.0, 12.0, 8.5); | |
| 182 | + | pen.line((6.0, 6.0), (18.0, 18.0)); | |
| 183 | + | } | |
| 184 | + | Icon::Anc => { | |
| 185 | + | pen.circle(12.0, 12.0, 8.5); | |
| 186 | + | pen.circle(12.0, 12.0, 4.5); | |
| 187 | + | pen.dot(12.0, 12.0, 1.4); | |
| 188 | + | } | |
| 189 | + | Icon::Transparency => { | |
| 190 | + | for n in 0..12u8 { | |
| 191 | + | let angle = f32::from(n) * 30f32.to_radians(); | |
| 192 | + | pen.dot(12.0 + 8.5 * angle.cos(), 12.0 + 8.5 * angle.sin(), 1.15); | |
| 193 | + | } | |
| 194 | + | pen.dot(12.0, 12.0, 2.2); | |
| 195 | + | } | |
| 196 | + | Icon::Game | Icon::Bolt => pen.polyline( | |
| 197 | + | &[ | |
| 198 | + | (13.5, 2.5), | |
| 199 | + | (5.0, 13.5), | |
| 200 | + | (11.5, 13.5), | |
| 201 | + | (10.5, 21.5), | |
| 202 | + | (19.0, 10.5), | |
| 203 | + | (12.5, 10.5), | |
| 204 | + | ], | |
| 205 | + | true, | |
| 206 | + | ), | |
| 207 | + | Icon::Spatial => { | |
| 208 | + | pen.dot(12.0, 12.0, 2.4); | |
| 209 | + | for (r, spread) in [(5.5, 40.0), (9.5, 35.0)] { | |
| 210 | + | pen.arc(12.0, 12.0, r, -spread, spread); | |
| 211 | + | pen.arc(12.0, 12.0, r, 180.0 - spread, 180.0 + spread); | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | Icon::Bass => { | |
| 215 | + | pen.polyline( | |
| 216 | + | &[ | |
| 217 | + | (3.5, 9.5), | |
| 218 | + | (7.5, 9.5), | |
| 219 | + | (12.5, 5.0), | |
| 220 | + | (12.5, 19.0), | |
| 221 | + | (7.5, 14.5), | |
| 222 | + | (3.5, 14.5), | |
| 223 | + | ], | |
| 224 | + | true, | |
| 225 | + | ); | |
| 226 | + | pen.arc(13.0, 12.0, 4.0, -50.0, 50.0); | |
| 227 | + | pen.arc(13.0, 12.0, 8.0, -50.0, 50.0); | |
| 228 | + | } | |
| 229 | + | Icon::Wind => { | |
| 230 | + | pen.line((3.0, 8.5), (13.0, 8.5)); | |
| 231 | + | pen.arc(13.0, 6.0, 2.5, 90.0, -180.0); | |
| 232 | + | pen.line((3.0, 12.5), (17.5, 12.5)); | |
| 233 | + | pen.arc(17.5, 15.5, 3.0, -90.0, 180.0); | |
| 234 | + | pen.line((3.0, 16.5), (9.0, 16.5)); | |
| 235 | + | } | |
| 236 | + | Icon::Dual => { | |
| 237 | + | pen.rect(2.5, 5.0, 12.5, 9.0, 1.5); | |
| 238 | + | pen.line((1.5, 17.5), (16.0, 17.5)); | |
| 239 | + | pen.rect(17.5, 8.0, 4.5, 11.0, 1.2); | |
| 240 | + | } | |
| 241 | + | Icon::Chevron => pen.polyline(&[(9.0, 5.5), (15.5, 12.0), (9.0, 18.5)], false), | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// A filled earbud: the head, the ear tip pointing in, and the stem going down. The tip is | |
| 246 | + | /// drawn in `tip`, or in the part's color when there is none. | |
| 247 | + | fn earbud(pen: &mut Pen, mirrored: bool, tip: Option<Color>) { | |
| 248 | + | let x = |x: f32, w: f32| if mirrored { 24.0 - x - w } else { x }; | |
| 249 | + | let color = pen.color; | |
| 250 | + | pen.color = tip.unwrap_or(color); | |
| 251 | + | pen.fill_rect(x(14.5, 6.0), 5.0, 6.0, 7.0, 2.8); | |
| 252 | + | pen.color = color; | |
| 253 | + | pen.dot(x(11.0, 0.0), 8.5, 6.3); | |
| 254 | + | pen.fill_rect(x(6.6, 4.4), 10.0, 4.4, 12.5, 2.2); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// What a battery ring shows in its middle. | |
| 258 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 259 | + | pub enum Part { | |
| 260 | + | Left, | |
| 261 | + | Right, | |
| 262 | + | Case, | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// A battery ring: the track, an arc for the charge (none when unknown) and the part in the | |
| 266 | + | /// middle. | |
| 267 | + | pub fn battery_ring<'a, Message: 'a>( | |
| 268 | + | part: Part, | |
| 269 | + | level: Option<u8>, | |
| 270 | + | size: f32, | |
| 271 | + | colors: RingColors, | |
| 272 | + | ) -> Element<'a, Message> { | |
| 273 | + | iced::widget::canvas(Ring { part, level, colors }) | |
| 274 | + | .width(size) | |
| 275 | + | .height(size) | |
| 276 | + | .into() | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[derive(Debug, Clone, Copy)] | |
| 280 | + | pub struct RingColors { | |
| 281 | + | pub track: Color, | |
| 282 | + | pub charge: Color, | |
| 283 | + | pub part: Color, | |
| 284 | + | /// The earbuds' ear tips. | |
| 285 | + | pub tip: Color, | |
| 286 | + | /// Details cut into the case (the lid line, the light). | |
| 287 | + | pub cutout: Color, | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | struct Ring { | |
| 291 | + | part: Part, | |
| 292 | + | level: Option<u8>, | |
| 293 | + | colors: RingColors, | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | impl<Message> canvas::Program<Message> for Ring { | |
| 297 | + | type State = (); | |
| 298 | + | ||
| 299 | + | fn draw( | |
| 300 | + | &self, | |
| 301 | + | _state: &(), | |
| 302 | + | renderer: &Renderer, | |
| 303 | + | _theme: &Theme, | |
| 304 | + | bounds: Rectangle, | |
| 305 | + | _cursor: mouse::Cursor, | |
| 306 | + | ) -> Vec<canvas::Geometry> { | |
| 307 | + | let mut frame = Frame::new(renderer, bounds.size()); | |
| 308 | + | let center = frame.center(); | |
| 309 | + | let width = bounds.width * 0.06; | |
| 310 | + | let radius = bounds.width / 2.0 - width / 2.0 - 1.0; | |
| 311 | + | let ring = |color: Color| { | |
| 312 | + | Stroke::default() | |
| 313 | + | .with_color(color) | |
| 314 | + | .with_width(width) | |
| 315 | + | .with_line_cap(LineCap::Round) | |
| 316 | + | }; | |
| 317 | + | frame.stroke(&Path::circle(center, radius), ring(self.colors.track)); | |
| 318 | + | if let Some(level) = self.level.filter(|&level| level > 0) { | |
| 319 | + | let sweep = f32::from(level.min(100)) / 100.0 * TAU; | |
| 320 | + | let arc = Path::new(|b| { | |
| 321 | + | b.arc(Arc { | |
| 322 | + | center, | |
| 323 | + | radius, | |
| 324 | + | start_angle: Radians(-FRAC_PI_2), | |
| 325 | + | end_angle: Radians(-FRAC_PI_2 + sweep), | |
| 326 | + | }); | |
| 327 | + | }); | |
| 328 | + | frame.stroke(&arc, ring(self.colors.charge)); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | let art = bounds.width * 0.44; | |
| 332 | + | let origin = Point::new(center.x - art / 2.0, center.y - art / 2.0); | |
| 333 | + | let mut pen = Pen::new(&mut frame, origin, art, self.colors.part); | |
| 334 | + | match self.part { | |
| 335 | + | Part::Left => earbud(&mut pen, false, Some(self.colors.tip)), | |
| 336 | + | Part::Right => earbud(&mut pen, true, Some(self.colors.tip)), | |
| 337 | + | Part::Case => { | |
| 338 | + | pen.fill_rect(2.0, 5.5, 20.0, 13.5, 6.0); | |
| 339 | + | pen.color = self.colors.cutout; | |
| 340 | + | pen.width = 0.9 * pen.scale; | |
| 341 | + | pen.line((2.0, 10.0), (22.0, 10.0)); | |
| 342 | + | pen.dot(12.0, 14.5, 1.0); | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | vec![frame.into_geometry()] | |
| 346 | + | } | |
| 347 | + | } | |
| @@ -0,0 +1,374 @@ | |||
| 1 | + | //! `budctl-gui`: control wireless earbuds from Linux, in a window. | |
| 2 | + | //! | |
| 3 | + | //! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first, | |
| 4 | + | //! the live connection later (CLAUDE.md, GUI). | |
| 5 | + | ||
| 6 | + | mod demo; | |
| 7 | + | mod icons; | |
| 8 | + | mod overview; | |
| 9 | + | mod theme; | |
| 10 | + | mod ui; | |
| 11 | + | ||
| 12 | + | use std::path::PathBuf; | |
| 13 | + | use std::process::ExitCode; | |
| 14 | + | ||
| 15 | + | use budctl_core::status::{AncLevel, NoiseControl, NoiseMode}; | |
| 16 | + | use iced::widget::{Column, column, container, row, scrollable, space, text}; | |
| 17 | + | use iced::{Alignment, Border, Element, Font, Length, Size, Subscription, Theme, event, window}; | |
| 18 | + | ||
| 19 | + | use icons::Icon; | |
| 20 | + | use theme::Look; | |
| 21 | + | ||
| 22 | + | const USAGE: &str = "usage: budctl-gui [--theme <palette.json>] | |
| 23 | + | ||
| 24 | + | Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in. | |
| 25 | + | The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …)."; | |
| 26 | + | ||
| 27 | + | fn main() -> ExitCode { | |
| 28 | + | let theme_file = match parse_args(std::env::args().skip(1)) { | |
| 29 | + | Ok(Args::Run { theme }) => theme.or_else(theme::default_file), | |
| 30 | + | Ok(Args::Help) => { | |
| 31 | + | println!("{USAGE}"); | |
| 32 | + | return ExitCode::SUCCESS; | |
| 33 | + | } | |
| 34 | + | Err(message) => { | |
| 35 | + | eprintln!("budctl-gui: {message}\n{USAGE}"); | |
| 36 | + | return ExitCode::from(1); | |
| 37 | + | } | |
| 38 | + | }; | |
| 39 | + | let font = theme::system_font(); | |
| 40 | + | let result = iced::application(move || App::new(theme_file.clone(), font), App::update, App::view) | |
| 41 | + | .title("budctl") | |
| 42 | + | .theme(App::theme) | |
| 43 | + | .subscription(App::subscription) | |
| 44 | + | .default_font(font) | |
| 45 | + | .window(window::Settings { | |
| 46 | + | size: Size::new(1040.0, 800.0), | |
| 47 | + | min_size: Some(Size::new(760.0, 540.0)), | |
| 48 | + | platform_specific: window::settings::PlatformSpecific { | |
| 49 | + | application_id: "budctl".to_owned(), | |
| 50 | + | ..window::settings::PlatformSpecific::default() | |
| 51 | + | }, | |
| 52 | + | ..window::Settings::default() | |
| 53 | + | }) | |
| 54 | + | .run(); | |
| 55 | + | match result { | |
| 56 | + | Ok(()) => ExitCode::SUCCESS, | |
| 57 | + | Err(e) => { | |
| 58 | + | eprintln!("budctl-gui: {e}"); | |
| 59 | + | ExitCode::FAILURE | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | enum Args { | |
| 65 | + | Run { theme: Option<PathBuf> }, | |
| 66 | + | Help, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> { | |
| 70 | + | let mut theme = None; | |
| 71 | + | while let Some(arg) = args.next() { | |
| 72 | + | match arg.as_str() { | |
| 73 | + | "--theme" => theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)), | |
| 74 | + | "-h" | "--help" => return Ok(Args::Help), | |
| 75 | + | other => return Err(format!("unknown argument {other:?}")), | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | Ok(Args::Run { theme }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 82 | + | enum Page { | |
| 83 | + | Overview, | |
| 84 | + | Equalizer, | |
| 85 | + | Gestures, | |
| 86 | + | Earbuds, | |
| 87 | + | Settings, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | impl Page { | |
| 91 | + | const ALL: [Page; 5] = [ | |
| 92 | + | Page::Overview, | |
| 93 | + | Page::Equalizer, | |
| 94 | + | Page::Gestures, | |
| 95 | + | Page::Earbuds, | |
| 96 | + | Page::Settings, | |
| 97 | + | ]; | |
| 98 | + | ||
| 99 | + | fn title(self) -> &'static str { | |
| 100 | + | match self { | |
| 101 | + | Page::Overview => "Overview", | |
| 102 | + | Page::Equalizer => "Equalizer", | |
| 103 | + | Page::Gestures => "Gestures", | |
| 104 | + | Page::Earbuds => "Earbud settings", | |
| 105 | + | Page::Settings => "App settings", | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fn icon(self) -> Icon { | |
| 110 | + | match self { | |
| 111 | + | Page::Overview => Icon::Overview, | |
| 112 | + | Page::Equalizer => Icon::Equalizer, | |
| 113 | + | Page::Gestures => Icon::Gestures, | |
| 114 | + | Page::Earbuds => Icon::Earbud, | |
| 115 | + | Page::Settings => Icon::Settings, | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[derive(Debug, Clone)] | |
| 121 | + | enum Message { | |
| 122 | + | Open(Page), | |
| 123 | + | Noise(NoiseMode), | |
| 124 | + | /// A switch's key, and whether to turn it on. | |
| 125 | + | Switch(&'static str, bool), | |
| 126 | + | Connect(bool), | |
| 127 | + | /// The window got focus: the palette file may have changed meanwhile. | |
| 128 | + | Focused, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | struct App { | |
| 132 | + | page: Page, | |
| 133 | + | buds: demo::Buds, | |
| 134 | + | /// The level "Noise cancelling" turns on at: the one last reported or picked. | |
| 135 | + | anc_level: AncLevel, | |
| 136 | + | look: Look, | |
| 137 | + | theme: Theme, | |
| 138 | + | palette: theme::Source, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | impl App { | |
| 142 | + | fn new(theme_file: Option<PathBuf>, font: Font) -> Self { | |
| 143 | + | let buds = demo::buds(); | |
| 144 | + | let anc_level = buds | |
| 145 | + | .status | |
| 146 | + | .noise_control | |
| 147 | + | .as_ref() | |
| 148 | + | .and_then(|now| now.level.as_deref()) | |
| 149 | + | .and_then(anc_level) | |
| 150 | + | .unwrap_or(AncLevel::Deep); | |
| 151 | + | let mut app = App { | |
| 152 | + | page: Page::Overview, | |
| 153 | + | buds, | |
| 154 | + | anc_level, | |
| 155 | + | look: Look { | |
| 156 | + | p: theme::BUILT_IN, | |
| 157 | + | font, | |
| 158 | + | }, | |
| 159 | + | theme: theme::iced_theme(&theme::BUILT_IN), | |
| 160 | + | palette: theme::Source::new(theme_file), | |
| 161 | + | }; | |
| 162 | + | app.refresh_palette(); | |
| 163 | + | app | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn update(&mut self, message: Message) { | |
| 167 | + | match message { | |
| 168 | + | Message::Open(page) => self.page = page, | |
| 169 | + | Message::Noise(mode) => { | |
| 170 | + | let level = match mode { | |
| 171 | + | NoiseMode::Anc(level) => { | |
| 172 | + | self.anc_level = level; | |
| 173 | + | Some(level.name().to_owned()) | |
| 174 | + | } | |
| 175 | + | NoiseMode::Off | NoiseMode::Transparency => None, | |
| 176 | + | }; | |
| 177 | + | self.buds.status.noise_control = Some(NoiseControl { | |
| 178 | + | mode: mode.name().to_owned(), | |
| 179 | + | level, | |
| 180 | + | }); | |
| 181 | + | } | |
| 182 | + | Message::Switch(key, on) => { | |
| 183 | + | if let Some(switch) = self.buds.status.switches.iter_mut().find(|s| s.key == key) { | |
| 184 | + | switch.on = on; | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | Message::Connect(on) => self.buds.connected = on, | |
| 188 | + | Message::Focused => self.refresh_palette(), | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | fn refresh_palette(&mut self) { | |
| 193 | + | let palette = match self.palette.refresh() { | |
| 194 | + | None => return, | |
| 195 | + | Some(Ok(palette)) => palette, | |
| 196 | + | Some(Err(e)) => { | |
| 197 | + | eprintln!("budctl-gui: {e}; using the built-in colors"); | |
| 198 | + | theme::BUILT_IN | |
| 199 | + | } | |
| 200 | + | }; | |
| 201 | + | self.look.p = palette; | |
| 202 | + | self.theme = theme::iced_theme(&palette); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn theme(&self) -> Theme { | |
| 206 | + | self.theme.clone() | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | fn subscription(&self) -> Subscription<Message> { | |
| 210 | + | event::listen_with(|event, _status, _window| match event { | |
| 211 | + | iced::Event::Window(window::Event::Focused) => Some(Message::Focused), | |
| 212 | + | _ => None, | |
| 213 | + | }) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | fn view(&self) -> Element<'_, Message> { | |
| 217 | + | let look = self.look; | |
| 218 | + | let page = match self.page { | |
| 219 | + | Page::Overview => overview::view(look, &self.buds, self.anc_level), | |
| 220 | + | page => column![ | |
| 221 | + | ui::title(look, page.title()), | |
| 222 | + | text("This page comes in a later step.").size(14).color(look.p.dim) | |
| 223 | + | ] | |
| 224 | + | .spacing(12) | |
| 225 | + | .into(), | |
| 226 | + | }; | |
| 227 | + | let p = look.p; | |
| 228 | + | let content = scrollable( | |
| 229 | + | container(container(page).max_width(860)) | |
| 230 | + | .center_x(Length::Fill) | |
| 231 | + | .padding([30, 36]), | |
| 232 | + | ) | |
| 233 | + | .height(Length::Fill) | |
| 234 | + | .style(move |theme, status| { | |
| 235 | + | let mut style = scrollable::default(theme, status); | |
| 236 | + | for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] { | |
| 237 | + | rail.background = None; | |
| 238 | + | rail.border = Border::default(); | |
| 239 | + | rail.scroller.background = p.surface_highest.into(); | |
| 240 | + | rail.scroller.border = Border::default().rounded(4); | |
| 241 | + | } | |
| 242 | + | style | |
| 243 | + | }); | |
| 244 | + | row![self.sidebar(), ui::divider(look), content].into() | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | fn sidebar(&self) -> Element<'_, Message> { | |
| 248 | + | let look = self.look; | |
| 249 | + | let p = look.p; | |
| 250 | + | let connected = self.buds.connected; | |
| 251 | + | let (light, state) = if connected { | |
| 252 | + | (p.ok, "Connected") | |
| 253 | + | } else { | |
| 254 | + | (p.dim, "Not connected") | |
| 255 | + | }; | |
| 256 | + | let device = container( | |
| 257 | + | column![ | |
| 258 | + | text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg), | |
| 259 | + | row![ | |
| 260 | + | ui::light(light), | |
| 261 | + | text(state).size(13).color(p.dim), | |
| 262 | + | space::horizontal(), | |
| 263 | + | ui::tag(look, "demo") | |
| 264 | + | ] | |
| 265 | + | .spacing(8) | |
| 266 | + | .align_y(Alignment::Center), | |
| 267 | + | ui::outline_button( | |
| 268 | + | look, | |
| 269 | + | if connected { "Disconnect" } else { "Connect" }, | |
| 270 | + | Message::Connect(!connected) | |
| 271 | + | ), | |
| 272 | + | ] | |
| 273 | + | .spacing(12), | |
| 274 | + | ) | |
| 275 | + | .padding(16) | |
| 276 | + | .style(move |_| container::Style { | |
| 277 | + | background: Some(p.surface.into()), | |
| 278 | + | border: Border { | |
| 279 | + | color: p.line, | |
| 280 | + | width: 1.0, | |
| 281 | + | radius: 18.0.into(), | |
| 282 | + | }, | |
| 283 | + | ..container::Style::default() | |
| 284 | + | }); | |
| 285 | + | let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| { | |
| 286 | + | pages.push(ui::nav_item( | |
| 287 | + | look, | |
| 288 | + | page.icon(), | |
| 289 | + | page.title(), | |
| 290 | + | page == self.page, | |
| 291 | + | Message::Open(page), | |
| 292 | + | )) | |
| 293 | + | }); | |
| 294 | + | container(column![device, pages].spacing(22)) | |
| 295 | + | .padding([18, 14]) | |
| 296 | + | .width(268) | |
| 297 | + | .height(Length::Fill) | |
| 298 | + | .style(move |_| container::Style { | |
| 299 | + | background: Some(p.surface_low.into()), | |
| 300 | + | ..container::Style::default() | |
| 301 | + | }) | |
| 302 | + | .into() | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | /// The level a [`NoiseControl::level`] names. | |
| 307 | + | fn anc_level(name: &str) -> Option<AncLevel> { | |
| 308 | + | [AncLevel::Light, AncLevel::Medium, AncLevel::Deep] | |
| 309 | + | .into_iter() | |
| 310 | + | .find(|level| level.name() == name) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | #[cfg(test)] | |
| 314 | + | mod tests { | |
| 315 | + | use super::*; | |
| 316 | + | use std::fs; | |
| 317 | + | use std::path::Path; | |
| 318 | + | ||
| 319 | + | #[test] | |
| 320 | + | fn noise_cancelling_remembers_its_level() { | |
| 321 | + | let mut app = App::new(None, Font::DEFAULT); | |
| 322 | + | app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light))); | |
| 323 | + | app.update(Message::Noise(NoiseMode::Transparency)); | |
| 324 | + | assert_eq!(app.anc_level, AncLevel::Light); | |
| 325 | + | let now = app.buds.status.noise_control.as_ref().unwrap(); | |
| 326 | + | assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None)); | |
| 327 | + | app.update(Message::Noise(NoiseMode::Anc(app.anc_level))); | |
| 328 | + | let now = app.buds.status.noise_control.as_ref().unwrap(); | |
| 329 | + | assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light"))); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | #[test] | |
| 333 | + | fn switches_change_by_key() { | |
| 334 | + | let mut app = App::new(None, Font::DEFAULT); | |
| 335 | + | app.update(Message::Switch("game", true)); | |
| 336 | + | let game = app.buds.status.switches.iter().find(|s| s.key == "game").unwrap(); | |
| 337 | + | assert!(game.on); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn arguments() { | |
| 342 | + | let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned())); | |
| 343 | + | assert!(matches!(args(&[]), Ok(Args::Run { theme: None }))); | |
| 344 | + | assert!(matches!(args(&["--theme", "x.json"]), Ok(Args::Run { theme: Some(_) }))); | |
| 345 | + | assert!(matches!(args(&["--help"]), Ok(Args::Help))); | |
| 346 | + | assert!(args(&["--theme"]).is_err()); | |
| 347 | + | assert!(args(&["--bogus"]).is_err()); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a | |
| 351 | + | /// display. `BUDCTL_THEME=<file>` renders with that palette. | |
| 352 | + | #[test] | |
| 353 | + | fn pages_render() { | |
| 354 | + | let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots"); | |
| 355 | + | let _ = fs::remove_dir_all(&dir); | |
| 356 | + | let font = theme::system_font(); | |
| 357 | + | let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from); | |
| 358 | + | let shots = Page::ALL | |
| 359 | + | .into_iter() | |
| 360 | + | .map(|page| (page, 800.0, format!("{page:?}").to_lowercase())) | |
| 361 | + | .chain([(Page::Overview, 1240.0, "overview-full".to_owned())]); | |
| 362 | + | for (page, height, name) in shots { | |
| 363 | + | let mut app = App::new(palette.clone(), font); | |
| 364 | + | app.page = page; | |
| 365 | + | let settings = iced::Settings { | |
| 366 | + | default_font: font, | |
| 367 | + | ..iced::Settings::default() | |
| 368 | + | }; | |
| 369 | + | let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view()); | |
| 370 | + | let snapshot = ui.snapshot(&app.theme).unwrap(); | |
| 371 | + | assert!(snapshot.matches_image(dir.join(name)).unwrap()); | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | } | |
| @@ -0,0 +1,171 @@ | |||
| 1 | + | //! The Overview page: battery, noise control, the switches, and the way to the other pages. | |
| 2 | + | ||
| 3 | + | use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch}; | |
| 4 | + | use iced::widget::{column, container, row, text}; | |
| 5 | + | use iced::{Alignment, Element, Length}; | |
| 6 | + | ||
| 7 | + | use crate::demo::Buds; | |
| 8 | + | use crate::icons::{self, Icon, Part, RingColors}; | |
| 9 | + | use crate::theme::Look; | |
| 10 | + | use crate::ui::{self, Segment}; | |
| 11 | + | use crate::{Message, Page}; | |
| 12 | + | ||
| 13 | + | pub fn view<'a>(look: Look, buds: &'a Buds, anc_level: AncLevel) -> Element<'a, Message> { | |
| 14 | + | column![ui::title(look, "Overview"), battery(look, buds)] | |
| 15 | + | .push( | |
| 16 | + | buds.status | |
| 17 | + | .noise_control | |
| 18 | + | .as_ref() | |
| 19 | + | .map(|now| noise_control(look, &now.mode, anc_level)), | |
| 20 | + | ) | |
| 21 | + | .push(switches(look, &buds.status.switches)) | |
| 22 | + | .push(more(look, buds)) | |
| 23 | + | .spacing(18) | |
| 24 | + | .into() | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> { | |
| 28 | + | let p = look.p; | |
| 29 | + | let colors = RingColors { | |
| 30 | + | track: p.surface_highest, | |
| 31 | + | charge: p.accent, | |
| 32 | + | part: p.fg, | |
| 33 | + | tip: p.dim, | |
| 34 | + | cutout: p.surface, | |
| 35 | + | }; | |
| 36 | + | let gauge = |part, label: &'a str, charge: Option<Charge>| -> Element<'a, Message> { | |
| 37 | + | let reading = match charge { | |
| 38 | + | Some(charge) => format!("{}%", charge.level), | |
| 39 | + | None => "—".to_owned(), | |
| 40 | + | }; | |
| 41 | + | let figure = row![text(reading).size(21).font(look.bold()).color(p.fg)] | |
| 42 | + | .push( | |
| 43 | + | charge | |
| 44 | + | .filter(|c| c.charging) | |
| 45 | + | .map(|_| icons::icon(Icon::Bolt, 16.0, p.icon)), | |
| 46 | + | ) | |
| 47 | + | .spacing(4) | |
| 48 | + | .align_y(Alignment::Center); | |
| 49 | + | column![ | |
| 50 | + | icons::battery_ring(part, charge.map(|c| c.level), 112.0, colors), | |
| 51 | + | figure, | |
| 52 | + | text(label).size(13).color(p.dim) | |
| 53 | + | ] | |
| 54 | + | .spacing(6) | |
| 55 | + | .align_x(Alignment::Center) | |
| 56 | + | .width(Length::Fill) | |
| 57 | + | .into() | |
| 58 | + | }; | |
| 59 | + | let battery = buds.status.battery; | |
| 60 | + | let gauges = row![ | |
| 61 | + | gauge(Part::Left, "Left", battery.left), | |
| 62 | + | gauge(Part::Case, "Case", battery.case), | |
| 63 | + | gauge(Part::Right, "Right", battery.right), | |
| 64 | + | ]; | |
| 65 | + | ui::card(look, vec![container(gauges).padding([22, 8]).into()]) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel) -> Element<'a, Message> { | |
| 69 | + | let modes = [ | |
| 70 | + | (Icon::NoiseOff, "Off", NoiseMode::Off), | |
| 71 | + | (Icon::Anc, "Noise cancelling", NoiseMode::Anc(anc_level)), | |
| 72 | + | (Icon::Transparency, "Transparency", NoiseMode::Transparency), | |
| 73 | + | ]; | |
| 74 | + | let segments = modes | |
| 75 | + | .into_iter() | |
| 76 | + | .map(|(icon, label, set)| Segment { | |
| 77 | + | icon: Some(icon), | |
| 78 | + | label, | |
| 79 | + | selected: set.name() == mode, | |
| 80 | + | on_press: Message::Noise(set), | |
| 81 | + | }) | |
| 82 | + | .collect(); | |
| 83 | + | let levels: Vec<Segment> = [ | |
| 84 | + | (AncLevel::Light, "Light"), | |
| 85 | + | (AncLevel::Medium, "Medium"), | |
| 86 | + | (AncLevel::Deep, "Deep"), | |
| 87 | + | ] | |
| 88 | + | .into_iter() | |
| 89 | + | .map(|(level, label)| Segment { | |
| 90 | + | icon: None, | |
| 91 | + | label, | |
| 92 | + | selected: level == anc_level, | |
| 93 | + | on_press: Message::Noise(NoiseMode::Anc(level)), | |
| 94 | + | }) | |
| 95 | + | .collect(); | |
| 96 | + | let strength = row![ | |
| 97 | + | text("Strength").size(13.5).color(look.p.dim).width(Length::Fill), | |
| 98 | + | container(ui::segmented(look, levels, false)).width(300) | |
| 99 | + | ] | |
| 100 | + | .align_y(Alignment::Center) | |
| 101 | + | .padding([0, 8]); | |
| 102 | + | column![ui::segmented(look, segments, true)] | |
| 103 | + | .push((mode == NoiseMode::Anc(anc_level).name()).then_some(strength)) | |
| 104 | + | .spacing(12) | |
| 105 | + | .into() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// How the page shows the switches it knows, in this order. Others still show, last, with a | |
| 109 | + | /// plain icon and no subtitle. | |
| 110 | + | const SWITCHES: [(&str, Icon, &str); 5] = [ | |
| 111 | + | ("game", Icon::Game, "Lower latency, for games and video"), | |
| 112 | + | ("spatial", Icon::Spatial, "Wider, surround-like sound"), | |
| 113 | + | ("bass-boost", Icon::Bass, "Stronger low end"), | |
| 114 | + | ("wind", Icon::Wind, "Less wind noise outdoors"), | |
| 115 | + | ("dual", Icon::Dual, "Stay connected to two devices at once"), | |
| 116 | + | ]; | |
| 117 | + | ||
| 118 | + | fn switches<'a>(look: Look, switches: &[Switch]) -> Element<'a, Message> { | |
| 119 | + | let known = |key: &str| SWITCHES.iter().find(|(k, _, _)| *k == key); | |
| 120 | + | let rank = |s: &&Switch| { | |
| 121 | + | SWITCHES | |
| 122 | + | .iter() | |
| 123 | + | .position(|(k, _, _)| *k == s.key) | |
| 124 | + | .unwrap_or(SWITCHES.len()) | |
| 125 | + | }; | |
| 126 | + | let mut shown: Vec<&Switch> = switches.iter().collect(); | |
| 127 | + | shown.sort_by_key(rank); | |
| 128 | + | let rows = shown | |
| 129 | + | .into_iter() | |
| 130 | + | .map(|s| { | |
| 131 | + | let (icon, subtitle) = known(s.key).map_or((Icon::Settings, None), |&(_, icon, subtitle)| { | |
| 132 | + | (icon, Some(subtitle.to_owned())) | |
| 133 | + | }); | |
| 134 | + | ui::setting( | |
| 135 | + | look, | |
| 136 | + | icon, | |
| 137 | + | s.label, | |
| 138 | + | subtitle, | |
| 139 | + | ui::switch(look, s.on), | |
| 140 | + | Message::Switch(s.key, !s.on), | |
| 141 | + | ) | |
| 142 | + | }) | |
| 143 | + | .collect(); | |
| 144 | + | ui::card(look, rows) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | fn more<'a>(look: Look, buds: &Buds) -> Element<'a, Message> { | |
| 148 | + | let open = |icon, title, subtitle: &str, page| { | |
| 149 | + | ui::setting( | |
| 150 | + | look, | |
| 151 | + | icon, | |
| 152 | + | title, | |
| 153 | + | Some(subtitle.to_owned()), | |
| 154 | + | ui::chevron(look), | |
| 155 | + | Message::Open(page), | |
| 156 | + | ) | |
| 157 | + | }; | |
| 158 | + | ui::card( | |
| 159 | + | look, | |
| 160 | + | vec![ | |
| 161 | + | open(Icon::Equalizer, "Equalizer", &buds.eq_name, Page::Equalizer), | |
| 162 | + | open(Icon::Gestures, "Gestures", "Taps and holds on each bud", Page::Gestures), | |
| 163 | + | open( | |
| 164 | + | Icon::Earbud, | |
| 165 | + | "Earbud settings", | |
| 166 | + | "Find my earbuds, firmware", | |
| 167 | + | Page::Earbuds, | |
| 168 | + | ), | |
| 169 | + | ], | |
| 170 | + | ) | |
| 171 | + | } | |
| @@ -0,0 +1,274 @@ | |||
| 1 | + | //! Colors and fonts. The colors are a built-in dark palette, or one read from a matugen JSON file. | |
| 2 | + | //! | |
| 3 | + | //! The file uses the keys of the owner's Aurora palette (`bg`, `fg`, `primary`…), so | |
| 4 | + | //! `~/.cache/matugen/aurora.json` works as it is, and the keys map to the same roles as in Aurora's | |
| 5 | + | //! `Theme.qml`, plus `secondary` for icons. A key the file lacks keeps its built-in color. | |
| 6 | + | ||
| 7 | + | use std::fs; | |
| 8 | + | use std::path::{Path, PathBuf}; | |
| 9 | + | use std::process::Command; | |
| 10 | + | use std::time::SystemTime; | |
| 11 | + | ||
| 12 | + | use iced::font::Weight; | |
| 13 | + | use iced::{Color, Font}; | |
| 14 | + | use serde::Deserialize; | |
| 15 | + | ||
| 16 | + | /// The colors the UI uses, by role. | |
| 17 | + | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 18 | + | pub struct Palette { | |
| 19 | + | /// Window background. | |
| 20 | + | pub bg: Color, | |
| 21 | + | /// Text. | |
| 22 | + | pub fg: Color, | |
| 23 | + | /// Secondary text, and icons at rest. | |
| 24 | + | pub dim: Color, | |
| 25 | + | /// What is on or selected: switches, the noise-control pill, battery rings. | |
| 26 | + | pub accent: Color, | |
| 27 | + | /// Icons and small marks on cards and the sidebar. matugen's `primary` (Aurora's key) is a | |
| 28 | + | /// container color, too dark to draw thin lines with on a dark card. | |
| 29 | + | pub icon: Color, | |
| 30 | + | /// Text and icons on `accent`. | |
| 31 | + | pub accent_fg: Color, | |
| 32 | + | /// A quiet accent: the open page in the sidebar. | |
| 33 | + | pub accent_dim: Color, | |
| 34 | + | /// The sidebar. | |
| 35 | + | pub surface_low: Color, | |
| 36 | + | /// Cards. | |
| 37 | + | pub surface: Color, | |
| 38 | + | /// Hovered rows, and switches when off. | |
| 39 | + | pub surface_high: Color, | |
| 40 | + | /// Battery ring tracks. | |
| 41 | + | pub surface_highest: Color, | |
| 42 | + | /// Card borders and separators. | |
| 43 | + | pub line: Color, | |
| 44 | + | pub err: Color, | |
| 45 | + | /// "Connected". matugen has no such role, so it stays green. | |
| 46 | + | pub ok: Color, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Dark, with a red accent. | |
| 50 | + | pub const BUILT_IN: Palette = Palette { | |
| 51 | + | bg: Color::from_rgb8(0x12, 0x12, 0x14), | |
| 52 | + | fg: Color::from_rgb8(0xEE, 0xEE, 0xF1), | |
| 53 | + | dim: Color::from_rgb8(0x9C, 0x9C, 0xA6), | |
| 54 | + | accent: Color::from_rgb8(0xE0, 0x30, 0x2E), | |
| 55 | + | icon: Color::from_rgb8(0xE0, 0x30, 0x2E), | |
| 56 | + | accent_fg: Color::from_rgb8(0xFF, 0xFF, 0xFF), | |
| 57 | + | accent_dim: Color::from_rgb8(0x3A, 0x1A, 0x1C), | |
| 58 | + | surface_low: Color::from_rgb8(0x17, 0x17, 0x1A), | |
| 59 | + | surface: Color::from_rgb8(0x1E, 0x1E, 0x22), | |
| 60 | + | surface_high: Color::from_rgb8(0x29, 0x29, 0x2E), | |
| 61 | + | surface_highest: Color::from_rgb8(0x33, 0x33, 0x39), | |
| 62 | + | line: Color::from_rgb8(0x2D, 0x2D, 0x33), | |
| 63 | + | err: Color::from_rgb8(0xFF, 0xB4, 0xAB), | |
| 64 | + | ok: Color::from_rgb8(0x4C, 0xC3, 0x6A), | |
| 65 | + | }; | |
| 66 | + | ||
| 67 | + | /// The palette file's keys. Other keys (`image`, `secondary`…) are ignored. | |
| 68 | + | #[derive(Deserialize)] | |
| 69 | + | #[serde(rename_all = "camelCase")] | |
| 70 | + | struct PaletteFile { | |
| 71 | + | bg: Option<String>, | |
| 72 | + | fg: Option<String>, | |
| 73 | + | primary: Option<String>, | |
| 74 | + | primary_fg: Option<String>, | |
| 75 | + | primary_container: Option<String>, | |
| 76 | + | secondary: Option<String>, | |
| 77 | + | surface_low: Option<String>, | |
| 78 | + | surface: Option<String>, | |
| 79 | + | surface_high: Option<String>, | |
| 80 | + | surface_highest: Option<String>, | |
| 81 | + | outline_variant: Option<String>, | |
| 82 | + | surface_variant_fg: Option<String>, | |
| 83 | + | err: Option<String>, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// A palette from the text of a palette file. | |
| 87 | + | pub fn parse(json: &str) -> Result<Palette, String> { | |
| 88 | + | let file: PaletteFile = serde_json::from_str(json).map_err(|e| e.to_string())?; | |
| 89 | + | let pick = |key: &str, value: Option<String>, default: Color| match value { | |
| 90 | + | None => Ok(default), | |
| 91 | + | Some(hex) => parse_hex(&hex).ok_or_else(|| format!("{key}: \"{hex}\" is not a #rrggbb color")), | |
| 92 | + | }; | |
| 93 | + | let b = BUILT_IN; | |
| 94 | + | Ok(Palette { | |
| 95 | + | bg: pick("bg", file.bg, b.bg)?, | |
| 96 | + | fg: pick("fg", file.fg, b.fg)?, | |
| 97 | + | dim: pick("surfaceVariantFg", file.surface_variant_fg, b.dim)?, | |
| 98 | + | accent: pick("primary", file.primary, b.accent)?, | |
| 99 | + | icon: pick("secondary", file.secondary, b.icon)?, | |
| 100 | + | accent_fg: pick("primaryFg", file.primary_fg, b.accent_fg)?, | |
| 101 | + | accent_dim: pick("primaryContainer", file.primary_container, b.accent_dim)?, | |
| 102 | + | surface_low: pick("surfaceLow", file.surface_low, b.surface_low)?, | |
| 103 | + | surface: pick("surface", file.surface, b.surface)?, | |
| 104 | + | surface_high: pick("surfaceHigh", file.surface_high, b.surface_high)?, | |
| 105 | + | surface_highest: pick("surfaceHighest", file.surface_highest, b.surface_highest)?, | |
| 106 | + | line: pick("outlineVariant", file.outline_variant, b.line)?, | |
| 107 | + | err: pick("err", file.err, b.err)?, | |
| 108 | + | ok: b.ok, | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// `#rrggbb` or `#rrggbbaa`. | |
| 113 | + | fn parse_hex(hex: &str) -> Option<Color> { | |
| 114 | + | let digits = hex.strip_prefix('#')?; | |
| 115 | + | let byte = |at: usize| u8::from_str_radix(digits.get(at..at + 2)?, 16).ok(); | |
| 116 | + | match digits.len() { | |
| 117 | + | 6 => Some(Color::from_rgb8(byte(0)?, byte(2)?, byte(4)?)), | |
| 118 | + | 8 => Some(Color::from_rgba8( | |
| 119 | + | byte(0)?, | |
| 120 | + | byte(2)?, | |
| 121 | + | byte(4)?, | |
| 122 | + | f32::from(byte(6)?) / 255.0, | |
| 123 | + | )), | |
| 124 | + | _ => None, | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`. | |
| 129 | + | pub fn default_file() -> Option<PathBuf> { | |
| 130 | + | let config = std::env::var_os("XDG_CONFIG_HOME") | |
| 131 | + | .map(PathBuf::from) | |
| 132 | + | .filter(|p| p.is_absolute()) | |
| 133 | + | .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?; | |
| 134 | + | Some(config.join("budctl/theme.json")) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// The palette file and the version of it last read, so it is only read again when it changes. | |
| 138 | + | pub struct Source { | |
| 139 | + | file: Option<PathBuf>, | |
| 140 | + | stamp: Option<SystemTime>, | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | impl Source { | |
| 144 | + | pub fn new(file: Option<PathBuf>) -> Self { | |
| 145 | + | Source { file, stamp: None } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// The palette, if the file changed since the last call: built-in when the file is gone, an | |
| 149 | + | /// error when it cannot be used. `None` when nothing changed. | |
| 150 | + | pub fn refresh(&mut self) -> Option<Result<Palette, String>> { | |
| 151 | + | let file = self.file.as_ref()?; | |
| 152 | + | let stamp = fs::metadata(file).and_then(|m| m.modified()).ok(); | |
| 153 | + | if stamp == self.stamp { | |
| 154 | + | return None; | |
| 155 | + | } | |
| 156 | + | self.stamp = stamp; | |
| 157 | + | if stamp.is_none() { | |
| 158 | + | return Some(Ok(BUILT_IN)); | |
| 159 | + | } | |
| 160 | + | Some( | |
| 161 | + | fs::read_to_string(file) | |
| 162 | + | .map_err(|e| e.to_string()) | |
| 163 | + | .and_then(|json| parse(&json)) | |
| 164 | + | .map_err(|e| format!("{}: {e}", file.display())), | |
| 165 | + | ) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// The palette and the fonts, handed to everything that draws. | |
| 170 | + | #[derive(Debug, Clone, Copy)] | |
| 171 | + | pub struct Look { | |
| 172 | + | pub p: Palette, | |
| 173 | + | pub font: Font, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | impl Look { | |
| 177 | + | pub fn medium(self) -> Font { | |
| 178 | + | Font { | |
| 179 | + | weight: Weight::Medium, | |
| 180 | + | ..self.font | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | pub fn bold(self) -> Font { | |
| 185 | + | Font { | |
| 186 | + | weight: Weight::Bold, | |
| 187 | + | ..self.font | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// The desktop's sans-serif font, as fontconfig names it. iced's own default is a font many | |
| 193 | + | /// systems lack. | |
| 194 | + | pub fn system_font() -> Font { | |
| 195 | + | let family = Command::new("fc-match") | |
| 196 | + | .args(["--format", "%{family[0]}", "sans-serif"]) | |
| 197 | + | .output() | |
| 198 | + | .ok() | |
| 199 | + | .filter(|out| out.status.success()) | |
| 200 | + | .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) | |
| 201 | + | .filter(|name| !name.is_empty()); | |
| 202 | + | match family { | |
| 203 | + | // Font names are &'static; this runs once. | |
| 204 | + | Some(name) => Font::with_name(Box::leak(name.into_boxed_str())), | |
| 205 | + | None => Font::DEFAULT, | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /// The iced theme for the widgets drawn with their default style. | |
| 210 | + | pub fn iced_theme(p: &Palette) -> iced::Theme { | |
| 211 | + | iced::Theme::custom( | |
| 212 | + | "budctl", | |
| 213 | + | iced::theme::Palette { | |
| 214 | + | background: p.bg, | |
| 215 | + | text: p.fg, | |
| 216 | + | primary: p.accent, | |
| 217 | + | success: p.ok, | |
| 218 | + | warning: p.accent, | |
| 219 | + | danger: p.err, | |
| 220 | + | }, | |
| 221 | + | ) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[cfg(test)] | |
| 225 | + | mod tests { | |
| 226 | + | use super::*; | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn reads_the_aurora_keys() { | |
| 230 | + | let palette = parse( | |
| 231 | + | r##"{ | |
| 232 | + | "image": "/some/wallpaper.jpg", | |
| 233 | + | "bg": "#0f0c0a", "fg": "#ebe0dc", | |
| 234 | + | "primary": "#7b4b2f", "primaryFg": "#fffaff", "primaryContainer": "#5c3823", | |
| 235 | + | "secondary": "#e2bfae", | |
| 236 | + | "surfaceLow": "#181311", "surface": "#1e1815", "surfaceHigh": "#2a211d", | |
| 237 | + | "surfaceHighest": "#362a26", "outline": "#9f8d85", "outlineVariant": "#52443d", | |
| 238 | + | "surfaceVariantFg": "#d6c3b9", "err": "#ffb4ab" | |
| 239 | + | }"##, | |
| 240 | + | ) | |
| 241 | + | .unwrap(); | |
| 242 | + | assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a)); | |
| 243 | + | assert_eq!(palette.accent, Color::from_rgb8(0x7b, 0x4b, 0x2f)); | |
| 244 | + | assert_eq!(palette.accent_dim, Color::from_rgb8(0x5c, 0x38, 0x23)); | |
| 245 | + | assert_eq!(palette.icon, Color::from_rgb8(0xe2, 0xbf, 0xae)); | |
| 246 | + | assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d)); | |
| 247 | + | assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9)); | |
| 248 | + | assert_eq!(palette.ok, BUILT_IN.ok); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[test] | |
| 252 | + | fn missing_keys_keep_the_built_in_colors() { | |
| 253 | + | let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap(); | |
| 254 | + | assert_eq!(palette.accent, Color::from_rgba8(0x11, 0x22, 0x33, 0.8)); | |
| 255 | + | assert_eq!(palette.bg, BUILT_IN.bg); | |
| 256 | + | assert_eq!(parse("{}").unwrap(), BUILT_IN); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn bad_files_are_errors() { | |
| 261 | + | assert!(parse("not json").is_err()); | |
| 262 | + | assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg")); | |
| 263 | + | assert!(parse(r##"{"bg": "#12345"}"##).is_err()); | |
| 264 | + | assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err()); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn a_missing_file_changes_nothing() { | |
| 269 | + | let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json"))); | |
| 270 | + | // No file now, and none read before: the built-in palette stays. | |
| 271 | + | assert_eq!(source.refresh(), None); | |
| 272 | + | assert_eq!(Source::new(None).refresh(), None); | |
| 273 | + | } | |
| 274 | + | } | |
| @@ -0,0 +1,261 @@ | |||
| 1 | + | //! The building blocks every page uses: cards, setting rows, switches, the segmented pill and | |
| 2 | + | //! the sidebar's pieces. Everything takes its colors from the [`Look`]. | |
| 3 | + | ||
| 4 | + | use iced::widget::{Column, Row, button, column, container, row, rule, space, text, toggler}; | |
| 5 | + | use iced::{Alignment, Border, Element, Length}; | |
| 6 | + | ||
| 7 | + | use crate::Message; | |
| 8 | + | use crate::icons::{self, Icon}; | |
| 9 | + | use crate::theme::Look; | |
| 10 | + | ||
| 11 | + | /// A page title. | |
| 12 | + | pub fn title<'a>(look: Look, title: &'a str) -> Element<'a, Message> { | |
| 13 | + | text(title).size(30).font(look.bold()).color(look.p.fg).into() | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// A rounded card holding rows, with a thin line between them. | |
| 17 | + | pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Message> { | |
| 18 | + | let p = look.p; | |
| 19 | + | let mut column = Column::new(); | |
| 20 | + | for (n, item) in rows.into_iter().enumerate() { | |
| 21 | + | if n > 0 { | |
| 22 | + | column = column.push(separator(look)); | |
| 23 | + | } | |
| 24 | + | column = column.push(item); | |
| 25 | + | } | |
| 26 | + | container(column) | |
| 27 | + | .padding(5) | |
| 28 | + | .width(Length::Fill) | |
| 29 | + | .style(move |_| container::Style { | |
| 30 | + | background: Some(p.surface.into()), | |
| 31 | + | border: Border { | |
| 32 | + | color: p.line, | |
| 33 | + | width: 1.0, | |
| 34 | + | radius: 18.0.into(), | |
| 35 | + | }, | |
| 36 | + | ..container::Style::default() | |
| 37 | + | }) | |
| 38 | + | .into() | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | fn separator<'a>(look: Look) -> Element<'a, Message> { | |
| 42 | + | let p = look.p; | |
| 43 | + | container(rule::horizontal(1).style(move |_| rule::Style { | |
| 44 | + | color: p.line, | |
| 45 | + | radius: 0.0.into(), | |
| 46 | + | fill_mode: rule::FillMode::Full, | |
| 47 | + | snap: true, | |
| 48 | + | })) | |
| 49 | + | .padding([0, 14]) | |
| 50 | + | .into() | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// A setting row: icon, title, an optional subtitle, and something on the right. The whole row | |
| 54 | + | /// is the click target. | |
| 55 | + | pub fn setting<'a>( | |
| 56 | + | look: Look, | |
| 57 | + | icon: Icon, | |
| 58 | + | title: &'a str, | |
| 59 | + | subtitle: Option<String>, | |
| 60 | + | trailing: Element<'a, Message>, | |
| 61 | + | on_press: Message, | |
| 62 | + | ) -> Element<'a, Message> { | |
| 63 | + | let p = look.p; | |
| 64 | + | let words = column![text(title).size(15).font(look.medium()).color(p.fg)] | |
| 65 | + | .push(subtitle.map(|s| text(s).size(13).color(p.dim))) | |
| 66 | + | .spacing(2) | |
| 67 | + | .width(Length::Fill); | |
| 68 | + | let content = row![icons::icon(icon, 22.0, p.icon), words, trailing] | |
| 69 | + | .spacing(16) | |
| 70 | + | .align_y(Alignment::Center) | |
| 71 | + | .padding([13, 16]); | |
| 72 | + | button(content) | |
| 73 | + | .on_press(on_press) | |
| 74 | + | .padding(0) | |
| 75 | + | .width(Length::Fill) | |
| 76 | + | .style(move |_, status| button::Style { | |
| 77 | + | background: hovered(status).then_some(p.surface_high.into()), | |
| 78 | + | text_color: p.fg, | |
| 79 | + | border: Border::default().rounded(14), | |
| 80 | + | ..button::Style::default() | |
| 81 | + | }) | |
| 82 | + | .into() | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | fn hovered(status: button::Status) -> bool { | |
| 86 | + | matches!(status, button::Status::Hovered | button::Status::Pressed) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// An on/off switch. It only shows the state: the row around it takes the click. | |
| 90 | + | pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> { | |
| 91 | + | let p = look.p; | |
| 92 | + | toggler(on) | |
| 93 | + | .size(24) | |
| 94 | + | .style(move |_, _| toggler::Style { | |
| 95 | + | background: (if on { p.accent } else { p.surface_high }).into(), | |
| 96 | + | background_border_width: 1.0, | |
| 97 | + | background_border_color: if on { p.accent } else { p.line }, | |
| 98 | + | foreground: (if on { p.accent_fg } else { p.dim }).into(), | |
| 99 | + | foreground_border_width: 0.0, | |
| 100 | + | foreground_border_color: p.accent_fg, | |
| 101 | + | text_color: None, | |
| 102 | + | border_radius: None, | |
| 103 | + | padding_ratio: 0.12, | |
| 104 | + | }) | |
| 105 | + | .into() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// A chevron: the row opens a page. | |
| 109 | + | pub fn chevron<'a>(look: Look) -> Element<'a, Message> { | |
| 110 | + | icons::icon(Icon::Chevron, 18.0, look.p.dim) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// One choice of a [`segmented`] control. | |
| 114 | + | pub struct Segment<'a> { | |
| 115 | + | pub icon: Option<Icon>, | |
| 116 | + | pub label: &'a str, | |
| 117 | + | pub selected: bool, | |
| 118 | + | pub on_press: Message, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// A pill of choices with the selected one filled, like the noise-control modes. `large` puts an | |
| 122 | + | /// icon above each label. | |
| 123 | + | pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool) -> Element<'a, Message> { | |
| 124 | + | let p = look.p; | |
| 125 | + | let mut choices = Row::new().spacing(4); | |
| 126 | + | for segment in segments { | |
| 127 | + | let selected = segment.selected; | |
| 128 | + | let color = if selected { p.accent_fg } else { p.dim }; | |
| 129 | + | let label = text(segment.label) | |
| 130 | + | .size(if large { 13.0 } else { 13.5 }) | |
| 131 | + | .font(look.medium()) | |
| 132 | + | .color(color); | |
| 133 | + | let inner: Element<'a, Message> = match segment.icon { | |
| 134 | + | Some(icon) if large => column![icons::icon(icon, 22.0, color), label] | |
| 135 | + | .spacing(6) | |
| 136 | + | .align_x(Alignment::Center) | |
| 137 | + | .into(), | |
| 138 | + | _ => label.into(), | |
| 139 | + | }; | |
| 140 | + | let choice = button(container(inner).center_x(Length::Fill)) | |
| 141 | + | .on_press(segment.on_press) | |
| 142 | + | .width(Length::Fill) | |
| 143 | + | .padding(if large { [11, 8] } else { [7, 12] }) | |
| 144 | + | .style(move |_, status| button::Style { | |
| 145 | + | background: if selected { | |
| 146 | + | Some(p.accent.into()) | |
| 147 | + | } else { | |
| 148 | + | hovered(status).then_some(p.surface_high.into()) | |
| 149 | + | }, | |
| 150 | + | text_color: color, | |
| 151 | + | border: Border::default().rounded(999), | |
| 152 | + | ..button::Style::default() | |
| 153 | + | }); | |
| 154 | + | choices = choices.push(choice); | |
| 155 | + | } | |
| 156 | + | container(choices) | |
| 157 | + | .padding(5) | |
| 158 | + | .width(Length::Fill) | |
| 159 | + | .style(move |_| container::Style { | |
| 160 | + | background: Some(p.surface.into()), | |
| 161 | + | border: Border { | |
| 162 | + | color: p.line, | |
| 163 | + | width: 1.0, | |
| 164 | + | radius: 999.0.into(), | |
| 165 | + | }, | |
| 166 | + | ..container::Style::default() | |
| 167 | + | }) | |
| 168 | + | .into() | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// A page in the sidebar. | |
| 172 | + | pub fn nav_item<'a>(look: Look, icon: Icon, label: &'a str, open: bool, on_press: Message) -> Element<'a, Message> { | |
| 173 | + | let p = look.p; | |
| 174 | + | let (color, icon_color) = if open { (p.fg, p.icon) } else { (p.dim, p.dim) }; | |
| 175 | + | let content = row![ | |
| 176 | + | icons::icon(icon, 19.0, icon_color), | |
| 177 | + | text(label) | |
| 178 | + | .size(14.5) | |
| 179 | + | .font(if open { look.medium() } else { look.font }) | |
| 180 | + | .color(color) | |
| 181 | + | ] | |
| 182 | + | .spacing(13) | |
| 183 | + | .align_y(Alignment::Center); | |
| 184 | + | button(content) | |
| 185 | + | .on_press(on_press) | |
| 186 | + | .width(Length::Fill) | |
| 187 | + | .padding([10, 14]) | |
| 188 | + | .style(move |_, status| button::Style { | |
| 189 | + | background: if open { | |
| 190 | + | Some(p.accent_dim.into()) | |
| 191 | + | } else { | |
| 192 | + | hovered(status).then_some(p.surface_high.into()) | |
| 193 | + | }, | |
| 194 | + | text_color: color, | |
| 195 | + | border: Border::default().rounded(12), | |
| 196 | + | ..button::Style::default() | |
| 197 | + | }) | |
| 198 | + | .into() | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// A wide button with an outline, like "Disconnect". | |
| 202 | + | pub fn outline_button<'a>(look: Look, label: &'a str, on_press: Message) -> Element<'a, Message> { | |
| 203 | + | let p = look.p; | |
| 204 | + | button(container(text(label).size(13.5).font(look.medium()).color(p.fg)).center_x(Length::Fill)) | |
| 205 | + | .on_press(on_press) | |
| 206 | + | .width(Length::Fill) | |
| 207 | + | .padding([8, 12]) | |
| 208 | + | .style(move |_, status| button::Style { | |
| 209 | + | background: hovered(status).then_some(p.surface_high.into()), | |
| 210 | + | text_color: p.fg, | |
| 211 | + | border: Border { | |
| 212 | + | color: p.line, | |
| 213 | + | width: 1.0, | |
| 214 | + | radius: 999.0.into(), | |
| 215 | + | }, | |
| 216 | + | ..button::Style::default() | |
| 217 | + | }) | |
| 218 | + | .into() | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// A small filled circle, like the "connected" light. | |
| 222 | + | pub fn light<'a>(color: iced::Color) -> Element<'a, Message> { | |
| 223 | + | container(space()) | |
| 224 | + | .width(8) | |
| 225 | + | .height(8) | |
| 226 | + | .style(move |_| container::Style { | |
| 227 | + | background: Some(color.into()), | |
| 228 | + | border: Border::default().rounded(4), | |
| 229 | + | ..container::Style::default() | |
| 230 | + | }) | |
| 231 | + | .into() | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// A small outlined tag, like "demo". | |
| 235 | + | pub fn tag<'a>(look: Look, label: &'a str) -> Element<'a, Message> { | |
| 236 | + | let p = look.p; | |
| 237 | + | container(text(label).size(11).font(look.medium()).color(p.icon)) | |
| 238 | + | .padding([1, 8]) | |
| 239 | + | .style(move |_| container::Style { | |
| 240 | + | border: Border { | |
| 241 | + | color: p.icon, | |
| 242 | + | width: 1.0, | |
| 243 | + | radius: 999.0.into(), | |
| 244 | + | }, | |
| 245 | + | ..container::Style::default() | |
| 246 | + | }) | |
| 247 | + | .into() | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// A thin vertical line, between the sidebar and the page. | |
| 251 | + | pub fn divider<'a>(look: Look) -> Element<'a, Message> { | |
| 252 | + | let p = look.p; | |
| 253 | + | rule::vertical(1) | |
| 254 | + | .style(move |_| rule::Style { | |
| 255 | + | color: p.line, | |
| 256 | + | radius: 0.0.into(), | |
| 257 | + | fill_mode: rule::FillMode::Full, | |
| 258 | + | snap: true, | |
| 259 | + | }) | |
| 260 | + | .into() | |
| 261 | + | } | |