Krolyxon
Commit 99a16bb

Branches

Refs →

budctl99a16bb

budctl-gui: Aurora's look instead of the reference app's

The owner's verdict: the first look was a replica of the other buds app.
budctl-gui now follows the owner's shell, Aurora, and its settings app:
- JetBrains Mono Nerd Font at a 12 px base; Nerd Font glyphs as icons
  (glyphs.rs), so the canvas icons and battery rings are gone;
- flat and square: 1 px lines, accent fills with background-colored
  text, uppercase accent section labels, lowercase words;
- Aurora's controls in ui.rs: switch rows (square switch), choices,
  buttons, level bars (the battery), rows into other pages;
- the settings app's sidebar: title, the buds, pages by group.

Colors follow Aurora's palette by default: --theme, else
~/.config/budctl/theme.json, else ~/.cache/matugen/aurora.json, else
built in. The palette keeps only the roles Aurora's controls use.

No new dependencies; iced's canvas feature is no longer needed.
Author
krolyxon
Date
2026-10-27 23:12 +0530
Parent
6d1e074
Commit
99a16bbf1b926d7df1664791e9ef4da28927935b

+523 −1199

9 files changed

  1. M CLAUDE.md +3 −3
  2. M Cargo.lock +1 −358
  3. M crates/budctl-gui/Cargo.toml +1 −1
  4. A crates/budctl-gui/src/glyphs.rs +27 −0
  5. D crates/budctl-gui/src/icons.rs +0 −347
  6. M crates/budctl-gui/src/main.rs +68 −96
  7. M crates/budctl-gui/src/overview.rs +113 −145
  8. M crates/budctl-gui/src/theme.rs +67 −75
  9. M crates/budctl-gui/src/ui.rs +243 −174
M CLAUDE.md View
@@ -117,9 +117,9 @@ Exit codes:
117117
118118 ## GUI
119119
120−- **Look:** a sidebar (device card, pages) and pages of cards: battery rings, a segmented noise-control pill, rows of switches. The owner's reference is a screenshot of another buds app; match the look, never its code (rule 2).
121−- **Theme:** a built-in dark palette with a red accent. Optionally, a matugen JSON file with the same keys as the owner's Aurora palette (`~/.cache/matugen/aurora.json`: `bg`, `fg`, `primary`, `surface`…), mapped to the same roles as Aurora's `Theme.qml`. It is read again whenever the window gets focus.
122−- **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top: `j`/`k` move a cursor, `h`/`l` change the value under it. The cursor shows only after a key press.
120+- **Look:** the owner's shell, Aurora (`~/.dotfiles/.config/quickshell/aurora`), and its settings app in particular: a sidebar (title, the buds, pages by group) and pages of uppercase accent section labels over dense rows. Flat, square corners, 1 px lines, accent fills with background-colored text, lowercase words, JetBrains Mono Nerd Font at a 12 px base, Nerd Font glyphs as icons (written as `\u{…}` escapes, never pasted). `ui.rs` rebuilds Aurora's controls: switch rows, choices, buttons, level bars.
121+- **Theme:** Aurora's matugen palette (`~/.cache/matugen/aurora.json`: `bg`, `fg`, `primary`, `surface`, `surfaceHigh`, `outlineVariant`, `surfaceVariantFg`, `err`), mapped to the same roles as Aurora's `Theme.qml`. `--theme <file>` or `~/.config/budctl/theme.json` take precedence; with no file, a built-in near-black palette with a red accent. The file is read again whenever the window gets focus.
122+- **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top: `j`/`k` move a cursor, `h`/`l` change the value under it. The cursor shows only after a key press. Keys Aurora's settings app already has mean the same here: `Ctrl+Tab`/`Ctrl+Shift+Tab` and `Ctrl+J`/`Ctrl+K` cycle pages, `Esc` closes.
123123 - **Logic:** keys and clicks both become messages for one `update` function, which stays testable without a display. `iced_test` renders pages to PNG, so the look can be checked without the owner.
124124
125125 ## Working with Claude
M Cargo.lock View
@@ -267,21 +267,6 @@ version = "1.5.1"
267267 source = "registry+https://github.com/rust-lang/crates.io-index"
268268 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
269269
270−[[package]]
271−name = "bit-set"
272−version = "0.8.0"
273−source = "registry+https://github.com/rust-lang/crates.io-index"
274−checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
275−dependencies = [
276− "bit-vec",
277−]
278−
279−[[package]]
280−name = "bit-vec"
281−version = "0.8.0"
282−source = "registry+https://github.com/rust-lang/crates.io-index"
283−checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
284−
285270 [[package]]
286271 name = "bitflags"
287272 version = "1.3.2"
@@ -529,15 +514,6 @@ dependencies = [
529514 "smithay-clipboard",
530515 ]
531516
532−[[package]]
533−name = "codespan-reporting"
534−version = "0.12.0"
535−source = "registry+https://github.com/rust-lang/crates.io-index"
536−checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
537−dependencies = [
538− "unicode-width",
539−]
540−
541517 [[package]]
542518 name = "colorchoice"
543519 version = "1.0.5"
@@ -666,19 +642,6 @@ version = "0.2.4"
666642 source = "registry+https://github.com/rust-lang/crates.io-index"
667643 checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
668644
669−[[package]]
670−name = "cryoglyph"
671−version = "0.1.0"
672−source = "registry+https://github.com/rust-lang/crates.io-index"
673−checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519"
674−dependencies = [
675− "cosmic-text",
676− "etagere",
677− "lru",
678− "rustc-hash 2.1.3",
679− "wgpu",
680−]
681−
682645 [[package]]
683646 name = "crypto-common"
684647 version = "0.1.7"
@@ -730,15 +693,6 @@ dependencies = [
730693 "libloading",
731694 ]
732695
733−[[package]]
734−name = "document-features"
735−version = "0.2.12"
736−source = "registry+https://github.com/rust-lang/crates.io-index"
737−checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
738−dependencies = [
739− "litrs",
740−]
741−
742696 [[package]]
743697 name = "downcast-rs"
744698 version = "1.2.1"
@@ -800,25 +754,6 @@ version = "3.4.0"
800754 source = "registry+https://github.com/rust-lang/crates.io-index"
801755 checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
802756
803−[[package]]
804−name = "etagere"
805−version = "0.2.15"
806−source = "registry+https://github.com/rust-lang/crates.io-index"
807−checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342"
808−dependencies = [
809− "euclid",
810− "svg_fmt",
811−]
812−
813−[[package]]
814−name = "euclid"
815−version = "0.22.14"
816−source = "registry+https://github.com/rust-lang/crates.io-index"
817−checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
818−dependencies = [
819− "num-traits",
820−]
821−
822757 [[package]]
823758 name = "event-listener"
824759 version = "5.4.2"
@@ -871,18 +806,6 @@ dependencies = [
871806 "zlib-rs",
872807 ]
873808
874−[[package]]
875−name = "float_next_after"
876−version = "1.0.0"
877−source = "registry+https://github.com/rust-lang/crates.io-index"
878−checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
879−
880−[[package]]
881−name = "foldhash"
882−version = "0.2.0"
883−source = "registry+https://github.com/rust-lang/crates.io-index"
884−checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
885−
886809 [[package]]
887810 name = "font-types"
888811 version = "0.10.1"
@@ -1091,16 +1014,6 @@ version = "0.25.0"
10911014 source = "registry+https://github.com/rust-lang/crates.io-index"
10921015 checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
10931016
1094−[[package]]
1095−name = "guillotiere"
1096−version = "0.6.2"
1097−source = "registry+https://github.com/rust-lang/crates.io-index"
1098−checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782"
1099−dependencies = [
1100− "euclid",
1101− "svg_fmt",
1102−]
1103−
11041017 [[package]]
11051018 name = "half"
11061019 version = "2.7.1"
@@ -1109,7 +1022,6 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
11091022 dependencies = [
11101023 "cfg-if",
11111024 "crunchy",
1112− "num-traits",
11131025 "zerocopy",
11141026 ]
11151027
@@ -1126,15 +1038,6 @@ dependencies = [
11261038 "smallvec",
11271039 ]
11281040
1129−[[package]]
1130−name = "hashbrown"
1131−version = "0.16.1"
1132−source = "registry+https://github.com/rust-lang/crates.io-index"
1133−checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1134−dependencies = [
1135− "foldhash",
1136−]
1137−
11381041 [[package]]
11391042 name = "hashbrown"
11401043 version = "0.17.1"
@@ -1159,12 +1062,6 @@ version = "0.4.3"
11591062 source = "registry+https://github.com/rust-lang/crates.io-index"
11601063 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
11611064
1162−[[package]]
1163−name = "hexf-parse"
1164−version = "0.2.1"
1165−source = "registry+https://github.com/rust-lang/crates.io-index"
1166−checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
1167−
11681065 [[package]]
11691066 name = "iced"
11701067 version = "0.14.0"
@@ -1237,7 +1134,6 @@ dependencies = [
12371134 "iced_core",
12381135 "iced_futures",
12391136 "log",
1240− "lyon_path",
12411137 "raw-window-handle",
12421138 "rustc-hash 2.1.3",
12431139 "thiserror 2.0.21",
@@ -1262,7 +1158,6 @@ checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05"
12621158 dependencies = [
12631159 "iced_graphics",
12641160 "iced_tiny_skia",
1265− "iced_wgpu",
12661161 "log",
12671162 "thiserror 2.0.21",
12681163 ]
@@ -1323,27 +1218,6 @@ dependencies = [
13231218 "tiny-skia",
13241219 ]
13251220
1326−[[package]]
1327−name = "iced_wgpu"
1328−version = "0.14.0"
1329−source = "registry+https://github.com/rust-lang/crates.io-index"
1330−checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13"
1331−dependencies = [
1332− "bitflags 2.13.2",
1333− "bytemuck",
1334− "cryoglyph",
1335− "futures",
1336− "glam",
1337− "guillotiere",
1338− "iced_debug",
1339− "iced_graphics",
1340− "log",
1341− "lyon",
1342− "rustc-hash 2.1.3",
1343− "thiserror 2.0.21",
1344− "wgpu",
1345−]
1346−
13471221 [[package]]
13481222 name = "iced_widget"
13491223 version = "0.14.2"
@@ -1383,7 +1257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
13831257 checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
13841258 dependencies = [
13851259 "equivalent",
1386− "hashbrown 0.17.1",
1260+ "hashbrown",
13871261 ]
13881262
13891263 [[package]]
@@ -1548,12 +1422,6 @@ version = "0.12.1"
15481422 source = "registry+https://github.com/rust-lang/crates.io-index"
15491423 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
15501424
1551−[[package]]
1552−name = "litrs"
1553−version = "1.0.1"
1554−source = "registry+https://github.com/rust-lang/crates.io-index"
1555−checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
1556−
15571425 [[package]]
15581426 name = "lock_api"
15591427 version = "0.4.14"
@@ -1569,64 +1437,6 @@ version = "0.4.34"
15691437 source = "registry+https://github.com/rust-lang/crates.io-index"
15701438 checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
15711439
1572−[[package]]
1573−name = "lru"
1574−version = "0.16.4"
1575−source = "registry+https://github.com/rust-lang/crates.io-index"
1576−checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
1577−
1578−[[package]]
1579−name = "lyon"
1580−version = "1.0.19"
1581−source = "registry+https://github.com/rust-lang/crates.io-index"
1582−checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7"
1583−dependencies = [
1584− "lyon_algorithms",
1585− "lyon_tessellation",
1586−]
1587−
1588−[[package]]
1589−name = "lyon_algorithms"
1590−version = "1.0.22"
1591−source = "registry+https://github.com/rust-lang/crates.io-index"
1592−checksum = "f3a6b9d4e8b48acd531fbf1241ad9b270df6e60b6b92bdea1469f4f40899d805"
1593−dependencies = [
1594− "lyon_path",
1595− "num-traits",
1596−]
1597−
1598−[[package]]
1599−name = "lyon_geom"
1600−version = "1.0.20"
1601−source = "registry+https://github.com/rust-lang/crates.io-index"
1602−checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e"
1603−dependencies = [
1604− "arrayvec",
1605− "euclid",
1606− "num-traits",
1607−]
1608−
1609−[[package]]
1610−name = "lyon_path"
1611−version = "1.0.19"
1612−source = "registry+https://github.com/rust-lang/crates.io-index"
1613−checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
1614−dependencies = [
1615− "lyon_geom",
1616− "num-traits",
1617−]
1618−
1619−[[package]]
1620−name = "lyon_tessellation"
1621−version = "1.0.23"
1622−source = "registry+https://github.com/rust-lang/crates.io-index"
1623−checksum = "fd4872650e70dc907dd4f7ebc449d7d657a0d2658e423b2e724970870bbbf75b"
1624−dependencies = [
1625− "float_next_after",
1626− "lyon_path",
1627− "num-traits",
1628−]
1629−
16301440 [[package]]
16311441 name = "memchr"
16321442 version = "2.8.3"
@@ -1671,31 +1481,6 @@ dependencies = [
16711481 "simd-adler32",
16721482 ]
16731483
1674−[[package]]
1675−name = "naga"
1676−version = "27.0.3"
1677−source = "registry+https://github.com/rust-lang/crates.io-index"
1678−checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
1679−dependencies = [
1680− "arrayvec",
1681− "bit-set",
1682− "bitflags 2.13.2",
1683− "cfg-if",
1684− "cfg_aliases",
1685− "codespan-reporting",
1686− "half",
1687− "hashbrown 0.16.1",
1688− "hexf-parse",
1689− "indexmap",
1690− "libm",
1691− "log",
1692− "num-traits",
1693− "once_cell",
1694− "rustc-hash 1.1.0",
1695− "thiserror 2.0.21",
1696− "unicode-ident",
1697−]
1698−
16991484 [[package]]
17001485 name = "ndk"
17011486 version = "0.9.0"
@@ -1742,7 +1527,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
17421527 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
17431528 dependencies = [
17441529 "autocfg",
1745− "libm",
17461530 ]
17471531
17481532 [[package]]
@@ -2189,21 +1973,6 @@ dependencies = [
21891973 "windows-sys 0.61.2",
21901974 ]
21911975
2192−[[package]]
2193−name = "portable-atomic"
2194−version = "1.15.0"
2195−source = "registry+https://github.com/rust-lang/crates.io-index"
2196−checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
2197−
2198−[[package]]
2199−name = "portable-atomic-util"
2200−version = "0.2.8"
2201−source = "registry+https://github.com/rust-lang/crates.io-index"
2202−checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
2203−dependencies = [
2204− "portable-atomic",
2205−]
2206−
22071976 [[package]]
22081977 name = "proc-macro-crate"
22091978 version = "3.5.0"
@@ -2222,12 +1991,6 @@ dependencies = [
22221991 "unicode-ident",
22231992 ]
22241993
2225−[[package]]
2226−name = "profiling"
2227−version = "1.0.18"
2228−source = "registry+https://github.com/rust-lang/crates.io-index"
2229−checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
2230−
22311994 [[package]]
22321995 name = "quick-xml"
22331996 version = "0.41.0"
@@ -2319,12 +2082,6 @@ dependencies = [
23192082 "bitflags 2.13.2",
23202083 ]
23212084
2322−[[package]]
2323−name = "renderdoc-sys"
2324−version = "1.1.0"
2325−source = "registry+https://github.com/rust-lang/crates.io-index"
2326−checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2327−
23282085 [[package]]
23292086 name = "roxmltree"
23302087 version = "0.20.0"
@@ -2674,12 +2431,6 @@ dependencies = [
26742431 "windows-sys 0.61.2",
26752432 ]
26762433
2677−[[package]]
2678−name = "static_assertions"
2679−version = "1.1.0"
2680−source = "registry+https://github.com/rust-lang/crates.io-index"
2681−checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
2682−
26832434 [[package]]
26842435 name = "strict-num"
26852436 version = "0.1.1"
@@ -2692,12 +2443,6 @@ version = "0.11.1"
26922443 source = "registry+https://github.com/rust-lang/crates.io-index"
26932444 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
26942445
2695−[[package]]
2696−name = "svg_fmt"
2697−version = "0.4.5"
2698−source = "registry+https://github.com/rust-lang/crates.io-index"
2699−checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb"
2700−
27012446 [[package]]
27022447 name = "swash"
27032448 version = "0.2.10"
@@ -2951,12 +2696,6 @@ version = "1.13.3"
29512696 source = "registry+https://github.com/rust-lang/crates.io-index"
29522697 checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
29532698
2954−[[package]]
2955−name = "unicode-width"
2956−version = "0.2.2"
2957−source = "registry+https://github.com/rust-lang/crates.io-index"
2958−checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
2959−
29602699 [[package]]
29612700 name = "utf8parse"
29622701 version = "0.2.2"
@@ -3224,102 +2963,6 @@ dependencies = [
32242963 "wasm-bindgen",
32252964 ]
32262965
3227−[[package]]
3228−name = "wgpu"
3229−version = "27.0.1"
3230−source = "registry+https://github.com/rust-lang/crates.io-index"
3231−checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
3232−dependencies = [
3233− "arrayvec",
3234− "bitflags 2.13.2",
3235− "cfg-if",
3236− "cfg_aliases",
3237− "document-features",
3238− "hashbrown 0.16.1",
3239− "log",
3240− "portable-atomic",
3241− "profiling",
3242− "raw-window-handle",
3243− "smallvec",
3244− "static_assertions",
3245− "wgpu-core",
3246− "wgpu-hal",
3247− "wgpu-types",
3248−]
3249−
3250−[[package]]
3251−name = "wgpu-core"
3252−version = "27.0.3"
3253−source = "registry+https://github.com/rust-lang/crates.io-index"
3254−checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
3255−dependencies = [
3256− "arrayvec",
3257− "bit-set",
3258− "bit-vec",
3259− "bitflags 2.13.2",
3260− "bytemuck",
3261− "cfg_aliases",
3262− "document-features",
3263− "hashbrown 0.16.1",
3264− "indexmap",
3265− "log",
3266− "naga",
3267− "once_cell",
3268− "parking_lot",
3269− "portable-atomic",
3270− "profiling",
3271− "raw-window-handle",
3272− "rustc-hash 1.1.0",
3273− "smallvec",
3274− "thiserror 2.0.21",
3275− "wgpu-core-deps-windows-linux-android",
3276− "wgpu-hal",
3277− "wgpu-types",
3278−]
3279−
3280−[[package]]
3281−name = "wgpu-core-deps-windows-linux-android"
3282−version = "27.0.0"
3283−source = "registry+https://github.com/rust-lang/crates.io-index"
3284−checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3"
3285−dependencies = [
3286− "wgpu-hal",
3287−]
3288−
3289−[[package]]
3290−name = "wgpu-hal"
3291−version = "27.0.4"
3292−source = "registry+https://github.com/rust-lang/crates.io-index"
3293−checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
3294−dependencies = [
3295− "bitflags 2.13.2",
3296− "cfg-if",
3297− "cfg_aliases",
3298− "libloading",
3299− "log",
3300− "naga",
3301− "portable-atomic",
3302− "portable-atomic-util",
3303− "raw-window-handle",
3304− "renderdoc-sys",
3305− "thiserror 2.0.21",
3306− "wgpu-types",
3307−]
3308−
3309−[[package]]
3310−name = "wgpu-types"
3311−version = "27.0.1"
3312−source = "registry+https://github.com/rust-lang/crates.io-index"
3313−checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb"
3314−dependencies = [
3315− "bitflags 2.13.2",
3316− "bytemuck",
3317− "js-sys",
3318− "log",
3319− "thiserror 2.0.21",
3320− "web-sys",
3321−]
3322−
33232966 [[package]]
33242967 name = "winapi-util"
33252968 version = "0.1.11"
M crates/budctl-gui/Cargo.toml View
@@ -14,7 +14,7 @@ path = "src/main.rs"
1414 [dependencies]
1515 budctl-core = { path = "../budctl-core" }
1616 # Wayland only (no X11) and CPU rendering (no GPU stack): the smallest iced that draws this UI.
17−iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "canvas", "crisp"] }
17+iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "crisp"] }
1818 serde = { version = "1.0.229", features = ["derive"] }
1919 serde_json = "1.0.154"
2020
A crates/budctl-gui/src/glyphs.rs View
@@ -0,0 +1,27 @@
1+//! Icons: Nerd Font glyphs from the Material Design range, the same set the owner's shell (Aurora)
2+//! uses. They are written as escapes: they are private-use characters, which editors mangle.
3+
4+/// earbuds
5+pub const EARBUDS: char = '\u{F184F}';
6+/// headphones
7+pub const HEADPHONES: char = '\u{F02CB}';
8+/// view-dashboard-outline
9+pub const OVERVIEW: char = '\u{F0A1D}';
10+/// tune-vertical
11+pub const TUNE: char = '\u{F066A}';
12+/// gesture-tap
13+pub const GESTURE: char = '\u{F0741}';
14+/// cog
15+pub const COG: char = '\u{F0493}';
16+/// circle-off-outline
17+pub const NOISE_OFF: char = '\u{F10D3}';
18+/// ear-hearing-off
19+pub const ANC: char = '\u{F0A45}';
20+/// ear-hearing
21+pub const TRANSPARENCY: char = '\u{F07C5}';
22+/// chevron-right
23+pub const CHEVRON: char = '\u{F0142}';
24+/// bluetooth-connect
25+pub const BT_CONNECT: char = '\u{F00B1}';
26+/// bluetooth-off
27+pub const BT_OFF: char = '\u{F00B2}';
D crates/budctl-gui/src/icons.rs View
@@ -1,347 +0,0 @@
1−//! Icons and battery rings, drawn as vector paths so they take any theme color and stay sharp at
2−//! any scale. Icons are designed on a 24 × 24 grid.
3−
4−use std::f32::consts::{FRAC_PI_2, TAU};
5−
6−use iced::widget::canvas::{self, Frame, LineCap, LineJoin, Path, Stroke, path::Arc};
7−use iced::{Color, Element, Point, Radians, Rectangle, Renderer, Size, Theme, mouse};
8−
9−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10−pub enum Icon {
11− Overview,
12− Equalizer,
13− Gestures,
14− Earbud,
15− Settings,
16− NoiseOff,
17− Anc,
18− Transparency,
19− Game,
20− Spatial,
21− Bass,
22− Wind,
23− Dual,
24− Chevron,
25− Bolt,
26−}
27−
28−/// `icon` at `size` pixels, in `color`.
29−pub fn icon<'a, Message: 'a>(icon: Icon, size: f32, color: Color) -> Element<'a, Message> {
30− iced::widget::canvas(Glyph { icon, color })
31− .width(size)
32− .height(size)
33− .into()
34−}
35−
36−struct Glyph {
37− icon: Icon,
38− color: Color,
39−}
40−
41−impl<Message> canvas::Program<Message> for Glyph {
42− type State = ();
43−
44− fn draw(
45− &self,
46− _state: &(),
47− renderer: &Renderer,
48− _theme: &Theme,
49− bounds: Rectangle,
50− _cursor: mouse::Cursor,
51− ) -> Vec<canvas::Geometry> {
52− let mut frame = Frame::new(renderer, bounds.size());
53− let mut pen = Pen::new(&mut frame, Point::ORIGIN, bounds.width, self.color);
54− draw_icon(&mut pen, self.icon);
55− vec![frame.into_geometry()]
56− }
57−}
58−
59−/// Draws on a 24 × 24 grid placed at `origin`, `size` pixels wide.
60−struct Pen<'f> {
61− frame: &'f mut Frame,
62− origin: Point,
63− scale: f32,
64− color: Color,
65− width: f32,
66−}
67−
68−impl<'f> Pen<'f> {
69− fn new(frame: &'f mut Frame, origin: Point, size: f32, color: Color) -> Self {
70− let scale = size / 24.0;
71− Pen {
72− frame,
73− origin,
74− scale,
75− color,
76− width: 1.9 * scale,
77− }
78− }
79−
80− fn at(&self, x: f32, y: f32) -> Point {
81− Point::new(self.origin.x + x * self.scale, self.origin.y + y * self.scale)
82− }
83−
84− fn stroke(&mut self, path: &Path) {
85− let stroke = Stroke::default()
86− .with_color(self.color)
87− .with_width(self.width)
88− .with_line_cap(LineCap::Round)
89− .with_line_join(LineJoin::Round);
90− self.frame.stroke(path, stroke);
91− }
92−
93− fn polyline(&mut self, points: &[(f32, f32)], closed: bool) {
94− let path = Path::new(|b| {
95− for (n, &(x, y)) in points.iter().enumerate() {
96− if n == 0 {
97− b.move_to(self.at(x, y));
98− } else {
99− b.line_to(self.at(x, y));
100− }
101− }
102− if closed {
103− b.close();
104− }
105− });
106− self.stroke(&path);
107− }
108−
109− fn line(&mut self, from: (f32, f32), to: (f32, f32)) {
110− self.polyline(&[from, to], false);
111− }
112−
113− fn circle(&mut self, x: f32, y: f32, r: f32) {
114− let path = Path::circle(self.at(x, y), r * self.scale);
115− self.stroke(&path);
116− }
117−
118− fn dot(&mut self, x: f32, y: f32, r: f32) {
119− let path = Path::circle(self.at(x, y), r * self.scale);
120− self.frame.fill(&path, self.color);
121− }
122−
123− /// Degrees, clockwise from three o'clock. `to` may be below `from` to go the other way.
124− fn arc(&mut self, x: f32, y: f32, r: f32, from: f32, to: f32) {
125− let path = Path::new(|b| {
126− b.arc(Arc {
127− center: self.at(x, y),
128− radius: r * self.scale,
129− start_angle: Radians(from.to_radians()),
130− end_angle: Radians(to.to_radians()),
131− });
132− });
133− self.stroke(&path);
134− }
135−
136− fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32) {
137− let path = self.rounded(x, y, w, h, r);
138− self.stroke(&path);
139− }
140−
141− fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32) {
142− let path = self.rounded(x, y, w, h, r);
143− self.frame.fill(&path, self.color);
144− }
145−
146− fn rounded(&self, x: f32, y: f32, w: f32, h: f32, r: f32) -> Path {
147− Path::rounded_rectangle(
148− self.at(x, y),
149− Size::new(w * self.scale, h * self.scale),
150− (r * self.scale).into(),
151− )
152− }
153−}
154−
155−fn draw_icon(pen: &mut Pen, icon: Icon) {
156− match icon {
157− Icon::Overview => {
158− for (x, y) in [(3.0, 3.0), (13.5, 3.0), (3.0, 13.5), (13.5, 13.5)] {
159− pen.rect(x, y, 7.5, 7.5, 2.0);
160− }
161− }
162− Icon::Equalizer => {
163− for (x, knob) in [(6.0, 14.0), (12.0, 8.0), (18.0, 16.0)] {
164− pen.line((x, 3.5), (x, 20.5));
165− pen.line((x - 2.5, knob), (x + 2.5, knob));
166− }
167− }
168− Icon::Gestures => {
169− pen.rect(9.5, 11.0, 5.0, 11.0, 2.5);
170− pen.arc(12.0, 11.5, 4.0, 200.0, 340.0);
171− pen.arc(12.0, 11.5, 7.5, 210.0, 330.0);
172− }
173− Icon::Earbud => earbud(pen, false, None),
174− Icon::Settings => {
175− for (y, knob) in [(6.0, 15.0), (12.0, 8.0), (18.0, 13.0)] {
176− pen.line((3.5, y), (20.5, y));
177− pen.dot(knob, y, 2.6);
178− }
179− }
180− Icon::NoiseOff => {
181− pen.circle(12.0, 12.0, 8.5);
182− pen.line((6.0, 6.0), (18.0, 18.0));
183− }
184− Icon::Anc => {
185− pen.circle(12.0, 12.0, 8.5);
186− pen.circle(12.0, 12.0, 4.5);
187− pen.dot(12.0, 12.0, 1.4);
188− }
189− Icon::Transparency => {
190− for n in 0..12u8 {
191− let angle = f32::from(n) * 30f32.to_radians();
192− pen.dot(12.0 + 8.5 * angle.cos(), 12.0 + 8.5 * angle.sin(), 1.15);
193− }
194− pen.dot(12.0, 12.0, 2.2);
195− }
196− Icon::Game | Icon::Bolt => pen.polyline(
197− &[
198− (13.5, 2.5),
199− (5.0, 13.5),
200− (11.5, 13.5),
201− (10.5, 21.5),
202− (19.0, 10.5),
203− (12.5, 10.5),
204− ],
205− true,
206− ),
207− Icon::Spatial => {
208− pen.dot(12.0, 12.0, 2.4);
209− for (r, spread) in [(5.5, 40.0), (9.5, 35.0)] {
210− pen.arc(12.0, 12.0, r, -spread, spread);
211− pen.arc(12.0, 12.0, r, 180.0 - spread, 180.0 + spread);
212− }
213− }
214− Icon::Bass => {
215− pen.polyline(
216− &[
217− (3.5, 9.5),
218− (7.5, 9.5),
219− (12.5, 5.0),
220− (12.5, 19.0),
221− (7.5, 14.5),
222− (3.5, 14.5),
223− ],
224− true,
225− );
226− pen.arc(13.0, 12.0, 4.0, -50.0, 50.0);
227− pen.arc(13.0, 12.0, 8.0, -50.0, 50.0);
228− }
229− Icon::Wind => {
230− pen.line((3.0, 8.5), (13.0, 8.5));
231− pen.arc(13.0, 6.0, 2.5, 90.0, -180.0);
232− pen.line((3.0, 12.5), (17.5, 12.5));
233− pen.arc(17.5, 15.5, 3.0, -90.0, 180.0);
234− pen.line((3.0, 16.5), (9.0, 16.5));
235− }
236− Icon::Dual => {
237− pen.rect(2.5, 5.0, 12.5, 9.0, 1.5);
238− pen.line((1.5, 17.5), (16.0, 17.5));
239− pen.rect(17.5, 8.0, 4.5, 11.0, 1.2);
240− }
241− Icon::Chevron => pen.polyline(&[(9.0, 5.5), (15.5, 12.0), (9.0, 18.5)], false),
242− }
243−}
244−
245−/// A filled earbud: the head, the ear tip pointing in, and the stem going down. The tip is
246−/// drawn in `tip`, or in the part's color when there is none.
247−fn earbud(pen: &mut Pen, mirrored: bool, tip: Option<Color>) {
248− let x = |x: f32, w: f32| if mirrored { 24.0 - x - w } else { x };
249− let color = pen.color;
250− pen.color = tip.unwrap_or(color);
251− pen.fill_rect(x(14.5, 6.0), 5.0, 6.0, 7.0, 2.8);
252− pen.color = color;
253− pen.dot(x(11.0, 0.0), 8.5, 6.3);
254− pen.fill_rect(x(6.6, 4.4), 10.0, 4.4, 12.5, 2.2);
255−}
256−
257−/// What a battery ring shows in its middle.
258−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259−pub enum Part {
260− Left,
261− Right,
262− Case,
263−}
264−
265−/// A battery ring: the track, an arc for the charge (none when unknown) and the part in the
266−/// middle.
267−pub fn battery_ring<'a, Message: 'a>(
268− part: Part,
269− level: Option<u8>,
270− size: f32,
271− colors: RingColors,
272−) -> Element<'a, Message> {
273− iced::widget::canvas(Ring { part, level, colors })
274− .width(size)
275− .height(size)
276− .into()
277−}
278−
279−#[derive(Debug, Clone, Copy)]
280−pub struct RingColors {
281− pub track: Color,
282− pub charge: Color,
283− pub part: Color,
284− /// The earbuds' ear tips.
285− pub tip: Color,
286− /// Details cut into the case (the lid line, the light).
287− pub cutout: Color,
288−}
289−
290−struct Ring {
291− part: Part,
292− level: Option<u8>,
293− colors: RingColors,
294−}
295−
296−impl<Message> canvas::Program<Message> for Ring {
297− type State = ();
298−
299− fn draw(
300− &self,
301− _state: &(),
302− renderer: &Renderer,
303− _theme: &Theme,
304− bounds: Rectangle,
305− _cursor: mouse::Cursor,
306− ) -> Vec<canvas::Geometry> {
307− let mut frame = Frame::new(renderer, bounds.size());
308− let center = frame.center();
309− let width = bounds.width * 0.06;
310− let radius = bounds.width / 2.0 - width / 2.0 - 1.0;
311− let ring = |color: Color| {
312− Stroke::default()
313− .with_color(color)
314− .with_width(width)
315− .with_line_cap(LineCap::Round)
316− };
317− frame.stroke(&Path::circle(center, radius), ring(self.colors.track));
318− if let Some(level) = self.level.filter(|&level| level > 0) {
319− let sweep = f32::from(level.min(100)) / 100.0 * TAU;
320− let arc = Path::new(|b| {
321− b.arc(Arc {
322− center,
323− radius,
324− start_angle: Radians(-FRAC_PI_2),
325− end_angle: Radians(-FRAC_PI_2 + sweep),
326− });
327− });
328− frame.stroke(&arc, ring(self.colors.charge));
329− }
330−
331− let art = bounds.width * 0.44;
332− let origin = Point::new(center.x - art / 2.0, center.y - art / 2.0);
333− let mut pen = Pen::new(&mut frame, origin, art, self.colors.part);
334− match self.part {
335− Part::Left => earbud(&mut pen, false, Some(self.colors.tip)),
336− Part::Right => earbud(&mut pen, true, Some(self.colors.tip)),
337− Part::Case => {
338− pen.fill_rect(2.0, 5.5, 20.0, 13.5, 6.0);
339− pen.color = self.colors.cutout;
340− pen.width = 0.9 * pen.scale;
341− pen.line((2.0, 10.0), (22.0, 10.0));
342− pen.dot(12.0, 14.5, 1.0);
343− }
344− }
345− vec![frame.into_geometry()]
346− }
347−}
M crates/budctl-gui/src/main.rs View
@@ -1,10 +1,10 @@
11 //! `budctl-gui`: control wireless earbuds from Linux, in a window.
22 //!
33 //! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first,
4−//! the live connection later (CLAUDE.md, GUI).
4+//! the live connection later (CLAUDE.md, GUI). It is styled after the owner's shell, Aurora.
55
66 mod demo;
7−mod icons;
7+mod glyphs;
88 mod overview;
99 mod theme;
1010 mod ui;
@@ -13,16 +13,16 @@ use std::path::PathBuf;
1313 use std::process::ExitCode;
1414
1515 use budctl_core::status::{AncLevel, NoiseControl, NoiseMode};
16−use iced::widget::{Column, column, container, row, scrollable, space, text};
17−use iced::{Alignment, Border, Element, Font, Length, Size, Subscription, Theme, event, window};
16+use iced::widget::{Column, column, container, row};
17+use iced::{Element, Font, Length, Size, Subscription, Theme, event, window};
1818
19−use icons::Icon;
2019 use theme::Look;
2120
2221 const USAGE: &str = "usage: budctl-gui [--theme <palette.json>]
2322
24−Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
25−The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).";
23+Colors come from --theme, else ~/.config/budctl/theme.json, else Aurora's matugen palette
24+(~/.cache/matugen/aurora.json), else they are built in. A palette file takes Aurora's keys
25+(bg, fg, primary, surface, surfaceHigh, outlineVariant, surfaceVariantFg, err).";
2626
2727 fn main() -> ExitCode {
2828 let theme_file = match parse_args(std::env::args().skip(1)) {
@@ -36,15 +36,15 @@ fn main() -> ExitCode {
3636 return ExitCode::from(1);
3737 }
3838 };
39− let font = theme::system_font();
39+ let font = theme::font();
4040 let result = iced::application(move || App::new(theme_file.clone(), font), App::update, App::view)
4141 .title("budctl")
4242 .theme(App::theme)
4343 .subscription(App::subscription)
4444 .default_font(font)
4545 .window(window::Settings {
46− size: Size::new(1040.0, 800.0),
47− min_size: Some(Size::new(760.0, 540.0)),
46+ size: Size::new(960.0, 680.0),
47+ min_size: Some(Size::new(720.0, 480.0)),
4848 platform_specific: window::settings::PlatformSpecific {
4949 application_id: "budctl".to_owned(),
5050 ..window::settings::PlatformSpecific::default()
@@ -96,23 +96,31 @@ impl Page {
9696 Page::Settings,
9797 ];
9898
99− fn title(self) -> &'static str {
99+ fn name(self) -> &'static str {
100100 match self {
101− Page::Overview => "Overview",
102− Page::Equalizer => "Equalizer",
103− Page::Gestures => "Gestures",
104− Page::Earbuds => "Earbud settings",
105− Page::Settings => "App settings",
101+ Page::Overview => "overview",
102+ Page::Equalizer => "equalizer",
103+ Page::Gestures => "gestures",
104+ Page::Earbuds => "earbuds",
105+ Page::Settings => "settings",
106106 }
107107 }
108108
109− fn icon(self) -> Icon {
109+ fn glyph(self) -> char {
110110 match self {
111− Page::Overview => Icon::Overview,
112− Page::Equalizer => Icon::Equalizer,
113− Page::Gestures => Icon::Gestures,
114− Page::Earbuds => Icon::Earbud,
115− Page::Settings => Icon::Settings,
111+ Page::Overview => glyphs::OVERVIEW,
112+ Page::Equalizer => glyphs::TUNE,
113+ Page::Gestures => glyphs::GESTURE,
114+ Page::Earbuds => glyphs::EARBUDS,
115+ Page::Settings => glyphs::COG,
116+ }
117+ }
118+
119+ /// The sidebar group it is listed under.
120+ fn group(self) -> &'static str {
121+ match self {
122+ Page::Settings => "app",
123+ _ => "buds",
116124 }
117125 }
118126 }
@@ -131,7 +139,7 @@ enum Message {
131139 struct App {
132140 page: Page,
133141 buds: demo::Buds,
134− /// The level "Noise cancelling" turns on at: the one last reported or picked.
142+ /// The level "noise cancelling" turns on at: the one last reported or picked.
135143 anc_level: AncLevel,
136144 look: Look,
137145 theme: Theme,
@@ -152,10 +160,7 @@ impl App {
152160 page: Page::Overview,
153161 buds,
154162 anc_level,
155− look: Look {
156− p: theme::BUILT_IN,
157− font,
158− },
163+ look: Look::new(theme::BUILT_IN, font),
159164 theme: theme::iced_theme(&theme::BUILT_IN),
160165 palette: theme::Source::new(theme_file),
161166 };
@@ -217,86 +222,53 @@ impl App {
217222 let look = self.look;
218223 let page = match self.page {
219224 Page::Overview => overview::view(look, &self.buds, self.anc_level),
220− page => column![
221− ui::title(look, page.title()),
222− text("This page comes in a later step.").size(14).color(look.p.dim)
223− ]
224− .spacing(12)
225− .into(),
225+ _ => ui::hint(look, "this page comes in a later step").into(),
226226 };
227− let p = look.p;
228− let content = scrollable(
229− container(container(page).max_width(860))
230− .center_x(Length::Fill)
231− .padding([30, 36]),
232− )
233− .height(Length::Fill)
234− .style(move |theme, status| {
235− let mut style = scrollable::default(theme, status);
236− for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
237− rail.background = None;
238− rail.border = Border::default();
239− rail.scroller.background = p.surface_highest.into();
240− rail.scroller.border = Border::default().rounded(4);
241− }
242− style
243− });
227+ let content = column![ui::title(look, self.page.name()), ui::scroll(look, page)]
228+ .spacing(12)
229+ .padding(16)
230+ .width(Length::Fill);
244231 row![self.sidebar(), ui::divider(look), content].into()
245232 }
246233
234+ /// Aurora's settings sidebar: the title, the buds, then the pages by group.
247235 fn sidebar(&self) -> Element<'_, Message> {
248236 let look = self.look;
249237 let p = look.p;
250238 let connected = self.buds.connected;
251− let (light, state) = if connected {
252− (p.ok, "Connected")
239+ let status = format!("{} · demo", if connected { "connected" } else { "not connected" });
240+ let connect = if connected {
241+ ui::button_(look, Some(glyphs::BT_OFF), "disconnect", false, Message::Connect(false))
253242 } else {
254− (p.dim, "Not connected")
243+ ui::button_(look, Some(glyphs::BT_CONNECT), "connect", false, Message::Connect(true))
255244 };
256− let device = container(
257− column![
258− text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
259− row![
260− ui::light(light),
261− text(state).size(13).color(p.dim),
262− space::horizontal(),
263− ui::tag(look, "demo")
264− ]
265− .spacing(8)
266− .align_y(Alignment::Center),
267− ui::outline_button(
268− look,
269− if connected { "Disconnect" } else { "Connect" },
270− Message::Connect(!connected)
271− ),
272− ]
273− .spacing(12),
274− )
275− .padding(16)
276− .style(move |_| container::Style {
277− background: Some(p.surface.into()),
278− border: Border {
279− color: p.line,
280− width: 1.0,
281− radius: 18.0.into(),
282− },
283− ..container::Style::default()
284− });
285− let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
286− pages.push(ui::nav_item(
245+ let mut pages = Column::new().spacing(2);
246+ let mut group = "";
247+ for page in Page::ALL {
248+ if page.group() != group {
249+ group = page.group();
250+ pages = pages.push(container(ui::group(look, group)).padding([6, 0]));
251+ }
252+ pages = pages.push(ui::nav_item(
287253 look,
288− page.icon(),
289− page.title(),
254+ page.glyph(),
255+ page.name(),
290256 page == self.page,
291257 Message::Open(page),
292− ))
293− });
294− container(column![device, pages].spacing(22))
295− .padding([18, 14])
296− .width(268)
258+ ));
259+ }
260+ let top = column![
261+ ui::sidebar_title(look, glyphs::EARBUDS, "budctl"),
262+ ui::device(look, &self.buds.name, status, connected),
263+ connect.width(Length::Fill),
264+ ]
265+ .spacing(8);
266+ container(column![top, pages].spacing(10))
267+ .padding(10)
268+ .width(200)
297269 .height(Length::Fill)
298270 .style(move |_| container::Style {
299− background: Some(p.surface_low.into()),
271+ background: Some(p.surface.into()),
300272 ..container::Style::default()
301273 })
302274 .into()
@@ -353,12 +325,12 @@ mod tests {
353325 fn pages_render() {
354326 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
355327 let _ = fs::remove_dir_all(&dir);
356− let font = theme::system_font();
328+ let font = theme::font();
357329 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
358330 let shots = Page::ALL
359331 .into_iter()
360− .map(|page| (page, 800.0, format!("{page:?}").to_lowercase()))
361− .chain([(Page::Overview, 1240.0, "overview-full".to_owned())]);
332+ .map(|page| (page, 680.0, page.name().to_owned()))
333+ .chain([(Page::Overview, 860.0, "overview-full".to_owned())]);
362334 for (page, height, name) in shots {
363335 let mut app = App::new(palette.clone(), font);
364336 app.page = page;
@@ -366,7 +338,7 @@ mod tests {
366338 default_font: font,
367339 ..iced::Settings::default()
368340 };
369− let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
341+ let mut ui = iced_test::Simulator::with_size(settings, Size::new(960.0, height), app.view());
370342 let snapshot = ui.snapshot(&app.theme).unwrap();
371343 assert!(snapshot.matches_image(dir.join(name)).unwrap());
372344 }
M crates/budctl-gui/src/overview.rs View
@@ -1,171 +1,139 @@
1−//! The Overview page: battery, noise control, the switches, and the way to the other pages.
1+//! The Overview page: battery, noise control, sound, and the buds' other settings.
22
33 use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch};
4−use iced::widget::{column, container, row, text};
5−use iced::{Alignment, Element, Length};
4+use iced::Element;
65
76 use crate::demo::Buds;
8−use crate::icons::{self, Icon, Part, RingColors};
7+use crate::glyphs;
98 use crate::theme::Look;
10−use crate::ui::{self, Segment};
9+use crate::ui::{self, Opt};
1110 use crate::{Message, Page};
1211
12+/// The switches the "sound" section shows, in this order. The others go under "buds".
13+const SOUND: [&str; 3] = ["game", "spatial", "bass-boost"];
14+
15+/// The line under a switch's name, for the switches the GUI knows.
16+fn about(key: &str) -> Option<&'static str> {
17+ match key {
18+ "game" => Some("lower latency, for games and video"),
19+ "spatial" => Some("wider, surround-like sound"),
20+ "bass-boost" => Some("stronger low end"),
21+ "wind" => Some("less wind noise outdoors"),
22+ "dual" => Some("stay connected to two devices at once"),
23+ _ => None,
24+ }
25+}
26+
1327 pub fn view<'a>(look: Look, buds: &'a Buds, anc_level: AncLevel) -> Element<'a, Message> {
14− column![ui::title(look, "Overview"), battery(look, buds)]
15− .push(
16− buds.status
17− .noise_control
18− .as_ref()
19− .map(|now| noise_control(look, &now.mode, anc_level)),
28+ let status = &buds.status;
29+ let switch = |s: &Switch| {
30+ ui::switch_row(
31+ look,
32+ s.label.to_lowercase(),
33+ about(s.key),
34+ s.on,
35+ Message::Switch(s.key, !s.on),
2036 )
21− .push(switches(look, &buds.status.switches))
22− .push(more(look, buds))
23− .spacing(18)
24− .into()
37+ };
38+
39+ let mut sound = vec![ui::section(look, "sound")];
40+ sound.extend(
41+ SOUND
42+ .iter()
43+ .filter_map(|key| status.switches.iter().find(|s| s.key == *key))
44+ .map(switch),
45+ );
46+ sound.push(ui::link_row(
47+ look,
48+ "equalizer",
49+ Some("presets, and your own"),
50+ Some(buds.eq_name.clone()),
51+ Message::Open(Page::Equalizer),
52+ ));
53+
54+ let mut rest = vec![ui::section(look, "buds")];
55+ rest.extend(status.switches.iter().filter(|s| !SOUND.contains(&s.key)).map(switch));
56+ rest.push(ui::link_row(
57+ look,
58+ "gestures",
59+ Some("taps and holds on each bud"),
60+ None,
61+ Message::Open(Page::Gestures),
62+ ));
63+ rest.push(ui::link_row(
64+ look,
65+ "earbuds",
66+ Some("find my earbuds, firmware"),
67+ None,
68+ Message::Open(Page::Earbuds),
69+ ));
70+
71+ let mut sections = vec![battery(look, buds)];
72+ sections.extend(
73+ status
74+ .noise_control
75+ .as_ref()
76+ .map(|now| noise_control(look, &now.mode, anc_level)),
77+ );
78+ sections.extend([sound, rest]);
79+ ui::sections(sections)
2580 }
2681
27−fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
82+fn battery<'a>(look: Look, buds: &Buds) -> Vec<Element<'a, Message>> {
2883 let p = look.p;
29− let colors = RingColors {
30− track: p.surface_highest,
31− charge: p.accent,
32− part: p.fg,
33− tip: p.dim,
34− cutout: p.surface,
35− };
36− let gauge = |part, label: &'a str, charge: Option<Charge>| -> Element<'a, Message> {
37− let reading = match charge {
38− Some(charge) => format!("{}%", charge.level),
39− None => "—".to_owned(),
40− };
41− let figure = row![text(reading).size(21).font(look.bold()).color(p.fg)]
42− .push(
43− charge
44− .filter(|c| c.charging)
45− .map(|_| icons::icon(Icon::Bolt, 16.0, p.icon)),
46− )
47− .spacing(4)
48− .align_y(Alignment::Center);
49− column![
50− icons::battery_ring(part, charge.map(|c| c.level), 112.0, colors),
51− figure,
52− text(label).size(13).color(p.dim)
53− ]
54− .spacing(6)
55− .align_x(Alignment::Center)
56− .width(Length::Fill)
57− .into()
84+ let bar = |name: &'a str, charge: Option<Charge>| match charge {
85+ Some(charge) => {
86+ let low = charge.level <= 15 && !charge.charging;
87+ let value = if charge.charging {
88+ format!("{}% charging", charge.level)
89+ } else {
90+ format!("{}%", charge.level)
91+ };
92+ let fraction = f32::from(charge.level.min(100)) / 100.0;
93+ ui::stat_bar(look, name, fraction, value, if low { p.err } else { p.accent })
94+ }
95+ None => ui::stat_bar(look, name, 0.0, "unknown".to_owned(), p.accent),
5896 };
5997 let battery = buds.status.battery;
60− let gauges = row![
61− gauge(Part::Left, "Left", battery.left),
62− gauge(Part::Case, "Case", battery.case),
63− gauge(Part::Right, "Right", battery.right),
64− ];
65− ui::card(look, vec![container(gauges).padding([22, 8]).into()])
98+ vec![
99+ ui::section(look, "battery"),
100+ bar("left", battery.left),
101+ bar("right", battery.right),
102+ bar("case", battery.case),
103+ ]
66104 }
67105
68−fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel) -> Element<'a, Message> {
106+fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel) -> Vec<Element<'a, Message>> {
69107 let modes = [
70− (Icon::NoiseOff, "Off", NoiseMode::Off),
71− (Icon::Anc, "Noise cancelling", NoiseMode::Anc(anc_level)),
72− (Icon::Transparency, "Transparency", NoiseMode::Transparency),
108+ (glyphs::NOISE_OFF, "off", NoiseMode::Off),
109+ (glyphs::ANC, "noise cancelling", NoiseMode::Anc(anc_level)),
110+ (glyphs::TRANSPARENCY, "transparency", NoiseMode::Transparency),
73111 ];
74− let segments = modes
112+ let options = modes
75113 .into_iter()
76− .map(|(icon, label, set)| Segment {
77− icon: Some(icon),
78− label,
79− selected: set.name() == mode,
114+ .map(|(glyph, words, set)| Opt {
115+ glyph: Some(glyph),
116+ words,
117+ chosen: set.name() == mode,
80118 on_press: Message::Noise(set),
81119 })
82120 .collect();
83− let levels: Vec<Segment> = [
84− (AncLevel::Light, "Light"),
85− (AncLevel::Medium, "Medium"),
86− (AncLevel::Deep, "Deep"),
87− ]
88− .into_iter()
89− .map(|(level, label)| Segment {
90− icon: None,
91− label,
92− selected: level == anc_level,
93− on_press: Message::Noise(NoiseMode::Anc(level)),
94− })
95− .collect();
96− let strength = row![
97− text("Strength").size(13.5).color(look.p.dim).width(Length::Fill),
98− container(ui::segmented(look, levels, false)).width(300)
99− ]
100− .align_y(Alignment::Center)
101− .padding([0, 8]);
102− column![ui::segmented(look, segments, true)]
103− .push((mode == NoiseMode::Anc(anc_level).name()).then_some(strength))
104− .spacing(12)
105− .into()
106−}
107−
108−/// How the page shows the switches it knows, in this order. Others still show, last, with a
109−/// plain icon and no subtitle.
110−const SWITCHES: [(&str, Icon, &str); 5] = [
111− ("game", Icon::Game, "Lower latency, for games and video"),
112− ("spatial", Icon::Spatial, "Wider, surround-like sound"),
113− ("bass-boost", Icon::Bass, "Stronger low end"),
114− ("wind", Icon::Wind, "Less wind noise outdoors"),
115− ("dual", Icon::Dual, "Stay connected to two devices at once"),
116−];
117−
118−fn switches<'a>(look: Look, switches: &[Switch]) -> Element<'a, Message> {
119− let known = |key: &str| SWITCHES.iter().find(|(k, _, _)| *k == key);
120− let rank = |s: &&Switch| {
121− SWITCHES
122− .iter()
123− .position(|(k, _, _)| *k == s.key)
124− .unwrap_or(SWITCHES.len())
125− };
126− let mut shown: Vec<&Switch> = switches.iter().collect();
127− shown.sort_by_key(rank);
128− let rows = shown
121+ let mut rows = vec![ui::section(look, "noise control"), ui::choice(look, "mode", options)];
122+ if mode == NoiseMode::Anc(anc_level).name() {
123+ let levels = [
124+ (AncLevel::Light, "light"),
125+ (AncLevel::Medium, "medium"),
126+ (AncLevel::Deep, "deep"),
127+ ]
129128 .into_iter()
130− .map(|s| {
131− let (icon, subtitle) = known(s.key).map_or((Icon::Settings, None), |&(_, icon, subtitle)| {
132− (icon, Some(subtitle.to_owned()))
133− });
134− ui::setting(
135− look,
136− icon,
137− s.label,
138− subtitle,
139− ui::switch(look, s.on),
140− Message::Switch(s.key, !s.on),
141− )
129+ .map(|(level, words)| Opt {
130+ glyph: None,
131+ words,
132+ chosen: level == anc_level,
133+ on_press: Message::Noise(NoiseMode::Anc(level)),
142134 })
143135 .collect();
144− ui::card(look, rows)
145−}
146−
147−fn more<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
148− let open = |icon, title, subtitle: &str, page| {
149− ui::setting(
150− look,
151− icon,
152− title,
153− Some(subtitle.to_owned()),
154− ui::chevron(look),
155− Message::Open(page),
156− )
157− };
158− ui::card(
159− look,
160− vec![
161− open(Icon::Equalizer, "Equalizer", &buds.eq_name, Page::Equalizer),
162− open(Icon::Gestures, "Gestures", "Taps and holds on each bud", Page::Gestures),
163− open(
164− Icon::Earbud,
165− "Earbud settings",
166− "Find my earbuds, firmware",
167− Page::Earbuds,
168− ),
169− ],
170− )
136+ rows.push(ui::choice(look, "strength", levels));
137+ }
138+ rows
171139 }
M crates/budctl-gui/src/theme.rs View
@@ -1,67 +1,49 @@
1−//! Colors and fonts. The colors are a built-in dark palette, or one read from a matugen JSON file.
1+//! Colors and fonts, matched to the owner's shell (Aurora): its matugen palette, JetBrains Mono, and
2+//! its 12 px base size.
23 //!
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.
4+//! The palette file uses Aurora's keys (`bg`, `fg`, `primary`…), so `~/.cache/matugen/aurora.json`
5+//! works as it is, and the keys map to the same roles as in Aurora's `Theme.qml`. A key the file
6+//! lacks keeps its built-in color.
67
78 use std::fs;
8−use std::path::{Path, PathBuf};
9+use std::path::PathBuf;
910 use std::process::Command;
1011 use std::time::SystemTime;
1112
12−use iced::font::Weight;
13+use iced::font::{Family, Weight};
1314 use iced::{Color, Font};
1415 use serde::Deserialize;
1516
16−/// The colors the UI uses, by role.
17+/// The colors the UI uses, by role, named as in Aurora.
1718 #[derive(Debug, Clone, Copy, PartialEq)]
1819 pub struct Palette {
19− /// Window background.
20+ /// Window background, and text on `accent`.
2021 pub bg: Color,
2122 /// Text.
2223 pub fg: Color,
23− /// Secondary text, and icons at rest.
24+ /// Secondary text: hints, and the labels of bars and groups.
2425 pub dim: Color,
25− /// What is on or selected: switches, the noise-control pill, battery rings.
26+ /// What is on or chosen: filled buttons and switches, section labels, bars.
2627 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.
28+ /// The sidebar, and hovered setting rows.
3729 pub surface: Color,
38− /// Hovered rows, and switches when off.
30+ /// Hovered buttons and rows, switches when off, bar tracks.
3931 pub surface_high: Color,
40− /// Battery ring tracks.
41− pub surface_highest: Color,
42− /// Card borders and separators.
32+ /// Borders and separators.
4333 pub line: Color,
4434 pub err: Color,
45− /// "Connected". matugen has no such role, so it stays green.
46− pub ok: Color,
4735 }
4836
49−/// Dark, with a red accent.
37+/// Near-black, with a red accent.
5038 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),
39+ bg: Color::from_rgb8(0x0D, 0x0D, 0x0F),
40+ fg: Color::from_rgb8(0xE4, 0xE4, 0xE8),
41+ dim: Color::from_rgb8(0xA5, 0xA5, 0xAE),
42+ accent: Color::from_rgb8(0xD9, 0x3A, 0x3A),
43+ surface: Color::from_rgb8(0x19, 0x19, 0x1C),
44+ surface_high: Color::from_rgb8(0x24, 0x24, 0x28),
45+ line: Color::from_rgb8(0x3B, 0x3B, 0x42),
6346 err: Color::from_rgb8(0xFF, 0xB4, 0xAB),
64− ok: Color::from_rgb8(0x4C, 0xC3, 0x6A),
6547 };
6648
6749 /// The palette file's keys. Other keys (`image`, `secondary`…) are ignored.
@@ -71,13 +53,8 @@ struct PaletteFile {
7153 bg: Option<String>,
7254 fg: Option<String>,
7355 primary: Option<String>,
74− primary_fg: Option<String>,
75− primary_container: Option<String>,
76− secondary: Option<String>,
77− surface_low: Option<String>,
7856 surface: Option<String>,
7957 surface_high: Option<String>,
80− surface_highest: Option<String>,
8158 outline_variant: Option<String>,
8259 surface_variant_fg: Option<String>,
8360 err: Option<String>,
@@ -96,16 +73,10 @@ pub fn parse(json: &str) -> Result<Palette, String> {
9673 fg: pick("fg", file.fg, b.fg)?,
9774 dim: pick("surfaceVariantFg", file.surface_variant_fg, b.dim)?,
9875 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)?,
10376 surface: pick("surface", file.surface, b.surface)?,
10477 surface_high: pick("surfaceHigh", file.surface_high, b.surface_high)?,
105− surface_highest: pick("surfaceHighest", file.surface_highest, b.surface_highest)?,
10678 line: pick("outlineVariant", file.outline_variant, b.line)?,
10779 err: pick("err", file.err, b.err)?,
108− ok: b.ok,
10980 })
11081 }
11182
@@ -125,13 +96,23 @@ fn parse_hex(hex: &str) -> Option<Color> {
12596 }
12697 }
12798
128−/// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`.
99+/// The palette file to follow when `--theme` names none: `~/.config/budctl/theme.json`, else
100+/// Aurora's own palette. When neither exists yet, the first, so creating it later takes effect.
129101 pub fn default_file() -> Option<PathBuf> {
130− let config = std::env::var_os("XDG_CONFIG_HOME")
131− .map(PathBuf::from)
132− .filter(|p| p.is_absolute())
133− .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
134− Some(config.join("budctl/theme.json"))
102+ let home = std::env::var_os("HOME").map(PathBuf::from)?;
103+ let xdg = |var: &str, fallback: &str| {
104+ std::env::var_os(var)
105+ .map(PathBuf::from)
106+ .filter(|p| p.is_absolute())
107+ .unwrap_or_else(|| home.join(fallback))
108+ };
109+ let ours = xdg("XDG_CONFIG_HOME", ".config").join("budctl/theme.json");
110+ let aurora = xdg("XDG_CACHE_HOME", ".cache").join("matugen/aurora.json");
111+ Some(if !ours.exists() && aurora.exists() {
112+ aurora
113+ } else {
114+ ours
115+ })
135116 }
136117
137118 /// The palette file and the version of it last read, so it is only read again when it changes.
@@ -166,19 +147,23 @@ impl Source {
166147 }
167148 }
168149
169−/// The palette and the fonts, handed to everything that draws.
150+/// The palette and the font, handed to everything that draws.
170151 #[derive(Debug, Clone, Copy)]
171152 pub struct Look {
172153 pub p: Palette,
173154 pub font: Font,
155+ /// The base text size (Aurora's `fontSize`). Other sizes are relative to it.
156+ pub size: f32,
174157 }
175158
176159 impl Look {
177− pub fn medium(self) -> Font {
178− Font {
179− weight: Weight::Medium,
180− ..self.font
181− }
160+ pub fn new(p: Palette, font: Font) -> Self {
161+ Look { p, font, size: 12.0 }
162+ }
163+
164+ /// The base size plus `delta` pixels.
165+ pub fn px(self, delta: f32) -> f32 {
166+ self.size + delta
182167 }
183168
184169 pub fn bold(self) -> Font {
@@ -189,24 +174,33 @@ impl Look {
189174 }
190175 }
191176
192−/// The desktop's sans-serif font, as fontconfig names it. iced's own default is a font many
193−/// systems lack.
194−pub fn system_font() -> Font {
177+/// Aurora's font. Its Nerd Font glyphs are the icons.
178+const FONT: &str = "JetBrainsMono Nerd Font";
179+
180+/// [`FONT`] when it is installed, else the desktop's monospace font as fontconfig names it.
181+pub fn font() -> Font {
182+ let installed = Command::new("fc-list")
183+ .args(["-q", FONT])
184+ .status()
185+ .is_ok_and(|status| status.success());
186+ if installed {
187+ return Font::with_name(FONT);
188+ }
195189 let family = Command::new("fc-match")
196− .args(["--format", "%{family[0]}", "sans-serif"])
190+ .args(["--format", "%{family[0]}", "monospace"])
197191 .output()
198192 .ok()
199193 .filter(|out| out.status.success())
200194 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
201195 .filter(|name| !name.is_empty());
202− match family {
196+ Font {
203197 // Font names are &'static; this runs once.
204− Some(name) => Font::with_name(Box::leak(name.into_boxed_str())),
205− None => Font::DEFAULT,
198+ family: family.map_or(Family::Monospace, |name| Family::Name(Box::leak(name.into_boxed_str()))),
199+ ..Font::DEFAULT
206200 }
207201 }
208202
209−/// The iced theme for the widgets drawn with their default style.
203+/// The iced theme, for what is drawn with iced's default styles (the scrollbar).
210204 pub fn iced_theme(p: &Palette) -> iced::Theme {
211205 iced::Theme::custom(
212206 "budctl",
@@ -214,7 +208,7 @@ pub fn iced_theme(p: &Palette) -> iced::Theme {
214208 background: p.bg,
215209 text: p.fg,
216210 primary: p.accent,
217− success: p.ok,
211+ success: p.accent,
218212 warning: p.accent,
219213 danger: p.err,
220214 },
@@ -241,11 +235,9 @@ mod tests {
241235 .unwrap();
242236 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a));
243237 assert_eq!(palette.accent, Color::from_rgb8(0x7b, 0x4b, 0x2f));
244− assert_eq!(palette.accent_dim, Color::from_rgb8(0x5c, 0x38, 0x23));
245− assert_eq!(palette.icon, Color::from_rgb8(0xe2, 0xbf, 0xae));
238+ assert_eq!(palette.surface_high, Color::from_rgb8(0x2a, 0x21, 0x1d));
246239 assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d));
247240 assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9));
248− assert_eq!(palette.ok, BUILT_IN.ok);
249241 }
250242
251243 #[test]
M crates/budctl-gui/src/ui.rs View
@@ -1,253 +1,322 @@
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 controls of the owner's shell (Aurora), rebuilt in iced. Aurora is flat and dense: square
2+//! corners, 1 px lines, accent fills with background-colored text, uppercase accent section
3+//! labels, lowercase words.
34
4−use iced::widget::{Column, Row, button, column, container, row, rule, space, text, toggler};
5−use iced::{Alignment, Border, Element, Length};
5+use iced::alignment::{Horizontal, Vertical};
6+use iced::widget::{Button, Column, Row, button, column, container, progress_bar, row, rule, scrollable, space, text};
7+use iced::{Alignment, Border, Color, Element, Length, Padding};
68
79 use crate::Message;
8−use crate::icons::{self, Icon};
10+use crate::glyphs;
911 use crate::theme::Look;
1012
11−/// A page title.
12−pub fn title<'a>(look: Look, title: &'a str) -> Element<'a, Message> {
13− text(title).size(30).font(look.bold()).color(look.p.fg).into()
14−}
15−
16−/// A rounded card holding rows, with a thin line between them.
17−pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Message> {
18− let p = look.p;
19− let mut column = Column::new();
20− for (n, item) in rows.into_iter().enumerate() {
21− if n > 0 {
22− column = column.push(separator(look));
23− }
24− column = column.push(item);
25− }
26− container(column)
27− .padding(5)
28− .width(Length::Fill)
29− .style(move |_| container::Style {
30− background: Some(p.surface.into()),
31− border: Border {
32− color: p.line,
33− width: 1.0,
34− radius: 18.0.into(),
35− },
36− ..container::Style::default()
37− })
13+/// The page title: uppercase and bold.
14+pub fn title<'a>(look: Look, title: &str) -> Element<'a, Message> {
15+ text(title.to_uppercase())
16+ .size(look.px(4.0))
17+ .font(look.bold())
18+ .color(look.p.fg)
3819 .into()
3920 }
4021
41−fn separator<'a>(look: Look) -> Element<'a, Message> {
22+/// An uppercase accent label, with a line filling the rest of the row.
23+pub fn section<'a>(look: Look, label: &str) -> Element<'a, Message> {
4224 let p = look.p;
43− container(rule::horizontal(1).style(move |_| rule::Style {
25+ let line = rule::horizontal(1).style(move |_| rule::Style {
4426 color: p.line,
4527 radius: 0.0.into(),
4628 fill_mode: rule::FillMode::Full,
4729 snap: true,
48− }))
49− .padding([0, 14])
50− .into()
30+ });
31+ row![text(label.to_uppercase()).size(look.px(-2.0)).color(p.accent), line]
32+ .spacing(8)
33+ .align_y(Alignment::Center)
34+ .into()
35+}
36+
37+/// Text in the base size and color.
38+pub fn label<'a>(look: Look, words: impl text::IntoFragment<'a>) -> text::Text<'a> {
39+ text(words).size(look.size).color(look.p.fg)
5140 }
5241
53−/// A setting row: icon, title, an optional subtitle, and something on the right. The whole row
54−/// is the click target.
55−pub fn setting<'a>(
42+/// Smaller, dimmer text, for hints.
43+pub fn hint<'a>(look: Look, words: impl text::IntoFragment<'a>) -> text::Text<'a> {
44+ text(words).size(look.px(-2.0)).color(look.p.dim)
45+}
46+
47+fn hovered(status: button::Status) -> bool {
48+ matches!(status, button::Status::Hovered | button::Status::Pressed)
49+}
50+
51+/// An on/off setting: its label, an optional hint below, and a square switch on the right. The
52+/// whole row is the click target.
53+pub fn switch_row<'a>(
5654 look: Look,
57− icon: Icon,
58− title: &'a str,
59− subtitle: Option<String>,
60− trailing: Element<'a, Message>,
61− on_press: Message,
55+ name: String,
56+ about: Option<&'a str>,
57+ on: bool,
58+ toggle: Message,
6259 ) -> Element<'a, Message> {
6360 let p = look.p;
64− let words = column![text(title).size(15).font(look.medium()).color(p.fg)]
65− .push(subtitle.map(|s| text(s).size(13).color(p.dim)))
66− .spacing(2)
61+ let words = column![label(look, name)]
62+ .push(about.map(|about| hint(look, about)))
6763 .width(Length::Fill);
68− let content = row![icons::icon(icon, 22.0, p.icon), words, trailing]
69− .spacing(16)
64+ let content = row![words, switch(look, on)]
65+ .spacing(10)
7066 .align_y(Alignment::Center)
71− .padding([13, 16]);
67+ .padding([5, 0]);
7268 button(content)
73− .on_press(on_press)
74− .padding(0)
69+ .on_press(toggle)
7570 .width(Length::Fill)
71+ .padding(0)
7672 .style(move |_, status| button::Style {
77− background: hovered(status).then_some(p.surface_high.into()),
73+ background: hovered(status).then_some(p.surface.into()),
7874 text_color: p.fg,
79− border: Border::default().rounded(14),
8075 ..button::Style::default()
8176 })
8277 .into()
8378 }
8479
85−fn hovered(status: button::Status) -> bool {
86− matches!(status, button::Status::Hovered | button::Status::Pressed)
87−}
88−
89−/// An on/off switch. It only shows the state: the row around it takes the click.
90−pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> {
80+/// A square track with a square knob: left and dim when off, right on the accent when on.
81+fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> {
9182 let p = look.p;
92− toggler(on)
93− .size(24)
94− .style(move |_, _| toggler::Style {
95− background: (if on { p.accent } else { p.surface_high }).into(),
96− background_border_width: 1.0,
97− background_border_color: if on { p.accent } else { p.line },
98− foreground: (if on { p.accent_fg } else { p.dim }).into(),
99− foreground_border_width: 0.0,
100− foreground_border_color: p.accent_fg,
101− text_color: None,
102− border_radius: None,
103− padding_ratio: 0.12,
83+ let knob = container(space())
84+ .width(12)
85+ .height(12)
86+ .style(move |_| container::Style {
87+ background: Some((if on { p.bg } else { p.dim }).into()),
88+ ..container::Style::default()
89+ });
90+ container(knob)
91+ .width(36)
92+ .height(18)
93+ .padding(3)
94+ .align_x(if on { Horizontal::Right } else { Horizontal::Left })
95+ .align_y(Vertical::Center)
96+ .style(move |_| container::Style {
97+ background: Some((if on { p.accent } else { p.surface_high }).into()),
98+ border: Border {
99+ color: if on { p.accent } else { p.line },
100+ width: 1.0,
101+ radius: 0.0.into(),
102+ },
103+ ..container::Style::default()
104104 })
105105 .into()
106106 }
107107
108−/// A chevron: the row opens a page.
109−pub fn chevron<'a>(look: Look) -> Element<'a, Message> {
110− icons::icon(Icon::Chevron, 18.0, look.p.dim)
111−}
112−
113−/// One choice of a [`segmented`] control.
114−pub struct Segment<'a> {
115− pub icon: Option<Icon>,
116− pub label: &'a str,
117− pub selected: bool,
118− pub on_press: Message,
119−}
120−
121−/// A pill of choices with the selected one filled, like the noise-control modes. `large` puts an
122−/// icon above each label.
123−pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool) -> Element<'a, Message> {
108+/// A button with a 1 px border, an optional glyph and a label. `active` fills it with the accent.
109+pub fn button_<'a>(
110+ look: Look,
111+ glyph: Option<char>,
112+ words: &'a str,
113+ active: bool,
114+ on_press: Message,
115+) -> Button<'a, Message> {
124116 let p = look.p;
125− let mut choices = Row::new().spacing(4);
126− for segment in segments {
127− let selected = segment.selected;
128− let color = if selected { p.accent_fg } else { p.dim };
129− let label = text(segment.label)
130− .size(if large { 13.0 } else { 13.5 })
131− .font(look.medium())
132− .color(color);
133− let inner: Element<'a, Message> = match segment.icon {
134− Some(icon) if large => column![icons::icon(icon, 22.0, color), label]
135− .spacing(6)
136− .align_x(Alignment::Center)
137− .into(),
138− _ => label.into(),
139− };
140− let choice = button(container(inner).center_x(Length::Fill))
141− .on_press(segment.on_press)
142− .width(Length::Fill)
143− .padding(if large { [11, 8] } else { [7, 12] })
144− .style(move |_, status| button::Style {
145− background: if selected {
117+ let color = if active { p.bg } else { p.fg };
118+ let content = Row::new()
119+ .push(glyph.map(|g| text(g.to_string()).size(15).color(color)))
120+ .push((!words.is_empty()).then(|| text(words).size(look.px(-1.0)).color(color)))
121+ .spacing(8)
122+ .align_y(Alignment::Center);
123+ button(container(content).center(Length::Fill))
124+ .on_press(on_press)
125+ .height(26)
126+ .padding(0)
127+ .style(move |_, status| {
128+ let hover = hovered(status);
129+ button::Style {
130+ background: if active {
146131 Some(p.accent.into())
147132 } else {
148− hovered(status).then_some(p.surface_high.into())
133+ hover.then_some(p.surface_high.into())
149134 },
150135 text_color: color,
151− border: Border::default().rounded(999),
136+ border: Border {
137+ color: if active || hover { p.accent } else { p.line },
138+ width: 1.0,
139+ radius: 0.0.into(),
140+ },
152141 ..button::Style::default()
153− });
154− choices = choices.push(choice);
155− }
156− container(choices)
157− .padding(5)
158− .width(Length::Fill)
159− .style(move |_| container::Style {
160− background: Some(p.surface.into()),
161− border: Border {
162− color: p.line,
163− width: 1.0,
164− radius: 999.0.into(),
165− },
166− ..container::Style::default()
142+ }
167143 })
144+}
145+
146+/// One option of a [`choice`].
147+pub struct Opt<'a> {
148+ pub glyph: Option<char>,
149+ pub words: &'a str,
150+ pub chosen: bool,
151+ pub on_press: Message,
152+}
153+
154+/// A label, then one button per option, all the same width, the chosen one filled.
155+pub fn choice<'a>(look: Look, name: &'a str, options: Vec<Opt<'a>>) -> Element<'a, Message> {
156+ let buttons = options.into_iter().fold(Row::new().spacing(2), |buttons, o| {
157+ buttons.push(button_(look, o.glyph, o.words, o.chosen, o.on_press).width(Length::FillPortion(1)))
158+ });
159+ row![label(look, name).width(150), buttons]
160+ .spacing(8)
161+ .align_y(Alignment::Center)
168162 .into()
169163 }
170164
171−/// A page in the sidebar.
172−pub fn nav_item<'a>(look: Look, icon: Icon, label: &'a str, open: bool, on_press: Message) -> Element<'a, Message> {
165+/// A label, a level bar and the value, like a battery's charge.
166+pub fn stat_bar<'a>(look: Look, name: &'a str, fraction: f32, value: String, fill: Color) -> Element<'a, Message> {
173167 let p = look.p;
174− let (color, icon_color) = if open { (p.fg, p.icon) } else { (p.dim, p.dim) };
175− let content = row![
176− icons::icon(icon, 19.0, icon_color),
177− text(label)
178− .size(14.5)
179− .font(if open { look.medium() } else { look.font })
180− .color(color)
168+ let bar = progress_bar(0.0..=1.0, fraction)
169+ .girth(6)
170+ .style(move |_| progress_bar::Style {
171+ background: p.surface_high.into(),
172+ bar: fill.into(),
173+ border: Border::default(),
174+ });
175+ row![
176+ text(name).size(look.px(-1.0)).color(p.dim).width(72),
177+ bar,
178+ text(value)
179+ .size(look.px(-1.0))
180+ .color(p.fg)
181+ .width(96)
182+ .align_x(Horizontal::Right)
181183 ]
182− .spacing(13)
183− .align_y(Alignment::Center);
184+ .spacing(10)
185+ .align_y(Alignment::Center)
186+ .into()
187+}
188+
189+/// A row that opens a page: its label, an optional hint below, and on the right a value and an
190+/// arrow. Lined up with the switch rows.
191+pub fn link_row<'a>(
192+ look: Look,
193+ name: &'a str,
194+ about: Option<&'a str>,
195+ value: Option<String>,
196+ on_press: Message,
197+) -> Element<'a, Message> {
198+ let p = look.p;
199+ let words = column![label(look, name)]
200+ .push(about.map(|about| hint(look, about)))
201+ .width(Length::Fill);
202+ let content = row![words]
203+ .push(value.map(|value| text(value).size(look.px(-1.0)).color(p.dim)))
204+ .push(text(glyphs::CHEVRON.to_string()).size(look.px(4.0)).color(p.dim))
205+ .spacing(10)
206+ .align_y(Alignment::Center)
207+ .padding([5, 0]);
184208 button(content)
185209 .on_press(on_press)
186210 .width(Length::Fill)
187− .padding([10, 14])
211+ .padding(0)
188212 .style(move |_, status| button::Style {
189− background: if open {
190− Some(p.accent_dim.into())
191− } else {
192− hovered(status).then_some(p.surface_high.into())
193− },
194− text_color: color,
195− border: Border::default().rounded(12),
213+ background: hovered(status).then_some(p.surface.into()),
214+ text_color: p.fg,
196215 ..button::Style::default()
197216 })
198217 .into()
199218 }
200219
201−/// A wide button with an outline, like "Disconnect".
202−pub fn outline_button<'a>(look: Look, label: &'a str, on_press: Message) -> Element<'a, Message> {
220+/// The sidebar's title: a glyph and a name, bold, on the accent.
221+pub fn sidebar_title<'a>(look: Look, glyph: char, name: &str) -> Element<'a, Message> {
222+ text(format!("{glyph} {name}"))
223+ .size(look.px(1.0))
224+ .font(look.bold())
225+ .color(look.p.accent)
226+ .into()
227+}
228+
229+/// A group heading in the sidebar.
230+pub fn group<'a>(look: Look, name: &str) -> Element<'a, Message> {
231+ text(name.to_uppercase()).size(look.px(-3.0)).color(look.p.dim).into()
232+}
233+
234+/// A page in the sidebar. The open one is filled with the accent.
235+pub fn nav_item<'a>(look: Look, glyph: char, name: &str, open: bool, on_press: Message) -> Element<'a, Message> {
203236 let p = look.p;
204− button(container(text(label).size(13.5).font(look.medium()).color(p.fg)).center_x(Length::Fill))
237+ let words = text(format!("{glyph} {name}"))
238+ .size(look.size)
239+ .font(if open { look.bold() } else { look.font })
240+ .color(if open { p.bg } else { p.fg });
241+ button(container(words).height(Length::Fill).align_y(Vertical::Center))
205242 .on_press(on_press)
206243 .width(Length::Fill)
207− .padding([8, 12])
244+ .height(30)
245+ .padding([0, 10])
208246 .style(move |_, status| button::Style {
209− background: hovered(status).then_some(p.surface_high.into()),
210− text_color: p.fg,
211− border: Border {
212− color: p.line,
213− width: 1.0,
214− radius: 999.0.into(),
247+ background: if open {
248+ Some(p.accent.into())
249+ } else {
250+ hovered(status).then_some(p.surface_high.into())
215251 },
252+ text_color: if open { p.bg } else { p.fg },
216253 ..button::Style::default()
217254 })
218255 .into()
219256 }
220257
221−/// A small filled circle, like the "connected" light.
222−pub fn light<'a>(color: iced::Color) -> Element<'a, Message> {
223− container(space())
224− .width(8)
225− .height(8)
258+/// The buds in the sidebar: a glyph, the name and a status line, in a 1 px frame that is the
259+/// accent while they are connected.
260+pub fn device<'a>(look: Look, name: &'a str, status: String, connected: bool) -> Element<'a, Message> {
261+ let p = look.p;
262+ let mark = if connected { p.accent } else { p.dim };
263+ let content = row![
264+ text(glyphs::HEADPHONES.to_string()).size(17).color(mark),
265+ column![
266+ text(name).size(look.size).font(look.bold()).color(p.fg),
267+ text(status).size(look.px(-3.0)).color(mark)
268+ ]
269+ ]
270+ .spacing(10)
271+ .align_y(Alignment::Center);
272+ container(content)
273+ .padding([7, 10])
274+ .width(Length::Fill)
226275 .style(move |_| container::Style {
227− background: Some(color.into()),
228− border: Border::default().rounded(4),
276+ border: Border {
277+ color: if connected { p.accent } else { p.line },
278+ width: 1.0,
279+ radius: 0.0.into(),
280+ },
229281 ..container::Style::default()
230282 })
231283 .into()
232284 }
233285
234−/// A small outlined tag, like "demo".
235−pub fn tag<'a>(look: Look, label: &'a str) -> Element<'a, Message> {
286+/// A column of sections with Aurora's spacing: 8 px between rows, a little more above each section.
287+pub fn sections<'a>(sections: Vec<Vec<Element<'a, Message>>>) -> Element<'a, Message> {
288+ let mut page = Column::new().spacing(14);
289+ for rows in sections {
290+ page = page.push(Column::with_children(rows).spacing(8));
291+ }
292+ page.into()
293+}
294+
295+/// A scrolling area with a thin, square scrollbar, clear of the content.
296+pub fn scroll<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> {
236297 let p = look.p;
237− container(text(label).size(11).font(look.medium()).color(p.icon))
238− .padding([1, 8])
239− .style(move |_| container::Style {
240− border: Border {
241− color: p.icon,
242− width: 1.0,
243− radius: 999.0.into(),
244− },
245− ..container::Style::default()
298+ let padded = container(content).padding(Padding {
299+ top: 0.0,
300+ right: 14.0,
301+ bottom: 8.0,
302+ left: 0.0,
303+ });
304+ scrollable(padded)
305+ .height(Length::Fill)
306+ .style(move |theme, status| {
307+ let mut style = scrollable::default(theme, status);
308+ for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
309+ rail.background = None;
310+ rail.border = Border::default();
311+ rail.scroller.background = p.surface_high.into();
312+ rail.scroller.border = Border::default();
313+ }
314+ style
246315 })
247316 .into()
248317 }
249318
250−/// A thin vertical line, between the sidebar and the page.
319+/// A 1 px vertical line, between the sidebar and the page.
251320 pub fn divider<'a>(look: Look) -> Element<'a, Message> {
252321 let p = look.p;
253322 rule::vertical(1)