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.
+1080 −850
25 files changed
- M .github/workflows/release.yml +1 −0
- M CHANGELOG.md +11 −0
- M CLAUDE.md +3 −2
- M README.md +19 −6
- A crates/budctl-gui/fonts/DotoRounded-Black.ttf
- A crates/budctl-gui/fonts/OFL.txt +95 −0
- A crates/budctl-gui/fonts/SpaceGrotesk-Medium.otf
- A crates/budctl-gui/fonts/SpaceGrotesk-Regular.otf
- A crates/budctl-gui/fonts/SpaceGrotesk-SemiBold.otf
- A crates/budctl-gui/fonts/SpaceMono-Regular.ttf
- A crates/budctl-gui/src/dots.rs +261 −0
- M crates/budctl-gui/src/earbuds.rs +22 −12
- M crates/budctl-gui/src/equalizer.rs +55 −70
- M crates/budctl-gui/src/gestures.rs +20 −24
- M crates/budctl-gui/src/icons.rs +7 −114
- M crates/budctl-gui/src/main.rs +151 −195
- M crates/budctl-gui/src/overview.rs +59 −83
- M crates/budctl-gui/src/settings.rs +10 −10
- M crates/budctl-gui/src/theme.rs +155 −142
- M crates/budctl-gui/src/ui.rs +211 −192
- M docs/screenshots/earbuds.png
- M docs/screenshots/equalizer.png
- M docs/screenshots/gestures.png
- M docs/screenshots/keys.png
- M docs/screenshots/overview.png
| @@ -39,6 +39,7 @@ jobs: | |||
| 39 | 39 | dir=budctl-${{ matrix.target }} | |
| 40 | 40 | mkdir "$dir" | |
| 41 | 41 | 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" | |
| 42 | 43 | tar -czf "$dir.tar.gz" "$dir" | |
| 43 | 44 | sha256sum "$dir.tar.gz" > "$dir.tar.gz.sha256" | |
| 44 | 45 | - uses: actions/upload-artifact@v7 | |
| @@ -1,5 +1,16 @@ | |||
| 1 | 1 | # Changelog | |
| 2 | 2 | ||
| 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 | + | ||
| 3 | 14 | ## 0.1.1 (2026-10-28) | |
| 4 | 15 | ||
| 5 | 16 | The first release, with builds for x86_64 and aarch64 Linux and an install script. | |
| @@ -114,8 +114,9 @@ Exit codes: | |||
| 114 | 114 | ||
| 115 | 115 | ## GUI | |
| 116 | 116 | ||
| 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. | |
| 119 | 120 | - **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top (`keys.rs`, `?` lists them in the window): | |
| 120 | 121 | - `j`/`k`, the arrows or Tab move a cursor over what the page can change; `gg`/`G` jump to the first or last item. | |
| 121 | 122 | - `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. | |
| @@ -118,12 +118,20 @@ The mouse works everywhere. Vim-like keys work on top of it (press `?` in the wi | |||
| 118 | 118 | ||
| 119 | 119 | The cursor shows only after you press a key, and the first key only shows it. | |
| 120 | 120 | ||
| 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. | |
| 127 | 135 | ||
| 128 | 136 | **Log:** each launch writes errors and session events (connected, each change and its answer, | |
| 129 | 137 | 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 | |||
| 160 | 168 | ## License | |
| 161 | 169 | ||
| 162 | 170 | 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. | |
[Binary file]
| @@ -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. | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
| @@ -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 | + | } | |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use budctl_core::status::DualDevice; | |
| 4 | 4 | use iced::Element; | |
| 5 | − | use iced::widget::{column, text}; | |
| 5 | + | use iced::widget::{column, container}; | |
| 6 | 6 | ||
| 7 | 7 | use crate::buds::Buds; | |
| 8 | 8 | use crate::icons::Icon; | |
| @@ -24,7 +24,6 @@ pub fn items(buds: &Buds) -> Vec<Item> { | |||
| 24 | 24 | ||
| 25 | 25 | /// `ringing`: the buds were told to ring and not yet to stop. | |
| 26 | 26 | pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Element<'a, Message> { | |
| 27 | − | let p = look.p; | |
| 28 | 27 | let dual = buds.status.switches.iter().find(|s| s.key == DUAL).map(|dual| { | |
| 29 | 28 | let about = if marks.pending.contains(&Item::Switch(DUAL)) { | |
| 30 | 29 | "Saving…" | |
| @@ -59,9 +58,7 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem | |||
| 59 | 58 | None, | |
| 60 | 59 | marks.focus == Some(Item::Find), | |
| 61 | 60 | ); | |
| 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")) }; | |
| 65 | 62 | let status = &buds.status; | |
| 66 | 63 | let facts = vec![ | |
| 67 | 64 | 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 | |||
| 84 | 81 | false, | |
| 85 | 82 | ), | |
| 86 | 83 | ]; | |
| 87 | − | column![ui::title(look, "Earbud settings")] | |
| 84 | + | column![ui::title(look, "earbud settings · connection, find, firmware")] | |
| 88 | 85 | .push(dual) | |
| 89 | 86 | .push(buds.dual.as_deref().map(|list| devices(look, list))) | |
| 90 | 87 | .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> { | |||
| 110 | 107 | } else { | |
| 111 | 108 | device.name.as_str() | |
| 112 | 109 | }; | |
| 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 | + | ) | |
| 115 | 121 | }) | |
| 116 | 122 | .collect(); | |
| 117 | 123 | 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() | |
| 119 | 127 | } else { | |
| 120 | 128 | ui::card(look, rows) | |
| 121 | 129 | }; | |
| 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]); | |
| 125 | 135 | column![ui::heading(look, "Devices"), list, note].spacing(10).into() | |
| 126 | 136 | } | |
| 127 | 137 | ||
| @@ -1,11 +1,12 @@ | |||
| 1 | 1 | //! The Equalizer page: the presets, and the bands of the custom ones. | |
| 2 | 2 | ||
| 3 | 3 | 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}; | |
| 5 | 5 | use iced::{Alignment, Border, Element, Length}; | |
| 6 | 6 | ||
| 7 | 7 | use crate::buds::{self, Buds}; | |
| 8 | − | use crate::icons::{self, Icon}; | |
| 8 | + | use crate::dots; | |
| 9 | + | use crate::icons::Icon; | |
| 9 | 10 | use crate::theme::Look; | |
| 10 | 11 | use crate::ui; | |
| 11 | 12 | use crate::{Draft, Item, Marks, Message}; | |
| @@ -57,9 +58,9 @@ pub fn view<'a>( | |||
| 57 | 58 | marks: Marks, | |
| 58 | 59 | ) -> Element<'a, Message> { | |
| 59 | 60 | let p = look.p; | |
| 60 | − | let title = ui::title(look, "Equalizer"); | |
| 61 | + | let title = ui::title(look, "equalizer · presets, and the bands of yours"); | |
| 61 | 62 | 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."); | |
| 63 | 64 | return column![title, none].spacing(12).into(); | |
| 64 | 65 | }; | |
| 65 | 66 | let current = eq.current.as_deref(); | |
| @@ -78,36 +79,31 @@ pub fn view<'a>( | |||
| 78 | 79 | } else { | |
| 79 | 80 | "Built in" | |
| 80 | 81 | }; | |
| 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)); | |
| 86 | 83 | let trailing: Element<'a, Message> = if preset.custom { | |
| 87 | − | row![ | |
| 88 | − | mark, | |
| 89 | − | ui::icon_button( | |
| 84 | + | row![mark] | |
| 85 | + | .push(ui::icon_button( | |
| 90 | 86 | look, | |
| 91 | 87 | Icon::Pencil, | |
| 92 | 88 | 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( | |
| 96 | 92 | look, | |
| 97 | 93 | Icon::Trash, | |
| 98 | 94 | 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() | |
| 105 | 100 | } else { | |
| 106 | − | mark | |
| 101 | + | row![mark].into() | |
| 107 | 102 | }; | |
| 108 | − | ui::setting( | |
| 103 | + | ui::list_row( | |
| 109 | 104 | look, | |
| 110 | 105 | Icon::Equalizer, | |
| 106 | + | chosen, | |
| 111 | 107 | buds::shown_name(preset), | |
| 112 | 108 | Some(subtitle.to_owned()), | |
| 113 | 109 | trailing, | |
| @@ -119,10 +115,12 @@ pub fn view<'a>( | |||
| 119 | 115 | let editor: Option<Element<'a, Message>> = match edited(buds, editing) { | |
| 120 | 116 | Some(preset) => Some(bands(look, preset, draft, marks)), | |
| 121 | 117 | 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(), | |
| 126 | 124 | ), | |
| 127 | 125 | None => None, | |
| 128 | 126 | }; | |
| @@ -136,11 +134,8 @@ fn new_preset<'a>(look: Look, marks: Marks) -> Element<'a, Message> { | |||
| 136 | 134 | let creating = marks | |
| 137 | 135 | .pending | |
| 138 | 136 | .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); | |
| 144 | 139 | row![about] | |
| 145 | 140 | .push(creating) | |
| 146 | 141 | .push(ui::pill_with( | |
| @@ -152,7 +147,7 @@ fn new_preset<'a>(look: Look, marks: Marks) -> Element<'a, Message> { | |||
| 152 | 147 | )) | |
| 153 | 148 | .spacing(14) | |
| 154 | 149 | .align_y(Alignment::Center) | |
| 155 | − | .padding([0, 8]) | |
| 150 | + | .padding([0, 18]) | |
| 156 | 151 | .into() | |
| 157 | 152 | } | |
| 158 | 153 | ||
| @@ -168,13 +163,10 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: | |||
| 168 | 163 | let saving = marks | |
| 169 | 164 | .pending | |
| 170 | 165 | .contains(&Item::Preset(id)) | |
| 171 | − | .then(|| text(SAVING).size(13).color(p.icon)); | |
| 166 | + | .then(|| ui::label(look, SAVING, 11.0, p.live)); | |
| 172 | 167 | 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) | |
| 178 | 170 | ] | |
| 179 | 171 | .push(saving) | |
| 180 | 172 | .push(ui::pill_with( | |
| @@ -193,58 +185,51 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: | |||
| 193 | 185 | )) | |
| 194 | 186 | .spacing(10) | |
| 195 | 187 | .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 | + | ); | |
| 199 | 192 | let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| { | |
| 200 | 193 | let focused = marks.focus == Some(Item::Band(id, band.hz)); | |
| 201 | 194 | 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 { | |
| 204 | 201 | preset: id, | |
| 205 | 202 | hz, | |
| 206 | 203 | 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, | |
| 223 | 205 | }, | |
| 224 | − | }); | |
| 206 | + | release: Message::SaveBands(id), | |
| 207 | + | on: p.fill, | |
| 208 | + | off: p.s3, | |
| 209 | + | }; | |
| 225 | 210 | 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) | |
| 229 | 214 | ] | |
| 230 | − | .spacing(10) | |
| 215 | + | .spacing(12) | |
| 231 | 216 | .align_x(Alignment::Center); | |
| 232 | 217 | sliders.push( | |
| 233 | 218 | container(band) | |
| 234 | 219 | .center_x(Length::Fill) | |
| 235 | − | .padding([12, 0]) | |
| 220 | + | .padding([14, 0]) | |
| 236 | 221 | .style(move |_| container::Style { | |
| 237 | − | background: focused.then_some(p.surface_high.into()), | |
| 222 | + | background: focused.then_some(p.s2.into()), | |
| 238 | 223 | border: Border { | |
| 239 | − | color: p.accent, | |
| 224 | + | color: p.fill, | |
| 240 | 225 | width: if focused { 2.0 } else { 0.0 }, | |
| 241 | − | radius: 12.0.into(), | |
| 226 | + | radius: 24.0.into(), | |
| 242 | 227 | }, | |
| 243 | 228 | ..container::Style::default() | |
| 244 | 229 | }), | |
| 245 | 230 | ) | |
| 246 | 231 | }); | |
| 247 | − | ui::panel(look, column![heading, note, sliders].spacing(14).into()) | |
| 232 | + | ui::panel(look, column![heading, note, sliders].spacing(16).into()) | |
| 248 | 233 | } | |
| 249 | 234 | ||
| 250 | 235 | /// `3` → "+3", `0` → "0", `-2` → "-2". | |
| @@ -4,7 +4,7 @@ use std::fmt; | |||
| 4 | 4 | ||
| 5 | 5 | use budctl_core::status::TouchControls; | |
| 6 | 6 | use iced::widget::overlay::menu; | |
| 7 | − | use iced::widget::{column, pick_list, text}; | |
| 7 | + | use iced::widget::{column, pick_list}; | |
| 8 | 8 | use iced::{Border, Element, Shadow}; | |
| 9 | 9 | ||
| 10 | 10 | use crate::buds::{self, Buds}; | |
| @@ -56,11 +56,9 @@ pub fn items(buds: &Buds) -> Vec<Item> { | |||
| 56 | 56 | /// `draft`: an action picked on the screen and not on the buds yet. | |
| 57 | 57 | pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a TouchDraft>, marks: Marks) -> Element<'a, Message> { | |
| 58 | 58 | let p = look.p; | |
| 59 | − | let title = ui::title(look, "Gestures"); | |
| 59 | + | let title = ui::title(look, "gestures · taps and holds on each bud"); | |
| 60 | 60 | 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."); | |
| 64 | 62 | return column![title, none].spacing(12).into(); | |
| 65 | 63 | }; | |
| 66 | 64 | let rows = rows(touch); | |
| @@ -85,30 +83,28 @@ pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a TouchDraft>, marks | |||
| 85 | 83 | save: true, | |
| 86 | 84 | } | |
| 87 | 85 | }) | |
| 88 | − | .width(220) | |
| 89 | − | .padding([8, 12]) | |
| 86 | + | .width(230) | |
| 87 | + | .padding([10, 18]) | |
| 90 | 88 | .text_size(14) | |
| 91 | − | .style(move |_, _| pick_list::Style { | |
| 89 | + | .font(look.medium()) | |
| 90 | + | .style(move |_, status| pick_list::Style { | |
| 92 | 91 | 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), | |
| 101 | 101 | }) | |
| 102 | 102 | .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), | |
| 109 | 105 | 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(), | |
| 112 | 108 | shadow: Shadow::default(), | |
| 113 | 109 | }); | |
| 114 | 110 | let saving = marks.pending.contains(&Item::Gesture(n)).then(|| SAVING.to_owned()); | |
| @@ -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 | |
| 2 | 2 | //! any scale. Icons are designed on a 24 × 24 grid. | |
| 3 | 3 | ||
| 4 | − | use std::f32::consts::{FRAC_PI_2, TAU}; | |
| 5 | − | ||
| 6 | 4 | use iced::widget::canvas::{self, Frame, LineCap, LineJoin, Path, Stroke, path::Arc}; | |
| 7 | 5 | use iced::{Color, Element, Point, Radians, Rectangle, Renderer, Size, Theme, mouse}; | |
| 8 | 6 | ||
| 9 | 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 10 | 8 | pub enum Icon { | |
| 11 | − | Overview, | |
| 12 | 9 | Equalizer, | |
| 13 | 10 | Gestures, | |
| 14 | 11 | Earbud, | |
| @@ -23,7 +20,6 @@ pub enum Icon { | |||
| 23 | 20 | Dual, | |
| 24 | 21 | Chevron, | |
| 25 | 22 | Bolt, | |
| 26 | − | Check, | |
| 27 | 23 | Find, | |
| 28 | 24 | Info, | |
| 29 | 25 | Chip, | |
| @@ -163,11 +159,6 @@ impl<'f> Pen<'f> { | |||
| 163 | 159 | ||
| 164 | 160 | fn draw_icon(pen: &mut Pen, icon: Icon) { | |
| 165 | 161 | 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 | − | } | |
| 171 | 162 | Icon::Equalizer => { | |
| 172 | 163 | for (x, knob) in [(6.0, 14.0), (12.0, 8.0), (18.0, 16.0)] { | |
| 173 | 164 | pen.line((x, 3.5), (x, 20.5)); | |
| @@ -179,7 +170,7 @@ fn draw_icon(pen: &mut Pen, icon: Icon) { | |||
| 179 | 170 | pen.arc(12.0, 11.5, 4.0, 200.0, 340.0); | |
| 180 | 171 | pen.arc(12.0, 11.5, 7.5, 210.0, 330.0); | |
| 181 | 172 | } | |
| 182 | − | Icon::Earbud => earbud(pen, false, None), | |
| 173 | + | Icon::Earbud => earbud(pen), | |
| 183 | 174 | Icon::Settings => { | |
| 184 | 175 | for (y, knob) in [(6.0, 15.0), (12.0, 8.0), (18.0, 13.0)] { | |
| 185 | 176 | pen.line((3.5, y), (20.5, y)); | |
| @@ -292,7 +283,6 @@ fn draw_icon(pen: &mut Pen, icon: Icon) { | |||
| 292 | 283 | } | |
| 293 | 284 | pen.line((8.0, 14.0), (16.0, 14.0)); | |
| 294 | 285 | } | |
| 295 | − | Icon::Check => pen.polyline(&[(4.5, 12.5), (9.5, 17.5), (19.5, 7.0)], false), | |
| 296 | 286 | Icon::Laptop => { | |
| 297 | 287 | pen.rect(4.0, 5.0, 16.0, 11.0, 1.5); | |
| 298 | 288 | pen.line((2.0, 19.0), (22.0, 19.0)); | |
| @@ -304,106 +294,9 @@ fn draw_icon(pen: &mut Pen, icon: Icon) { | |||
| 304 | 294 | } | |
| 305 | 295 | } | |
| 306 | 296 | ||
| 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); | |
| 409 | 302 | } | |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | //! (`keys.rs`). `--demo` shows made-up buds instead. | |
| 6 | 6 | ||
| 7 | 7 | mod buds; | |
| 8 | + | mod dots; | |
| 8 | 9 | mod earbuds; | |
| 9 | 10 | mod equalizer; | |
| 10 | 11 | mod gestures; | |
| @@ -29,9 +30,8 @@ use budctl_core::status::{AncLevel, EqBand, EqPreset, NoiseControl, NoiseMode, S | |||
| 29 | 30 | use iced::keyboard::{self, Key, Modifiers}; | |
| 30 | 31 | use iced::widget::operation::{self, RelativeOffset}; | |
| 31 | 32 | 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}; | |
| 33 | 34 | ||
| 34 | − | use icons::Icon; | |
| 35 | 35 | use keys::Action; | |
| 36 | 36 | use theme::Look; | |
| 37 | 37 | use worker::{Command, Event, Reading}; | |
| @@ -42,8 +42,8 @@ const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] | |||
| 42 | 42 | --demo shows made-up buds, without Bluetooth. | |
| 43 | 43 | --debug hex-dumps every frame sent and received on stderr, as budctl --debug does. | |
| 44 | 44 | 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, …). | |
| 47 | 47 | Preferences (default device, idle time, start page): ~/.config/budctl/config.json. | |
| 48 | 48 | Each launch logs errors and session events to ~/.local/state/budctl/gui.log. | |
| 49 | 49 | Press ? in the window for the keys."; | |
| @@ -91,18 +91,21 @@ fn main() -> ExitCode { | |||
| 91 | 91 | write_built_in_theme(); | |
| 92 | 92 | } | |
| 93 | 93 | let theme_file = options.theme.clone().or_else(theme::default_file); | |
| 94 | − | let font = theme::system_font(); | |
| 95 | 94 | 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); | |
| 97 | 96 | app.page = start_page; | |
| 98 | 97 | app.idle = idle; | |
| 99 | 98 | app | |
| 100 | 99 | }; | |
| 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 | |
| 102 | 105 | .title("budctl") | |
| 103 | 106 | .theme(App::theme) | |
| 104 | 107 | .subscription(App::subscription) | |
| 105 | − | .default_font(font) | |
| 108 | + | .default_font(theme::UI) | |
| 106 | 109 | .window(window::Settings { | |
| 107 | 110 | size: Size::new(1040.0, 800.0), | |
| 108 | 111 | min_size: Some(Size::new(760.0, 540.0)), | |
| @@ -210,13 +213,14 @@ impl Page { | |||
| 210 | 213 | } | |
| 211 | 214 | } | |
| 212 | 215 | ||
| 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 { | |
| 214 | 218 | 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", | |
| 220 | 224 | } | |
| 221 | 225 | } | |
| 222 | 226 | ||
| @@ -443,7 +447,7 @@ struct App { | |||
| 443 | 447 | } | |
| 444 | 448 | ||
| 445 | 449 | 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 { | |
| 447 | 451 | let buds = if demo { buds::demo() } else { buds::unknown() }; | |
| 448 | 452 | let anc_level = buds | |
| 449 | 453 | .status | |
| @@ -456,10 +460,7 @@ impl App { | |||
| 456 | 460 | page: Page::Overview, | |
| 457 | 461 | buds, | |
| 458 | 462 | anc_level, | |
| 459 | − | look: Look { | |
| 460 | − | p: theme::BUILT_IN, | |
| 461 | − | font, | |
| 462 | − | }, | |
| 463 | + | look: Look { p: theme::BUILT_IN }, | |
| 463 | 464 | theme: theme::iced_theme(&theme::BUILT_IN), | |
| 464 | 465 | palette: theme::Source::new(theme_file), | |
| 465 | 466 | keys: keys::Keys::default(), | |
| @@ -1328,17 +1329,17 @@ impl App { | |||
| 1328 | 1329 | } | |
| 1329 | 1330 | page => column![ | |
| 1330 | 1331 | 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]) | |
| 1332 | 1333 | ] | |
| 1333 | − | .spacing(12) | |
| 1334 | + | .spacing(16) | |
| 1334 | 1335 | .into(), | |
| 1335 | 1336 | }; | |
| 1336 | − | let page = column![self.problem(), page].spacing(18); | |
| 1337 | + | let page = column![self.problem(), page].spacing(20); | |
| 1337 | 1338 | let p = look.p; | |
| 1338 | 1339 | let content = scrollable( | |
| 1339 | 1340 | container(container(page).max_width(860)) | |
| 1340 | 1341 | .center_x(Length::Fill) | |
| 1341 | − | .padding([30, 36]), | |
| 1342 | + | .padding([34, 36]), | |
| 1342 | 1343 | ) | |
| 1343 | 1344 | .id(SCROLL) | |
| 1344 | 1345 | .height(Length::Fill) | |
| @@ -1347,12 +1348,18 @@ impl App { | |||
| 1347 | 1348 | for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] { | |
| 1348 | 1349 | rail.background = None; | |
| 1349 | 1350 | rail.border = Border::default(); | |
| 1350 | − | rail.scroller.background = p.surface_highest.into(); | |
| 1351 | + | rail.scroller.background = p.s3.into(); | |
| 1351 | 1352 | rail.scroller.border = Border::default().rounded(4); | |
| 1352 | 1353 | } | |
| 1353 | 1354 | style | |
| 1354 | 1355 | }); | |
| 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 | + | }); | |
| 1356 | 1363 | if self.help { | |
| 1357 | 1364 | stack![window, self.help()].into() | |
| 1358 | 1365 | } else if self.confirm_find { | |
| @@ -1387,18 +1394,24 @@ impl App { | |||
| 1387 | 1394 | "budctl looks for the paired buds it supports and reads them.", | |
| 1388 | 1395 | ), | |
| 1389 | 1396 | }; | |
| 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 | + | }; | |
| 1392 | 1404 | 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) | |
| 1395 | 1408 | ] | |
| 1396 | − | .spacing(8); | |
| 1409 | + | .spacing(10); | |
| 1397 | 1410 | 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()) | |
| 1400 | 1413 | ] | |
| 1401 | − | .spacing(18) | |
| 1414 | + | .spacing(20) | |
| 1402 | 1415 | .into() | |
| 1403 | 1416 | } | |
| 1404 | 1417 | ||
| @@ -1412,22 +1425,19 @@ impl App { | |||
| 1412 | 1425 | let look = self.look; | |
| 1413 | 1426 | let p = look.p; | |
| 1414 | 1427 | 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) | |
| 1417 | 1431 | ] | |
| 1418 | − | .spacing(16) | |
| 1432 | + | .spacing(14) | |
| 1419 | 1433 | .align_y(Alignment::Center); | |
| 1420 | 1434 | Some( | |
| 1421 | 1435 | container(content) | |
| 1422 | − | .padding([12, 16]) | |
| 1436 | + | .padding([12, 22]) | |
| 1423 | 1437 | .width(Length::Fill) | |
| 1424 | 1438 | .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), | |
| 1431 | 1441 | ..container::Style::default() | |
| 1432 | 1442 | }) | |
| 1433 | 1443 | .into(), | |
| @@ -1438,138 +1448,89 @@ impl App { | |||
| 1438 | 1448 | let look = self.look; | |
| 1439 | 1449 | let p = look.p; | |
| 1440 | 1450 | 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"), | |
| 1446 | 1456 | }; | |
| 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() | |
| 1455 | 1463 | ] | |
| 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))) | |
| 1476 | 1473 | }); | |
| 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) | |
| 1481 | 1478 | .height(Length::Fill) | |
| 1482 | − | .style(move |_| container::Style { | |
| 1483 | − | background: Some(p.surface_low.into()), | |
| 1484 | − | ..container::Style::default() | |
| 1485 | − | }) | |
| 1486 | 1479 | .into() | |
| 1487 | 1480 | } | |
| 1488 | 1481 | ||
| 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> { | |
| 1491 | 1490 | 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); | |
| 1500 | 1491 | let buttons = row![ | |
| 1501 | 1492 | 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)), | |
| 1504 | 1495 | ] | |
| 1505 | 1496 | .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 | + | ) | |
| 1514 | 1510 | } | |
| 1515 | 1511 | ||
| 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. | |
| 1518 | 1513 | 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 | + | ) | |
| 1542 | 1520 | } | |
| 1543 | 1521 | ||
| 1544 | − | /// "Delete the preset?", over a dimmed window: its bands are lost. | |
| 1522 | + | /// "Delete the preset?": its bands are lost. | |
| 1545 | 1523 | fn confirm_delete(&self, id: u8) -> Element<'_, Message> { | |
| 1546 | − | let look = self.look; | |
| 1547 | − | let p = look.p; | |
| 1548 | 1524 | let name = self | |
| 1549 | 1525 | .buds | |
| 1550 | 1526 | .preset(id) | |
| 1551 | 1527 | .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 | + | ) | |
| 1573 | 1534 | } | |
| 1574 | 1535 | ||
| 1575 | 1536 | /// The dialog that asks for a preset's name, over a dimmed window. | |
| @@ -1588,60 +1549,56 @@ impl App { | |||
| 1588 | 1549 | .on_input(Message::NameInput) | |
| 1589 | 1550 | .on_submit(Message::SubmitName) | |
| 1590 | 1551 | .size(16) | |
| 1591 | − | .padding([10, 14]) | |
| 1552 | + | .padding([12, 20]) | |
| 1592 | 1553 | .style(move |_, _| text_input::Style { | |
| 1593 | − | background: p.surface_high.into(), | |
| 1554 | + | background: p.s2.into(), | |
| 1594 | 1555 | border: Border { | |
| 1595 | − | color: p.accent, | |
| 1556 | + | color: p.fill, | |
| 1596 | 1557 | width: 1.0, | |
| 1597 | − | radius: 10.0.into(), | |
| 1558 | + | radius: 999.0.into(), | |
| 1598 | 1559 | }, | |
| 1599 | − | icon: p.dim, | |
| 1600 | − | placeholder: p.dim, | |
| 1560 | + | icon: p.grey, | |
| 1561 | + | placeholder: p.grey, | |
| 1601 | 1562 | value: p.fg, | |
| 1602 | − | selection: p.accent_dim, | |
| 1563 | + | selection: p.container, | |
| 1603 | 1564 | }); | |
| 1604 | 1565 | let problem = prompt | |
| 1605 | 1566 | .problem | |
| 1606 | 1567 | .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])); | |
| 1608 | 1569 | let buttons = row![ | |
| 1609 | 1570 | space::horizontal(), | |
| 1610 | 1571 | ui::pill(look, "Cancel", false, Message::CancelName), | |
| 1611 | 1572 | ui::pill(look, action, true, Message::SubmitName), | |
| 1612 | 1573 | ] | |
| 1613 | 1574 | .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] | |
| 1615 | 1576 | .push(problem) | |
| 1616 | 1577 | .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)) | |
| 1625 | 1580 | } | |
| 1626 | 1581 | ||
| 1627 | 1582 | /// The list of keys, over a dimmed window. A key or a click closes it. | |
| 1628 | 1583 | fn help(&self) -> Element<'_, Message> { | |
| 1629 | 1584 | let look = self.look; | |
| 1630 | 1585 | 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 | + | ) | |
| 1643 | 1596 | }); | |
| 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)) | |
| 1645 | 1602 | } | |
| 1646 | 1603 | } | |
| 1647 | 1604 | ||
| @@ -1683,12 +1640,12 @@ mod tests { | |||
| 1683 | 1640 | ||
| 1684 | 1641 | /// With the made-up buds. | |
| 1685 | 1642 | fn app() -> App { | |
| 1686 | − | App::new(None, Font::DEFAULT, None, true) | |
| 1643 | + | App::new(None, None, true) | |
| 1687 | 1644 | } | |
| 1688 | 1645 | ||
| 1689 | 1646 | /// Talking to a worker that the test plays: the receiver gets what the app sends it. | |
| 1690 | 1647 | 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); | |
| 1692 | 1649 | let (worker, commands) = mpsc::channel(); | |
| 1693 | 1650 | let _ = app.update(Message::Worker(Event::Ready(worker))); | |
| 1694 | 1651 | (app, commands) | |
| @@ -2361,10 +2318,9 @@ mod tests { | |||
| 2361 | 2318 | fn pages_render() { | |
| 2362 | 2319 | let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots"); | |
| 2363 | 2320 | let _ = fs::remove_dir_all(&dir); | |
| 2364 | − | let font = theme::system_font(); | |
| 2365 | 2321 | let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from); | |
| 2366 | 2322 | 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); | |
| 2368 | 2324 | app.page = page; | |
| 2369 | 2325 | press(&mut app, keys); | |
| 2370 | 2326 | app | |
| @@ -2400,10 +2356,10 @@ mod tests { | |||
| 2400 | 2356 | let mut deleting = on(Page::Equalizer, ""); | |
| 2401 | 2357 | let _ = deleting.update(Message::DeletePreset(4)); | |
| 2402 | 2358 | 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); | |
| 2404 | 2360 | connecting.link = Link::Connecting; | |
| 2405 | 2361 | 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); | |
| 2407 | 2363 | busy.answer(Event::Failed { | |
| 2408 | 2364 | kind: Kind::Busy, | |
| 2409 | 2365 | message: "control channel busy: is another app connected?".to_owned(), | |
| @@ -2418,7 +2374,7 @@ mod tests { | |||
| 2418 | 2374 | let _ = saving.update(Message::Switch("spatial", false)); | |
| 2419 | 2375 | let _ = saving.update(Message::Noise(NoiseMode::Off)); | |
| 2420 | 2376 | shots.push(("saving".to_owned(), 1240.0, saving)); | |
| 2421 | − | render(&dir, font, shots); | |
| 2377 | + | render(&dir, shots); | |
| 2422 | 2378 | } | |
| 2423 | 2379 | ||
| 2424 | 2380 | /// The README's screenshots: the demo buds in the built-in palette, written to | |
| @@ -2429,9 +2385,8 @@ mod tests { | |||
| 2429 | 2385 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); | |
| 2430 | 2386 | let scratch = root.join("target/readme-screenshots"); | |
| 2431 | 2387 | let _ = fs::remove_dir_all(&scratch); | |
| 2432 | − | let font = theme::system_font(); | |
| 2433 | 2388 | 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); | |
| 2435 | 2390 | app.page = page; | |
| 2436 | 2391 | press(&mut app, keys); | |
| 2437 | 2392 | app | |
| @@ -2446,7 +2401,7 @@ mod tests { | |||
| 2446 | 2401 | ("keys".to_owned(), 800.0, on(Page::Overview, "?")), | |
| 2447 | 2402 | ]; | |
| 2448 | 2403 | let names: Vec<String> = shots.iter().map(|(name, _, _)| name.clone()).collect(); | |
| 2449 | − | render(&scratch, font, shots); | |
| 2404 | + | render(&scratch, shots); | |
| 2450 | 2405 | let out = root.join("docs/screenshots"); | |
| 2451 | 2406 | fs::create_dir_all(&out).unwrap(); | |
| 2452 | 2407 | for name in names { | |
| @@ -2459,10 +2414,11 @@ mod tests { | |||
| 2459 | 2414 | } | |
| 2460 | 2415 | ||
| 2461 | 2416 | /// 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)>) { | |
| 2463 | 2418 | for (name, height, app) in shots { | |
| 2464 | 2419 | let settings = iced::Settings { | |
| 2465 | − | default_font: font, | |
| 2420 | + | default_font: theme::UI, | |
| 2421 | + | fonts: theme::FONTS.into_iter().map(Into::into).collect(), | |
| 2466 | 2422 | ..iced::Settings::default() | |
| 2467 | 2423 | }; | |
| 2468 | 2424 | let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view()); | |
| @@ -1,13 +1,13 @@ | |||
| 1 | 1 | //! The Overview page: battery, noise control, the switches, and the way to the other pages. | |
| 2 | 2 | ||
| 3 | 3 | 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}; | |
| 7 | 6 | ||
| 8 | 7 | use crate::buds::Buds; | |
| 8 | + | use crate::dots; | |
| 9 | 9 | use crate::equalizer; | |
| 10 | − | use crate::icons::{self, Icon, Part, RingColors}; | |
| 10 | + | use crate::icons::{self, Icon}; | |
| 11 | 11 | use crate::theme::Look; | |
| 12 | 12 | use crate::ui::{self, Segment}; | |
| 13 | 13 | use crate::{Item, Marks, Message, Page}; | |
| @@ -45,58 +45,52 @@ pub fn view<'a>( | |||
| 45 | 45 | bass: Option<i8>, | |
| 46 | 46 | marks: Marks, | |
| 47 | 47 | ) -> 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() | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> { | |
| 62 | 65 | 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), | |
| 74 | 70 | }; | |
| 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) | |
| 82 | 80 | .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() | |
| 92 | 86 | }; | |
| 93 | 87 | let battery = buds.status.battery; | |
| 94 | 88 | 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), | |
| 98 | 92 | ]; | |
| 99 | − | ui::card(look, vec![container(gauges).padding([22, 8]).into()]) | |
| 93 | + | ui::card(look, vec![container(gauges).padding([26, 8]).into()]) | |
| 100 | 94 | } | |
| 101 | 95 | ||
| 102 | 96 | 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) | |||
| 129 | 123 | }) | |
| 130 | 124 | .collect(); | |
| 131 | 125 | 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) | |
| 134 | 128 | ] | |
| 135 | 129 | .align_y(Alignment::Center) | |
| 136 | − | .padding([0, 8]); | |
| 130 | + | .padding([0, 18]); | |
| 137 | 131 | let saving = marks | |
| 138 | 132 | .pending | |
| 139 | 133 | .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)); | |
| 141 | 135 | column![ui::segmented(look, segments, true, focus == Some(Item::NoiseMode))] | |
| 142 | 136 | .push(saving) | |
| 143 | 137 | .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 | |||
| 202 | 196 | } else { | |
| 203 | 197 | "Last set here: the buds do not report it" | |
| 204 | 198 | }; | |
| 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 { | |
| 210 | 206 | level: i8::try_from(level).unwrap_or(0), | |
| 211 | − | save: false, | |
| 207 | + | save, | |
| 212 | 208 | }, | |
| 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); | |
| 240 | 216 | ui::setting( | |
| 241 | 217 | look, | |
| 242 | 218 | Icon::Bass, | |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | 5 | use iced::Element; | |
| 6 | − | use iced::widget::{column, space, text}; | |
| 6 | + | use iced::widget::{column, container, space}; | |
| 7 | 7 | ||
| 8 | 8 | use crate::Message; | |
| 9 | 9 | use crate::icons::Icon; | |
| @@ -17,7 +17,6 @@ pub struct Colors<'a> { | |||
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> { | |
| 20 | − | let p = look.p; | |
| 21 | 20 | let source = match (colors.file, colors.problem) { | |
| 22 | 21 | (_, Some(problem)) => format!("Built in: {problem}"), | |
| 23 | 22 | (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> { | |||
| 25 | 24 | }; | |
| 26 | 25 | let none = || -> Element<'a, Message> { space().into() }; | |
| 27 | 26 | 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]); | |
| 35 | 35 | let keys = ui::setting( | |
| 36 | 36 | look, | |
| 37 | 37 | Icon::Keys, | |
| @@ -69,7 +69,7 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> { | |||
| 69 | 69 | false, | |
| 70 | 70 | ); | |
| 71 | 71 | column![ | |
| 72 | − | ui::title(look, "App settings"), | |
| 72 | + | ui::title(look, "app settings · colors, keys, files"), | |
| 73 | 73 | column![ui::card(look, vec![palette]), how].spacing(10), | |
| 74 | 74 | ui::card(look, vec![keys]), | |
| 75 | 75 | ui::card(look, vec![files, log]), | |
| @@ -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. | |
| 2 | 2 | //! | |
| 3 | − | //! The file uses the keys of the owner's Aurora palette (`bg`, `fg`, `primary`…), so | |
| 4 | − | //! `~/.cache/matugen/aurora.json` works as it is, and the keys map to the same roles as in Aurora's | |
| 5 | − | //! `Theme.qml`, plus `secondary` for icons. A key the file lacks keeps its built-in color. | |
| 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). | |
| 6 | 8 | ||
| 9 | + | use std::collections::HashMap; | |
| 7 | 10 | use std::fs; | |
| 8 | 11 | use std::path::{Path, PathBuf}; | |
| 9 | − | use std::process::Command; | |
| 10 | 12 | use std::time::SystemTime; | |
| 11 | 13 | ||
| 12 | − | use iced::font::Weight; | |
| 14 | + | use iced::font::{Family, Weight}; | |
| 13 | 15 | use iced::{Color, Font}; | |
| 14 | − | use serde::Deserialize; | |
| 15 | 16 | ||
| 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. | |
| 17 | 18 | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 18 | 19 | pub struct Palette { | |
| 19 | − | /// Window background. | |
| 20 | + | /// Window background (`surfaceLowest`). | |
| 20 | 21 | 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`). | |
| 22 | 33 | 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, | |
| 44 | 46 | pub err: Color, | |
| 45 | − | /// "Connected". matugen has no such role, so it stays green. | |
| 46 | − | pub ok: Color, | |
| 47 | + | pub err_fg: Color, | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | − | /// Dark, with a red accent. | |
| 50 | + | /// Near-black surfaces, white "on" states and a red for live things. | |
| 50 | 51 | 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), | |
| 63 | 66 | err: Color::from_rgb8(0xFF, 0xB4, 0xAB), | |
| 64 | − | ok: Color::from_rgb8(0x4C, 0xC3, 0x6A), | |
| 67 | + | err_fg: Color::from_rgb8(0x69, 0x00, 0x05), | |
| 65 | 68 | }; | |
| 66 | 69 | ||
| 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 | + | ]; | |
| 85 | 94 | ||
| 86 | 95 | /// A palette from the text of a palette file. | |
| 87 | 96 | 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) | |
| 110 | 114 | } | |
| 111 | 115 | ||
| 112 | 116 | /// `#rrggbb` or `#rrggbbaa`. | |
| @@ -133,24 +137,10 @@ pub fn default_file() -> Option<PathBuf> { | |||
| 133 | 137 | /// A palette as a palette file, with every key [`parse`] reads: the built-in one is written to | |
| 134 | 138 | /// [`default_file`] when there is none, to start a matugen template from. | |
| 135 | 139 | 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 | |
| 152 | 142 | .iter() | |
| 153 | − | .map(|(key, color)| format!(" \"{key}\": \"{}\"", to_hex(*color))) | |
| 143 | + | .map(|(key, _, role)| format!(" \"{key}\": \"{}\"", to_hex(*role(&mut p)))) | |
| 154 | 144 | .collect(); | |
| 155 | 145 | format!("{{\n{}\n}}\n", lines.join(",\n")) | |
| 156 | 146 | } | |
| @@ -202,43 +192,53 @@ impl Source { | |||
| 202 | 192 | } | |
| 203 | 193 | } | |
| 204 | 194 | ||
| 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 | + | ||
| 205 | 215 | /// The palette and the fonts, handed to everything that draws. | |
| 206 | 216 | #[derive(Debug, Clone, Copy)] | |
| 207 | 217 | pub struct Look { | |
| 208 | 218 | pub p: Palette, | |
| 209 | − | pub font: Font, | |
| 210 | 219 | } | |
| 211 | 220 | ||
| 212 | 221 | impl Look { | |
| 213 | 222 | pub fn medium(self) -> Font { | |
| 214 | 223 | Font { | |
| 215 | 224 | weight: Weight::Medium, | |
| 216 | − | ..self.font | |
| 225 | + | ..UI | |
| 217 | 226 | } | |
| 218 | 227 | } | |
| 219 | 228 | ||
| 220 | − | pub fn bold(self) -> Font { | |
| 229 | + | pub fn semibold(self) -> Font { | |
| 221 | 230 | Font { | |
| 222 | − | weight: Weight::Bold, | |
| 223 | − | ..self.font | |
| 231 | + | weight: Weight::Semibold, | |
| 232 | + | ..UI | |
| 224 | 233 | } | |
| 225 | 234 | } | |
| 226 | − | } | |
| 227 | 235 | ||
| 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 | |
| 242 | 242 | } | |
| 243 | 243 | } | |
| 244 | 244 | ||
| @@ -249,9 +249,9 @@ pub fn iced_theme(p: &Palette) -> iced::Theme { | |||
| 249 | 249 | iced::theme::Palette { | |
| 250 | 250 | background: p.bg, | |
| 251 | 251 | 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, | |
| 255 | 255 | danger: p.err, | |
| 256 | 256 | }, | |
| 257 | 257 | ) | |
| @@ -266,28 +266,40 @@ mod tests { | |||
| 266 | 266 | let palette = parse( | |
| 267 | 267 | r##"{ | |
| 268 | 268 | "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" | |
| 275 | 276 | }"##, | |
| 276 | 277 | ) | |
| 277 | 278 | .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)); | |
| 285 | 297 | } | |
| 286 | 298 | ||
| 287 | 299 | #[test] | |
| 288 | 300 | fn missing_keys_keep_the_built_in_colors() { | |
| 289 | 301 | 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)); | |
| 291 | 303 | assert_eq!(palette.bg, BUILT_IN.bg); | |
| 292 | 304 | assert_eq!(parse("{}").unwrap(), BUILT_IN); | |
| 293 | 305 | } | |
| @@ -296,6 +308,7 @@ mod tests { | |||
| 296 | 308 | fn bad_files_are_errors() { | |
| 297 | 309 | assert!(parse("not json").is_err()); | |
| 298 | 310 | assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg")); | |
| 311 | + | assert!(parse(r#"{"primary": 3}"#).unwrap_err().contains("primary")); | |
| 299 | 312 | assert!(parse(r##"{"bg": "#12345"}"##).is_err()); | |
| 300 | 313 | assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err()); | |
| 301 | 314 | } | |
| @@ -304,7 +317,7 @@ mod tests { | |||
| 304 | 317 | fn the_written_palette_reads_back_the_same() { | |
| 305 | 318 | assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN); | |
| 306 | 319 | let see_through = Palette { | |
| 307 | − | accent: Color::from_rgba8(0x11, 0x22, 0x33, 0.8), | |
| 320 | + | fill: Color::from_rgba8(0x11, 0x22, 0x33, 0.8), | |
| 308 | 321 | ..BUILT_IN | |
| 309 | 322 | }; | |
| 310 | 323 | assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through); | |
| @@ -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`]. | |
| 3 | 4 | ||
| 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}; | |
| 6 | 8 | ||
| 7 | 9 | use crate::Message; | |
| 8 | 10 | 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 | + | } | |
| 10 | 47 | ||
| 11 | 48 | /// 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() | |
| 14 | 51 | } | |
| 15 | 52 | ||
| 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() | |
| 19 | 56 | } | |
| 20 | 57 | ||
| 21 | − | /// A rounded card holding rows, with a thin line between them. | |
| 58 | + | /// A round card holding rows. | |
| 22 | 59 | pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Message> { | |
| 23 | 60 | 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) | |
| 33 | 63 | .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)) | |
| 43 | 65 | .into() | |
| 44 | 66 | } | |
| 45 | 67 | ||
| 46 | − | /// A rounded panel like a card, around content of its own. | |
| 68 | + | /// A round card around content of its own. | |
| 47 | 69 | pub fn panel<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> { | |
| 48 | 70 | let p = look.p; | |
| 49 | 71 | container(content) | |
| 50 | 72 | .padding(24) | |
| 51 | 73 | .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) | |
| 52 | 91 | .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()), | |
| 59 | 93 | ..container::Style::default() | |
| 60 | 94 | }) | |
| 61 | 95 | .into() | |
| 62 | 96 | } | |
| 63 | 97 | ||
| 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) | |
| 74 | 113 | } | |
| 75 | 114 | ||
| 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>( | |
| 79 | 118 | look: Look, | |
| 80 | 119 | icon: Icon, | |
| 120 | + | current: bool, | |
| 81 | 121 | title: impl text::IntoFragment<'a>, | |
| 82 | 122 | subtitle: Option<String>, | |
| 83 | 123 | trailing: Element<'a, Message>, | |
| @@ -85,31 +125,52 @@ pub fn setting<'a>( | |||
| 85 | 125 | focused: bool, | |
| 86 | 126 | ) -> Element<'a, Message> { | |
| 87 | 127 | 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) | |
| 91 | 135 | .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) | |
| 94 | 146 | .align_y(Alignment::Center) | |
| 95 | − | .padding([13, 16]); | |
| 147 | + | .padding([6, 14]); | |
| 148 | + | let content = container(content).padding([0, 6]); | |
| 96 | 149 | button(content) | |
| 97 | 150 | .on_press_maybe(on_press) | |
| 98 | 151 | .padding(0) | |
| 99 | 152 | .width(Length::Fill) | |
| 100 | 153 | .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()), | |
| 102 | 155 | text_color: p.fg, | |
| 103 | − | border: cursor(p, focused, 14.0), | |
| 156 | + | border: cursor(p, focused, CARD), | |
| 104 | 157 | ..button::Style::default() | |
| 105 | 158 | }) | |
| 106 | 159 | .into() | |
| 107 | 160 | } | |
| 108 | 161 | ||
| 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 { | |
| 111 | 172 | Border { | |
| 112 | − | color: p.accent, | |
| 173 | + | color: p.fill, | |
| 113 | 174 | width: if focused { 2.0 } else { 0.0 }, | |
| 114 | 175 | radius: radius.into(), | |
| 115 | 176 | } | |
| @@ -119,28 +180,28 @@ fn hovered(status: button::Status) -> bool { | |||
| 119 | 180 | matches!(status, button::Status::Hovered | button::Status::Pressed) | |
| 120 | 181 | } | |
| 121 | 182 | ||
| 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. | |
| 123 | 185 | pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> { | |
| 124 | 186 | 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)) | |
| 138 | 199 | .into() | |
| 139 | 200 | } | |
| 140 | 201 | ||
| 141 | 202 | /// A chevron: the row opens a page. | |
| 142 | 203 | 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) | |
| 144 | 205 | } | |
| 145 | 206 | ||
| 146 | 207 | /// One choice of a [`segmented`] control. | |
| @@ -151,120 +212,97 @@ pub struct Segment<'a> { | |||
| 151 | 212 | pub on_press: Message, | |
| 152 | 213 | } | |
| 153 | 214 | ||
| 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. | |
| 156 | 217 | pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool, focused: bool) -> Element<'a, Message> { | |
| 157 | 218 | let p = look.p; | |
| 158 | 219 | let mut choices = Row::new().spacing(4); | |
| 159 | 220 | for segment in segments { | |
| 160 | 221 | 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); | |
| 166 | 224 | 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) | |
| 169 | 227 | .align_x(Alignment::Center) | |
| 170 | 228 | .into(), | |
| 171 | − | _ => label.into(), | |
| 229 | + | _ => words, | |
| 172 | 230 | }; | |
| 173 | 231 | let choice = button(container(inner).center_x(Length::Fill)) | |
| 174 | 232 | .on_press(segment.on_press) | |
| 175 | 233 | .width(Length::Fill) | |
| 176 | − | .padding(if large { [11, 8] } else { [7, 12] }) | |
| 234 | + | .padding(if large { [14, 8] } else { [10, 12] }) | |
| 177 | 235 | .style(move |_, status| button::Style { | |
| 178 | 236 | background: if selected { | |
| 179 | − | Some(p.accent.into()) | |
| 237 | + | Some(p.fill.into()) | |
| 180 | 238 | } else { | |
| 181 | − | hovered(status).then_some(p.surface_high.into()) | |
| 239 | + | hovered(status).then_some(p.s3.into()) | |
| 182 | 240 | }, | |
| 183 | 241 | text_color: color, | |
| 184 | − | border: Border::default().rounded(999), | |
| 242 | + | border: Border::default().rounded(ROUND), | |
| 185 | 243 | ..button::Style::default() | |
| 186 | 244 | }); | |
| 187 | 245 | choices = choices.push(choice); | |
| 188 | 246 | } | |
| 189 | 247 | container(choices) | |
| 190 | − | .padding(5) | |
| 248 | + | .padding(6) | |
| 191 | 249 | .width(Length::Fill) | |
| 192 | 250 | .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), | |
| 203 | 253 | ..container::Style::default() | |
| 204 | 254 | }) | |
| 205 | 255 | .into() | |
| 206 | 256 | } | |
| 207 | 257 | ||
| 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> { | |
| 210 | 260 | 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 | + | }; | |
| 221 | 272 | button(content) | |
| 222 | 273 | .on_press(on_press) | |
| 223 | 274 | .width(Length::Fill) | |
| 224 | − | .padding([10, 14]) | |
| 275 | + | .padding([6, 10]) | |
| 225 | 276 | .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), | |
| 233 | 280 | ..button::Style::default() | |
| 234 | 281 | }) | |
| 235 | 282 | .into() | |
| 236 | 283 | } | |
| 237 | 284 | ||
| 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) | |
| 241 | 289 | } | |
| 242 | 290 | ||
| 243 | 291 | /// 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> { | |
| 251 | 293 | 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)) | |
| 254 | 296 | .on_press(on_press) | |
| 255 | − | .padding([7, 16]) | |
| 297 | + | .padding([10, 18]) | |
| 256 | 298 | .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 | + | }), | |
| 262 | 304 | 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), | |
| 268 | 306 | ..button::Style::default() | |
| 269 | 307 | }) | |
| 270 | 308 | .into() | |
| @@ -273,80 +311,61 @@ pub fn pill_with<'a>( | |||
| 273 | 311 | /// A round button with just an icon, like the pencil on a preset's row. | |
| 274 | 312 | pub fn icon_button<'a>(look: Look, icon: Icon, focused: bool, on_press: Message) -> Element<'a, Message> { | |
| 275 | 313 | 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)) | |
| 277 | 315 | .on_press(on_press) | |
| 278 | − | .padding(8) | |
| 316 | + | .padding(0) | |
| 279 | 317 | .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()), | |
| 281 | 319 | 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), | |
| 287 | 321 | ..button::Style::default() | |
| 288 | 322 | }) | |
| 289 | 323 | .into() | |
| 290 | 324 | } | |
| 291 | 325 | ||
| 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> { | |
| 294 | 328 | 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)) | |
| 296 | 330 | .on_press(on_press) | |
| 297 | 331 | .width(Length::Fill) | |
| 298 | − | .padding([8, 12]) | |
| 332 | + | .padding([11, 12]) | |
| 299 | 333 | .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()), | |
| 301 | 335 | 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), | |
| 307 | 337 | ..button::Style::default() | |
| 308 | 338 | }) | |
| 309 | 339 | .into() | |
| 310 | 340 | } | |
| 311 | 341 | ||
| 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> { | |
| 314 | 344 | 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)) | |
| 322 | 348 | .into() | |
| 323 | 349 | } | |
| 324 | 350 | ||
| 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> { | |
| 327 | 353 | 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() | |
| 339 | 362 | } | |
| 340 | 363 | ||
| 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> { | |
| 343 | 366 | 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)) | |
| 351 | 370 | .into() | |
| 352 | 371 | } | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]