Krolyxon
Commit 6d1e074

Branches

Refs →

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/).
Author
krolyxon
Date
2026-10-27 22:58 +0530
Parent
a307193
Commit
6d1e07476357db23f12719f588cd94682783f03e

+4271 −70

11 files changed

  1. M CLAUDE.md +18 −8
  2. M Cargo.lock +2717 −50
  3. M Cargo.toml +1 −1
  4. M crates/budctl-core/tests/guards.rs +37 −11
  5. A crates/budctl-gui/Cargo.toml +25 −0
  6. A crates/budctl-gui/src/demo.rs +46 −0
  7. A crates/budctl-gui/src/icons.rs +347 −0
  8. A crates/budctl-gui/src/main.rs +374 −0
  9. A crates/budctl-gui/src/overview.rs +171 −0
  10. A crates/budctl-gui/src/theme.rs +274 −0
  11. A crates/budctl-gui/src/ui.rs +261 −0
M CLAUDE.md View
@@ -1,13 +1,13 @@
11 # budctl
22
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.
44
55 The first supported device is the **realme Buds T310**. More devices are added as plugins when the owner gets new earbuds.
66
77 ## Goals
88
99 - 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`.
1111 - One small, readable codebase that the owner fully understands.
1212 - 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.
1313 - 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
1515 ## Non-goals
1616
1717 - 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.
1919 - No support for models the owner doesn't have. Don't speculatively add devices.
2020 - No Windows or macOS support.
2121
2222 ## Hard rules
2323
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.
2729 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.
2830 - Committed files do not name or cite those references. Each fact carries an evidence level instead (rule 3).
2931 - 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
3739 - Rust (edition 2024) in a cargo workspace:
3840 - `crates/budctl-core`: library with transport, BlueZ and device plugins.
3941 - `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).
4143 - Bluetooth goes through BlueZ:
4244 - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record.
4345 - `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
4749 - Keep dependencies minimal. Every new dependency needs a reason in the commit message. The current direct dependencies:
4850 - core: `libc`, `zbus`, `serde`
4951 - cli: `clap`, `serde_json`
52+ - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests
5053
5154 ## Device plugin contract
5255
@@ -109,9 +112,16 @@ Exit codes:
109112 9. hold cycle
110113 10. ~~fit test~~ (dropped: the T310 acks it but runs no test)
111114 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.
113116 5. **More devices**: as acquired.
114117
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+
115125 ## Working with Claude
116126
117127 - 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.
M Cargo.lock View

[Diff too large] View file

M Cargo.toml View
@@ -1,6 +1,6 @@
11 [workspace]
22 resolver = "3"
3−members = ["crates/budctl-core", "crates/budctl-cli"]
3+members = ["crates/budctl-core", "crates/budctl-cli", "crates/budctl-gui"]
44
55 [workspace.package]
66 version = "0.1.0"
M crates/budctl-core/tests/guards.rs View
@@ -1,6 +1,6 @@
11 //! Checks for the CLAUDE.md hard rules that the compiler and clippy cannot make on their own.
22 //!
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.
44 //! - Rule 3: every `Spec: <file>.md#<anchor>` comment points at an anchor that exists.
55 //!
66 //! The CLI's exit codes are checked in crates/budctl-cli/tests/cli.rs.
@@ -11,6 +11,7 @@
1111 use std::collections::BTreeSet;
1212 use std::fs;
1313 use std::path::{Path, PathBuf};
14+use std::process::Command;
1415
1516 fn workspace() -> PathBuf {
1617 Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
@@ -94,25 +95,50 @@ fn banned_crates() -> BTreeSet<String> {
9495 names
9596 }
9697
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()
101121 .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)
104124 .collect();
105125 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"
108128 );
129+ names
130+}
131+
132+#[test]
133+fn no_banned_crate_in_the_build() {
134+ let built = built_crates();
109135 let found: Vec<String> = banned_crates()
110136 .into_iter()
111− .filter(|b| locked.contains(b.as_str()))
137+ .filter(|banned| built.contains(banned))
112138 .collect();
113139 assert!(
114140 found.is_empty(),
115− "banned crates in Cargo.lock (see deny.toml): {found:?}"
141+ "banned crates in the build (see deny.toml): {found:?}"
116142 );
117143 }
118144
A crates/budctl-gui/Cargo.toml View
@@ -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
A crates/budctl-gui/src/demo.rs View
@@ -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+}
A crates/budctl-gui/src/icons.rs View
@@ -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+}
A crates/budctl-gui/src/main.rs View
@@ -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+}
A crates/budctl-gui/src/overview.rs View
@@ -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+}
A crates/budctl-gui/src/theme.rs View
@@ -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+}
A crates/budctl-gui/src/ui.rs View
@@ -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+}