Krolyxon
Commit 7c15b41

Branches

Refs →

budctl7c15b41

budctl-gui: Nothing OS shapes in Material You colors, like Aurora

The desktop app takes on the design language of the owner's Aurora shell:
- Sidebar of dot-matrix page words (the open one large, with the live dot);
  each page under a caps caption.
- Round cards of rows with the icon in a circle; pill switches, pill tracks
  for noise control and its strength, caps chips for buttons; dialogs on a
  dimmed scrim.
- dots.rs: LED-dot battery rings with dot-matrix figures, and dotted sliders
  for the bass level and the EQ bands (drag, or the wheel steps and saves).
- theme.rs: palette roles after Aurora's Theme.qml (s1/s2/s3, mute, grey,
  fill, live = tertiary). The palette file reads Aurora's Material 3 keys, so
  ~/.cache/matugen/aurora.json works as it is; bg and fg are still read.
  The built-in palette: near-black, white "on" states, red for live things.

Fonts are built into the binary (crates/budctl-gui/fonts, SIL OFL 1.1): Space
Grotesk, Space Mono, and "Doto Rounded", a static rounded-dot black instance
of Doto subset to Latin and renamed as the OFL asks. No new crate
dependencies. The release archive now carries OFL-fonts.txt.

README screenshots rendered again; README, CHANGELOG and CLAUDE.md describe
the new look and the palette keys.
Author
krolyxon
Date
2026-10-28 21:18 +0530
Parent
1b48cfa
Commit
7c15b4175a6d3386a53c68fcfb3cfa9d4c0bdf9e

+1080 −850

25 files changed

  1. M .github/workflows/release.yml +1 −0
  2. M CHANGELOG.md +11 −0
  3. M CLAUDE.md +3 −2
  4. M README.md +19 −6
  5. A crates/budctl-gui/fonts/DotoRounded-Black.ttf
  6. A crates/budctl-gui/fonts/OFL.txt +95 −0
  7. A crates/budctl-gui/fonts/SpaceGrotesk-Medium.otf
  8. A crates/budctl-gui/fonts/SpaceGrotesk-Regular.otf
  9. A crates/budctl-gui/fonts/SpaceGrotesk-SemiBold.otf
  10. A crates/budctl-gui/fonts/SpaceMono-Regular.ttf
  11. A crates/budctl-gui/src/dots.rs +261 −0
  12. M crates/budctl-gui/src/earbuds.rs +22 −12
  13. M crates/budctl-gui/src/equalizer.rs +55 −70
  14. M crates/budctl-gui/src/gestures.rs +20 −24
  15. M crates/budctl-gui/src/icons.rs +7 −114
  16. M crates/budctl-gui/src/main.rs +151 −195
  17. M crates/budctl-gui/src/overview.rs +59 −83
  18. M crates/budctl-gui/src/settings.rs +10 −10
  19. M crates/budctl-gui/src/theme.rs +155 −142
  20. M crates/budctl-gui/src/ui.rs +211 −192
  21. M docs/screenshots/earbuds.png
  22. M docs/screenshots/equalizer.png
  23. M docs/screenshots/gestures.png
  24. M docs/screenshots/keys.png
  25. M docs/screenshots/overview.png
M .github/workflows/release.yml View
@@ -39,6 +39,7 @@ jobs:
3939 dir=budctl-${{ matrix.target }}
4040 mkdir "$dir"
4141 cp target/release/budctl target/release/budctl-gui contrib/budctl.desktop install.sh README.md LICENSE "$dir/"
42+ cp crates/budctl-gui/fonts/OFL.txt "$dir/OFL-fonts.txt"
4243 tar -czf "$dir.tar.gz" "$dir"
4344 sha256sum "$dir.tar.gz" > "$dir.tar.gz.sha256"
4445 - uses: actions/upload-artifact@v7
M CHANGELOG.md View
@@ -1,5 +1,16 @@
11 # Changelog
22
3+## Unreleased
4+
5+- `budctl-gui` has a new look, after Nothing OS in Material You colors, like the owner's Aurora
6+ shell: dot-matrix page names and battery figures (Doto), LED battery rings, dotted sliders for
7+ the bass level and the EQ bands (drag, or the mouse wheel), pill switches, tracks and chips,
8+ Space Grotesk and Space Mono text. The fonts are built in (SIL OFL 1.1, `OFL-fonts.txt` in the
9+ release archive).
10+- The palette file takes Aurora's Material 3 keys (`primary`, `tertiary`, `surfaceLow`,
11+ `outline`…), so `~/.cache/matugen/aurora.json` works as it is. `bg` and `fg` are still read;
12+ `surface` and `secondary` are no longer used.
13+
314 ## 0.1.1 (2026-10-28)
415
516 The first release, with builds for x86_64 and aarch64 Linux and an install script.
M CLAUDE.md View
@@ -114,8 +114,9 @@ Exit codes:
114114
115115 ## GUI
116116
117−- **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).
118−- **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.
117+- **Look:** Nothing OS's shapes and type in Material You colors, as in the owner's Aurora shell (its `Theme.qml` and primitives are the reference; match the look, never copy code, rule 2). A sidebar of dot-matrix page words (the open one large, with the live dot), each page under a caps caption; round cards of rows with the icon in a circle; LED-dot battery rings with Doto figures; dotted sliders; pill switches, tracks and chips. The pieces are in `ui.rs` and `dots.rs`.
118+- **Fonts:** built into the binary from `crates/budctl-gui/fonts/` (OFL 1.1, `OFL.txt`): Space Grotesk (UI text), Space Mono (caps labels), and "Doto Rounded", a static rounded-dot black instance of Doto made with fontTools (`varLib.instancer ROND=100 wght=900`, subset to Latin, renamed as the OFL asks). Doto is never set under 26 px.
119+- **Theme:** a built-in palette (near-black surfaces, white "on" states, red for live things). Optionally, a matugen JSON file with the Material 3 keys of the owner's Aurora palette (`~/.cache/matugen/aurora.json`: `primary`, `tertiary`, `surfaceLowest`…`surfaceHighest`, `outline`…; `bg`/`fg` still read), mapped to the same roles as Aurora's `Theme.qml`: primary = fills, tertiary = live, the surface ladder = s1/s2/s3. It is read again whenever the window gets focus.
119120 - **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top (`keys.rs`, `?` lists them in the window):
120121 - `j`/`k`, the arrows or Tab move a cursor over what the page can change; `gg`/`G` jump to the first or last item.
121122 - `h`/`l` pick the previous or next choice, or turn a switch off or on, and `l` opens a row's page. Enter and Space toggle, step round the choices, or open.
M README.md View
@@ -118,12 +118,20 @@ The mouse works everywhere. Vim-like keys work on top of it (press `?` in the wi
118118
119119 The cursor shows only after you press a key, and the first key only shows it.
120120
121−**Colors:** a built-in dark palette with a red accent, or a [matugen](https://github.com/InioX/matugen)
122−palette: `--theme <file>`, else `~/.config/budctl/theme.json`. The file is JSON with these keys, all
123−optional: `bg`, `fg`, `primary`, `primaryFg`, `primaryContainer`, `secondary`, `surfaceLow`,
124−`surface`, `surfaceHigh`, `surfaceHighest`, `outlineVariant`, `surfaceVariantFg`, `err`. It is read
125−again whenever the window gets focus, so a new wallpaper's colors show up there too. When the file
126−is missing, the app writes the built-in colors there: a starting point for a matugen template.
121+**Look:** Nothing OS shapes and type in Material You colors: dot-matrix page names and battery
122+figures, LED rings and dotted sliders, pill switches and tracks. The fonts (Doto, Space Grotesk,
123+Space Mono) are built into the app.
124+
125+**Colors:** a built-in palette (near-black, white "on" states, red for live things such as the open
126+page's dot and "Saving…"), or a [matugen](https://github.com/InioX/matugen) palette: `--theme
127+<file>`, else `~/.config/budctl/theme.json`. The file is JSON with Material 3 roles, `on_x` spelled
128+`xFg`, all optional: `primary`, `primaryFg`, `primaryContainer`, `primaryContainerFg`, `tertiary`,
129+`tertiaryFg`, `surfaceLowest`, `surfaceLow`, `surfaceHigh`, `surfaceHighest`, `surfaceFg`,
130+`surfaceVariantFg`, `outline`, `outlineVariant`, `err`, `errFg` (`bg` and `fg` still work for
131+`surfaceLowest` and `surfaceFg`; other keys are ignored). A matugen template only needs lines like
132+`"primary": "{{colors.primary.default.hex}}"`. The file is read again whenever the window gets
133+focus, so a new wallpaper's colors show up there too. When the file is missing, the app writes the
134+built-in colors there: a starting point for a template.
127135
128136 **Log:** each launch writes errors and session events (connected, each change and its answer,
129137 closed) to `~/.local/state/budctl/gui.log`, replacing the last launch's.
@@ -160,3 +168,8 @@ how sure, is written down in [`docs/protocol/`](docs/protocol/): the
160168 ## License
161169
162170 GPL-3.0-or-later. See [LICENSE](LICENSE).
171+
172+The desktop app carries the Doto, Space Grotesk and Space Mono fonts, under the SIL Open Font
173+License 1.1 ([`crates/budctl-gui/fonts/OFL.txt`](crates/budctl-gui/fonts/OFL.txt)). The Doto in it
174+is a static instance (rounded dots, black weight, Latin only), renamed "Doto Rounded" as the
175+license asks of modified fonts.
A crates/budctl-gui/fonts/DotoRounded-Black.ttf View

[Binary file]

A crates/budctl-gui/fonts/OFL.txt View
@@ -0,0 +1,95 @@
1+Copyright 2024 The Doto Project Authors (https://github.com/oliverlalan/Doto)
2+Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)
3+Copyright 2016 Google Inc. & Colophon Foundry. Copyright 2017 Florian Karsten (https://github.com/floriankarsten/space-grotesk)
4+
5+This Font Software is licensed under the SIL Open Font License, Version 1.1.
6+This license is copied below, and is also available with a FAQ at:
7+https://openfontlicense.org
8+
9+
10+-----------------------------------------------------------
11+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
12+-----------------------------------------------------------
13+
14+PREAMBLE
15+The goals of the Open Font License (OFL) are to stimulate worldwide
16+development of collaborative font projects, to support the font creation
17+efforts of academic and linguistic communities, and to provide a free and
18+open framework in which fonts may be shared and improved in partnership
19+with others.
20+
21+The OFL allows the licensed fonts to be used, studied, modified and
22+redistributed freely as long as they are not sold by themselves. The
23+fonts, including any derivative works, can be bundled, embedded,
24+redistributed and/or sold with any software provided that any reserved
25+names are not used by derivative works. The fonts and derivatives,
26+however, cannot be released under any other type of license. The
27+requirement for fonts to remain under this license does not apply
28+to any document created using the fonts or their derivatives.
29+
30+DEFINITIONS
31+"Font Software" refers to the set of files released by the Copyright
32+Holder(s) under this license and clearly marked as such. This may
33+include source files, build scripts and documentation.
34+
35+"Reserved Font Name" refers to any names specified as such after the
36+copyright statement(s).
37+
38+"Original Version" refers to the collection of Font Software components as
39+distributed by the Copyright Holder(s).
40+
41+"Modified Version" refers to any derivative made by adding to, deleting,
42+or substituting -- in part or in whole -- any of the components of the
43+Original Version, by changing formats or by porting the Font Software to a
44+new environment.
45+
46+"Author" refers to any designer, engineer, programmer, technical
47+writer or other person who contributed to the Font Software.
48+
49+PERMISSION & CONDITIONS
50+Permission is hereby granted, free of charge, to any person obtaining
51+a copy of the Font Software, to use, study, copy, merge, embed, modify,
52+redistribute, and sell modified and unmodified copies of the Font
53+Software, subject to the following conditions:
54+
55+1) Neither the Font Software nor any of its individual components,
56+in Original or Modified Versions, may be sold by itself.
57+
58+2) Original or Modified Versions of the Font Software may be bundled,
59+redistributed and/or sold with any software, provided that each copy
60+contains the above copyright notice and this license. These can be
61+included either as stand-alone text files, human-readable headers or
62+in the appropriate machine-readable metadata fields within text or
63+binary files as long as those fields can be easily viewed by the user.
64+
65+3) No Modified Version of the Font Software may use the Reserved Font
66+Name(s) unless explicit written permission is granted by the corresponding
67+Copyright Holder. This restriction only applies to the primary font name as
68+presented to the users.
69+
70+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
71+Software shall not be used to promote, endorse or advertise any
72+Modified Version, except to acknowledge the contribution(s) of the
73+Copyright Holder(s) and the Author(s) or with their explicit written
74+permission.
75+
76+5) The Font Software, modified or unmodified, in part or in whole,
77+must be distributed entirely under this license, and must not be
78+distributed under any other license. The requirement for fonts to
79+remain under this license does not apply to any document created
80+using the Font Software.
81+
82+TERMINATION
83+This license becomes null and void if any of the above conditions are
84+not met.
85+
86+DISCLAIMER
87+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
88+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
89+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
90+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
91+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
92+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
93+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
94+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
95+OTHER DEALINGS IN THE FONT SOFTWARE.
A crates/budctl-gui/fonts/SpaceGrotesk-Medium.otf View

[Binary file]

A crates/budctl-gui/fonts/SpaceGrotesk-Regular.otf View

[Binary file]

A crates/budctl-gui/fonts/SpaceGrotesk-SemiBold.otf View

[Binary file]

A crates/budctl-gui/fonts/SpaceMono-Regular.ttf View

[Binary file]

A crates/budctl-gui/src/dots.rs View
@@ -0,0 +1,261 @@
1+//! LED dots, like the Glyph lights: battery rings, and the sliders for the bass level and the EQ
2+//! bands. Drawn on a canvas, so they take any theme color.
3+
4+use std::f32::consts::{FRAC_PI_2, TAU};
5+
6+use iced::widget::canvas::{self, Event, Frame, Path};
7+use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme, mouse};
8+
9+use crate::Message;
10+
11+/// A ring of `n` dots, `size` pixels across: the fraction `lit` lights clockwise from the top in
12+/// `on`, the rest stay `off`. `None` lights nothing.
13+pub fn ring<'a>(lit: Option<f32>, size: f32, on: Color, off: Color) -> Element<'a, Message> {
14+ iced::widget::canvas(Ring { lit, on, off })
15+ .width(size)
16+ .height(size)
17+ .into()
18+}
19+
20+struct Ring {
21+ lit: Option<f32>,
22+ on: Color,
23+ off: Color,
24+}
25+
26+impl canvas::Program<Message> for Ring {
27+ type State = ();
28+
29+ fn draw(
30+ &self,
31+ _state: &(),
32+ renderer: &Renderer,
33+ _theme: &Theme,
34+ bounds: Rectangle,
35+ _cursor: mouse::Cursor,
36+ ) -> Vec<canvas::Geometry> {
37+ const N: u16 = 44;
38+ let mut frame = Frame::new(renderer, bounds.size());
39+ let center = frame.center();
40+ let dot = (bounds.width * 0.045).max(3.0);
41+ let radius = bounds.width / 2.0 - dot / 2.0 - 1.0;
42+ let lit = self.lit.map_or(0, |f| lit_count(f, N));
43+ for n in 0..N {
44+ let angle = f32::from(n) / f32::from(N) * TAU - FRAC_PI_2;
45+ let at = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin());
46+ let color = if n < lit { self.on } else { self.off };
47+ frame.fill(&Path::circle(at, dot / 2.0), color);
48+ }
49+ vec![frame.into_geometry()]
50+ }
51+}
52+
53+/// How many of `n` dots the fraction `f` lights.
54+fn lit_count(f: f32, n: u16) -> u16 {
55+ let f = f.clamp(0.0, 1.0);
56+ // At most n, so the cast keeps the value.
57+ #[allow(clippy::cast_possible_truncation)]
58+ let lit = (f * f32::from(n)).round() as u16;
59+ lit.min(n)
60+}
61+
62+/// A dotted slider over `low..=high`, one dot per step: the dots up to the value are lit, the
63+/// value's own dot is larger. With `low < 0`, as for EQ gains, the lit dots run from 0.
64+///
65+/// A press or a drag sends `change(value, false)`, letting go sends `release`, and the wheel
66+/// steps by one with `change(value, true)`.
67+pub struct Slider<F> {
68+ pub low: i16,
69+ pub high: i16,
70+ pub value: i16,
71+ pub vertical: bool,
72+ pub change: F,
73+ pub release: Message,
74+ pub on: Color,
75+ pub off: Color,
76+}
77+
78+impl<F: Fn(i16, bool) -> Message + 'static> Slider<F> {
79+ /// The slider, `length` pixels along its axis.
80+ pub fn view<'a>(self, length: f32) -> Element<'a, Message> {
81+ let vertical = self.vertical;
82+ let canvas = iced::widget::canvas(self);
83+ if vertical {
84+ canvas.width(28).height(length).into()
85+ } else {
86+ canvas.width(length).height(Length::Fixed(24.0)).into()
87+ }
88+ }
89+}
90+
91+impl<F> Slider<F> {
92+ fn steps(&self) -> u16 {
93+ u16::try_from(self.high.saturating_sub(self.low)).unwrap_or(0).max(1)
94+ }
95+
96+ /// Where the dot for `value` sits along the slider, from 0 to 1.
97+ fn place(&self, value: i16) -> f32 {
98+ f32::from(value.saturating_sub(self.low)) / f32::from(self.steps())
99+ }
100+
101+ /// The value under the cursor at `along` (0 to 1 along the slider).
102+ fn value_at(&self, along: f32) -> i16 {
103+ let along = along.clamp(0.0, 1.0);
104+ // Rounded and within 0..=steps, so it fits.
105+ #[allow(clippy::cast_possible_truncation)]
106+ let step = (along * f32::from(self.steps())).round() as u16;
107+ self.low.saturating_add(i16::try_from(step).unwrap_or(0)).min(self.high)
108+ }
109+
110+ /// 0 to 1 along the slider for a point inside `bounds`: left to right, or bottom to top.
111+ fn along(&self, bounds: Rectangle, at: Point) -> f32 {
112+ let pad = DOT_BIG;
113+ if self.vertical {
114+ (bounds.y + bounds.height - pad - at.y) / (bounds.height - 2.0 * pad).max(1.0)
115+ } else {
116+ (at.x - bounds.x - pad) / (bounds.width - 2.0 * pad).max(1.0)
117+ }
118+ }
119+}
120+
121+/// The value's dot, and the others.
122+const DOT_BIG: f32 = 11.0;
123+const DOT: f32 = 5.0;
124+
125+#[derive(Default)]
126+pub struct Drag {
127+ held: bool,
128+}
129+
130+impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
131+ type State = Drag;
132+
133+ fn update(
134+ &self,
135+ state: &mut Drag,
136+ event: &Event,
137+ bounds: Rectangle,
138+ cursor: mouse::Cursor,
139+ ) -> Option<canvas::Action<Message>> {
140+ let Event::Mouse(event) = event else {
141+ return None;
142+ };
143+ let change = |at: Point| {
144+ let value = self.value_at(self.along(bounds, at));
145+ canvas::Action::publish((self.change)(value, false)).and_capture()
146+ };
147+ match event {
148+ mouse::Event::ButtonPressed(mouse::Button::Left) => {
149+ let at = cursor.position_over(bounds)?;
150+ state.held = true;
151+ Some(change(at))
152+ }
153+ mouse::Event::CursorMoved { .. } if state.held => Some(change(cursor.position()?)),
154+ mouse::Event::ButtonReleased(mouse::Button::Left) if state.held => {
155+ state.held = false;
156+ Some(canvas::Action::publish(self.release.clone()).and_capture())
157+ }
158+ mouse::Event::WheelScrolled { delta } => {
159+ cursor.position_over(bounds)?;
160+ let y = match delta {
161+ mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => *y,
162+ };
163+ let to = if y > 0.0 {
164+ self.value.saturating_add(1).min(self.high)
165+ } else if y < 0.0 {
166+ self.value.saturating_sub(1).max(self.low)
167+ } else {
168+ return None;
169+ };
170+ (to != self.value).then(|| canvas::Action::publish((self.change)(to, true)).and_capture())
171+ }
172+ _ => None,
173+ }
174+ }
175+
176+ fn draw(
177+ &self,
178+ _state: &Drag,
179+ renderer: &Renderer,
180+ _theme: &Theme,
181+ bounds: Rectangle,
182+ _cursor: mouse::Cursor,
183+ ) -> Vec<canvas::Geometry> {
184+ let mut frame = Frame::new(renderer, bounds.size());
185+ let size = bounds.size();
186+ let at = |along: f32| {
187+ if self.vertical {
188+ Point::new(
189+ size.width / 2.0,
190+ size.height - DOT_BIG - along * (size.height - 2.0 * DOT_BIG),
191+ )
192+ } else {
193+ Point::new(DOT_BIG + along * (size.width - 2.0 * DOT_BIG), size.height / 2.0)
194+ }
195+ };
196+ let base = self.low.max(0).min(self.high);
197+ let (from, to) = (base.min(self.value), base.max(self.value));
198+ let mut value = self.low;
199+ while value <= self.high {
200+ let lit = (from..=to).contains(&value);
201+ let (diameter, color) = if value == self.value {
202+ (DOT_BIG, self.on)
203+ } else if lit {
204+ (DOT, self.on)
205+ } else {
206+ (DOT, self.off)
207+ };
208+ frame.fill(&Path::circle(at(self.place(value)), diameter / 2.0), color);
209+ value = match value.checked_add(1) {
210+ Some(next) => next,
211+ None => break,
212+ };
213+ }
214+ vec![frame.into_geometry()]
215+ }
216+
217+ fn mouse_interaction(&self, state: &Drag, bounds: Rectangle, cursor: mouse::Cursor) -> mouse::Interaction {
218+ if state.held || cursor.is_over(bounds) {
219+ mouse::Interaction::Pointer
220+ } else {
221+ mouse::Interaction::default()
222+ }
223+ }
224+}
225+
226+#[cfg(test)]
227+mod tests {
228+ use super::*;
229+
230+ fn slider(low: i16, high: i16) -> Slider<fn(i16, bool) -> Message> {
231+ Slider {
232+ low,
233+ high,
234+ value: 0,
235+ vertical: false,
236+ change: |_, _| Message::SaveBass,
237+ release: Message::SaveBass,
238+ on: Color::WHITE,
239+ off: Color::BLACK,
240+ }
241+ }
242+
243+ #[test]
244+ fn rings_light_their_share() {
245+ assert_eq!(lit_count(0.8, 44), 35);
246+ assert_eq!(lit_count(1.5, 44), 44);
247+ assert_eq!(lit_count(-1.0, 44), 0);
248+ }
249+
250+ #[test]
251+ fn sliders_find_the_value_under_the_cursor() {
252+ let eq = slider(-6, 6);
253+ assert_eq!(eq.value_at(0.0), -6);
254+ assert_eq!(eq.value_at(0.5), 0);
255+ assert_eq!(eq.value_at(1.0), 6);
256+ assert_eq!(eq.value_at(2.0), 6);
257+ assert_eq!(eq.place(0), 0.5);
258+ // A slider with one value does not divide by zero.
259+ assert_eq!(slider(3, 3).value_at(0.7), 3);
260+ }
261+}
M crates/budctl-gui/src/earbuds.rs View
@@ -2,7 +2,7 @@
22
33 use budctl_core::status::DualDevice;
44 use iced::Element;
5−use iced::widget::{column, text};
5+use iced::widget::{column, container};
66
77 use crate::buds::Buds;
88 use crate::icons::Icon;
@@ -24,7 +24,6 @@ pub fn items(buds: &Buds) -> Vec<Item> {
2424
2525 /// `ringing`: the buds were told to ring and not yet to stop.
2626 pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Element<'a, Message> {
27− let p = look.p;
2827 let dual = buds.status.switches.iter().find(|s| s.key == DUAL).map(|dual| {
2928 let about = if marks.pending.contains(&Item::Switch(DUAL)) {
3029 "Saving…"
@@ -59,9 +58,7 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem
5958 None,
6059 marks.focus == Some(Item::Find),
6160 );
62− let value = |value: Option<&'a str>| -> Element<'a, Message> {
63− text(value.unwrap_or("Unknown")).size(14).color(p.dim).into()
64− };
61+ let value = |value: Option<&'a str>| -> Element<'a, Message> { ui::value(look, value.unwrap_or("unknown")) };
6562 let status = &buds.status;
6663 let facts = vec![
6764 ui::setting(look, Icon::Earbud, "Name", None, value(Some(&buds.name)), None, false),
@@ -84,7 +81,7 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem
8481 false,
8582 ),
8683 ];
87− column![ui::title(look, "Earbud settings")]
84+ column![ui::title(look, "earbud settings · connection, find, firmware")]
8885 .push(dual)
8986 .push(buds.dual.as_deref().map(|list| devices(look, list)))
9087 .push(column![ui::heading(look, "Find my earbuds"), ui::card(look, vec![find])].spacing(10))
@@ -110,18 +107,31 @@ fn devices<'a>(look: Look, list: &'a [DualDevice]) -> Element<'a, Message> {
110107 } else {
111108 device.name.as_str()
112109 };
113− let address = text(device.address.to_string()).size(13).color(p.dim);
114− ui::setting(look, icon, name, Some(state(device)), address.into(), None, false)
110+ let address = ui::label(look, &device.address.to_string(), 11.0, p.grey);
111+ ui::list_row(
112+ look,
113+ icon,
114+ device.connected,
115+ name,
116+ Some(state(device)),
117+ address,
118+ None,
119+ false,
120+ )
115121 })
116122 .collect();
117123 let list: Element<'a, Message> = if rows.is_empty() {
118− text("The buds list no devices.").size(13).color(p.dim).into()
124+ container(ui::note(look, "The buds list no devices."))
125+ .padding([0, 18])
126+ .into()
119127 } else {
120128 ui::card(look, rows)
121129 };
122− let note = text("These buds cannot connect, disconnect or forget a device from here.")
123− .size(13)
124− .color(p.dim);
130+ let note = container(ui::note(
131+ look,
132+ "These buds cannot connect, disconnect or forget a device from here.",
133+ ))
134+ .padding([0, 18]);
125135 column![ui::heading(look, "Devices"), list, note].spacing(10).into()
126136 }
127137
M crates/budctl-gui/src/equalizer.rs View
@@ -1,11 +1,12 @@
11 //! The Equalizer page: the presets, and the bands of the custom ones.
22
33 use budctl_core::status::{EqBand, EqPreset};
4−use iced::widget::{Row, column, container, row, slider, space, text, vertical_slider};
4+use iced::widget::{Row, column, container, row, text};
55 use iced::{Alignment, Border, Element, Length};
66
77 use crate::buds::{self, Buds};
8−use crate::icons::{self, Icon};
8+use crate::dots;
9+use crate::icons::Icon;
910 use crate::theme::Look;
1011 use crate::ui;
1112 use crate::{Draft, Item, Marks, Message};
@@ -57,9 +58,9 @@ pub fn view<'a>(
5758 marks: Marks,
5859 ) -> Element<'a, Message> {
5960 let p = look.p;
60− let title = ui::title(look, "Equalizer");
61+ let title = ui::title(look, "equalizer · presets, and the bands of yours");
6162 let Some(eq) = &buds.eq else {
62− let none = text("These buds did not list their presets.").size(14).color(p.dim);
63+ let none = ui::note(look, "These buds did not list their presets.");
6364 return column![title, none].spacing(12).into();
6465 };
6566 let current = eq.current.as_deref();
@@ -78,36 +79,31 @@ pub fn view<'a>(
7879 } else {
7980 "Built in"
8081 };
81− let mark: Element<'a, Message> = if chosen {
82− icons::icon(Icon::Check, 20.0, p.icon)
83− } else {
84− space().width(20).into()
85− };
82+ let mark: Option<Element<'a, Message>> = chosen.then(|| ui::label(look, "in use", 10.5, p.fg));
8683 let trailing: Element<'a, Message> = if preset.custom {
87− row![
88− mark,
89− ui::icon_button(
84+ row![mark]
85+ .push(ui::icon_button(
9086 look,
9187 Icon::Pencil,
9288 marks.focus == Some(Item::Edit(id)),
93− Message::EditPreset(id)
94− ),
95− ui::icon_button(
89+ Message::EditPreset(id),
90+ ))
91+ .push(ui::icon_button(
9692 look,
9793 Icon::Trash,
9894 marks.focus == Some(Item::Delete(id)),
99− Message::DeletePreset(id)
100− ),
101− ]
102− .spacing(6)
103− .align_y(Alignment::Center)
104− .into()
95+ Message::DeletePreset(id),
96+ ))
97+ .spacing(8)
98+ .align_y(Alignment::Center)
99+ .into()
105100 } else {
106− mark
101+ row![mark].into()
107102 };
108− ui::setting(
103+ ui::list_row(
109104 look,
110105 Icon::Equalizer,
106+ chosen,
111107 buds::shown_name(preset),
112108 Some(subtitle.to_owned()),
113109 trailing,
@@ -119,10 +115,12 @@ pub fn view<'a>(
119115 let editor: Option<Element<'a, Message>> = match edited(buds, editing) {
120116 Some(preset) => Some(bands(look, preset, draft, marks)),
121117 None if eq.presets.iter().any(|p| p.custom) => Some(
122− text("Use the pencil on one of your presets to change its bands.")
123− .size(13)
124− .color(p.dim)
125− .into(),
118+ container(ui::note(
119+ look,
120+ "Use the pencil on one of your presets to change its bands.",
121+ ))
122+ .padding([0, 18])
123+ .into(),
126124 ),
127125 None => None,
128126 };
@@ -136,11 +134,8 @@ fn new_preset<'a>(look: Look, marks: Marks) -> Element<'a, Message> {
136134 let creating = marks
137135 .pending
138136 .contains(&Item::NewPreset)
139− .then(|| text("Creating…").size(13).color(look.p.icon));
140− let about = text("A new preset starts flat and is put to use.")
141− .size(13)
142− .color(look.p.dim)
143− .width(Length::Fill);
137+ .then(|| ui::label(look, "Creating…", 11.0, look.p.live));
138+ let about = container(ui::note(look, "A new preset starts flat and is put to use.")).width(Length::Fill);
144139 row![about]
145140 .push(creating)
146141 .push(ui::pill_with(
@@ -152,7 +147,7 @@ fn new_preset<'a>(look: Look, marks: Marks) -> Element<'a, Message> {
152147 ))
153148 .spacing(14)
154149 .align_y(Alignment::Center)
155− .padding([0, 8])
150+ .padding([0, 18])
156151 .into()
157152 }
158153
@@ -168,13 +163,10 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks:
168163 let saving = marks
169164 .pending
170165 .contains(&Item::Preset(id))
171− .then(|| text(SAVING).size(13).color(p.icon));
166+ .then(|| ui::label(look, SAVING, 11.0, p.live));
172167 let heading = row![
173− text(preset.name.as_str()).size(16).font(look.medium()).color(p.fg),
174− text(format!("{low:+} to {high:+} dB"))
175− .size(13)
176− .color(p.dim)
177− .width(Length::Fill)
168+ text(preset.name.as_str()).size(20).font(look.semibold()).color(p.fg),
169+ container(ui::label(look, &format!("{low:+} to {high:+} dB"), 11.0, p.grey)).width(Length::Fill)
178170 ]
179171 .push(saving)
180172 .push(ui::pill_with(
@@ -193,58 +185,51 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks:
193185 ))
194186 .spacing(10)
195187 .align_y(Alignment::Center);
196− let note = text("Moving a band saves this preset and puts it in use, so you hear it.")
197− .size(13)
198− .color(p.dim);
188+ let note = ui::note(
189+ look,
190+ "Moving a band saves this preset and puts it in use, so you hear it.",
191+ );
199192 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
200193 let focused = marks.focus == Some(Item::Band(id, band.hz));
201194 let hz = band.hz;
202− let slider = vertical_slider(i16::from(low)..=i16::from(high), i16::from(band.gain), move |gain| {
203− Message::Band {
195+ let slider = dots::Slider {
196+ low: i16::from(low),
197+ high: i16::from(high),
198+ value: i16::from(band.gain),
199+ vertical: true,
200+ change: move |gain, save| Message::Band {
204201 preset: id,
205202 hz,
206203 gain: i8::try_from(gain).unwrap_or(0),
207− save: false,
208− }
209− })
210− .on_release(Message::SaveBands(id))
211− .height(170)
212− .style(move |_, _| slider::Style {
213− rail: slider::Rail {
214− backgrounds: (p.accent.into(), p.surface_highest.into()),
215− width: 6.0,
216− border: Border::default().rounded(3),
217− },
218− handle: slider::Handle {
219− shape: slider::HandleShape::Circle { radius: 9.0 },
220− background: p.fg.into(),
221− border_width: 2.0,
222− border_color: p.accent,
204+ save,
223205 },
224− });
206+ release: Message::SaveBands(id),
207+ on: p.fill,
208+ off: p.s3,
209+ };
225210 let band = column![
226− text(gain(band.gain)).size(14).font(look.medium()).color(p.fg),
227− slider,
228− text(frequency(hz)).size(12).color(p.dim)
211+ ui::num(look, &gain(band.gain), 28.0, p.fg),
212+ slider.view(190.0),
213+ ui::label(look, &frequency(hz), 10.5, p.grey)
229214 ]
230− .spacing(10)
215+ .spacing(12)
231216 .align_x(Alignment::Center);
232217 sliders.push(
233218 container(band)
234219 .center_x(Length::Fill)
235− .padding([12, 0])
220+ .padding([14, 0])
236221 .style(move |_| container::Style {
237− background: focused.then_some(p.surface_high.into()),
222+ background: focused.then_some(p.s2.into()),
238223 border: Border {
239− color: p.accent,
224+ color: p.fill,
240225 width: if focused { 2.0 } else { 0.0 },
241− radius: 12.0.into(),
226+ radius: 24.0.into(),
242227 },
243228 ..container::Style::default()
244229 }),
245230 )
246231 });
247− ui::panel(look, column![heading, note, sliders].spacing(14).into())
232+ ui::panel(look, column![heading, note, sliders].spacing(16).into())
248233 }
249234
250235 /// `3` → "+3", `0` → "0", `-2` → "-2".
M crates/budctl-gui/src/gestures.rs View
@@ -4,7 +4,7 @@ use std::fmt;
44
55 use budctl_core::status::TouchControls;
66 use iced::widget::overlay::menu;
7−use iced::widget::{column, pick_list, text};
7+use iced::widget::{column, pick_list};
88 use iced::{Border, Element, Shadow};
99
1010 use crate::buds::{self, Buds};
@@ -56,11 +56,9 @@ pub fn items(buds: &Buds) -> Vec<Item> {
5656 /// `draft`: an action picked on the screen and not on the buds yet.
5757 pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a TouchDraft>, marks: Marks) -> Element<'a, Message> {
5858 let p = look.p;
59− let title = ui::title(look, "Gestures");
59+ let title = ui::title(look, "gestures · taps and holds on each bud");
6060 let Some(touch) = &buds.touch else {
61− let none = text("These buds did not report their touch controls.")
62− .size(14)
63− .color(p.dim);
61+ let none = ui::note(look, "These buds did not report their touch controls.");
6462 return column![title, none].spacing(12).into();
6563 };
6664 let rows = rows(touch);
@@ -85,30 +83,28 @@ pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a TouchDraft>, marks
8583 save: true,
8684 }
8785 })
88− .width(220)
89− .padding([8, 12])
86+ .width(230)
87+ .padding([10, 18])
9088 .text_size(14)
91− .style(move |_, _| pick_list::Style {
89+ .font(look.medium())
90+ .style(move |_, status| pick_list::Style {
9291 text_color: p.fg,
93− placeholder_color: p.dim,
94− handle_color: p.dim,
95− background: p.surface_high.into(),
96− border: Border {
97− color: p.line,
98− width: 1.0,
99− radius: 10.0.into(),
100− },
92+ placeholder_color: p.grey,
93+ handle_color: p.grey,
94+ background: if matches!(status, pick_list::Status::Hovered | pick_list::Status::Opened { .. }) {
95+ p.mute
96+ } else {
97+ p.s2
98+ }
99+ .into(),
100+ border: Border::default().rounded(999),
101101 })
102102 .menu_style(move |_| menu::Style {
103− background: p.surface_high.into(),
104− border: Border {
105− color: p.line,
106− width: 1.0,
107− radius: 10.0.into(),
108− },
103+ background: p.s2.into(),
104+ border: Border::default().rounded(20),
109105 text_color: p.fg,
110− selected_text_color: p.accent_fg,
111− selected_background: p.accent.into(),
106+ selected_text_color: p.fill_fg,
107+ selected_background: p.fill.into(),
112108 shadow: Shadow::default(),
113109 });
114110 let saving = marks.pending.contains(&Item::Gesture(n)).then(|| SAVING.to_owned());
M crates/budctl-gui/src/icons.rs View
@@ -1,14 +1,11 @@
1−//! Icons and battery rings, drawn as vector paths so they take any theme color and stay sharp at
1+//! Icons, drawn as vector paths so they take any theme color and stay sharp at
22 //! any scale. Icons are designed on a 24 × 24 grid.
33
4−use std::f32::consts::{FRAC_PI_2, TAU};
5−
64 use iced::widget::canvas::{self, Frame, LineCap, LineJoin, Path, Stroke, path::Arc};
75 use iced::{Color, Element, Point, Radians, Rectangle, Renderer, Size, Theme, mouse};
86
97 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
108 pub enum Icon {
11− Overview,
129 Equalizer,
1310 Gestures,
1411 Earbud,
@@ -23,7 +20,6 @@ pub enum Icon {
2320 Dual,
2421 Chevron,
2522 Bolt,
26− Check,
2723 Find,
2824 Info,
2925 Chip,
@@ -163,11 +159,6 @@ impl<'f> Pen<'f> {
163159
164160 fn draw_icon(pen: &mut Pen, icon: Icon) {
165161 match icon {
166− Icon::Overview => {
167− for (x, y) in [(3.0, 3.0), (13.5, 3.0), (3.0, 13.5), (13.5, 13.5)] {
168− pen.rect(x, y, 7.5, 7.5, 2.0);
169− }
170− }
171162 Icon::Equalizer => {
172163 for (x, knob) in [(6.0, 14.0), (12.0, 8.0), (18.0, 16.0)] {
173164 pen.line((x, 3.5), (x, 20.5));
@@ -179,7 +170,7 @@ fn draw_icon(pen: &mut Pen, icon: Icon) {
179170 pen.arc(12.0, 11.5, 4.0, 200.0, 340.0);
180171 pen.arc(12.0, 11.5, 7.5, 210.0, 330.0);
181172 }
182− Icon::Earbud => earbud(pen, false, None),
173+ Icon::Earbud => earbud(pen),
183174 Icon::Settings => {
184175 for (y, knob) in [(6.0, 15.0), (12.0, 8.0), (18.0, 13.0)] {
185176 pen.line((3.5, y), (20.5, y));
@@ -292,7 +283,6 @@ fn draw_icon(pen: &mut Pen, icon: Icon) {
292283 }
293284 pen.line((8.0, 14.0), (16.0, 14.0));
294285 }
295− Icon::Check => pen.polyline(&[(4.5, 12.5), (9.5, 17.5), (19.5, 7.0)], false),
296286 Icon::Laptop => {
297287 pen.rect(4.0, 5.0, 16.0, 11.0, 1.5);
298288 pen.line((2.0, 19.0), (22.0, 19.0));
@@ -304,106 +294,9 @@ fn draw_icon(pen: &mut Pen, icon: Icon) {
304294 }
305295 }
306296
307−/// A filled earbud: the head, the ear tip pointing in, and the stem going down. The tip is
308−/// drawn in `tip`, or in the part's color when there is none.
309−fn earbud(pen: &mut Pen, mirrored: bool, tip: Option<Color>) {
310− let x = |x: f32, w: f32| if mirrored { 24.0 - x - w } else { x };
311− let color = pen.color;
312− pen.color = tip.unwrap_or(color);
313− pen.fill_rect(x(14.5, 6.0), 5.0, 6.0, 7.0, 2.8);
314− pen.color = color;
315− pen.dot(x(11.0, 0.0), 8.5, 6.3);
316− pen.fill_rect(x(6.6, 4.4), 10.0, 4.4, 12.5, 2.2);
317−}
318−
319−/// What a battery ring shows in its middle.
320−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321−pub enum Part {
322− Left,
323− Right,
324− Case,
325−}
326−
327−/// A battery ring: the track, an arc for the charge (none when unknown) and the part in the
328−/// middle.
329−pub fn battery_ring<'a, Message: 'a>(
330− part: Part,
331− level: Option<u8>,
332− size: f32,
333− colors: RingColors,
334−) -> Element<'a, Message> {
335− iced::widget::canvas(Ring { part, level, colors })
336− .width(size)
337− .height(size)
338− .into()
339−}
340−
341−#[derive(Debug, Clone, Copy)]
342−pub struct RingColors {
343− pub track: Color,
344− pub charge: Color,
345− pub part: Color,
346− /// The earbuds' ear tips.
347− pub tip: Color,
348− /// Details cut into the case (the lid line, the light).
349− pub cutout: Color,
350−}
351−
352−struct Ring {
353− part: Part,
354− level: Option<u8>,
355− colors: RingColors,
356−}
357−
358−impl<Message> canvas::Program<Message> for Ring {
359− type State = ();
360−
361− fn draw(
362− &self,
363− _state: &(),
364− renderer: &Renderer,
365− _theme: &Theme,
366− bounds: Rectangle,
367− _cursor: mouse::Cursor,
368− ) -> Vec<canvas::Geometry> {
369− let mut frame = Frame::new(renderer, bounds.size());
370− let center = frame.center();
371− let width = bounds.width * 0.06;
372− let radius = bounds.width / 2.0 - width / 2.0 - 1.0;
373− let ring = |color: Color| {
374− Stroke::default()
375− .with_color(color)
376− .with_width(width)
377− .with_line_cap(LineCap::Round)
378− };
379− frame.stroke(&Path::circle(center, radius), ring(self.colors.track));
380− if let Some(level) = self.level.filter(|&level| level > 0) {
381− let sweep = f32::from(level.min(100)) / 100.0 * TAU;
382− let arc = Path::new(|b| {
383− b.arc(Arc {
384− center,
385− radius,
386− start_angle: Radians(-FRAC_PI_2),
387− end_angle: Radians(-FRAC_PI_2 + sweep),
388− });
389− });
390− frame.stroke(&arc, ring(self.colors.charge));
391− }
392−
393− let art = bounds.width * 0.44;
394− let origin = Point::new(center.x - art / 2.0, center.y - art / 2.0);
395− let mut pen = Pen::new(&mut frame, origin, art, self.colors.part);
396− match self.part {
397− Part::Left => earbud(&mut pen, false, Some(self.colors.tip)),
398− Part::Right => earbud(&mut pen, true, Some(self.colors.tip)),
399− Part::Case => {
400− pen.fill_rect(2.0, 5.5, 20.0, 13.5, 6.0);
401− pen.color = self.colors.cutout;
402− pen.width = 0.9 * pen.scale;
403− pen.line((2.0, 10.0), (22.0, 10.0));
404− pen.dot(12.0, 14.5, 1.0);
405− }
406− }
407− vec![frame.into_geometry()]
408− }
297+/// A filled earbud: the head, the ear tip pointing in, and the stem going down.
298+fn earbud(pen: &mut Pen) {
299+ pen.fill_rect(14.5, 5.0, 6.0, 7.0, 2.8);
300+ pen.dot(11.0, 8.5, 6.3);
301+ pen.fill_rect(6.6, 10.0, 4.4, 12.5, 2.2);
409302 }
M crates/budctl-gui/src/main.rs View
@@ -5,6 +5,7 @@
55 //! (`keys.rs`). `--demo` shows made-up buds instead.
66
77 mod buds;
8+mod dots;
89 mod earbuds;
910 mod equalizer;
1011 mod gestures;
@@ -29,9 +30,8 @@ use budctl_core::status::{AncLevel, EqBand, EqPreset, NoiseControl, NoiseMode, S
2930 use iced::keyboard::{self, Key, Modifiers};
3031 use iced::widget::operation::{self, RelativeOffset};
3132 use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text, text_input};
32−use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, Task, Theme, event, mouse, window};
33+use iced::{Alignment, Border, Element, Length, Size, Subscription, Task, Theme, event, mouse, window};
3334
34−use icons::Icon;
3535 use keys::Action;
3636 use theme::Look;
3737 use worker::{Command, Event, Reading};
@@ -42,8 +42,8 @@ const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>]
4242 --demo shows made-up buds, without Bluetooth.
4343 --debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
4444 Colors come from --theme, else from ~/.config/budctl/theme.json, else built in. That file is
45−written with the built-in colors when missing. It takes the keys of a matugen palette like
46−Aurora's (bg, fg, primary, …).
45+written with the built-in colors when missing. It takes the Material 3 keys of a matugen
46+palette like Aurora's (primary, tertiary, surfaceLow, outline, …).
4747 Preferences (default device, idle time, start page): ~/.config/budctl/config.json.
4848 Each launch logs errors and session events to ~/.local/state/budctl/gui.log.
4949 Press ? in the window for the keys.";
@@ -91,18 +91,21 @@ fn main() -> ExitCode {
9191 write_built_in_theme();
9292 }
9393 let theme_file = options.theme.clone().or_else(theme::default_file);
94− let font = theme::system_font();
9594 let boot = move || {
96− let mut app = App::new(theme_file.clone(), font, device, options.demo);
95+ let mut app = App::new(theme_file.clone(), device, options.demo);
9796 app.page = start_page;
9897 app.idle = idle;
9998 app
10099 };
101− let result = iced::application(boot, App::update, App::view)
100+ let mut application = iced::application(boot, App::update, App::view);
101+ for font in theme::FONTS {
102+ application = application.font(font);
103+ }
104+ let result = application
102105 .title("budctl")
103106 .theme(App::theme)
104107 .subscription(App::subscription)
105− .default_font(font)
108+ .default_font(theme::UI)
106109 .window(window::Settings {
107110 size: Size::new(1040.0, 800.0),
108111 min_size: Some(Size::new(760.0, 540.0)),
@@ -210,13 +213,14 @@ impl Page {
210213 }
211214 }
212215
213− fn icon(self) -> Icon {
216+ /// The page's word in the sidebar, short enough to stand large in dots.
217+ fn word(self) -> &'static str {
214218 match self {
215− Page::Overview => Icon::Overview,
216− Page::Equalizer => Icon::Equalizer,
217− Page::Gestures => Icon::Gestures,
218− Page::Earbuds => Icon::Earbud,
219− Page::Settings => Icon::Settings,
219+ Page::Overview => "overview",
220+ Page::Equalizer => "eq",
221+ Page::Gestures => "gestures",
222+ Page::Earbuds => "buds",
223+ Page::Settings => "app",
220224 }
221225 }
222226
@@ -443,7 +447,7 @@ struct App {
443447 }
444448
445449 impl App {
446− fn new(theme_file: Option<PathBuf>, font: Font, device: Option<BdAddr>, demo: bool) -> Self {
450+ fn new(theme_file: Option<PathBuf>, device: Option<BdAddr>, demo: bool) -> Self {
447451 let buds = if demo { buds::demo() } else { buds::unknown() };
448452 let anc_level = buds
449453 .status
@@ -456,10 +460,7 @@ impl App {
456460 page: Page::Overview,
457461 buds,
458462 anc_level,
459− look: Look {
460− p: theme::BUILT_IN,
461− font,
462− },
463+ look: Look { p: theme::BUILT_IN },
463464 theme: theme::iced_theme(&theme::BUILT_IN),
464465 palette: theme::Source::new(theme_file),
465466 keys: keys::Keys::default(),
@@ -1328,17 +1329,17 @@ impl App {
13281329 }
13291330 page => column![
13301331 ui::title(look, page.title()),
1331− text("This page comes in a later step.").size(14).color(look.p.dim)
1332+ container(ui::note(look, "Connect your buds to see this page.")).padding([0, 18])
13321333 ]
1333− .spacing(12)
1334+ .spacing(16)
13341335 .into(),
13351336 };
1336− let page = column![self.problem(), page].spacing(18);
1337+ let page = column![self.problem(), page].spacing(20);
13371338 let p = look.p;
13381339 let content = scrollable(
13391340 container(container(page).max_width(860))
13401341 .center_x(Length::Fill)
1341− .padding([30, 36]),
1342+ .padding([34, 36]),
13421343 )
13431344 .id(SCROLL)
13441345 .height(Length::Fill)
@@ -1347,12 +1348,18 @@ impl App {
13471348 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
13481349 rail.background = None;
13491350 rail.border = Border::default();
1350− rail.scroller.background = p.surface_highest.into();
1351+ rail.scroller.background = p.s3.into();
13511352 rail.scroller.border = Border::default().rounded(4);
13521353 }
13531354 style
13541355 });
1355− let window = row![self.sidebar(), ui::divider(look), content];
1356+ let window = container(row![self.sidebar(), content])
1357+ .width(Length::Fill)
1358+ .height(Length::Fill)
1359+ .style(move |_| container::Style {
1360+ background: Some(p.bg.into()),
1361+ ..container::Style::default()
1362+ });
13561363 if self.help {
13571364 stack![window, self.help()].into()
13581365 } else if self.confirm_find {
@@ -1387,18 +1394,24 @@ impl App {
13871394 "budctl looks for the paired buds it supports and reads them.",
13881395 ),
13891396 };
1390− let retry = matches!(self.link, Link::Failed { .. })
1391− .then(|| container(ui::outline_button(look, "Try again", Message::Refresh)).width(180));
1397+ let failed = matches!(self.link, Link::Failed { .. });
1398+ let retry = failed.then(|| container(ui::wide_button(look, "Try again", Message::Refresh)).width(180));
1399+ let state = if failed {
1400+ row![ui::dot(p.err, 10.0), ui::label(look, "no link", 11.0, p.err)]
1401+ } else {
1402+ row![ui::dot(p.live, 10.0), ui::label(look, "searching", 11.0, p.live)]
1403+ };
13921404 let words = column![
1393− text(headline).size(20).font(look.bold()).color(p.fg),
1394− text(detail).size(14).color(p.dim)
1405+ state.spacing(10).align_y(Alignment::Center),
1406+ ui::headline(look, headline),
1407+ ui::note(look, detail)
13951408 ]
1396− .spacing(8);
1409+ .spacing(10);
13971410 column![
1398− ui::title(look, "Overview"),
1399− ui::panel(look, column![words].push(retry).spacing(18).into())
1411+ ui::title(look, "overview · battery, noise control, sound"),
1412+ ui::panel(look, column![words].push(retry).spacing(22).into())
14001413 ]
1401− .spacing(18)
1414+ .spacing(20)
14021415 .into()
14031416 }
14041417
@@ -1412,22 +1425,19 @@ impl App {
14121425 let look = self.look;
14131426 let p = look.p;
14141427 let content = row![
1415− text(message.as_str()).size(14).color(p.err).width(Length::Fill),
1416− container(ui::outline_button(look, "Try again", Message::Refresh)).width(140)
1428+ ui::dot(p.err, 10.0),
1429+ container(text(message.as_str()).size(14).color(p.err)).width(Length::Fill),
1430+ container(ui::wide_button(look, "Try again", Message::Refresh)).width(150)
14171431 ]
1418− .spacing(16)
1432+ .spacing(14)
14191433 .align_y(Alignment::Center);
14201434 Some(
14211435 container(content)
1422− .padding([12, 16])
1436+ .padding([12, 22])
14231437 .width(Length::Fill)
14241438 .style(move |_| container::Style {
1425− background: Some(p.surface.into()),
1426− border: Border {
1427− color: p.err,
1428− width: 1.0,
1429− radius: 14.0.into(),
1430− },
1439+ background: Some(p.s1.into()),
1440+ border: Border::default().rounded(999),
14311441 ..container::Style::default()
14321442 })
14331443 .into(),
@@ -1438,138 +1448,89 @@ impl App {
14381448 let look = self.look;
14391449 let p = look.p;
14401450 let (light, state) = match &self.link {
1441− Link::Demo | Link::Live => (p.ok, "Connected"),
1442− Link::Connecting => (p.dim, "Connecting…"),
1443− Link::Failed { kind: Kind::Busy, .. } => (p.err, "Busy"),
1444− Link::Failed { .. } if !self.buds.connected => (p.dim, "Not connected"),
1445− Link::Failed { .. } => (p.err, "Error"),
1451+ Link::Demo | Link::Live => (p.fill, "connected"),
1452+ Link::Connecting => (p.live, "connecting…"),
1453+ Link::Failed { kind: Kind::Busy, .. } => (p.err, "busy"),
1454+ Link::Failed { .. } if !self.buds.connected => (p.mute, "not connected"),
1455+ Link::Failed { .. } => (p.err, "error"),
14461456 };
1447− let device = container(
1448− column![
1449− text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
1450− row![ui::light(light), text(state).size(13).color(p.dim), space::horizontal()]
1451− .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
1452− .spacing(8)
1453− .align_y(Alignment::Center),
1454− ui::outline_button(look, "Refresh", Message::Refresh),
1457+ let device = column![
1458+ text(self.buds.name.as_str()).size(18).font(look.semibold()).color(p.fg),
1459+ row![
1460+ ui::dot(light, 8.0),
1461+ ui::label(look, state, 11.0, p.grey),
1462+ space::horizontal()
14551463 ]
1456− .spacing(12),
1457− )
1458− .padding(16)
1459− .style(move |_| container::Style {
1460− background: Some(p.surface.into()),
1461− border: Border {
1462− color: p.line,
1463− width: 1.0,
1464− radius: 18.0.into(),
1465− },
1466− ..container::Style::default()
1467− });
1468− let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
1469− pages.push(ui::nav_item(
1470− look,
1471− page.icon(),
1472− page.title(),
1473− page == self.page,
1474− Message::Open(page),
1475− ))
1464+ .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
1465+ .spacing(8)
1466+ .align_y(Alignment::Center),
1467+ ui::wide_button(look, "Refresh", Message::Refresh),
1468+ ]
1469+ .spacing(14)
1470+ .padding([0, 10]);
1471+ let pages = Page::ALL.into_iter().fold(Column::new().spacing(2), |pages, page| {
1472+ pages.push(ui::nav_item(look, page.word(), page == self.page, Message::Open(page)))
14761473 });
1477− let hint = text("Press ? for keys").size(12.5).color(p.dim);
1478− container(column![device, pages, space::vertical(), hint].spacing(22))
1479− .padding([18, 14])
1480− .width(268)
1474+ let hint = container(ui::label(look, "? keys · q quit", 10.5, p.grey)).padding([0, 10]);
1475+ container(column![device, pages, space::vertical(), hint].spacing(34))
1476+ .padding([34, 18])
1477+ .width(272)
14811478 .height(Length::Fill)
1482− .style(move |_| container::Style {
1483− background: Some(p.surface_low.into()),
1484− ..container::Style::default()
1485− })
14861479 .into()
14871480 }
14881481
1489− /// "Ring the buds?", over a dimmed window: the sound is loud enough to hurt in the ears.
1490− fn confirm_find(&self) -> Element<'_, Message> {
1482+ /// A dialog over the dimmed window: heading, words, and Cancel beside the main action.
1483+ fn dialog<'a>(
1484+ &self,
1485+ heading: String,
1486+ words: &'a str,
1487+ answer: fn(bool) -> Message,
1488+ action: &'a str,
1489+ ) -> Element<'a, Message> {
14911490 let look = self.look;
1492− let p = look.p;
1493− let words = column![
1494− text("Ring your buds?").size(22).font(look.bold()).color(p.fg),
1495− text("Take them out of your ears first: the sound is loud. They ring until you press Stop.")
1496− .size(14)
1497− .color(p.dim),
1498− ]
1499− .spacing(8);
15001491 let buttons = row![
15011492 space::horizontal(),
1502− ui::pill(look, "Cancel", false, Message::ConfirmFind(false)),
1503− ui::pill(look, "Ring", true, Message::ConfirmFind(true)),
1493+ ui::pill(look, "Cancel", false, answer(false)),
1494+ ui::pill(look, action, true, answer(true)),
15041495 ]
15051496 .spacing(10);
1506− let dialog = ui::panel(look, column![words, buttons].spacing(22).into());
1507− let backdrop = container(container(dialog).width(480))
1508− .center(Length::Fill)
1509− .style(|_| container::Style {
1510− background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1511− ..container::Style::default()
1512− });
1513− opaque(backdrop)
1497+ let body = column![ui::headline(look, heading), ui::note(look, words)].spacing(10);
1498+ let dialog = ui::panel(look, column![body, buttons].spacing(26).into());
1499+ opaque(ui::scrim(look, dialog, 480.0))
1500+ }
1501+
1502+ /// "Ring the buds?": the sound is loud enough to hurt in the ears.
1503+ fn confirm_find(&self) -> Element<'_, Message> {
1504+ self.dialog(
1505+ "Ring your buds?".to_owned(),
1506+ "Take them out of your ears first: the sound is loud. They ring until you press Stop.",
1507+ Message::ConfirmFind,
1508+ "Ring",
1509+ )
15141510 }
15151511
1516− /// "Turn dual connection off?", over a dimmed window: with two devices connected, the buds may
1517− /// drop one.
1512+ /// "Turn dual connection off?": with two devices connected, the buds may drop one.
15181513 fn confirm_dual(&self) -> Element<'_, Message> {
1519− let look = self.look;
1520− let p = look.p;
1521− let words = column![
1522− text("Turn dual connection off?").size(22).font(look.bold()).color(p.fg),
1523− text("If two devices are connected to your buds, they may drop one of them.")
1524− .size(14)
1525− .color(p.dim),
1526− ]
1527− .spacing(8);
1528− let buttons = row![
1529− space::horizontal(),
1530− ui::pill(look, "Cancel", false, Message::ConfirmDual(false)),
1531− ui::pill(look, "Turn off", true, Message::ConfirmDual(true)),
1532− ]
1533− .spacing(10);
1534− let dialog = ui::panel(look, column![words, buttons].spacing(22).into());
1535− let backdrop = container(container(dialog).width(460))
1536− .center(Length::Fill)
1537− .style(|_| container::Style {
1538− background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1539− ..container::Style::default()
1540− });
1541− opaque(backdrop)
1514+ self.dialog(
1515+ "Turn dual connection off?".to_owned(),
1516+ "If two devices are connected to your buds, they may drop one of them.",
1517+ Message::ConfirmDual,
1518+ "Turn off",
1519+ )
15421520 }
15431521
1544− /// "Delete the preset?", over a dimmed window: its bands are lost.
1522+ /// "Delete the preset?": its bands are lost.
15451523 fn confirm_delete(&self, id: u8) -> Element<'_, Message> {
1546− let look = self.look;
1547− let p = look.p;
15481524 let name = self
15491525 .buds
15501526 .preset(id)
15511527 .map_or("this preset", |preset| preset.name.as_str());
1552− let words = column![
1553− text(format!("Delete {name}?")).size(22).font(look.bold()).color(p.fg),
1554− text("Its bands are lost. The buds keep their other presets.")
1555− .size(14)
1556− .color(p.dim),
1557− ]
1558− .spacing(8);
1559− let buttons = row![
1560− space::horizontal(),
1561− ui::pill(look, "Cancel", false, Message::ConfirmDelete(false)),
1562− ui::pill(look, "Delete", true, Message::ConfirmDelete(true)),
1563− ]
1564− .spacing(10);
1565− let dialog = ui::panel(look, column![words, buttons].spacing(22).into());
1566− let backdrop = container(container(dialog).width(460))
1567− .center(Length::Fill)
1568− .style(|_| container::Style {
1569− background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1570− ..container::Style::default()
1571− });
1572− opaque(backdrop)
1528+ self.dialog(
1529+ format!("Delete {name}?"),
1530+ "Its bands are lost. The buds keep their other presets.",
1531+ Message::ConfirmDelete,
1532+ "Delete",
1533+ )
15731534 }
15741535
15751536 /// The dialog that asks for a preset's name, over a dimmed window.
@@ -1588,60 +1549,56 @@ impl App {
15881549 .on_input(Message::NameInput)
15891550 .on_submit(Message::SubmitName)
15901551 .size(16)
1591− .padding([10, 14])
1552+ .padding([12, 20])
15921553 .style(move |_, _| text_input::Style {
1593− background: p.surface_high.into(),
1554+ background: p.s2.into(),
15941555 border: Border {
1595− color: p.accent,
1556+ color: p.fill,
15961557 width: 1.0,
1597− radius: 10.0.into(),
1558+ radius: 999.0.into(),
15981559 },
1599− icon: p.dim,
1600− placeholder: p.dim,
1560+ icon: p.grey,
1561+ placeholder: p.grey,
16011562 value: p.fg,
1602− selection: p.accent_dim,
1563+ selection: p.container,
16031564 });
16041565 let problem = prompt
16051566 .problem
16061567 .as_deref()
1607− .map(|problem| text(problem).size(13).color(p.err));
1568+ .map(|problem| container(text(problem).size(13).color(p.err)).padding([0, 20]));
16081569 let buttons = row![
16091570 space::horizontal(),
16101571 ui::pill(look, "Cancel", false, Message::CancelName),
16111572 ui::pill(look, action, true, Message::SubmitName),
16121573 ]
16131574 .spacing(10);
1614− let dialog = column![text(heading).size(22).font(look.bold()).color(p.fg), field]
1575+ let dialog = column![ui::headline(look, heading), field]
16151576 .push(problem)
16161577 .push(buttons)
1617− .spacing(16);
1618− let backdrop = container(container(ui::panel(look, dialog.into())).width(460))
1619− .center(Length::Fill)
1620− .style(|_| container::Style {
1621− background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1622− ..container::Style::default()
1623− });
1624− opaque(backdrop)
1578+ .spacing(18);
1579+ opaque(ui::scrim(look, ui::panel(look, dialog.into()), 480.0))
16251580 }
16261581
16271582 /// The list of keys, over a dimmed window. A key or a click closes it.
16281583 fn help(&self) -> Element<'_, Message> {
16291584 let look = self.look;
16301585 let p = look.p;
1631− let rows = keys::HELP.iter().fold(Column::new().spacing(9), |rows, &(keys, what)| {
1632− rows.push(row![
1633− text(keys).size(14).font(look.medium()).color(p.fg).width(170),
1634− text(what).size(14).color(p.dim)
1635− ])
1636− });
1637− let list = ui::panel(look, column![ui::title(look, "Keys"), rows].spacing(18).into());
1638− let backdrop = container(container(list).width(560))
1639− .center(Length::Fill)
1640− .style(|_| container::Style {
1641− background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1642− ..container::Style::default()
1586+ let rows = keys::HELP
1587+ .iter()
1588+ .fold(Column::new().spacing(10), |rows, &(keys, what)| {
1589+ rows.push(
1590+ row![
1591+ text(keys).size(13).font(look.mono()).color(p.fg).width(180),
1592+ text(what).size(14).color(p.fg_dim)
1593+ ]
1594+ .align_y(Alignment::Center),
1595+ )
16431596 });
1644− opaque(mouse_area(backdrop).on_press(Message::CloseHelp))
1597+ let heading = row![ui::num(look, "keys", 40.0, p.fg), ui::dot(p.live, 12.0)]
1598+ .spacing(12)
1599+ .align_y(Alignment::Center);
1600+ let list = ui::panel(look, column![heading, rows].spacing(22).into());
1601+ opaque(mouse_area(ui::scrim(look, list, 580.0)).on_press(Message::CloseHelp))
16451602 }
16461603 }
16471604
@@ -1683,12 +1640,12 @@ mod tests {
16831640
16841641 /// With the made-up buds.
16851642 fn app() -> App {
1686− App::new(None, Font::DEFAULT, None, true)
1643+ App::new(None, None, true)
16871644 }
16881645
16891646 /// Talking to a worker that the test plays: the receiver gets what the app sends it.
16901647 fn live() -> (App, Receiver<Command>) {
1691− let mut app = App::new(None, Font::DEFAULT, None, false);
1648+ let mut app = App::new(None, None, false);
16921649 let (worker, commands) = mpsc::channel();
16931650 let _ = app.update(Message::Worker(Event::Ready(worker)));
16941651 (app, commands)
@@ -2361,10 +2318,9 @@ mod tests {
23612318 fn pages_render() {
23622319 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
23632320 let _ = fs::remove_dir_all(&dir);
2364− let font = theme::system_font();
23652321 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
23662322 let on = |page: Page, keys: &str| {
2367− let mut app = App::new(palette.clone(), font, None, true);
2323+ let mut app = App::new(palette.clone(), None, true);
23682324 app.page = page;
23692325 press(&mut app, keys);
23702326 app
@@ -2400,10 +2356,10 @@ mod tests {
24002356 let mut deleting = on(Page::Equalizer, "");
24012357 let _ = deleting.update(Message::DeletePreset(4));
24022358 shots.push(("delete-confirm".to_owned(), 800.0, deleting));
2403− let mut connecting = App::new(palette.clone(), font, None, false);
2359+ let mut connecting = App::new(palette.clone(), None, false);
24042360 connecting.link = Link::Connecting;
24052361 shots.push(("connecting".to_owned(), 800.0, connecting));
2406− let mut busy = App::new(palette.clone(), font, None, false);
2362+ let mut busy = App::new(palette.clone(), None, false);
24072363 busy.answer(Event::Failed {
24082364 kind: Kind::Busy,
24092365 message: "control channel busy: is another app connected?".to_owned(),
@@ -2418,7 +2374,7 @@ mod tests {
24182374 let _ = saving.update(Message::Switch("spatial", false));
24192375 let _ = saving.update(Message::Noise(NoiseMode::Off));
24202376 shots.push(("saving".to_owned(), 1240.0, saving));
2421− render(&dir, font, shots);
2377+ render(&dir, shots);
24222378 }
24232379
24242380 /// The README's screenshots: the demo buds in the built-in palette, written to
@@ -2429,9 +2385,8 @@ mod tests {
24292385 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
24302386 let scratch = root.join("target/readme-screenshots");
24312387 let _ = fs::remove_dir_all(&scratch);
2432− let font = theme::system_font();
24332388 let on = |page: Page, keys: &str| {
2434− let mut app = App::new(None, font, None, true);
2389+ let mut app = App::new(None, None, true);
24352390 app.page = page;
24362391 press(&mut app, keys);
24372392 app
@@ -2446,7 +2401,7 @@ mod tests {
24462401 ("keys".to_owned(), 800.0, on(Page::Overview, "?")),
24472402 ];
24482403 let names: Vec<String> = shots.iter().map(|(name, _, _)| name.clone()).collect();
2449− render(&scratch, font, shots);
2404+ render(&scratch, shots);
24502405 let out = root.join("docs/screenshots");
24512406 fs::create_dir_all(&out).unwrap();
24522407 for name in names {
@@ -2459,10 +2414,11 @@ mod tests {
24592414 }
24602415
24612416 /// Renders each (name, window height, app) 1040 px wide, to `<dir>/<name>-tiny-skia.png`.
2462− fn render(dir: &Path, font: Font, shots: Vec<(String, f32, App)>) {
2417+ fn render(dir: &Path, shots: Vec<(String, f32, App)>) {
24632418 for (name, height, app) in shots {
24642419 let settings = iced::Settings {
2465− default_font: font,
2420+ default_font: theme::UI,
2421+ fonts: theme::FONTS.into_iter().map(Into::into).collect(),
24662422 ..iced::Settings::default()
24672423 };
24682424 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
M crates/budctl-gui/src/overview.rs View
@@ -1,13 +1,13 @@
11 //! The Overview page: battery, noise control, the switches, and the way to the other pages.
22
33 use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch};
4−use iced::alignment::Horizontal;
5−use iced::widget::{column, container, row, slider, text};
6−use iced::{Alignment, Border, Element, Length};
4+use iced::widget::{column, container, row, stack};
5+use iced::{Alignment, Element, Length};
76
87 use crate::buds::Buds;
8+use crate::dots;
99 use crate::equalizer;
10−use crate::icons::{self, Icon, Part, RingColors};
10+use crate::icons::{self, Icon};
1111 use crate::theme::Look;
1212 use crate::ui::{self, Segment};
1313 use crate::{Item, Marks, Message, Page};
@@ -45,58 +45,52 @@ pub fn view<'a>(
4545 bass: Option<i8>,
4646 marks: Marks,
4747 ) -> Element<'a, Message> {
48− column![ui::title(look, "Overview"), battery(look, buds)]
49− .push(
50− buds.status
51− .noise_control
52− .as_ref()
53− .map(|now| noise_control(look, &now.mode, anc_level, marks)),
54− )
55− .push(switches(look, buds, bass, marks))
56− .push(more(look, buds, marks.focus))
57− .spacing(18)
58− .into()
48+ column![
49+ ui::title(look, "overview · battery, noise control, sound"),
50+ battery(look, buds)
51+ ]
52+ .push(
53+ buds.status
54+ .noise_control
55+ .as_ref()
56+ .map(|now| noise_control(look, &now.mode, anc_level, marks)),
57+ )
58+ .push(switches(look, buds, bass, marks))
59+ .push(more(look, buds, marks.focus))
60+ .spacing(18)
61+ .into()
5962 }
6063
6164 fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
6265 let p = look.p;
63− let colors = RingColors {
64− track: p.surface_highest,
65− charge: p.accent,
66− part: p.fg,
67− tip: p.dim,
68− cutout: p.surface,
69− };
70− let gauge = |part, label: &'a str, charge: Option<Charge>| -> Element<'a, Message> {
71− let reading = match charge {
72− Some(charge) => format!("{}%", charge.level),
73− None => "—".to_owned(),
66+ let gauge = |label: &str, charge: Option<Charge>| -> Element<'a, Message> {
67+ let figure = match charge {
68+ Some(charge) => ui::num(look, &charge.level.to_string(), 44.0, p.fg),
69+ None => ui::num(look, "--", 44.0, p.mute),
7470 };
75− let figure = row![text(reading).size(21).font(look.bold()).color(p.fg)]
76− .push(
77− charge
78− .filter(|c| c.charging)
79− .map(|_| icons::icon(Icon::Bolt, 16.0, p.icon)),
80− )
81− .spacing(4)
71+ let ring = stack![
72+ dots::ring(charge.map(|c| f32::from(c.level) / 100.0), 150.0, p.fill, p.s3),
73+ container(figure).center(150),
74+ ];
75+ let charging = charge.is_some_and(|c| c.charging);
76+ let caption = row![ui::label(look, label, 11.0, p.grey)]
77+ .push(charging.then(|| icons::icon(Icon::Bolt, 14.0, p.live)))
78+ .push(charging.then(|| ui::label(look, "charging", 11.0, p.live)))
79+ .spacing(6)
8280 .align_y(Alignment::Center);
83− column![
84− icons::battery_ring(part, charge.map(|c| c.level), 112.0, colors),
85− figure,
86− text(label).size(13).color(p.dim)
87− ]
88− .spacing(6)
89− .align_x(Alignment::Center)
90− .width(Length::Fill)
91− .into()
81+ column![ring, caption]
82+ .spacing(14)
83+ .align_x(Alignment::Center)
84+ .width(Length::Fill)
85+ .into()
9286 };
9387 let battery = buds.status.battery;
9488 let gauges = row![
95− gauge(Part::Left, "Left", battery.left),
96− gauge(Part::Case, "Case", battery.case),
97− gauge(Part::Right, "Right", battery.right),
89+ gauge("left", battery.left),
90+ gauge("case", battery.case),
91+ gauge("right", battery.right),
9892 ];
99− ui::card(look, vec![container(gauges).padding([22, 8]).into()])
93+ ui::card(look, vec![container(gauges).padding([26, 8]).into()])
10094 }
10195
10296 fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel, marks: Marks) -> Element<'a, Message> {
@@ -129,15 +123,15 @@ fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel, marks: Marks)
129123 })
130124 .collect();
131125 let strength = row![
132− text("Strength").size(13.5).color(look.p.dim).width(Length::Fill),
133− container(ui::segmented(look, levels, false, focus == Some(Item::AncLevel))).width(300)
126+ container(ui::label(look, "strength", 11.0, look.p.grey)).width(Length::Fill),
127+ container(ui::segmented(look, levels, false, focus == Some(Item::AncLevel))).width(340)
134128 ]
135129 .align_y(Alignment::Center)
136− .padding([0, 8]);
130+ .padding([0, 18]);
137131 let saving = marks
138132 .pending
139133 .contains(&Item::NoiseMode)
140− .then(|| container(text(SAVING).size(13).color(look.p.icon)).center_x(Length::Fill));
134+ .then(|| container(ui::label(look, SAVING, 11.0, look.p.live)).center_x(Length::Fill));
141135 column![ui::segmented(look, segments, true, focus == Some(Item::NoiseMode))]
142136 .push(saving)
143137 .push((mode == NoiseMode::Anc(anc_level).name()).then_some(strength))
@@ -202,41 +196,23 @@ fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: M
202196 } else {
203197 "Last set here: the buds do not report it"
204198 };
205− let shown = level.map_or_else(|| "Not set".to_owned(), equalizer::gain);
206− let slider = slider(
207− i16::from(low)..=i16::from(high),
208− i16::from(level.unwrap_or(0)),
209− |level| Message::Bass {
199+ let shown = level.map_or_else(|| "not set".to_owned(), equalizer::gain);
200+ let slider = dots::Slider {
201+ low: i16::from(low),
202+ high: i16::from(high),
203+ value: i16::from(level.unwrap_or(0)),
204+ vertical: false,
205+ change: |level, save| Message::Bass {
210206 level: i8::try_from(level).unwrap_or(0),
211− save: false,
207+ save,
212208 },
213− )
214− .on_release(Message::SaveBass)
215− .width(200)
216− .style(move |_, _| slider::Style {
217− rail: slider::Rail {
218− backgrounds: (p.accent.into(), p.surface_highest.into()),
219− width: 6.0,
220− border: Border::default().rounded(3),
221− },
222− handle: slider::Handle {
223− shape: slider::HandleShape::Circle { radius: 9.0 },
224− background: p.fg.into(),
225− border_width: 2.0,
226− border_color: p.accent,
227− },
228− });
229− let control = row![
230− text(shown)
231− .size(14)
232− .font(look.medium())
233− .color(p.dim)
234− .width(64)
235− .align_x(Horizontal::Right),
236− slider
237− ]
238− .spacing(14)
239− .align_y(Alignment::Center);
209+ release: Message::SaveBass,
210+ on: p.fill,
211+ off: p.s3,
212+ };
213+ let control = row![container(ui::value(look, &shown)).align_right(76), slider.view(200.0)]
214+ .spacing(14)
215+ .align_y(Alignment::Center);
240216 ui::setting(
241217 look,
242218 Icon::Bass,
M crates/budctl-gui/src/settings.rs View
@@ -3,7 +3,7 @@
33 use std::path::Path;
44
55 use iced::Element;
6−use iced::widget::{column, space, text};
6+use iced::widget::{column, container, space};
77
88 use crate::Message;
99 use crate::icons::Icon;
@@ -17,7 +17,6 @@ pub struct Colors<'a> {
1717 }
1818
1919 pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> {
20− let p = look.p;
2120 let source = match (colors.file, colors.problem) {
2221 (_, Some(problem)) => format!("Built in: {problem}"),
2322 (Some(file), None) if file.exists() => format!("From {}", file.display()),
@@ -25,13 +24,14 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> {
2524 };
2625 let none = || -> Element<'a, Message> { space().into() };
2726 let palette = ui::setting(look, Icon::Settings, "Colors", Some(source), none(), None, false);
28− let how = text(
29− "budctl follows a matugen palette with Aurora's keys (bg, fg, primary, surface…): the file given \
30− with --theme, else ~/.config/budctl/theme.json, which starts out with the built-in colors. \
31− It is read again whenever the window gets focus.",
32− )
33− .size(13)
34− .color(p.dim);
27+ let how = container(ui::note(
28+ look,
29+ "budctl follows a matugen palette with Aurora's Material 3 keys (primary, tertiary, surfaceLow, \
30+ outline…), so ~/.cache/matugen/aurora.json works as it is: the file given with --theme, else \
31+ ~/.config/budctl/theme.json, which starts out with the built-in colors. It is read again \
32+ whenever the window gets focus.",
33+ ))
34+ .padding([0, 18]);
3535 let keys = ui::setting(
3636 look,
3737 Icon::Keys,
@@ -69,7 +69,7 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> {
6969 false,
7070 );
7171 column![
72− ui::title(look, "App settings"),
72+ ui::title(look, "app settings · colors, keys, files"),
7373 column![ui::card(look, vec![palette]), how].spacing(10),
7474 ui::card(look, vec![keys]),
7575 ui::card(look, vec![files, log]),
M crates/budctl-gui/src/theme.rs View
@@ -1,112 +1,116 @@
1−//! Colors and fonts. The colors are a built-in dark palette, or one read from a matugen JSON file.
1+//! Colors and fonts: Nothing OS's shapes and type, in Material You colors.
22 //!
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.
3+//! The colors are a built-in palette (near-black, white "on" states, one red for live things), or
4+//! one read from a matugen JSON file. The file uses the keys of the owner's Aurora palette
5+//! (`~/.cache/matugen/aurora.json`: Material 3 roles, `on_x` spelled `xFg`), so that file works as
6+//! it is, and the roles map as in Aurora's `Theme.qml`. A key the file lacks keeps its built-in
7+//! color. The fonts ship inside the binary (`fonts/`, OFL).
68
9+use std::collections::HashMap;
710 use std::fs;
811 use std::path::{Path, PathBuf};
9−use std::process::Command;
1012 use std::time::SystemTime;
1113
12−use iced::font::Weight;
14+use iced::font::{Family, Weight};
1315 use iced::{Color, Font};
14−use serde::Deserialize;
1516
16−/// The colors the UI uses, by role.
17+/// The colors the UI uses, by role: a ladder of surfaces, and the "on" states on top.
1718 #[derive(Debug, Clone, Copy, PartialEq)]
1819 pub struct Palette {
19− /// Window background.
20+ /// Window background (`surfaceLowest`).
2021 pub bg: Color,
21− /// Text.
22+ /// Cards and dialogs (`surfaceLow`).
23+ pub s1: Color,
24+ /// Icon circles, tracks, inputs, the switches' and pills' "off" fill (`surfaceHigh`).
25+ pub s2: Color,
26+ /// Unlit dots, hovered rows (`surfaceHighest`).
27+ pub s3: Color,
28+ /// Inactive things: the pages not open, an off switch's knob (`outlineVariant`).
29+ pub mute: Color,
30+ /// Caps labels (`outline`).
31+ pub grey: Color,
32+ /// Text (`surfaceFg`).
2233 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,
34+ /// Secondary text (`surfaceVariantFg`).
35+ pub fg_dim: Color,
36+ /// What is on or selected: switches, the chosen segment, lit dots, the cursor (`primary`).
37+ pub fill: Color,
38+ /// Text and icons on `fill` (`primaryFg`).
39+ pub fill_fg: Color,
40+ /// Tonal emphasis: text selection (`primaryContainer`).
41+ pub container: Color,
42+ pub container_fg: Color,
43+ /// Live things, one at a time: the open page's dot, "Saving…", ringing, charging (`tertiary`).
44+ pub live: Color,
45+ pub live_fg: Color,
4446 pub err: Color,
45− /// "Connected". matugen has no such role, so it stays green.
46− pub ok: Color,
47+ pub err_fg: Color,
4748 }
4849
49−/// Dark, with a red accent.
50+/// Near-black surfaces, white "on" states and a red for live things.
5051 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),
52+ bg: Color::from_rgb8(0x00, 0x00, 0x00),
53+ s1: Color::from_rgb8(0x11, 0x11, 0x12),
54+ s2: Color::from_rgb8(0x1C, 0x1C, 0x1E),
55+ s3: Color::from_rgb8(0x2A, 0x2A, 0x2D),
56+ mute: Color::from_rgb8(0x45, 0x45, 0x49),
57+ grey: Color::from_rgb8(0x8E, 0x8E, 0x93),
58+ fg: Color::from_rgb8(0xF2, 0xF2, 0xF2),
59+ fg_dim: Color::from_rgb8(0xB8, 0xB8, 0xBC),
60+ fill: Color::from_rgb8(0xF2, 0xF2, 0xF2),
61+ fill_fg: Color::from_rgb8(0x00, 0x00, 0x00),
62+ container: Color::from_rgb8(0x3A, 0x3A, 0x3E),
63+ container_fg: Color::from_rgb8(0xF2, 0xF2, 0xF2),
64+ live: Color::from_rgb8(0xD7, 0x19, 0x21),
65+ live_fg: Color::from_rgb8(0xFF, 0xFF, 0xFF),
6366 err: Color::from_rgb8(0xFF, 0xB4, 0xAB),
64− ok: Color::from_rgb8(0x4C, 0xC3, 0x6A),
67+ err_fg: Color::from_rgb8(0x69, 0x00, 0x05),
6568 };
6669
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−}
70+/// Where in the palette a key's color goes.
71+type Role = fn(&mut Palette) -> &mut Color;
72+
73+/// The palette file's keys, with each role's key and where it goes. `bg` and `fg` are the older
74+/// names of `surfaceLowest` and `surfaceFg`, still read. Other keys (`image`, `secondary`…) are
75+/// ignored.
76+const KEYS: [(&str, Option<&str>, Role); 16] = [
77+ ("surfaceLowest", Some("bg"), |p| &mut p.bg),
78+ ("surfaceLow", None, |p| &mut p.s1),
79+ ("surfaceHigh", None, |p| &mut p.s2),
80+ ("surfaceHighest", None, |p| &mut p.s3),
81+ ("outlineVariant", None, |p| &mut p.mute),
82+ ("outline", None, |p| &mut p.grey),
83+ ("surfaceFg", Some("fg"), |p| &mut p.fg),
84+ ("surfaceVariantFg", None, |p| &mut p.fg_dim),
85+ ("primary", None, |p| &mut p.fill),
86+ ("primaryFg", None, |p| &mut p.fill_fg),
87+ ("primaryContainer", None, |p| &mut p.container),
88+ ("primaryContainerFg", None, |p| &mut p.container_fg),
89+ ("tertiary", None, |p| &mut p.live),
90+ ("tertiaryFg", None, |p| &mut p.live_fg),
91+ ("err", None, |p| &mut p.err),
92+ ("errFg", None, |p| &mut p.err_fg),
93+];
8594
8695 /// A palette from the text of a palette file.
8796 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− })
97+ let file: HashMap<String, serde_json::Value> = serde_json::from_str(json).map_err(|e| e.to_string())?;
98+ let mut palette = BUILT_IN;
99+ for (key, old, role) in KEYS {
100+ let found = [Some(key), old]
101+ .into_iter()
102+ .flatten()
103+ .find_map(|name| file.get(name).map(|value| (name, value)));
104+ let Some((name, value)) = found else {
105+ continue;
106+ };
107+ let color = value
108+ .as_str()
109+ .and_then(parse_hex)
110+ .ok_or_else(|| format!("{name}: {value} is not a #rrggbb color"))?;
111+ *role(&mut palette) = color;
112+ }
113+ Ok(palette)
110114 }
111115
112116 /// `#rrggbb` or `#rrggbbaa`.
@@ -133,24 +137,10 @@ pub fn default_file() -> Option<PathBuf> {
133137 /// A palette as a palette file, with every key [`parse`] reads: the built-in one is written to
134138 /// [`default_file`] when there is none, to start a matugen template from.
135139 pub fn to_json(p: &Palette) -> String {
136− let keys = [
137− ("bg", p.bg),
138− ("fg", p.fg),
139− ("primary", p.accent),
140− ("primaryFg", p.accent_fg),
141− ("primaryContainer", p.accent_dim),
142− ("secondary", p.icon),
143− ("surfaceLow", p.surface_low),
144− ("surface", p.surface),
145− ("surfaceHigh", p.surface_high),
146− ("surfaceHighest", p.surface_highest),
147− ("outlineVariant", p.line),
148− ("surfaceVariantFg", p.dim),
149− ("err", p.err),
150− ];
151− let lines: Vec<String> = keys
140+ let mut p = *p;
141+ let lines: Vec<String> = KEYS
152142 .iter()
153− .map(|(key, color)| format!(" \"{key}\": \"{}\"", to_hex(*color)))
143+ .map(|(key, _, role)| format!(" \"{key}\": \"{}\"", to_hex(*role(&mut p))))
154144 .collect();
155145 format!("{{\n{}\n}}\n", lines.join(",\n"))
156146 }
@@ -202,43 +192,53 @@ impl Source {
202192 }
203193 }
204194
195+/// The fonts the binary carries, to load at start.
196+pub const FONTS: [&[u8]; 5] = [
197+ include_bytes!("../fonts/SpaceGrotesk-Regular.otf"),
198+ include_bytes!("../fonts/SpaceGrotesk-Medium.otf"),
199+ include_bytes!("../fonts/SpaceGrotesk-SemiBold.otf"),
200+ include_bytes!("../fonts/SpaceMono-Regular.ttf"),
201+ include_bytes!("../fonts/DotoRounded-Black.ttf"),
202+];
203+
204+/// UI text: titles semibold, body regular.
205+pub const UI: Font = Font::with_name("Space Grotesk");
206+/// Caps labels.
207+const MONO: Font = Font::with_name("Space Mono");
208+/// Numbers and short words, as round LED dots. Never under 26 px.
209+const DOT: Font = Font {
210+ family: Family::Name("Doto Rounded"),
211+ weight: Weight::Black,
212+ ..Font::DEFAULT
213+};
214+
205215 /// The palette and the fonts, handed to everything that draws.
206216 #[derive(Debug, Clone, Copy)]
207217 pub struct Look {
208218 pub p: Palette,
209− pub font: Font,
210219 }
211220
212221 impl Look {
213222 pub fn medium(self) -> Font {
214223 Font {
215224 weight: Weight::Medium,
216− ..self.font
225+ ..UI
217226 }
218227 }
219228
220− pub fn bold(self) -> Font {
229+ pub fn semibold(self) -> Font {
221230 Font {
222− weight: Weight::Bold,
223− ..self.font
231+ weight: Weight::Semibold,
232+ ..UI
224233 }
225234 }
226−}
227235
228−/// The desktop's sans-serif font, as fontconfig names it. iced's own default is a font many
229−/// systems lack.
230−pub fn system_font() -> Font {
231− let family = Command::new("fc-match")
232− .args(["--format", "%{family[0]}", "sans-serif"])
233− .output()
234− .ok()
235− .filter(|out| out.status.success())
236− .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
237− .filter(|name| !name.is_empty());
238− match family {
239− // Font names are &'static; this runs once.
240− Some(name) => Font::with_name(Box::leak(name.into_boxed_str())),
241− None => Font::DEFAULT,
236+ pub fn mono(self) -> Font {
237+ MONO
238+ }
239+
240+ pub fn dot(self) -> Font {
241+ DOT
242242 }
243243 }
244244
@@ -249,9 +249,9 @@ pub fn iced_theme(p: &Palette) -> iced::Theme {
249249 iced::theme::Palette {
250250 background: p.bg,
251251 text: p.fg,
252− primary: p.accent,
253− success: p.ok,
254− warning: p.accent,
252+ primary: p.fill,
253+ success: p.fill,
254+ warning: p.live,
255255 danger: p.err,
256256 },
257257 )
@@ -266,28 +266,40 @@ mod tests {
266266 let palette = parse(
267267 r##"{
268268 "image": "/some/wallpaper.jpg",
269− "bg": "#0f0c0a", "fg": "#ebe0dc",
270− "primary": "#7b4b2f", "primaryFg": "#fffaff", "primaryContainer": "#5c3823",
271− "secondary": "#e2bfae",
272− "surfaceLow": "#181311", "surface": "#1e1815", "surfaceHigh": "#2a211d",
273− "surfaceHighest": "#362a26", "outline": "#9f8d85", "outlineVariant": "#52443d",
274− "surfaceVariantFg": "#d6c3b9", "err": "#ffb4ab"
269+ "primary": "#d7c2be", "primaryFg": "#3b2d2b", "primaryContainer": "#52433f",
270+ "primaryContainerFg": "#f4ddd9", "secondary": "#d0c4c2",
271+ "tertiary": "#e0c38c", "tertiaryFg": "#3f2e04",
272+ "surfaceLowest": "#0f0e0e", "surfaceLow": "#1d1b1b", "surfaceContainer": "#211f1f",
273+ "surfaceHigh": "#2c2929", "surfaceHighest": "#373434", "surfaceFg": "#e7e1e0",
274+ "surfaceVariantFg": "#d2c3c1", "outline": "#9b8e8c", "outlineVariant": "#4e4443",
275+ "err": "#ffb4ab", "errFg": "#690005"
275276 }"##,
276277 )
277278 .unwrap();
278− assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a));
279− assert_eq!(palette.accent, Color::from_rgb8(0x7b, 0x4b, 0x2f));
280− assert_eq!(palette.accent_dim, Color::from_rgb8(0x5c, 0x38, 0x23));
281− assert_eq!(palette.icon, Color::from_rgb8(0xe2, 0xbf, 0xae));
282− assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d));
283− assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9));
284− assert_eq!(palette.ok, BUILT_IN.ok);
279+ assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e));
280+ assert_eq!(palette.s1, Color::from_rgb8(0x1d, 0x1b, 0x1b));
281+ assert_eq!(palette.s3, Color::from_rgb8(0x37, 0x34, 0x34));
282+ assert_eq!(palette.fill, Color::from_rgb8(0xd7, 0xc2, 0xbe));
283+ assert_eq!(palette.live, Color::from_rgb8(0xe0, 0xc3, 0x8c));
284+ assert_eq!(palette.grey, Color::from_rgb8(0x9b, 0x8e, 0x8c));
285+ assert_eq!(palette.mute, Color::from_rgb8(0x4e, 0x44, 0x43));
286+ assert_eq!(palette.err_fg, Color::from_rgb8(0x69, 0x00, 0x05));
287+ }
288+
289+ #[test]
290+ fn reads_the_older_names() {
291+ let palette = parse(r##"{"bg": "#0f0e0e", "fg": "#e7e1e0", "surface": "#211f1f"}"##).unwrap();
292+ assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e));
293+ assert_eq!(palette.fg, Color::from_rgb8(0xe7, 0xe1, 0xe0));
294+ // The newer name wins.
295+ let both = parse(r##"{"bg": "#000001", "surfaceLowest": "#000002"}"##).unwrap();
296+ assert_eq!(both.bg, Color::from_rgb8(0, 0, 2));
285297 }
286298
287299 #[test]
288300 fn missing_keys_keep_the_built_in_colors() {
289301 let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap();
290− assert_eq!(palette.accent, Color::from_rgba8(0x11, 0x22, 0x33, 0.8));
302+ assert_eq!(palette.fill, Color::from_rgba8(0x11, 0x22, 0x33, 0.8));
291303 assert_eq!(palette.bg, BUILT_IN.bg);
292304 assert_eq!(parse("{}").unwrap(), BUILT_IN);
293305 }
@@ -296,6 +308,7 @@ mod tests {
296308 fn bad_files_are_errors() {
297309 assert!(parse("not json").is_err());
298310 assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg"));
311+ assert!(parse(r#"{"primary": 3}"#).unwrap_err().contains("primary"));
299312 assert!(parse(r##"{"bg": "#12345"}"##).is_err());
300313 assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err());
301314 }
@@ -304,7 +317,7 @@ mod tests {
304317 fn the_written_palette_reads_back_the_same() {
305318 assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN);
306319 let see_through = Palette {
307− accent: Color::from_rgba8(0x11, 0x22, 0x33, 0.8),
320+ fill: Color::from_rgba8(0x11, 0x22, 0x33, 0.8),
308321 ..BUILT_IN
309322 };
310323 assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through);
M crates/budctl-gui/src/ui.rs View
@@ -1,83 +1,123 @@
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`].
1+//! The building blocks every page uses, in Nothing OS's shapes: caps labels, dot-matrix words,
2+//! round cards, rows with their icon in a circle, pill switches, pill tracks and chips.
3+//! Everything takes its colors from the [`Look`].
34
4−use iced::widget::{Column, Row, button, column, container, row, rule, space, text, toggler};
5−use iced::{Alignment, Border, Element, Length};
5+use iced::alignment::Horizontal;
6+use iced::widget::{Column, Row, button, column, container, row, space, text};
7+use iced::{Alignment, Border, Color, Element, Length};
68
79 use crate::Message;
810 use crate::icons::{self, Icon};
9−use crate::theme::Look;
11+use crate::theme::{Look, Palette};
12+
13+/// Round corners: cards, and the rows in them.
14+const CARD: f32 = 28.0;
15+/// Fully round: pills, chips and circles.
16+const ROUND: f32 = 999.0;
17+
18+/// A caps label in the monospace font, like "LEFT" or "BUILT IN".
19+pub fn label<'a>(look: Look, words: &str, size: f32, color: Color) -> Element<'a, Message> {
20+ text(words.to_uppercase())
21+ .size(size)
22+ .font(look.mono())
23+ .color(color)
24+ .into()
25+}
26+
27+/// Numbers and short words as LED dots. Never under 26 px: smaller, the dots blur.
28+pub fn num<'a>(look: Look, words: &str, size: f32, color: Color) -> Element<'a, Message> {
29+ text(words.to_uppercase())
30+ .size(size)
31+ .font(look.dot())
32+ .color(color)
33+ .into()
34+}
35+
36+/// A page's caption, at its top: "overview · battery, noise control, sound".
37+pub fn title<'a>(look: Look, caption: &str) -> Element<'a, Message> {
38+ container(label(look, caption, 12.0, look.p.grey))
39+ .padding([0, 18])
40+ .into()
41+}
42+
43+/// A dialog's heading.
44+pub fn headline<'a>(look: Look, words: impl text::IntoFragment<'a>) -> Element<'a, Message> {
45+ text(words).size(24).font(look.semibold()).color(look.p.fg).into()
46+}
1047
1148 /// A heading above a card.
12−pub fn heading<'a>(look: Look, words: &'a str) -> Element<'a, Message> {
13− text(words).size(15).font(look.medium()).color(look.p.dim).into()
49+pub fn heading<'a>(look: Look, words: &str) -> Element<'a, Message> {
50+ container(label(look, words, 11.0, look.p.grey)).padding([0, 18]).into()
1451 }
1552
16−/// A page title.
17−pub fn title<'a>(look: Look, title: &'a str) -> Element<'a, Message> {
18− text(title).size(30).font(look.bold()).color(look.p.fg).into()
53+/// Plain words under or beside a card.
54+pub fn note<'a>(look: Look, words: impl text::IntoFragment<'a>) -> Element<'a, Message> {
55+ text(words).size(13.5).color(look.p.fg_dim).into()
1956 }
2057
21−/// A rounded card holding rows, with a thin line between them.
58+/// A round card holding rows.
2259 pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Message> {
2360 let p = look.p;
24− let mut column = Column::new();
25− for (n, item) in rows.into_iter().enumerate() {
26− if n > 0 {
27− column = column.push(separator(look));
28− }
29− column = column.push(item);
30− }
31− container(column)
32− .padding(5)
61+ container(Column::with_children(rows).spacing(2))
62+ .padding(6)
3363 .width(Length::Fill)
34− .style(move |_| container::Style {
35− background: Some(p.surface.into()),
36− border: Border {
37− color: p.line,
38− width: 1.0,
39− radius: 18.0.into(),
40− },
41− ..container::Style::default()
42− })
64+ .style(move |_| surface(p.s1, CARD))
4365 .into()
4466 }
4567
46−/// A rounded panel like a card, around content of its own.
68+/// A round card around content of its own.
4769 pub fn panel<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> {
4870 let p = look.p;
4971 container(content)
5072 .padding(24)
5173 .width(Length::Fill)
74+ .style(move |_| surface(p.s1, CARD))
75+ .into()
76+}
77+
78+fn surface(color: Color, radius: f32) -> container::Style {
79+ container::Style {
80+ background: Some(color.into()),
81+ border: Border::default().rounded(radius),
82+ ..container::Style::default()
83+ }
84+}
85+
86+/// `content` in the middle of the window, `width` wide, over the dimmed page.
87+pub fn scrim<'a>(look: Look, content: Element<'a, Message>, width: f32) -> Element<'a, Message> {
88+ let bg = look.p.bg;
89+ container(container(content).width(width))
90+ .center(Length::Fill)
5291 .style(move |_| container::Style {
53− background: Some(p.surface.into()),
54− border: Border {
55− color: p.line,
56− width: 1.0,
57− radius: 18.0.into(),
58− },
92+ background: Some(Color { a: 0.74, ..bg }.into()),
5993 ..container::Style::default()
6094 })
6195 .into()
6296 }
6397
64−fn separator<'a>(look: Look) -> Element<'a, Message> {
65− let p = look.p;
66− container(rule::horizontal(1).style(move |_| rule::Style {
67− color: p.line,
68− radius: 0.0.into(),
69− fill_mode: rule::FillMode::Full,
70− snap: true,
71− }))
72− .padding([0, 14])
73− .into()
98+/// A setting row: icon in a circle, title, an optional subtitle, and something on the right.
99+/// With `on_press`, the whole row is the click target. `focused`: the keyboard cursor is on it.
100+///
101+/// A subtitle ending in "…" ("Saving…", "Starting…") says something is under way, and shows
102+/// in the live color.
103+pub fn setting<'a>(
104+ look: Look,
105+ icon: Icon,
106+ title: impl text::IntoFragment<'a>,
107+ subtitle: Option<String>,
108+ trailing: Element<'a, Message>,
109+ on_press: Option<Message>,
110+ focused: bool,
111+) -> Element<'a, Message> {
112+ list_row(look, icon, false, title, subtitle, trailing, on_press, focused)
74113 }
75114
76−/// A setting row: icon, title, an optional subtitle, and something on the right. With
77−/// `on_press`, the whole row is the click target. `focused`: the keyboard cursor is on it.
78−pub fn setting<'a>(
115+/// A [`setting`] row that can be `current`, like the preset in use: its circle is filled.
116+#[allow(clippy::too_many_arguments)]
117+pub fn list_row<'a>(
79118 look: Look,
80119 icon: Icon,
120+ current: bool,
81121 title: impl text::IntoFragment<'a>,
82122 subtitle: Option<String>,
83123 trailing: Element<'a, Message>,
@@ -85,31 +125,52 @@ pub fn setting<'a>(
85125 focused: bool,
86126 ) -> Element<'a, Message> {
87127 let p = look.p;
88− let words = column![text(title).size(15).font(look.medium()).color(p.fg)]
89− .push(subtitle.map(|s| text(s).size(13).color(p.dim)))
90− .spacing(2)
128+ let subtitle = subtitle.map(|words| {
129+ let color = if words.ends_with('…') { p.live } else { p.grey };
130+ label(look, &words, 10.5, color)
131+ });
132+ let words = column![text(title).size(15.5).font(look.medium()).color(p.fg)]
133+ .push(subtitle)
134+ .spacing(3)
91135 .width(Length::Fill);
92− let content = row![icons::icon(icon, 22.0, p.icon), words, trailing]
93− .spacing(16)
136+ let circle = if current {
137+ p.fill
138+ } else if focused {
139+ p.s3
140+ } else {
141+ p.s2
142+ };
143+ let glyph = if current { p.fill_fg } else { p.fg };
144+ let content = row![disc(icons::icon(icon, 20.0, glyph), 44.0, circle), words, trailing]
145+ .spacing(14)
94146 .align_y(Alignment::Center)
95− .padding([13, 16]);
147+ .padding([6, 14]);
148+ let content = container(content).padding([0, 6]);
96149 button(content)
97150 .on_press_maybe(on_press)
98151 .padding(0)
99152 .width(Length::Fill)
100153 .style(move |_, status| button::Style {
101− background: (focused || hovered(status)).then_some(p.surface_high.into()),
154+ background: (focused || hovered(status)).then_some(p.s3.into()),
102155 text_color: p.fg,
103− border: cursor(p, focused, 14.0),
156+ border: cursor(p, focused, CARD),
104157 ..button::Style::default()
105158 })
106159 .into()
107160 }
108161
109−/// The keyboard cursor: a 2 px accent frame. No frame without it.
110−fn cursor(p: crate::theme::Palette, focused: bool, radius: f32) -> Border {
162+/// `content` centered in a circle `size` across.
163+fn disc<'a>(content: Element<'a, Message>, size: f32, color: Color) -> Element<'a, Message> {
164+ container(content)
165+ .center(size)
166+ .style(move |_| surface(color, ROUND))
167+ .into()
168+}
169+
170+/// The keyboard cursor: a 2 px frame in the fill color. No frame without it.
171+fn cursor(p: Palette, focused: bool, radius: f32) -> Border {
111172 Border {
112− color: p.accent,
173+ color: p.fill,
113174 width: if focused { 2.0 } else { 0.0 },
114175 radius: radius.into(),
115176 }
@@ -119,28 +180,28 @@ fn hovered(status: button::Status) -> bool {
119180 matches!(status, button::Status::Hovered | button::Status::Pressed)
120181 }
121182
122−/// An on/off switch. It only shows the state: the row around it takes the click.
183+/// An on/off switch: off, a dark pill with a dim knob on the left; on, the fill runs to the
184+/// right end, with a dot in it. It only shows the state: the row around it takes the click.
123185 pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> {
124186 let p = look.p;
125− toggler(on)
126− .size(24)
127− .style(move |_, _| toggler::Style {
128− background: (if on { p.accent } else { p.surface_high }).into(),
129− background_border_width: 1.0,
130− background_border_color: if on { p.accent } else { p.line },
131− foreground: (if on { p.accent_fg } else { p.dim }).into(),
132− foreground_border_width: 0.0,
133− foreground_border_color: p.accent_fg,
134− text_color: None,
135− border_radius: None,
136− padding_ratio: 0.12,
137− })
187+ let knob = container(on.then(|| dot(p.fill_fg, 10.0)))
188+ .width(if on { Length::Fill } else { Length::Fixed(26.0) })
189+ .height(26)
190+ .align_x(Horizontal::Right)
191+ .align_y(Alignment::Center)
192+ .padding([0, 8])
193+ .style(move |_| surface(if on { p.fill } else { p.mute }, ROUND));
194+ container(knob)
195+ .width(64)
196+ .height(34)
197+ .padding(4)
198+ .style(move |_| surface(p.s2, ROUND))
138199 .into()
139200 }
140201
141202 /// A chevron: the row opens a page.
142203 pub fn chevron<'a>(look: Look) -> Element<'a, Message> {
143− icons::icon(Icon::Chevron, 18.0, look.p.dim)
204+ icons::icon(Icon::Chevron, 18.0, look.p.grey)
144205 }
145206
146207 /// One choice of a [`segmented`] control.
@@ -151,120 +212,97 @@ pub struct Segment<'a> {
151212 pub on_press: Message,
152213 }
153214
154−/// A pill of choices with the selected one filled, like the noise-control modes. `large` puts an
155−/// icon above each label. `focused`: the keyboard cursor is on it.
215+/// A one-of-n pill track: the chosen cell is filled. `large` puts an icon above each label.
216+/// `focused`: the keyboard cursor is on it.
156217 pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool, focused: bool) -> Element<'a, Message> {
157218 let p = look.p;
158219 let mut choices = Row::new().spacing(4);
159220 for segment in segments {
160221 let selected = segment.selected;
161− let color = if selected { p.accent_fg } else { p.dim };
162− let label = text(segment.label)
163− .size(if large { 13.0 } else { 13.5 })
164− .font(look.medium())
165− .color(color);
222+ let color = if selected { p.fill_fg } else { p.grey };
223+ let words = label(look, segment.label, if large { 11.5 } else { 11.0 }, color);
166224 let inner: Element<'a, Message> = match segment.icon {
167− Some(icon) if large => column![icons::icon(icon, 22.0, color), label]
168− .spacing(6)
225+ Some(icon) if large => column![icons::icon(icon, 22.0, if selected { p.fill_fg } else { p.fg }), words]
226+ .spacing(8)
169227 .align_x(Alignment::Center)
170228 .into(),
171− _ => label.into(),
229+ _ => words,
172230 };
173231 let choice = button(container(inner).center_x(Length::Fill))
174232 .on_press(segment.on_press)
175233 .width(Length::Fill)
176− .padding(if large { [11, 8] } else { [7, 12] })
234+ .padding(if large { [14, 8] } else { [10, 12] })
177235 .style(move |_, status| button::Style {
178236 background: if selected {
179− Some(p.accent.into())
237+ Some(p.fill.into())
180238 } else {
181− hovered(status).then_some(p.surface_high.into())
239+ hovered(status).then_some(p.s3.into())
182240 },
183241 text_color: color,
184− border: Border::default().rounded(999),
242+ border: Border::default().rounded(ROUND),
185243 ..button::Style::default()
186244 });
187245 choices = choices.push(choice);
188246 }
189247 container(choices)
190− .padding(5)
248+ .padding(6)
191249 .width(Length::Fill)
192250 .style(move |_| container::Style {
193− background: Some(p.surface.into()),
194− border: if focused {
195− cursor(p, true, 999.0)
196− } else {
197− Border {
198− color: p.line,
199− width: 1.0,
200− radius: 999.0.into(),
201− }
202− },
251+ background: Some(p.s2.into()),
252+ border: cursor(p, focused, ROUND),
203253 ..container::Style::default()
204254 })
205255 .into()
206256 }
207257
208−/// A page in the sidebar.
209−pub fn nav_item<'a>(look: Look, icon: Icon, label: &'a str, open: bool, on_press: Message) -> Element<'a, Message> {
258+/// A page in the sidebar: a dot-matrix word, large with the live dot when open.
259+pub fn nav_item<'a>(look: Look, word: &str, open: bool, on_press: Message) -> Element<'a, Message> {
210260 let p = look.p;
211− let (color, icon_color) = if open { (p.fg, p.icon) } else { (p.dim, p.dim) };
212− let content = row![
213− icons::icon(icon, 19.0, icon_color),
214− text(label)
215− .size(14.5)
216− .font(if open { look.medium() } else { look.font })
217− .color(color)
218− ]
219− .spacing(13)
220− .align_y(Alignment::Center);
261+ let content: Element<'a, Message> = if open {
262+ row![dot(p.live, 14.0), num(look, word, 40.0, p.fg)]
263+ .spacing(14)
264+ .align_y(Alignment::Center)
265+ .into()
266+ } else {
267+ row![space().width(14), num(look, word, 26.0, p.mute)]
268+ .spacing(14)
269+ .align_y(Alignment::Center)
270+ .into()
271+ };
221272 button(content)
222273 .on_press(on_press)
223274 .width(Length::Fill)
224− .padding([10, 14])
275+ .padding([6, 10])
225276 .style(move |_, status| button::Style {
226− background: if open {
227− Some(p.accent_dim.into())
228− } else {
229− hovered(status).then_some(p.surface_high.into())
230− },
231− text_color: color,
232− border: Border::default().rounded(12),
277+ background: (!open && hovered(status)).then_some(p.s1.into()),
278+ text_color: p.fg,
279+ border: Border::default().rounded(CARD),
233280 ..button::Style::default()
234281 })
235282 .into()
236283 }
237284
238−/// A small rounded button: outlined, or `filled` with the accent.
239−pub fn pill<'a>(look: Look, label: &'a str, filled: bool, on_press: Message) -> Element<'a, Message> {
240− pill_with(look, label, filled, false, on_press)
285+/// A chip: a caps label in a pill. `filled` with the fill color, for the main action, or on a
286+/// dark pill.
287+pub fn pill<'a>(look: Look, words: &str, filled: bool, on_press: Message) -> Element<'a, Message> {
288+ pill_with(look, words, filled, false, on_press)
241289 }
242290
243291 /// A [`pill`] that can carry the keyboard cursor.
244−pub fn pill_with<'a>(
245− look: Look,
246− label: &'a str,
247− filled: bool,
248− focused: bool,
249− on_press: Message,
250−) -> Element<'a, Message> {
292+pub fn pill_with<'a>(look: Look, words: &str, filled: bool, focused: bool, on_press: Message) -> Element<'a, Message> {
251293 let p = look.p;
252− let color = if filled { p.accent_fg } else { p.fg };
253− button(text(label).size(13.5).font(look.medium()).color(color))
294+ let color = if filled { p.fill_fg } else { p.fg };
295+ button(label(look, words, 11.0, color))
254296 .on_press(on_press)
255− .padding([7, 16])
297+ .padding([10, 18])
256298 .style(move |_, status| button::Style {
257− background: if filled {
258− Some(p.accent.into())
259− } else {
260− (focused || hovered(status)).then_some(p.surface_high.into())
261− },
299+ background: Some(match (filled, hovered(status)) {
300+ (true, _) => p.fill.into(),
301+ (false, true) => p.mute.into(),
302+ (false, false) => p.s3.into(),
303+ }),
262304 text_color: color,
263− border: Border {
264− color: if filled || focused { p.accent } else { p.line },
265− width: if focused { 2.0 } else { 1.0 },
266− radius: 999.0.into(),
267− },
305+ border: cursor(p, focused, ROUND),
268306 ..button::Style::default()
269307 })
270308 .into()
@@ -273,80 +311,61 @@ pub fn pill_with<'a>(
273311 /// A round button with just an icon, like the pencil on a preset's row.
274312 pub fn icon_button<'a>(look: Look, icon: Icon, focused: bool, on_press: Message) -> Element<'a, Message> {
275313 let p = look.p;
276− button(icons::icon(icon, 19.0, if focused { p.fg } else { p.dim }))
314+ button(container(icons::icon(icon, 18.0, p.fg)).center(40))
277315 .on_press(on_press)
278− .padding(8)
316+ .padding(0)
279317 .style(move |_, status| button::Style {
280− background: (focused || hovered(status)).then_some(p.surface_highest.into()),
318+ background: Some(if focused || hovered(status) { p.mute } else { p.s2 }.into()),
281319 text_color: p.fg,
282− border: Border {
283− color: p.accent,
284− width: if focused { 2.0 } else { 0.0 },
285− radius: 999.0.into(),
286− },
320+ border: cursor(p, focused, ROUND),
287321 ..button::Style::default()
288322 })
289323 .into()
290324 }
291325
292−/// A wide button with an outline, like "Disconnect".
293−pub fn outline_button<'a>(look: Look, label: &'a str, on_press: Message) -> Element<'a, Message> {
326+/// A wide chip, like "Refresh".
327+pub fn wide_button<'a>(look: Look, words: &str, on_press: Message) -> Element<'a, Message> {
294328 let p = look.p;
295− button(container(text(label).size(13.5).font(look.medium()).color(p.fg)).center_x(Length::Fill))
329+ button(container(label(look, words, 11.0, p.fg)).center_x(Length::Fill))
296330 .on_press(on_press)
297331 .width(Length::Fill)
298− .padding([8, 12])
332+ .padding([11, 12])
299333 .style(move |_, status| button::Style {
300− background: hovered(status).then_some(p.surface_high.into()),
334+ background: Some(if hovered(status) { p.mute } else { p.s3 }.into()),
301335 text_color: p.fg,
302− border: Border {
303− color: p.line,
304− width: 1.0,
305− radius: 999.0.into(),
306− },
336+ border: Border::default().rounded(ROUND),
307337 ..button::Style::default()
308338 })
309339 .into()
310340 }
311341
312−/// A small filled circle, like the "connected" light.
313−pub fn light<'a>(color: iced::Color) -> Element<'a, Message> {
342+/// A filled circle, like the "connected" light or the live dot.
343+pub fn dot<'a>(color: Color, size: f32) -> Element<'a, Message> {
314344 container(space())
315− .width(8)
316− .height(8)
317− .style(move |_| container::Style {
318− background: Some(color.into()),
319− border: Border::default().rounded(4),
320− ..container::Style::default()
321− })
345+ .width(size)
346+ .height(size)
347+ .style(move |_| surface(color, size / 2.0))
322348 .into()
323349 }
324350
325−/// A small outlined tag, like "demo".
326−pub fn tag<'a>(look: Look, label: &'a str) -> Element<'a, Message> {
351+/// A small caps tag with a live dot, like "demo".
352+pub fn tag<'a>(look: Look, words: &str) -> Element<'a, Message> {
327353 let p = look.p;
328− container(text(label).size(11).font(look.medium()).color(p.icon))
329− .padding([1, 8])
330− .style(move |_| container::Style {
331− border: Border {
332− color: p.icon,
333− width: 1.0,
334− radius: 999.0.into(),
335− },
336− ..container::Style::default()
337− })
338− .into()
354+ container(
355+ row![label(look, words, 10.0, p.fg), dot(p.live, 6.0)]
356+ .spacing(6)
357+ .align_y(Alignment::Center),
358+ )
359+ .padding([4, 10])
360+ .style(move |_| surface(p.s2, ROUND))
361+ .into()
339362 }
340363
341−/// A thin vertical line, between the sidebar and the page.
342−pub fn divider<'a>(look: Look) -> Element<'a, Message> {
364+/// A value in a small dark pill, like a bass level or a firmware version.
365+pub fn value<'a>(look: Look, words: &str) -> Element<'a, Message> {
343366 let p = look.p;
344− rule::vertical(1)
345− .style(move |_| rule::Style {
346− color: p.line,
347− radius: 0.0.into(),
348− fill_mode: rule::FillMode::Full,
349− snap: true,
350− })
367+ container(label(look, words, 11.0, p.fg))
368+ .padding([7, 12])
369+ .style(move |_| surface(p.s2, ROUND))
351370 .into()
352371 }
M docs/screenshots/earbuds.png View

[Binary file]

M docs/screenshots/equalizer.png View

[Binary file]

M docs/screenshots/gestures.png View

[Binary file]

M docs/screenshots/keys.png View

[Binary file]

M docs/screenshots/overview.png View

[Binary file]