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.
+523 −1199
9 files changed
- M CLAUDE.md +3 −3
- M Cargo.lock +1 −358
- M crates/budctl-gui/Cargo.toml +1 −1
- A crates/budctl-gui/src/glyphs.rs +27 −0
- D crates/budctl-gui/src/icons.rs +0 −347
- M crates/budctl-gui/src/main.rs +68 −96
- M crates/budctl-gui/src/overview.rs +113 −145
- M crates/budctl-gui/src/theme.rs +67 −75
- M crates/budctl-gui/src/ui.rs +243 −174
| @@ -117,9 +117,9 @@ Exit codes: | |||
| 117 | 117 | ||
| 118 | 118 | ## GUI | |
| 119 | 119 | ||
| 120 | − | - **Look:** a sidebar (device card, pages) and pages of cards: battery rings, a segmented noise-control pill, rows of switches. The owner's reference is a screenshot of another buds app; match the look, never its code (rule 2). | |
| 121 | − | - **Theme:** a built-in dark palette with a red accent. Optionally, a matugen JSON file with the same keys as the owner's Aurora palette (`~/.cache/matugen/aurora.json`: `bg`, `fg`, `primary`, `surface`…), mapped to the same roles as Aurora's `Theme.qml`. It is read again whenever the window gets focus. | |
| 122 | − | - **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top: `j`/`k` move a cursor, `h`/`l` change the value under it. The cursor shows only after a key press. | |
| 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. | |
| 123 | 123 | - **Logic:** keys and clicks both become messages for one `update` function, which stays testable without a display. `iced_test` renders pages to PNG, so the look can be checked without the owner. | |
| 124 | 124 | ||
| 125 | 125 | ## Working with Claude | |
| @@ -267,21 +267,6 @@ version = "1.5.1" | |||
| 267 | 267 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 268 | 268 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 269 | 269 | ||
| 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 | − | ||
| 285 | 270 | [[package]] | |
| 286 | 271 | name = "bitflags" | |
| 287 | 272 | version = "1.3.2" | |
| @@ -529,15 +514,6 @@ dependencies = [ | |||
| 529 | 514 | "smithay-clipboard", | |
| 530 | 515 | ] | |
| 531 | 516 | ||
| 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 | − | ||
| 541 | 517 | [[package]] | |
| 542 | 518 | name = "colorchoice" | |
| 543 | 519 | version = "1.0.5" | |
| @@ -666,19 +642,6 @@ version = "0.2.4" | |||
| 666 | 642 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 667 | 643 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 668 | 644 | ||
| 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 | − | ||
| 682 | 645 | [[package]] | |
| 683 | 646 | name = "crypto-common" | |
| 684 | 647 | version = "0.1.7" | |
| @@ -730,15 +693,6 @@ dependencies = [ | |||
| 730 | 693 | "libloading", | |
| 731 | 694 | ] | |
| 732 | 695 | ||
| 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 | − | ||
| 742 | 696 | [[package]] | |
| 743 | 697 | name = "downcast-rs" | |
| 744 | 698 | version = "1.2.1" | |
| @@ -800,25 +754,6 @@ version = "3.4.0" | |||
| 800 | 754 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 801 | 755 | checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" | |
| 802 | 756 | ||
| 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 | − | ||
| 822 | 757 | [[package]] | |
| 823 | 758 | name = "event-listener" | |
| 824 | 759 | version = "5.4.2" | |
| @@ -871,18 +806,6 @@ dependencies = [ | |||
| 871 | 806 | "zlib-rs", | |
| 872 | 807 | ] | |
| 873 | 808 | ||
| 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 | − | ||
| 886 | 809 | [[package]] | |
| 887 | 810 | name = "font-types" | |
| 888 | 811 | version = "0.10.1" | |
| @@ -1091,16 +1014,6 @@ version = "0.25.0" | |||
| 1091 | 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1092 | 1015 | checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" | |
| 1093 | 1016 | ||
| 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 | − | ||
| 1104 | 1017 | [[package]] | |
| 1105 | 1018 | name = "half" | |
| 1106 | 1019 | version = "2.7.1" | |
| @@ -1109,7 +1022,6 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | |||
| 1109 | 1022 | dependencies = [ | |
| 1110 | 1023 | "cfg-if", | |
| 1111 | 1024 | "crunchy", | |
| 1112 | − | "num-traits", | |
| 1113 | 1025 | "zerocopy", | |
| 1114 | 1026 | ] | |
| 1115 | 1027 | ||
| @@ -1126,15 +1038,6 @@ dependencies = [ | |||
| 1126 | 1038 | "smallvec", | |
| 1127 | 1039 | ] | |
| 1128 | 1040 | ||
| 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 | − | ||
| 1138 | 1041 | [[package]] | |
| 1139 | 1042 | name = "hashbrown" | |
| 1140 | 1043 | version = "0.17.1" | |
| @@ -1159,12 +1062,6 @@ version = "0.4.3" | |||
| 1159 | 1062 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1160 | 1063 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | |
| 1161 | 1064 | ||
| 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 | − | ||
| 1168 | 1065 | [[package]] | |
| 1169 | 1066 | name = "iced" | |
| 1170 | 1067 | version = "0.14.0" | |
| @@ -1237,7 +1134,6 @@ dependencies = [ | |||
| 1237 | 1134 | "iced_core", | |
| 1238 | 1135 | "iced_futures", | |
| 1239 | 1136 | "log", | |
| 1240 | − | "lyon_path", | |
| 1241 | 1137 | "raw-window-handle", | |
| 1242 | 1138 | "rustc-hash 2.1.3", | |
| 1243 | 1139 | "thiserror 2.0.21", | |
| @@ -1262,7 +1158,6 @@ checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" | |||
| 1262 | 1158 | dependencies = [ | |
| 1263 | 1159 | "iced_graphics", | |
| 1264 | 1160 | "iced_tiny_skia", | |
| 1265 | − | "iced_wgpu", | |
| 1266 | 1161 | "log", | |
| 1267 | 1162 | "thiserror 2.0.21", | |
| 1268 | 1163 | ] | |
| @@ -1323,27 +1218,6 @@ dependencies = [ | |||
| 1323 | 1218 | "tiny-skia", | |
| 1324 | 1219 | ] | |
| 1325 | 1220 | ||
| 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 | − | ||
| 1347 | 1221 | [[package]] | |
| 1348 | 1222 | name = "iced_widget" | |
| 1349 | 1223 | version = "0.14.2" | |
| @@ -1383,7 +1257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1383 | 1257 | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | |
| 1384 | 1258 | dependencies = [ | |
| 1385 | 1259 | "equivalent", | |
| 1386 | − | "hashbrown 0.17.1", | |
| 1260 | + | "hashbrown", | |
| 1387 | 1261 | ] | |
| 1388 | 1262 | ||
| 1389 | 1263 | [[package]] | |
| @@ -1548,12 +1422,6 @@ version = "0.12.1" | |||
| 1548 | 1422 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1549 | 1423 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| 1550 | 1424 | ||
| 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 | − | ||
| 1557 | 1425 | [[package]] | |
| 1558 | 1426 | name = "lock_api" | |
| 1559 | 1427 | version = "0.4.14" | |
| @@ -1569,64 +1437,6 @@ version = "0.4.34" | |||
| 1569 | 1437 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1570 | 1438 | checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" | |
| 1571 | 1439 | ||
| 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 | − | ||
| 1630 | 1440 | [[package]] | |
| 1631 | 1441 | name = "memchr" | |
| 1632 | 1442 | version = "2.8.3" | |
| @@ -1671,31 +1481,6 @@ dependencies = [ | |||
| 1671 | 1481 | "simd-adler32", | |
| 1672 | 1482 | ] | |
| 1673 | 1483 | ||
| 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 | − | ||
| 1699 | 1484 | [[package]] | |
| 1700 | 1485 | name = "ndk" | |
| 1701 | 1486 | version = "0.9.0" | |
| @@ -1742,7 +1527,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1742 | 1527 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 1743 | 1528 | dependencies = [ | |
| 1744 | 1529 | "autocfg", | |
| 1745 | − | "libm", | |
| 1746 | 1530 | ] | |
| 1747 | 1531 | ||
| 1748 | 1532 | [[package]] | |
| @@ -2189,21 +1973,6 @@ dependencies = [ | |||
| 2189 | 1973 | "windows-sys 0.61.2", | |
| 2190 | 1974 | ] | |
| 2191 | 1975 | ||
| 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 | − | ||
| 2207 | 1976 | [[package]] | |
| 2208 | 1977 | name = "proc-macro-crate" | |
| 2209 | 1978 | version = "3.5.0" | |
| @@ -2222,12 +1991,6 @@ dependencies = [ | |||
| 2222 | 1991 | "unicode-ident", | |
| 2223 | 1992 | ] | |
| 2224 | 1993 | ||
| 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 | − | ||
| 2231 | 1994 | [[package]] | |
| 2232 | 1995 | name = "quick-xml" | |
| 2233 | 1996 | version = "0.41.0" | |
| @@ -2319,12 +2082,6 @@ dependencies = [ | |||
| 2319 | 2082 | "bitflags 2.13.2", | |
| 2320 | 2083 | ] | |
| 2321 | 2084 | ||
| 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 | − | ||
| 2328 | 2085 | [[package]] | |
| 2329 | 2086 | name = "roxmltree" | |
| 2330 | 2087 | version = "0.20.0" | |
| @@ -2674,12 +2431,6 @@ dependencies = [ | |||
| 2674 | 2431 | "windows-sys 0.61.2", | |
| 2675 | 2432 | ] | |
| 2676 | 2433 | ||
| 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 | − | ||
| 2683 | 2434 | [[package]] | |
| 2684 | 2435 | name = "strict-num" | |
| 2685 | 2436 | version = "0.1.1" | |
| @@ -2692,12 +2443,6 @@ version = "0.11.1" | |||
| 2692 | 2443 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2693 | 2444 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | |
| 2694 | 2445 | ||
| 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 | − | ||
| 2701 | 2446 | [[package]] | |
| 2702 | 2447 | name = "swash" | |
| 2703 | 2448 | version = "0.2.10" | |
| @@ -2951,12 +2696,6 @@ version = "1.13.3" | |||
| 2951 | 2696 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2952 | 2697 | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | |
| 2953 | 2698 | ||
| 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 | − | ||
| 2960 | 2699 | [[package]] | |
| 2961 | 2700 | name = "utf8parse" | |
| 2962 | 2701 | version = "0.2.2" | |
| @@ -3224,102 +2963,6 @@ dependencies = [ | |||
| 3224 | 2963 | "wasm-bindgen", | |
| 3225 | 2964 | ] | |
| 3226 | 2965 | ||
| 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 | − | ||
| 3323 | 2966 | [[package]] | |
| 3324 | 2967 | name = "winapi-util" | |
| 3325 | 2968 | version = "0.1.11" | |
| @@ -14,7 +14,7 @@ path = "src/main.rs" | |||
| 14 | 14 | [dependencies] | |
| 15 | 15 | budctl-core = { path = "../budctl-core" } | |
| 16 | 16 | # Wayland only (no X11) and CPU rendering (no GPU stack): the smallest iced that draws this UI. | |
| 17 | − | iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "canvas", "crisp"] } | |
| 17 | + | iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "crisp"] } | |
| 18 | 18 | serde = { version = "1.0.229", features = ["derive"] } | |
| 19 | 19 | serde_json = "1.0.154" | |
| 20 | 20 | ||
| @@ -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}'; | |
| @@ -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 | − | } | |
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | //! `budctl-gui`: control wireless earbuds from Linux, in a window. | |
| 2 | 2 | //! | |
| 3 | 3 | //! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first, | |
| 4 | − | //! the live connection later (CLAUDE.md, GUI). | |
| 4 | + | //! the live connection later (CLAUDE.md, GUI). It is styled after the owner's shell, Aurora. | |
| 5 | 5 | ||
| 6 | 6 | mod demo; | |
| 7 | − | mod icons; | |
| 7 | + | mod glyphs; | |
| 8 | 8 | mod overview; | |
| 9 | 9 | mod theme; | |
| 10 | 10 | mod ui; | |
| @@ -13,16 +13,16 @@ use std::path::PathBuf; | |||
| 13 | 13 | use std::process::ExitCode; | |
| 14 | 14 | ||
| 15 | 15 | use budctl_core::status::{AncLevel, NoiseControl, NoiseMode}; | |
| 16 | − | use iced::widget::{Column, column, container, row, scrollable, space, text}; | |
| 17 | − | use iced::{Alignment, Border, Element, Font, Length, Size, Subscription, Theme, event, window}; | |
| 16 | + | use iced::widget::{Column, column, container, row}; | |
| 17 | + | use iced::{Element, Font, Length, Size, Subscription, Theme, event, window}; | |
| 18 | 18 | ||
| 19 | − | use icons::Icon; | |
| 20 | 19 | use theme::Look; | |
| 21 | 20 | ||
| 22 | 21 | const USAGE: &str = "usage: budctl-gui [--theme <palette.json>] | |
| 23 | 22 | ||
| 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)."; | |
| 26 | 26 | ||
| 27 | 27 | fn main() -> ExitCode { | |
| 28 | 28 | let theme_file = match parse_args(std::env::args().skip(1)) { | |
| @@ -36,15 +36,15 @@ fn main() -> ExitCode { | |||
| 36 | 36 | return ExitCode::from(1); | |
| 37 | 37 | } | |
| 38 | 38 | }; | |
| 39 | − | let font = theme::system_font(); | |
| 39 | + | let font = theme::font(); | |
| 40 | 40 | let result = iced::application(move || App::new(theme_file.clone(), font), App::update, App::view) | |
| 41 | 41 | .title("budctl") | |
| 42 | 42 | .theme(App::theme) | |
| 43 | 43 | .subscription(App::subscription) | |
| 44 | 44 | .default_font(font) | |
| 45 | 45 | .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)), | |
| 48 | 48 | platform_specific: window::settings::PlatformSpecific { | |
| 49 | 49 | application_id: "budctl".to_owned(), | |
| 50 | 50 | ..window::settings::PlatformSpecific::default() | |
| @@ -96,23 +96,31 @@ impl Page { | |||
| 96 | 96 | Page::Settings, | |
| 97 | 97 | ]; | |
| 98 | 98 | ||
| 99 | − | fn title(self) -> &'static str { | |
| 99 | + | fn name(self) -> &'static str { | |
| 100 | 100 | match self { | |
| 101 | − | Page::Overview => "Overview", | |
| 102 | − | Page::Equalizer => "Equalizer", | |
| 103 | − | Page::Gestures => "Gestures", | |
| 104 | − | Page::Earbuds => "Earbud settings", | |
| 105 | − | Page::Settings => "App settings", | |
| 101 | + | Page::Overview => "overview", | |
| 102 | + | Page::Equalizer => "equalizer", | |
| 103 | + | Page::Gestures => "gestures", | |
| 104 | + | Page::Earbuds => "earbuds", | |
| 105 | + | Page::Settings => "settings", | |
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | − | fn icon(self) -> Icon { | |
| 109 | + | fn glyph(self) -> char { | |
| 110 | 110 | match self { | |
| 111 | − | Page::Overview => Icon::Overview, | |
| 112 | − | Page::Equalizer => Icon::Equalizer, | |
| 113 | − | Page::Gestures => Icon::Gestures, | |
| 114 | − | Page::Earbuds => Icon::Earbud, | |
| 115 | − | Page::Settings => Icon::Settings, | |
| 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", | |
| 116 | 124 | } | |
| 117 | 125 | } | |
| 118 | 126 | } | |
| @@ -131,7 +139,7 @@ enum Message { | |||
| 131 | 139 | struct App { | |
| 132 | 140 | page: Page, | |
| 133 | 141 | 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. | |
| 135 | 143 | anc_level: AncLevel, | |
| 136 | 144 | look: Look, | |
| 137 | 145 | theme: Theme, | |
| @@ -152,10 +160,7 @@ impl App { | |||
| 152 | 160 | page: Page::Overview, | |
| 153 | 161 | buds, | |
| 154 | 162 | anc_level, | |
| 155 | − | look: Look { | |
| 156 | − | p: theme::BUILT_IN, | |
| 157 | − | font, | |
| 158 | − | }, | |
| 163 | + | look: Look::new(theme::BUILT_IN, font), | |
| 159 | 164 | theme: theme::iced_theme(&theme::BUILT_IN), | |
| 160 | 165 | palette: theme::Source::new(theme_file), | |
| 161 | 166 | }; | |
| @@ -217,86 +222,53 @@ impl App { | |||
| 217 | 222 | let look = self.look; | |
| 218 | 223 | let page = match self.page { | |
| 219 | 224 | 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(), | |
| 226 | 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 | − | }); | |
| 227 | + | let content = column![ui::title(look, self.page.name()), ui::scroll(look, page)] | |
| 228 | + | .spacing(12) | |
| 229 | + | .padding(16) | |
| 230 | + | .width(Length::Fill); | |
| 244 | 231 | row![self.sidebar(), ui::divider(look), content].into() | |
| 245 | 232 | } | |
| 246 | 233 | ||
| 234 | + | /// Aurora's settings sidebar: the title, the buds, then the pages by group. | |
| 247 | 235 | fn sidebar(&self) -> Element<'_, Message> { | |
| 248 | 236 | let look = self.look; | |
| 249 | 237 | let p = look.p; | |
| 250 | 238 | 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)) | |
| 253 | 242 | } else { | |
| 254 | − | (p.dim, "Not connected") | |
| 243 | + | ui::button_(look, Some(glyphs::BT_CONNECT), "connect", false, Message::Connect(true)) | |
| 255 | 244 | }; | |
| 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( | |
| 287 | 253 | look, | |
| 288 | − | page.icon(), | |
| 289 | − | page.title(), | |
| 254 | + | page.glyph(), | |
| 255 | + | page.name(), | |
| 290 | 256 | page == self.page, | |
| 291 | 257 | 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) | |
| 297 | 269 | .height(Length::Fill) | |
| 298 | 270 | .style(move |_| container::Style { | |
| 299 | − | background: Some(p.surface_low.into()), | |
| 271 | + | background: Some(p.surface.into()), | |
| 300 | 272 | ..container::Style::default() | |
| 301 | 273 | }) | |
| 302 | 274 | .into() | |
| @@ -353,12 +325,12 @@ mod tests { | |||
| 353 | 325 | fn pages_render() { | |
| 354 | 326 | let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots"); | |
| 355 | 327 | let _ = fs::remove_dir_all(&dir); | |
| 356 | − | let font = theme::system_font(); | |
| 328 | + | let font = theme::font(); | |
| 357 | 329 | let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from); | |
| 358 | 330 | let shots = Page::ALL | |
| 359 | 331 | .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())]); | |
| 362 | 334 | for (page, height, name) in shots { | |
| 363 | 335 | let mut app = App::new(palette.clone(), font); | |
| 364 | 336 | app.page = page; | |
| @@ -366,7 +338,7 @@ mod tests { | |||
| 366 | 338 | default_font: font, | |
| 367 | 339 | ..iced::Settings::default() | |
| 368 | 340 | }; | |
| 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()); | |
| 370 | 342 | let snapshot = ui.snapshot(&app.theme).unwrap(); | |
| 371 | 343 | assert!(snapshot.matches_image(dir.join(name)).unwrap()); | |
| 372 | 344 | } | |
| @@ -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. | |
| 2 | 2 | ||
| 3 | 3 | 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; | |
| 6 | 5 | ||
| 7 | 6 | use crate::demo::Buds; | |
| 8 | − | use crate::icons::{self, Icon, Part, RingColors}; | |
| 7 | + | use crate::glyphs; | |
| 9 | 8 | use crate::theme::Look; | |
| 10 | − | use crate::ui::{self, Segment}; | |
| 9 | + | use crate::ui::{self, Opt}; | |
| 11 | 10 | use crate::{Message, Page}; | |
| 12 | 11 | ||
| 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 | + | ||
| 13 | 27 | 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), | |
| 20 | 36 | ) | |
| 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) | |
| 25 | 80 | } | |
| 26 | 81 | ||
| 27 | − | fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> { | |
| 82 | + | fn battery<'a>(look: Look, buds: &Buds) -> Vec<Element<'a, Message>> { | |
| 28 | 83 | 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), | |
| 58 | 96 | }; | |
| 59 | 97 | 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 | + | ] | |
| 66 | 104 | } | |
| 67 | 105 | ||
| 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>> { | |
| 69 | 107 | 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), | |
| 73 | 111 | ]; | |
| 74 | − | let segments = modes | |
| 112 | + | let options = modes | |
| 75 | 113 | .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, | |
| 80 | 118 | on_press: Message::Noise(set), | |
| 81 | 119 | }) | |
| 82 | 120 | .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 | + | ] | |
| 129 | 128 | .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)), | |
| 142 | 134 | }) | |
| 143 | 135 | .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 | |
| 171 | 139 | } | |
| @@ -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. | |
| 2 | 3 | //! | |
| 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. | |
| 6 | 7 | ||
| 7 | 8 | use std::fs; | |
| 8 | − | use std::path::{Path, PathBuf}; | |
| 9 | + | use std::path::PathBuf; | |
| 9 | 10 | use std::process::Command; | |
| 10 | 11 | use std::time::SystemTime; | |
| 11 | 12 | ||
| 12 | − | use iced::font::Weight; | |
| 13 | + | use iced::font::{Family, Weight}; | |
| 13 | 14 | use iced::{Color, Font}; | |
| 14 | 15 | use serde::Deserialize; | |
| 15 | 16 | ||
| 16 | − | /// The colors the UI uses, by role. | |
| 17 | + | /// The colors the UI uses, by role, named as in Aurora. | |
| 17 | 18 | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 18 | 19 | pub struct Palette { | |
| 19 | − | /// Window background. | |
| 20 | + | /// Window background, and text on `accent`. | |
| 20 | 21 | pub bg: Color, | |
| 21 | 22 | /// Text. | |
| 22 | 23 | pub fg: Color, | |
| 23 | − | /// Secondary text, and icons at rest. | |
| 24 | + | /// Secondary text: hints, and the labels of bars and groups. | |
| 24 | 25 | 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. | |
| 26 | 27 | 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. | |
| 37 | 29 | pub surface: Color, | |
| 38 | − | /// Hovered rows, and switches when off. | |
| 30 | + | /// Hovered buttons and rows, switches when off, bar tracks. | |
| 39 | 31 | pub surface_high: Color, | |
| 40 | − | /// Battery ring tracks. | |
| 41 | − | pub surface_highest: Color, | |
| 42 | − | /// Card borders and separators. | |
| 32 | + | /// Borders and separators. | |
| 43 | 33 | pub line: Color, | |
| 44 | 34 | pub err: Color, | |
| 45 | − | /// "Connected". matugen has no such role, so it stays green. | |
| 46 | − | pub ok: Color, | |
| 47 | 35 | } | |
| 48 | 36 | ||
| 49 | − | /// Dark, with a red accent. | |
| 37 | + | /// Near-black, with a red accent. | |
| 50 | 38 | 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), | |
| 63 | 46 | err: Color::from_rgb8(0xFF, 0xB4, 0xAB), | |
| 64 | − | ok: Color::from_rgb8(0x4C, 0xC3, 0x6A), | |
| 65 | 47 | }; | |
| 66 | 48 | ||
| 67 | 49 | /// The palette file's keys. Other keys (`image`, `secondary`…) are ignored. | |
| @@ -71,13 +53,8 @@ struct PaletteFile { | |||
| 71 | 53 | bg: Option<String>, | |
| 72 | 54 | fg: Option<String>, | |
| 73 | 55 | primary: Option<String>, | |
| 74 | − | primary_fg: Option<String>, | |
| 75 | − | primary_container: Option<String>, | |
| 76 | − | secondary: Option<String>, | |
| 77 | − | surface_low: Option<String>, | |
| 78 | 56 | surface: Option<String>, | |
| 79 | 57 | surface_high: Option<String>, | |
| 80 | − | surface_highest: Option<String>, | |
| 81 | 58 | outline_variant: Option<String>, | |
| 82 | 59 | surface_variant_fg: Option<String>, | |
| 83 | 60 | err: Option<String>, | |
| @@ -96,16 +73,10 @@ pub fn parse(json: &str) -> Result<Palette, String> { | |||
| 96 | 73 | fg: pick("fg", file.fg, b.fg)?, | |
| 97 | 74 | dim: pick("surfaceVariantFg", file.surface_variant_fg, b.dim)?, | |
| 98 | 75 | accent: pick("primary", file.primary, b.accent)?, | |
| 99 | − | icon: pick("secondary", file.secondary, b.icon)?, | |
| 100 | − | accent_fg: pick("primaryFg", file.primary_fg, b.accent_fg)?, | |
| 101 | − | accent_dim: pick("primaryContainer", file.primary_container, b.accent_dim)?, | |
| 102 | − | surface_low: pick("surfaceLow", file.surface_low, b.surface_low)?, | |
| 103 | 76 | surface: pick("surface", file.surface, b.surface)?, | |
| 104 | 77 | surface_high: pick("surfaceHigh", file.surface_high, b.surface_high)?, | |
| 105 | − | surface_highest: pick("surfaceHighest", file.surface_highest, b.surface_highest)?, | |
| 106 | 78 | line: pick("outlineVariant", file.outline_variant, b.line)?, | |
| 107 | 79 | err: pick("err", file.err, b.err)?, | |
| 108 | − | ok: b.ok, | |
| 109 | 80 | }) | |
| 110 | 81 | } | |
| 111 | 82 | ||
| @@ -125,13 +96,23 @@ fn parse_hex(hex: &str) -> Option<Color> { | |||
| 125 | 96 | } | |
| 126 | 97 | } | |
| 127 | 98 | ||
| 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. | |
| 129 | 101 | 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 | + | }) | |
| 135 | 116 | } | |
| 136 | 117 | ||
| 137 | 118 | /// 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 { | |||
| 166 | 147 | } | |
| 167 | 148 | } | |
| 168 | 149 | ||
| 169 | − | /// The palette and the fonts, handed to everything that draws. | |
| 150 | + | /// The palette and the font, handed to everything that draws. | |
| 170 | 151 | #[derive(Debug, Clone, Copy)] | |
| 171 | 152 | pub struct Look { | |
| 172 | 153 | pub p: Palette, | |
| 173 | 154 | pub font: Font, | |
| 155 | + | /// The base text size (Aurora's `fontSize`). Other sizes are relative to it. | |
| 156 | + | pub size: f32, | |
| 174 | 157 | } | |
| 175 | 158 | ||
| 176 | 159 | 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 | |
| 182 | 167 | } | |
| 183 | 168 | ||
| 184 | 169 | pub fn bold(self) -> Font { | |
| @@ -189,24 +174,33 @@ impl Look { | |||
| 189 | 174 | } | |
| 190 | 175 | } | |
| 191 | 176 | ||
| 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 | + | } | |
| 195 | 189 | let family = Command::new("fc-match") | |
| 196 | − | .args(["--format", "%{family[0]}", "sans-serif"]) | |
| 190 | + | .args(["--format", "%{family[0]}", "monospace"]) | |
| 197 | 191 | .output() | |
| 198 | 192 | .ok() | |
| 199 | 193 | .filter(|out| out.status.success()) | |
| 200 | 194 | .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) | |
| 201 | 195 | .filter(|name| !name.is_empty()); | |
| 202 | − | match family { | |
| 196 | + | Font { | |
| 203 | 197 | // 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 | |
| 206 | 200 | } | |
| 207 | 201 | } | |
| 208 | 202 | ||
| 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). | |
| 210 | 204 | pub fn iced_theme(p: &Palette) -> iced::Theme { | |
| 211 | 205 | iced::Theme::custom( | |
| 212 | 206 | "budctl", | |
| @@ -214,7 +208,7 @@ pub fn iced_theme(p: &Palette) -> iced::Theme { | |||
| 214 | 208 | background: p.bg, | |
| 215 | 209 | text: p.fg, | |
| 216 | 210 | primary: p.accent, | |
| 217 | − | success: p.ok, | |
| 211 | + | success: p.accent, | |
| 218 | 212 | warning: p.accent, | |
| 219 | 213 | danger: p.err, | |
| 220 | 214 | }, | |
| @@ -241,11 +235,9 @@ mod tests { | |||
| 241 | 235 | .unwrap(); | |
| 242 | 236 | assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a)); | |
| 243 | 237 | 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)); | |
| 246 | 239 | assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d)); | |
| 247 | 240 | assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9)); | |
| 248 | − | assert_eq!(palette.ok, BUILT_IN.ok); | |
| 249 | 241 | } | |
| 250 | 242 | ||
| 251 | 243 | #[test] | |
| @@ -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. | |
| 3 | 4 | ||
| 4 | − | use iced::widget::{Column, Row, button, column, container, row, rule, space, text, toggler}; | |
| 5 | − | use iced::{Alignment, Border, Element, Length}; | |
| 5 | + | use iced::alignment::{Horizontal, 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}; | |
| 6 | 8 | ||
| 7 | 9 | use crate::Message; | |
| 8 | − | use crate::icons::{self, Icon}; | |
| 10 | + | use crate::glyphs; | |
| 9 | 11 | use crate::theme::Look; | |
| 10 | 12 | ||
| 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) | |
| 38 | 19 | .into() | |
| 39 | 20 | } | |
| 40 | 21 | ||
| 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> { | |
| 42 | 24 | let p = look.p; | |
| 43 | − | container(rule::horizontal(1).style(move |_| rule::Style { | |
| 25 | + | let line = rule::horizontal(1).style(move |_| rule::Style { | |
| 44 | 26 | color: p.line, | |
| 45 | 27 | radius: 0.0.into(), | |
| 46 | 28 | fill_mode: rule::FillMode::Full, | |
| 47 | 29 | 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) | |
| 51 | 40 | } | |
| 52 | 41 | ||
| 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>( | |
| 56 | 54 | 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, | |
| 62 | 59 | ) -> Element<'a, Message> { | |
| 63 | 60 | 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))) | |
| 67 | 63 | .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) | |
| 70 | 66 | .align_y(Alignment::Center) | |
| 71 | − | .padding([13, 16]); | |
| 67 | + | .padding([5, 0]); | |
| 72 | 68 | button(content) | |
| 73 | − | .on_press(on_press) | |
| 74 | − | .padding(0) | |
| 69 | + | .on_press(toggle) | |
| 75 | 70 | .width(Length::Fill) | |
| 71 | + | .padding(0) | |
| 76 | 72 | .style(move |_, status| button::Style { | |
| 77 | − | background: hovered(status).then_some(p.surface_high.into()), | |
| 73 | + | background: hovered(status).then_some(p.surface.into()), | |
| 78 | 74 | text_color: p.fg, | |
| 79 | − | border: Border::default().rounded(14), | |
| 80 | 75 | ..button::Style::default() | |
| 81 | 76 | }) | |
| 82 | 77 | .into() | |
| 83 | 78 | } | |
| 84 | 79 | ||
| 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> { | |
| 91 | 82 | 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() | |
| 104 | 104 | }) | |
| 105 | 105 | .into() | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | − | /// A chevron: the row opens a page. | |
| 109 | − | pub fn chevron<'a>(look: Look) -> Element<'a, Message> { | |
| 110 | − | icons::icon(Icon::Chevron, 18.0, look.p.dim) | |
| 111 | − | } | |
| 112 | − | ||
| 113 | − | /// One choice of a [`segmented`] control. | |
| 114 | − | pub struct Segment<'a> { | |
| 115 | − | pub icon: Option<Icon>, | |
| 116 | − | pub label: &'a str, | |
| 117 | − | pub selected: bool, | |
| 118 | − | pub on_press: Message, | |
| 119 | − | } | |
| 120 | − | ||
| 121 | − | /// A pill of choices with the selected one filled, like the noise-control modes. `large` puts an | |
| 122 | − | /// icon above each label. | |
| 123 | − | pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool) -> Element<'a, Message> { | |
| 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> { | |
| 124 | 116 | 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 { | |
| 146 | 131 | Some(p.accent.into()) | |
| 147 | 132 | } else { | |
| 148 | − | hovered(status).then_some(p.surface_high.into()) | |
| 133 | + | hover.then_some(p.surface_high.into()) | |
| 149 | 134 | }, | |
| 150 | 135 | 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 | + | }, | |
| 152 | 141 | ..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 | + | } | |
| 167 | 143 | }) | |
| 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) | |
| 168 | 162 | .into() | |
| 169 | 163 | } | |
| 170 | 164 | ||
| 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> { | |
| 173 | 167 | 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) | |
| 181 | 183 | ] | |
| 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]); | |
| 184 | 208 | button(content) | |
| 185 | 209 | .on_press(on_press) | |
| 186 | 210 | .width(Length::Fill) | |
| 187 | − | .padding([10, 14]) | |
| 211 | + | .padding(0) | |
| 188 | 212 | .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, | |
| 196 | 215 | ..button::Style::default() | |
| 197 | 216 | }) | |
| 198 | 217 | .into() | |
| 199 | 218 | } | |
| 200 | 219 | ||
| 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> { | |
| 203 | 236 | 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)) | |
| 205 | 242 | .on_press(on_press) | |
| 206 | 243 | .width(Length::Fill) | |
| 207 | − | .padding([8, 12]) | |
| 244 | + | .height(30) | |
| 245 | + | .padding([0, 10]) | |
| 208 | 246 | .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()) | |
| 215 | 251 | }, | |
| 252 | + | text_color: if open { p.bg } else { p.fg }, | |
| 216 | 253 | ..button::Style::default() | |
| 217 | 254 | }) | |
| 218 | 255 | .into() | |
| 219 | 256 | } | |
| 220 | 257 | ||
| 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) | |
| 226 | 275 | .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 | + | }, | |
| 229 | 281 | ..container::Style::default() | |
| 230 | 282 | }) | |
| 231 | 283 | .into() | |
| 232 | 284 | } | |
| 233 | 285 | ||
| 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> { | |
| 236 | 297 | 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 | |
| 246 | 315 | }) | |
| 247 | 316 | .into() | |
| 248 | 317 | } | |
| 249 | 318 | ||
| 250 | − | /// A thin vertical line, between the sidebar and the page. | |
| 319 | + | /// A 1 px vertical line, between the sidebar and the page. | |
| 251 | 320 | pub fn divider<'a>(look: Look) -> Element<'a, Message> { | |
| 252 | 321 | let p = look.p; | |
| 253 | 322 | rule::vertical(1) | |