Krolyxon
Commit 59f791a

Branches

Refs →

budctl59f791a

Back to the first GUI look (owner's choice)

The owner tried the Aurora restyle and preferred the first design. This
reverts commit f782ac0.
Author
krolyxon
Date
2026-10-27 23:16 +0530
Parent
99a16bb
Commit
59f791a2c6d8adc04ea89560ff6ccd02f6a185dd

+1199 −523

9 files changed

  1. M CLAUDE.md +3 −3
  2. M Cargo.lock +358 −1
  3. M crates/budctl-gui/Cargo.toml +1 −1
  4. D crates/budctl-gui/src/glyphs.rs +0 −27
  5. A crates/budctl-gui/src/icons.rs +347 −0
  6. M crates/budctl-gui/src/main.rs +96 −68
  7. M crates/budctl-gui/src/overview.rs +145 −113
  8. M crates/budctl-gui/src/theme.rs +75 −67
  9. M crates/budctl-gui/src/ui.rs +174 −243
M CLAUDE.md View
@@ -117,9 +117,9 @@ Exit codes:
117117
118118 ## GUI
119119
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.
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.
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,6 +267,21 @@ 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+
270285 [[package]]
271286 name = "bitflags"
272287 version = "1.3.2"
@@ -514,6 +529,15 @@ dependencies = [
514529 "smithay-clipboard",
515530 ]
516531
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+
517541 [[package]]
518542 name = "colorchoice"
519543 version = "1.0.5"
@@ -642,6 +666,19 @@ version = "0.2.4"
642666 source = "registry+https://github.com/rust-lang/crates.io-index"
643667 checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
644668
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+
645682 [[package]]
646683 name = "crypto-common"
647684 version = "0.1.7"
@@ -693,6 +730,15 @@ dependencies = [
693730 "libloading",
694731 ]
695732
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+
696742 [[package]]
697743 name = "downcast-rs"
698744 version = "1.2.1"
@@ -754,6 +800,25 @@ version = "3.4.0"
754800 source = "registry+https://github.com/rust-lang/crates.io-index"
755801 checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
756802
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+
757822 [[package]]
758823 name = "event-listener"
759824 version = "5.4.2"
@@ -806,6 +871,18 @@ dependencies = [
806871 "zlib-rs",
807872 ]
808873
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+
809886 [[package]]
810887 name = "font-types"
811888 version = "0.10.1"
@@ -1014,6 +1091,16 @@ version = "0.25.0"
10141091 source = "registry+https://github.com/rust-lang/crates.io-index"
10151092 checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
10161093
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+
10171104 [[package]]
10181105 name = "half"
10191106 version = "2.7.1"
@@ -1022,6 +1109,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
10221109 dependencies = [
10231110 "cfg-if",
10241111 "crunchy",
1112+ "num-traits",
10251113 "zerocopy",
10261114 ]
10271115
@@ -1038,6 +1126,15 @@ dependencies = [
10381126 "smallvec",
10391127 ]
10401128
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+
10411138 [[package]]
10421139 name = "hashbrown"
10431140 version = "0.17.1"
@@ -1062,6 +1159,12 @@ version = "0.4.3"
10621159 source = "registry+https://github.com/rust-lang/crates.io-index"
10631160 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
10641161
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+
10651168 [[package]]
10661169 name = "iced"
10671170 version = "0.14.0"
@@ -1134,6 +1237,7 @@ dependencies = [
11341237 "iced_core",
11351238 "iced_futures",
11361239 "log",
1240+ "lyon_path",
11371241 "raw-window-handle",
11381242 "rustc-hash 2.1.3",
11391243 "thiserror 2.0.21",
@@ -1158,6 +1262,7 @@ checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05"
11581262 dependencies = [
11591263 "iced_graphics",
11601264 "iced_tiny_skia",
1265+ "iced_wgpu",
11611266 "log",
11621267 "thiserror 2.0.21",
11631268 ]
@@ -1218,6 +1323,27 @@ dependencies = [
12181323 "tiny-skia",
12191324 ]
12201325
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+
12211347 [[package]]
12221348 name = "iced_widget"
12231349 version = "0.14.2"
@@ -1257,7 +1383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
12571383 checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
12581384 dependencies = [
12591385 "equivalent",
1260− "hashbrown",
1386+ "hashbrown 0.17.1",
12611387 ]
12621388
12631389 [[package]]
@@ -1422,6 +1548,12 @@ version = "0.12.1"
14221548 source = "registry+https://github.com/rust-lang/crates.io-index"
14231549 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
14241550
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+
14251557 [[package]]
14261558 name = "lock_api"
14271559 version = "0.4.14"
@@ -1437,6 +1569,64 @@ version = "0.4.34"
14371569 source = "registry+https://github.com/rust-lang/crates.io-index"
14381570 checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
14391571
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+
14401630 [[package]]
14411631 name = "memchr"
14421632 version = "2.8.3"
@@ -1481,6 +1671,31 @@ dependencies = [
14811671 "simd-adler32",
14821672 ]
14831673
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+
14841699 [[package]]
14851700 name = "ndk"
14861701 version = "0.9.0"
@@ -1527,6 +1742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
15271742 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
15281743 dependencies = [
15291744 "autocfg",
1745+ "libm",
15301746 ]
15311747
15321748 [[package]]
@@ -1973,6 +2189,21 @@ dependencies = [
19732189 "windows-sys 0.61.2",
19742190 ]
19752191
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+
19762207 [[package]]
19772208 name = "proc-macro-crate"
19782209 version = "3.5.0"
@@ -1991,6 +2222,12 @@ dependencies = [
19912222 "unicode-ident",
19922223 ]
19932224
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+
19942231 [[package]]
19952232 name = "quick-xml"
19962233 version = "0.41.0"
@@ -2082,6 +2319,12 @@ dependencies = [
20822319 "bitflags 2.13.2",
20832320 ]
20842321
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+
20852328 [[package]]
20862329 name = "roxmltree"
20872330 version = "0.20.0"
@@ -2431,6 +2674,12 @@ dependencies = [
24312674 "windows-sys 0.61.2",
24322675 ]
24332676
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+
24342683 [[package]]
24352684 name = "strict-num"
24362685 version = "0.1.1"
@@ -2443,6 +2692,12 @@ version = "0.11.1"
24432692 source = "registry+https://github.com/rust-lang/crates.io-index"
24442693 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
24452694
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+
24462701 [[package]]
24472702 name = "swash"
24482703 version = "0.2.10"
@@ -2696,6 +2951,12 @@ version = "1.13.3"
26962951 source = "registry+https://github.com/rust-lang/crates.io-index"
26972952 checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
26982953
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+
26992960 [[package]]
27002961 name = "utf8parse"
27012962 version = "0.2.2"
@@ -2963,6 +3224,102 @@ dependencies = [
29633224 "wasm-bindgen",
29643225 ]
29653226
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+
29663323 [[package]]
29673324 name = "winapi-util"
29683325 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", "crisp"] }
17+iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "canvas", "crisp"] }
1818 serde = { version = "1.0.229", features = ["derive"] }
1919 serde_json = "1.0.154"
2020
D crates/budctl-gui/src/glyphs.rs View
@@ -1,27 +0,0 @@
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}';
A crates/budctl-gui/src/icons.rs View
@@ -0,0 +1,347 @@
1+//! Icons and battery rings, drawn as vector paths so they take any theme color and stay sharp at
2+//! any scale. Icons are designed on a 24 × 24 grid.
3+
4+use std::f32::consts::{FRAC_PI_2, TAU};
5+
6+use iced::widget::canvas::{self, Frame, LineCap, LineJoin, Path, Stroke, path::Arc};
7+use iced::{Color, Element, Point, Radians, Rectangle, Renderer, Size, Theme, mouse};
8+
9+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10+pub enum Icon {
11+ Overview,
12+ Equalizer,
13+ Gestures,
14+ Earbud,
15+ Settings,
16+ NoiseOff,
17+ Anc,
18+ Transparency,
19+ Game,
20+ Spatial,
21+ Bass,
22+ Wind,
23+ Dual,
24+ Chevron,
25+ Bolt,
26+}
27+
28+/// `icon` at `size` pixels, in `color`.
29+pub fn icon<'a, Message: 'a>(icon: Icon, size: f32, color: Color) -> Element<'a, Message> {
30+ iced::widget::canvas(Glyph { icon, color })
31+ .width(size)
32+ .height(size)
33+ .into()
34+}
35+
36+struct Glyph {
37+ icon: Icon,
38+ color: Color,
39+}
40+
41+impl<Message> canvas::Program<Message> for Glyph {
42+ type State = ();
43+
44+ fn draw(
45+ &self,
46+ _state: &(),
47+ renderer: &Renderer,
48+ _theme: &Theme,
49+ bounds: Rectangle,
50+ _cursor: mouse::Cursor,
51+ ) -> Vec<canvas::Geometry> {
52+ let mut frame = Frame::new(renderer, bounds.size());
53+ let mut pen = Pen::new(&mut frame, Point::ORIGIN, bounds.width, self.color);
54+ draw_icon(&mut pen, self.icon);
55+ vec![frame.into_geometry()]
56+ }
57+}
58+
59+/// Draws on a 24 × 24 grid placed at `origin`, `size` pixels wide.
60+struct Pen<'f> {
61+ frame: &'f mut Frame,
62+ origin: Point,
63+ scale: f32,
64+ color: Color,
65+ width: f32,
66+}
67+
68+impl<'f> Pen<'f> {
69+ fn new(frame: &'f mut Frame, origin: Point, size: f32, color: Color) -> Self {
70+ let scale = size / 24.0;
71+ Pen {
72+ frame,
73+ origin,
74+ scale,
75+ color,
76+ width: 1.9 * scale,
77+ }
78+ }
79+
80+ fn at(&self, x: f32, y: f32) -> Point {
81+ Point::new(self.origin.x + x * self.scale, self.origin.y + y * self.scale)
82+ }
83+
84+ fn stroke(&mut self, path: &Path) {
85+ let stroke = Stroke::default()
86+ .with_color(self.color)
87+ .with_width(self.width)
88+ .with_line_cap(LineCap::Round)
89+ .with_line_join(LineJoin::Round);
90+ self.frame.stroke(path, stroke);
91+ }
92+
93+ fn polyline(&mut self, points: &[(f32, f32)], closed: bool) {
94+ let path = Path::new(|b| {
95+ for (n, &(x, y)) in points.iter().enumerate() {
96+ if n == 0 {
97+ b.move_to(self.at(x, y));
98+ } else {
99+ b.line_to(self.at(x, y));
100+ }
101+ }
102+ if closed {
103+ b.close();
104+ }
105+ });
106+ self.stroke(&path);
107+ }
108+
109+ fn line(&mut self, from: (f32, f32), to: (f32, f32)) {
110+ self.polyline(&[from, to], false);
111+ }
112+
113+ fn circle(&mut self, x: f32, y: f32, r: f32) {
114+ let path = Path::circle(self.at(x, y), r * self.scale);
115+ self.stroke(&path);
116+ }
117+
118+ fn dot(&mut self, x: f32, y: f32, r: f32) {
119+ let path = Path::circle(self.at(x, y), r * self.scale);
120+ self.frame.fill(&path, self.color);
121+ }
122+
123+ /// Degrees, clockwise from three o'clock. `to` may be below `from` to go the other way.
124+ fn arc(&mut self, x: f32, y: f32, r: f32, from: f32, to: f32) {
125+ let path = Path::new(|b| {
126+ b.arc(Arc {
127+ center: self.at(x, y),
128+ radius: r * self.scale,
129+ start_angle: Radians(from.to_radians()),
130+ end_angle: Radians(to.to_radians()),
131+ });
132+ });
133+ self.stroke(&path);
134+ }
135+
136+ fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32) {
137+ let path = self.rounded(x, y, w, h, r);
138+ self.stroke(&path);
139+ }
140+
141+ fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32) {
142+ let path = self.rounded(x, y, w, h, r);
143+ self.frame.fill(&path, self.color);
144+ }
145+
146+ fn rounded(&self, x: f32, y: f32, w: f32, h: f32, r: f32) -> Path {
147+ Path::rounded_rectangle(
148+ self.at(x, y),
149+ Size::new(w * self.scale, h * self.scale),
150+ (r * self.scale).into(),
151+ )
152+ }
153+}
154+
155+fn draw_icon(pen: &mut Pen, icon: Icon) {
156+ match icon {
157+ Icon::Overview => {
158+ for (x, y) in [(3.0, 3.0), (13.5, 3.0), (3.0, 13.5), (13.5, 13.5)] {
159+ pen.rect(x, y, 7.5, 7.5, 2.0);
160+ }
161+ }
162+ Icon::Equalizer => {
163+ for (x, knob) in [(6.0, 14.0), (12.0, 8.0), (18.0, 16.0)] {
164+ pen.line((x, 3.5), (x, 20.5));
165+ pen.line((x - 2.5, knob), (x + 2.5, knob));
166+ }
167+ }
168+ Icon::Gestures => {
169+ pen.rect(9.5, 11.0, 5.0, 11.0, 2.5);
170+ pen.arc(12.0, 11.5, 4.0, 200.0, 340.0);
171+ pen.arc(12.0, 11.5, 7.5, 210.0, 330.0);
172+ }
173+ Icon::Earbud => earbud(pen, false, None),
174+ Icon::Settings => {
175+ for (y, knob) in [(6.0, 15.0), (12.0, 8.0), (18.0, 13.0)] {
176+ pen.line((3.5, y), (20.5, y));
177+ pen.dot(knob, y, 2.6);
178+ }
179+ }
180+ Icon::NoiseOff => {
181+ pen.circle(12.0, 12.0, 8.5);
182+ pen.line((6.0, 6.0), (18.0, 18.0));
183+ }
184+ Icon::Anc => {
185+ pen.circle(12.0, 12.0, 8.5);
186+ pen.circle(12.0, 12.0, 4.5);
187+ pen.dot(12.0, 12.0, 1.4);
188+ }
189+ Icon::Transparency => {
190+ for n in 0..12u8 {
191+ let angle = f32::from(n) * 30f32.to_radians();
192+ pen.dot(12.0 + 8.5 * angle.cos(), 12.0 + 8.5 * angle.sin(), 1.15);
193+ }
194+ pen.dot(12.0, 12.0, 2.2);
195+ }
196+ Icon::Game | Icon::Bolt => pen.polyline(
197+ &[
198+ (13.5, 2.5),
199+ (5.0, 13.5),
200+ (11.5, 13.5),
201+ (10.5, 21.5),
202+ (19.0, 10.5),
203+ (12.5, 10.5),
204+ ],
205+ true,
206+ ),
207+ Icon::Spatial => {
208+ pen.dot(12.0, 12.0, 2.4);
209+ for (r, spread) in [(5.5, 40.0), (9.5, 35.0)] {
210+ pen.arc(12.0, 12.0, r, -spread, spread);
211+ pen.arc(12.0, 12.0, r, 180.0 - spread, 180.0 + spread);
212+ }
213+ }
214+ Icon::Bass => {
215+ pen.polyline(
216+ &[
217+ (3.5, 9.5),
218+ (7.5, 9.5),
219+ (12.5, 5.0),
220+ (12.5, 19.0),
221+ (7.5, 14.5),
222+ (3.5, 14.5),
223+ ],
224+ true,
225+ );
226+ pen.arc(13.0, 12.0, 4.0, -50.0, 50.0);
227+ pen.arc(13.0, 12.0, 8.0, -50.0, 50.0);
228+ }
229+ Icon::Wind => {
230+ pen.line((3.0, 8.5), (13.0, 8.5));
231+ pen.arc(13.0, 6.0, 2.5, 90.0, -180.0);
232+ pen.line((3.0, 12.5), (17.5, 12.5));
233+ pen.arc(17.5, 15.5, 3.0, -90.0, 180.0);
234+ pen.line((3.0, 16.5), (9.0, 16.5));
235+ }
236+ Icon::Dual => {
237+ pen.rect(2.5, 5.0, 12.5, 9.0, 1.5);
238+ pen.line((1.5, 17.5), (16.0, 17.5));
239+ pen.rect(17.5, 8.0, 4.5, 11.0, 1.2);
240+ }
241+ Icon::Chevron => pen.polyline(&[(9.0, 5.5), (15.5, 12.0), (9.0, 18.5)], false),
242+ }
243+}
244+
245+/// A filled earbud: the head, the ear tip pointing in, and the stem going down. The tip is
246+/// drawn in `tip`, or in the part's color when there is none.
247+fn earbud(pen: &mut Pen, mirrored: bool, tip: Option<Color>) {
248+ let x = |x: f32, w: f32| if mirrored { 24.0 - x - w } else { x };
249+ let color = pen.color;
250+ pen.color = tip.unwrap_or(color);
251+ pen.fill_rect(x(14.5, 6.0), 5.0, 6.0, 7.0, 2.8);
252+ pen.color = color;
253+ pen.dot(x(11.0, 0.0), 8.5, 6.3);
254+ pen.fill_rect(x(6.6, 4.4), 10.0, 4.4, 12.5, 2.2);
255+}
256+
257+/// What a battery ring shows in its middle.
258+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259+pub enum Part {
260+ Left,
261+ Right,
262+ Case,
263+}
264+
265+/// A battery ring: the track, an arc for the charge (none when unknown) and the part in the
266+/// middle.
267+pub fn battery_ring<'a, Message: 'a>(
268+ part: Part,
269+ level: Option<u8>,
270+ size: f32,
271+ colors: RingColors,
272+) -> Element<'a, Message> {
273+ iced::widget::canvas(Ring { part, level, colors })
274+ .width(size)
275+ .height(size)
276+ .into()
277+}
278+
279+#[derive(Debug, Clone, Copy)]
280+pub struct RingColors {
281+ pub track: Color,
282+ pub charge: Color,
283+ pub part: Color,
284+ /// The earbuds' ear tips.
285+ pub tip: Color,
286+ /// Details cut into the case (the lid line, the light).
287+ pub cutout: Color,
288+}
289+
290+struct Ring {
291+ part: Part,
292+ level: Option<u8>,
293+ colors: RingColors,
294+}
295+
296+impl<Message> canvas::Program<Message> for Ring {
297+ type State = ();
298+
299+ fn draw(
300+ &self,
301+ _state: &(),
302+ renderer: &Renderer,
303+ _theme: &Theme,
304+ bounds: Rectangle,
305+ _cursor: mouse::Cursor,
306+ ) -> Vec<canvas::Geometry> {
307+ let mut frame = Frame::new(renderer, bounds.size());
308+ let center = frame.center();
309+ let width = bounds.width * 0.06;
310+ let radius = bounds.width / 2.0 - width / 2.0 - 1.0;
311+ let ring = |color: Color| {
312+ Stroke::default()
313+ .with_color(color)
314+ .with_width(width)
315+ .with_line_cap(LineCap::Round)
316+ };
317+ frame.stroke(&Path::circle(center, radius), ring(self.colors.track));
318+ if let Some(level) = self.level.filter(|&level| level > 0) {
319+ let sweep = f32::from(level.min(100)) / 100.0 * TAU;
320+ let arc = Path::new(|b| {
321+ b.arc(Arc {
322+ center,
323+ radius,
324+ start_angle: Radians(-FRAC_PI_2),
325+ end_angle: Radians(-FRAC_PI_2 + sweep),
326+ });
327+ });
328+ frame.stroke(&arc, ring(self.colors.charge));
329+ }
330+
331+ let art = bounds.width * 0.44;
332+ let origin = Point::new(center.x - art / 2.0, center.y - art / 2.0);
333+ let mut pen = Pen::new(&mut frame, origin, art, self.colors.part);
334+ match self.part {
335+ Part::Left => earbud(&mut pen, false, Some(self.colors.tip)),
336+ Part::Right => earbud(&mut pen, true, Some(self.colors.tip)),
337+ Part::Case => {
338+ pen.fill_rect(2.0, 5.5, 20.0, 13.5, 6.0);
339+ pen.color = self.colors.cutout;
340+ pen.width = 0.9 * pen.scale;
341+ pen.line((2.0, 10.0), (22.0, 10.0));
342+ pen.dot(12.0, 14.5, 1.0);
343+ }
344+ }
345+ vec![frame.into_geometry()]
346+ }
347+}
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). It is styled after the owner's shell, Aurora.
4+//! the live connection later (CLAUDE.md, GUI).
55
66 mod demo;
7−mod glyphs;
7+mod icons;
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};
17−use iced::{Element, Font, Length, Size, Subscription, Theme, event, window};
16+use iced::widget::{Column, column, container, row, scrollable, space, text};
17+use iced::{Alignment, Border, Element, Font, Length, Size, Subscription, Theme, event, window};
1818
19+use icons::Icon;
1920 use theme::Look;
2021
2122 const USAGE: &str = "usage: budctl-gui [--theme <palette.json>]
2223
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).";
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, …).";
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::font();
39+ let font = theme::system_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(960.0, 680.0),
47− min_size: Some(Size::new(720.0, 480.0)),
46+ size: Size::new(1040.0, 800.0),
47+ min_size: Some(Size::new(760.0, 540.0)),
4848 platform_specific: window::settings::PlatformSpecific {
4949 application_id: "budctl".to_owned(),
5050 ..window::settings::PlatformSpecific::default()
@@ -96,31 +96,23 @@ impl Page {
9696 Page::Settings,
9797 ];
9898
99− fn name(self) -> &'static str {
99+ fn title(self) -> &'static str {
100100 match self {
101− Page::Overview => "overview",
102− Page::Equalizer => "equalizer",
103− Page::Gestures => "gestures",
104− Page::Earbuds => "earbuds",
105− Page::Settings => "settings",
101+ Page::Overview => "Overview",
102+ Page::Equalizer => "Equalizer",
103+ Page::Gestures => "Gestures",
104+ Page::Earbuds => "Earbud settings",
105+ Page::Settings => "App settings",
106106 }
107107 }
108108
109− fn glyph(self) -> char {
109+ fn icon(self) -> Icon {
110110 match self {
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",
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,
124116 }
125117 }
126118 }
@@ -139,7 +131,7 @@ enum Message {
139131 struct App {
140132 page: Page,
141133 buds: demo::Buds,
142− /// The level "noise cancelling" turns on at: the one last reported or picked.
134+ /// The level "Noise cancelling" turns on at: the one last reported or picked.
143135 anc_level: AncLevel,
144136 look: Look,
145137 theme: Theme,
@@ -160,7 +152,10 @@ impl App {
160152 page: Page::Overview,
161153 buds,
162154 anc_level,
163− look: Look::new(theme::BUILT_IN, font),
155+ look: Look {
156+ p: theme::BUILT_IN,
157+ font,
158+ },
164159 theme: theme::iced_theme(&theme::BUILT_IN),
165160 palette: theme::Source::new(theme_file),
166161 };
@@ -222,53 +217,86 @@ impl App {
222217 let look = self.look;
223218 let page = match self.page {
224219 Page::Overview => overview::view(look, &self.buds, self.anc_level),
225− _ => ui::hint(look, "this page comes in a later step").into(),
226− };
227− let content = column![ui::title(look, self.page.name()), ui::scroll(look, page)]
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+ ]
228224 .spacing(12)
229− .padding(16)
230− .width(Length::Fill);
225+ .into(),
226+ };
227+ let p = look.p;
228+ let content = scrollable(
229+ container(container(page).max_width(860))
230+ .center_x(Length::Fill)
231+ .padding([30, 36]),
232+ )
233+ .height(Length::Fill)
234+ .style(move |theme, status| {
235+ let mut style = scrollable::default(theme, status);
236+ for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
237+ rail.background = None;
238+ rail.border = Border::default();
239+ rail.scroller.background = p.surface_highest.into();
240+ rail.scroller.border = Border::default().rounded(4);
241+ }
242+ style
243+ });
231244 row![self.sidebar(), ui::divider(look), content].into()
232245 }
233246
234− /// Aurora's settings sidebar: the title, the buds, then the pages by group.
235247 fn sidebar(&self) -> Element<'_, Message> {
236248 let look = self.look;
237249 let p = look.p;
238250 let connected = self.buds.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))
251+ let (light, state) = if connected {
252+ (p.ok, "Connected")
242253 } else {
243− ui::button_(look, Some(glyphs::BT_CONNECT), "connect", false, Message::Connect(true))
254+ (p.dim, "Not connected")
244255 };
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(
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(
253287 look,
254− page.glyph(),
255− page.name(),
288+ page.icon(),
289+ page.title(),
256290 page == self.page,
257291 Message::Open(page),
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)
292+ ))
293+ });
294+ container(column![device, pages].spacing(22))
295+ .padding([18, 14])
296+ .width(268)
269297 .height(Length::Fill)
270298 .style(move |_| container::Style {
271− background: Some(p.surface.into()),
299+ background: Some(p.surface_low.into()),
272300 ..container::Style::default()
273301 })
274302 .into()
@@ -325,12 +353,12 @@ mod tests {
325353 fn pages_render() {
326354 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
327355 let _ = fs::remove_dir_all(&dir);
328− let font = theme::font();
356+ let font = theme::system_font();
329357 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
330358 let shots = Page::ALL
331359 .into_iter()
332− .map(|page| (page, 680.0, page.name().to_owned()))
333− .chain([(Page::Overview, 860.0, "overview-full".to_owned())]);
360+ .map(|page| (page, 800.0, format!("{page:?}").to_lowercase()))
361+ .chain([(Page::Overview, 1240.0, "overview-full".to_owned())]);
334362 for (page, height, name) in shots {
335363 let mut app = App::new(palette.clone(), font);
336364 app.page = page;
@@ -338,7 +366,7 @@ mod tests {
338366 default_font: font,
339367 ..iced::Settings::default()
340368 };
341− let mut ui = iced_test::Simulator::with_size(settings, Size::new(960.0, height), app.view());
369+ let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
342370 let snapshot = ui.snapshot(&app.theme).unwrap();
343371 assert!(snapshot.matches_image(dir.join(name)).unwrap());
344372 }
M crates/budctl-gui/src/overview.rs View
@@ -1,139 +1,171 @@
1−//! The Overview page: battery, noise control, sound, and the buds' other settings.
1+//! The Overview page: battery, noise control, the switches, and the way to the other pages.
22
33 use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch};
4−use iced::Element;
4+use iced::widget::{column, container, row, text};
5+use iced::{Alignment, Element, Length};
56
67 use crate::demo::Buds;
7−use crate::glyphs;
8+use crate::icons::{self, Icon, Part, RingColors};
89 use crate::theme::Look;
9−use crate::ui::{self, Opt};
10+use crate::ui::{self, Segment};
1011 use crate::{Message, Page};
1112
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−
2713 pub fn view<'a>(look: Look, buds: &'a Buds, anc_level: AncLevel) -> Element<'a, Message> {
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),
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)),
3620 )
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)
21+ .push(switches(look, &buds.status.switches))
22+ .push(more(look, buds))
23+ .spacing(18)
24+ .into()
8025 }
8126
82−fn battery<'a>(look: Look, buds: &Buds) -> Vec<Element<'a, Message>> {
27+fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
8328 let p = look.p;
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),
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()
9658 };
9759 let battery = buds.status.battery;
98− vec![
99− ui::section(look, "battery"),
100− bar("left", battery.left),
101− bar("right", battery.right),
102− bar("case", battery.case),
103− ]
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()])
10466 }
10567
106−fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel) -> Vec<Element<'a, Message>> {
68+fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel) -> Element<'a, Message> {
10769 let modes = [
108− (glyphs::NOISE_OFF, "off", NoiseMode::Off),
109− (glyphs::ANC, "noise cancelling", NoiseMode::Anc(anc_level)),
110− (glyphs::TRANSPARENCY, "transparency", NoiseMode::Transparency),
70+ (Icon::NoiseOff, "Off", NoiseMode::Off),
71+ (Icon::Anc, "Noise cancelling", NoiseMode::Anc(anc_level)),
72+ (Icon::Transparency, "Transparency", NoiseMode::Transparency),
11173 ];
112− let options = modes
74+ let segments = modes
11375 .into_iter()
114− .map(|(glyph, words, set)| Opt {
115− glyph: Some(glyph),
116− words,
117− chosen: set.name() == mode,
76+ .map(|(icon, label, set)| Segment {
77+ icon: Some(icon),
78+ label,
79+ selected: set.name() == mode,
11880 on_press: Message::Noise(set),
11981 })
12082 .collect();
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− ]
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
128129 .into_iter()
129− .map(|(level, words)| Opt {
130− glyph: None,
131− words,
132− chosen: level == anc_level,
133− on_press: Message::Noise(NoiseMode::Anc(level)),
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+ )
134142 })
135143 .collect();
136− rows.push(ui::choice(look, "strength", levels));
137− }
138− rows
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+ )
139171 }
M crates/budctl-gui/src/theme.rs View
@@ -1,49 +1,67 @@
1−//! Colors and fonts, matched to the owner's shell (Aurora): its matugen palette, JetBrains Mono, and
2−//! its 12 px base size.
1+//! Colors and fonts. The colors are a built-in dark palette, or one read from a matugen JSON file.
32 //!
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.
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.
76
87 use std::fs;
9−use std::path::PathBuf;
8+use std::path::{Path, PathBuf};
109 use std::process::Command;
1110 use std::time::SystemTime;
1211
13−use iced::font::{Family, Weight};
12+use iced::font::Weight;
1413 use iced::{Color, Font};
1514 use serde::Deserialize;
1615
17−/// The colors the UI uses, by role, named as in Aurora.
16+/// The colors the UI uses, by role.
1817 #[derive(Debug, Clone, Copy, PartialEq)]
1918 pub struct Palette {
20− /// Window background, and text on `accent`.
19+ /// Window background.
2120 pub bg: Color,
2221 /// Text.
2322 pub fg: Color,
24− /// Secondary text: hints, and the labels of bars and groups.
23+ /// Secondary text, and icons at rest.
2524 pub dim: Color,
26− /// What is on or chosen: filled buttons and switches, section labels, bars.
25+ /// What is on or selected: switches, the noise-control pill, battery rings.
2726 pub accent: Color,
28− /// The sidebar, and hovered setting rows.
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.
2937 pub surface: Color,
30− /// Hovered buttons and rows, switches when off, bar tracks.
38+ /// Hovered rows, and switches when off.
3139 pub surface_high: Color,
32− /// Borders and separators.
40+ /// Battery ring tracks.
41+ pub surface_highest: Color,
42+ /// Card borders and separators.
3343 pub line: Color,
3444 pub err: Color,
45+ /// "Connected". matugen has no such role, so it stays green.
46+ pub ok: Color,
3547 }
3648
37−/// Near-black, with a red accent.
49+/// Dark, with a red accent.
3850 pub const BUILT_IN: Palette = Palette {
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),
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),
4663 err: Color::from_rgb8(0xFF, 0xB4, 0xAB),
64+ ok: Color::from_rgb8(0x4C, 0xC3, 0x6A),
4765 };
4866
4967 /// The palette file's keys. Other keys (`image`, `secondary`…) are ignored.
@@ -53,8 +71,13 @@ struct PaletteFile {
5371 bg: Option<String>,
5472 fg: Option<String>,
5573 primary: Option<String>,
74+ primary_fg: Option<String>,
75+ primary_container: Option<String>,
76+ secondary: Option<String>,
77+ surface_low: Option<String>,
5678 surface: Option<String>,
5779 surface_high: Option<String>,
80+ surface_highest: Option<String>,
5881 outline_variant: Option<String>,
5982 surface_variant_fg: Option<String>,
6083 err: Option<String>,
@@ -73,10 +96,16 @@ pub fn parse(json: &str) -> Result<Palette, String> {
7396 fg: pick("fg", file.fg, b.fg)?,
7497 dim: pick("surfaceVariantFg", file.surface_variant_fg, b.dim)?,
7598 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)?,
76103 surface: pick("surface", file.surface, b.surface)?,
77104 surface_high: pick("surfaceHigh", file.surface_high, b.surface_high)?,
105+ surface_highest: pick("surfaceHighest", file.surface_highest, b.surface_highest)?,
78106 line: pick("outlineVariant", file.outline_variant, b.line)?,
79107 err: pick("err", file.err, b.err)?,
108+ ok: b.ok,
80109 })
81110 }
82111
@@ -96,23 +125,13 @@ fn parse_hex(hex: &str) -> Option<Color> {
96125 }
97126 }
98127
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.
128+/// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`.
101129 pub fn default_file() -> Option<PathBuf> {
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− })
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"))
116135 }
117136
118137 /// The palette file and the version of it last read, so it is only read again when it changes.
@@ -147,23 +166,19 @@ impl Source {
147166 }
148167 }
149168
150−/// The palette and the font, handed to everything that draws.
169+/// The palette and the fonts, handed to everything that draws.
151170 #[derive(Debug, Clone, Copy)]
152171 pub struct Look {
153172 pub p: Palette,
154173 pub font: Font,
155− /// The base text size (Aurora's `fontSize`). Other sizes are relative to it.
156− pub size: f32,
157174 }
158175
159176 impl Look {
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
177+ pub fn medium(self) -> Font {
178+ Font {
179+ weight: Weight::Medium,
180+ ..self.font
181+ }
167182 }
168183
169184 pub fn bold(self) -> Font {
@@ -174,33 +189,24 @@ impl Look {
174189 }
175190 }
176191
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− }
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 {
189195 let family = Command::new("fc-match")
190− .args(["--format", "%{family[0]}", "monospace"])
196+ .args(["--format", "%{family[0]}", "sans-serif"])
191197 .output()
192198 .ok()
193199 .filter(|out| out.status.success())
194200 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
195201 .filter(|name| !name.is_empty());
196− Font {
202+ match family {
197203 // Font names are &'static; this runs once.
198− family: family.map_or(Family::Monospace, |name| Family::Name(Box::leak(name.into_boxed_str()))),
199− ..Font::DEFAULT
204+ Some(name) => Font::with_name(Box::leak(name.into_boxed_str())),
205+ None => Font::DEFAULT,
200206 }
201207 }
202208
203−/// The iced theme, for what is drawn with iced's default styles (the scrollbar).
209+/// The iced theme for the widgets drawn with their default style.
204210 pub fn iced_theme(p: &Palette) -> iced::Theme {
205211 iced::Theme::custom(
206212 "budctl",
@@ -208,7 +214,7 @@ pub fn iced_theme(p: &Palette) -> iced::Theme {
208214 background: p.bg,
209215 text: p.fg,
210216 primary: p.accent,
211− success: p.accent,
217+ success: p.ok,
212218 warning: p.accent,
213219 danger: p.err,
214220 },
@@ -235,9 +241,11 @@ mod tests {
235241 .unwrap();
236242 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a));
237243 assert_eq!(palette.accent, Color::from_rgb8(0x7b, 0x4b, 0x2f));
238− assert_eq!(palette.surface_high, Color::from_rgb8(0x2a, 0x21, 0x1d));
244+ assert_eq!(palette.accent_dim, Color::from_rgb8(0x5c, 0x38, 0x23));
245+ assert_eq!(palette.icon, Color::from_rgb8(0xe2, 0xbf, 0xae));
239246 assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d));
240247 assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9));
248+ assert_eq!(palette.ok, BUILT_IN.ok);
241249 }
242250
243251 #[test]
M crates/budctl-gui/src/ui.rs View
@@ -1,322 +1,253 @@
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.
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`].
43
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};
4+use iced::widget::{Column, Row, button, column, container, row, rule, space, text, toggler};
5+use iced::{Alignment, Border, Element, Length};
86
97 use crate::Message;
10−use crate::glyphs;
8+use crate::icons::{self, Icon};
119 use crate::theme::Look;
1210
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)
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+ })
1938 .into()
2039 }
2140
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> {
41+fn separator<'a>(look: Look) -> Element<'a, Message> {
2442 let p = look.p;
25− let line = rule::horizontal(1).style(move |_| rule::Style {
43+ container(rule::horizontal(1).style(move |_| rule::Style {
2644 color: p.line,
2745 radius: 0.0.into(),
2846 fill_mode: rule::FillMode::Full,
2947 snap: true,
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)
40−}
41−
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)
48+ }))
49+ .padding([0, 14])
50+ .into()
4951 }
5052
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>(
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>(
5456 look: Look,
55− name: String,
56− about: Option<&'a str>,
57− on: bool,
58− toggle: Message,
57+ icon: Icon,
58+ title: &'a str,
59+ subtitle: Option<String>,
60+ trailing: Element<'a, Message>,
61+ on_press: Message,
5962 ) -> Element<'a, Message> {
6063 let p = look.p;
61− let words = column![label(look, name)]
62− .push(about.map(|about| hint(look, about)))
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)
6367 .width(Length::Fill);
64− let content = row![words, switch(look, on)]
65− .spacing(10)
68+ let content = row![icons::icon(icon, 22.0, p.icon), words, trailing]
69+ .spacing(16)
6670 .align_y(Alignment::Center)
67− .padding([5, 0]);
71+ .padding([13, 16]);
6872 button(content)
69− .on_press(toggle)
70− .width(Length::Fill)
73+ .on_press(on_press)
7174 .padding(0)
75+ .width(Length::Fill)
7276 .style(move |_, status| button::Style {
73− background: hovered(status).then_some(p.surface.into()),
77+ background: hovered(status).then_some(p.surface_high.into()),
7478 text_color: p.fg,
79+ border: Border::default().rounded(14),
7580 ..button::Style::default()
7681 })
7782 .into()
7883 }
7984
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> {
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> {
8291 let p = look.p;
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()
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,
104104 })
105105 .into()
106106 }
107107
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> {
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> {
116124 let p = look.p;
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 {
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 {
131146 Some(p.accent.into())
132147 } else {
133− hover.then_some(p.surface_high.into())
148+ hovered(status).then_some(p.surface_high.into())
134149 },
135150 text_color: color,
136− border: Border {
137− color: if active || hover { p.accent } else { p.line },
138− width: 1.0,
139− radius: 0.0.into(),
140− },
151+ border: Border::default().rounded(999),
141152 ..button::Style::default()
142− }
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()
143167 })
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)
162168 .into()
163169 }
164170
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> {
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> {
167173 let p = look.p;
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)
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)
183181 ]
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]);
182+ .spacing(13)
183+ .align_y(Alignment::Center);
208184 button(content)
209185 .on_press(on_press)
210186 .width(Length::Fill)
211− .padding(0)
187+ .padding([10, 14])
212188 .style(move |_, status| button::Style {
213− background: hovered(status).then_some(p.surface.into()),
214− text_color: p.fg,
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),
215196 ..button::Style::default()
216197 })
217198 .into()
218199 }
219200
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> {
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> {
236203 let p = look.p;
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))
204+ button(container(text(label).size(13.5).font(look.medium()).color(p.fg)).center_x(Length::Fill))
242205 .on_press(on_press)
243206 .width(Length::Fill)
244− .height(30)
245− .padding([0, 10])
207+ .padding([8, 12])
246208 .style(move |_, status| button::Style {
247− background: if open {
248− Some(p.accent.into())
249− } else {
250− hovered(status).then_some(p.surface_high.into())
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(),
251215 },
252− text_color: if open { p.bg } else { p.fg },
253216 ..button::Style::default()
254217 })
255218 .into()
256219 }
257220
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)
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)
275226 .style(move |_| container::Style {
276− border: Border {
277− color: if connected { p.accent } else { p.line },
278− width: 1.0,
279− radius: 0.0.into(),
280− },
227+ background: Some(color.into()),
228+ border: Border::default().rounded(4),
281229 ..container::Style::default()
282230 })
283231 .into()
284232 }
285233
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> {
234+/// A small outlined tag, like "demo".
235+pub fn tag<'a>(look: Look, label: &'a str) -> Element<'a, Message> {
297236 let p = look.p;
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
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()
315246 })
316247 .into()
317248 }
318249
319−/// A 1 px vertical line, between the sidebar and the page.
250+/// A thin vertical line, between the sidebar and the page.
320251 pub fn divider<'a>(look: Look) -> Element<'a, Message> {
321252 let p = look.p;
322253 rule::vertical(1)