budctla345a10
budctl-gui: the Earbud settings and App settings pages
Earbud settings: "Find my earbuds" rings both buds after a "Ring your buds?" confirmation (only Enter or the Ring button says yes; the sound is loud), and Stop asks nothing. The buds keep ringing after the session closes, so the button stays on Stop until it is pressed. Below, the buds' name, firmware and audio codec. App settings: where the colors come from (the palette file, or built in and why), the keys, and what budctl is. The worker gained Command::Find (Session::find). No new dependencies.
+324 −1
7 files changed
- A crates/budctl-gui/src/earbuds.rs +67 −0
- M crates/budctl-gui/src/icons.rs +37 −0
- M crates/budctl-gui/src/main.rs +123 −1
- A crates/budctl-gui/src/settings.rs +60 −0
- M crates/budctl-gui/src/theme.rs +5 −0
- M crates/budctl-gui/src/ui.rs +24 −0
- M crates/budctl-gui/src/worker.rs +8 −0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | //! The Earbud settings page: find the buds, and what they run. | |
| 2 | + | ||
| 3 | + | use iced::Element; | |
| 4 | + | use iced::widget::{column, text}; | |
| 5 | + | ||
| 6 | + | use crate::buds::Buds; | |
| 7 | + | use crate::icons::Icon; | |
| 8 | + | use crate::theme::Look; | |
| 9 | + | use crate::ui; | |
| 10 | + | use crate::{Item, Marks, Message}; | |
| 11 | + | ||
| 12 | + | /// What the keyboard cursor can rest on. | |
| 13 | + | pub fn items() -> Vec<Item> { | |
| 14 | + | vec![Item::Find] | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// `ringing`: the buds were told to ring and not yet to stop. | |
| 18 | + | pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Element<'a, Message> { | |
| 19 | + | let p = look.p; | |
| 20 | + | let about = match (marks.pending.contains(&Item::Find), ringing) { | |
| 21 | + | (true, false) => "Starting…", | |
| 22 | + | (true, true) => "Stopping…", | |
| 23 | + | (false, true) => "Ringing: stop it once you have found them", | |
| 24 | + | (false, false) => "Plays a loud sound from both buds", | |
| 25 | + | }; | |
| 26 | + | let find = ui::setting( | |
| 27 | + | look, | |
| 28 | + | Icon::Find, | |
| 29 | + | "Find my earbuds", | |
| 30 | + | Some(about.to_owned()), | |
| 31 | + | ui::pill(look, if ringing { "Stop" } else { "Ring" }, ringing, Message::Find), | |
| 32 | + | None, | |
| 33 | + | marks.focus == Some(Item::Find), | |
| 34 | + | ); | |
| 35 | + | let value = |value: Option<&'a str>| -> Element<'a, Message> { | |
| 36 | + | text(value.unwrap_or("Unknown")).size(14).color(p.dim).into() | |
| 37 | + | }; | |
| 38 | + | let status = &buds.status; | |
| 39 | + | let facts = vec![ | |
| 40 | + | ui::setting(look, Icon::Earbud, "Name", None, value(Some(&buds.name)), None, false), | |
| 41 | + | ui::setting( | |
| 42 | + | look, | |
| 43 | + | Icon::Chip, | |
| 44 | + | "Firmware", | |
| 45 | + | None, | |
| 46 | + | value(status.firmware.as_deref()), | |
| 47 | + | None, | |
| 48 | + | false, | |
| 49 | + | ), | |
| 50 | + | ui::setting( | |
| 51 | + | look, | |
| 52 | + | Icon::Info, | |
| 53 | + | "Audio codec", | |
| 54 | + | None, | |
| 55 | + | value(status.codec.as_deref()), | |
| 56 | + | None, | |
| 57 | + | false, | |
| 58 | + | ), | |
| 59 | + | ]; | |
| 60 | + | column![ | |
| 61 | + | ui::title(look, "Earbud settings"), | |
| 62 | + | ui::card(look, vec![find]), | |
| 63 | + | column![ui::heading(look, "About these buds"), ui::card(look, facts)].spacing(10), | |
| 64 | + | ] | |
| 65 | + | .spacing(18) | |
| 66 | + | .into() | |
| 67 | + | } | |
| @@ -24,6 +24,10 @@ pub enum Icon { | |||
| 24 | 24 | Chevron, | |
| 25 | 25 | Bolt, | |
| 26 | 26 | Check, | |
| 27 | + | Find, | |
| 28 | + | Info, | |
| 29 | + | Chip, | |
| 30 | + | Keys, | |
| 27 | 31 | } | |
| 28 | 32 | ||
| 29 | 33 | /// `icon` at `size` pixels, in `color`. | |
| @@ -240,6 +244,39 @@ fn draw_icon(pen: &mut Pen, icon: Icon) { | |||
| 240 | 244 | pen.rect(17.5, 8.0, 4.5, 11.0, 1.2); | |
| 241 | 245 | } | |
| 242 | 246 | Icon::Chevron => pen.polyline(&[(9.0, 5.5), (15.5, 12.0), (9.0, 18.5)], false), | |
| 247 | + | Icon::Find => { | |
| 248 | + | pen.circle(12.0, 12.0, 6.5); | |
| 249 | + | pen.dot(12.0, 12.0, 2.0); | |
| 250 | + | for (from, to) in [ | |
| 251 | + | ((12.0, 2.0), (12.0, 5.0)), | |
| 252 | + | ((12.0, 19.0), (12.0, 22.0)), | |
| 253 | + | ((2.0, 12.0), (5.0, 12.0)), | |
| 254 | + | ((19.0, 12.0), (22.0, 12.0)), | |
| 255 | + | ] { | |
| 256 | + | pen.line(from, to); | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | Icon::Info => { | |
| 260 | + | pen.circle(12.0, 12.0, 9.0); | |
| 261 | + | pen.line((12.0, 11.0), (12.0, 16.5)); | |
| 262 | + | pen.dot(12.0, 7.8, 1.2); | |
| 263 | + | } | |
| 264 | + | Icon::Chip => { | |
| 265 | + | pen.rect(6.5, 6.5, 11.0, 11.0, 2.0); | |
| 266 | + | for at in [9.5, 14.5] { | |
| 267 | + | pen.line((at, 2.5), (at, 6.5)); | |
| 268 | + | pen.line((at, 17.5), (at, 21.5)); | |
| 269 | + | pen.line((2.5, at), (6.5, at)); | |
| 270 | + | pen.line((17.5, at), (21.5, at)); | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | Icon::Keys => { | |
| 274 | + | pen.rect(2.5, 6.0, 19.0, 12.0, 2.5); | |
| 275 | + | for x in [6.5, 10.0, 13.5, 17.0] { | |
| 276 | + | pen.dot(x, 10.0, 0.9); | |
| 277 | + | } | |
| 278 | + | pen.line((8.0, 14.0), (16.0, 14.0)); | |
| 279 | + | } | |
| 243 | 280 | Icon::Check => pen.polyline(&[(4.5, 12.5), (9.5, 17.5), (19.5, 7.0)], false), | |
| 244 | 281 | } | |
| 245 | 282 | } | |
| @@ -5,11 +5,13 @@ | |||
| 5 | 5 | //! (`keys.rs`). `--demo` shows made-up buds instead. | |
| 6 | 6 | ||
| 7 | 7 | mod buds; | |
| 8 | + | mod earbuds; | |
| 8 | 9 | mod equalizer; | |
| 9 | 10 | mod gestures; | |
| 10 | 11 | mod icons; | |
| 11 | 12 | mod keys; | |
| 12 | 13 | mod overview; | |
| 14 | + | mod settings; | |
| 13 | 15 | mod theme; | |
| 14 | 16 | mod ui; | |
| 15 | 17 | mod worker; | |
| @@ -186,6 +188,8 @@ enum Item { | |||
| 186 | 188 | Gesture(u8), | |
| 187 | 189 | /// A mode of the hold cycle, by its place among the modes the buds offer. | |
| 188 | 190 | Cycle(u8), | |
| 191 | + | /// Find my earbuds. | |
| 192 | + | Find, | |
| 189 | 193 | } | |
| 190 | 194 | ||
| 191 | 195 | /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the | |
| @@ -251,6 +255,11 @@ enum Message { | |||
| 251 | 255 | }, | |
| 252 | 256 | /// Put a mode (by its place) in the hold cycle, or take it out. | |
| 253 | 257 | Cycle(u8, bool), | |
| 258 | + | /// Ring the buds (after a confirmation), or stop them ringing. | |
| 259 | + | Find, | |
| 260 | + | /// The answer to "Ring the buds?". | |
| 261 | + | ConfirmFind(bool), | |
| 262 | + | ShowHelp, | |
| 254 | 263 | /// Read the buds again. | |
| 255 | 264 | Refresh, | |
| 256 | 265 | Worker(Event), | |
| @@ -291,6 +300,12 @@ struct App { | |||
| 291 | 300 | draft: Option<Draft>, | |
| 292 | 301 | /// A gesture's action picked and not written yet. | |
| 293 | 302 | touch_draft: Option<TouchDraft>, | |
| 303 | + | /// The buds were told to ring and not yet to stop. | |
| 304 | + | ringing: bool, | |
| 305 | + | /// "Ring the buds?" is open. | |
| 306 | + | confirm_find: bool, | |
| 307 | + | /// Why the palette file could not be used, if it could not. | |
| 308 | + | palette_problem: Option<String>, | |
| 294 | 309 | } | |
| 295 | 310 | ||
| 296 | 311 | impl App { | |
| @@ -324,6 +339,9 @@ impl App { | |||
| 324 | 339 | read_at: None, | |
| 325 | 340 | draft: None, | |
| 326 | 341 | touch_draft: None, | |
| 342 | + | ringing: false, | |
| 343 | + | confirm_find: false, | |
| 344 | + | palette_problem: None, | |
| 327 | 345 | }; | |
| 328 | 346 | app.refresh_palette(); | |
| 329 | 347 | app | |
| @@ -349,6 +367,15 @@ impl App { | |||
| 349 | 367 | } | |
| 350 | 368 | } | |
| 351 | 369 | Message::Cycle(mode, on) => self.cycle(mode, on), | |
| 370 | + | Message::Find if self.ringing => self.write(Item::Find, Command::Find(false)), | |
| 371 | + | Message::Find => self.confirm_find = true, | |
| 372 | + | Message::ConfirmFind(ring) => { | |
| 373 | + | self.confirm_find = false; | |
| 374 | + | if ring { | |
| 375 | + | self.write(Item::Find, Command::Find(true)); | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | Message::ShowHelp => self.help = true, | |
| 352 | 379 | Message::Refresh => self.refresh(), | |
| 353 | 380 | Message::Worker(event) => self.answer(event), | |
| 354 | 381 | Message::Focused => { | |
| @@ -399,6 +426,7 @@ impl App { | |||
| 399 | 426 | touch.hold_cycle = Some(modes); | |
| 400 | 427 | } | |
| 401 | 428 | } | |
| 429 | + | Command::Find(ring) => self.ringing = ring, | |
| 402 | 430 | Command::CustomEq(_, bands) => { | |
| 403 | 431 | if let Some(preset) = self.buds.preset(item_preset(item)) { | |
| 404 | 432 | let mut preset = preset.clone(); | |
| @@ -507,6 +535,11 @@ impl App { | |||
| 507 | 535 | self.save_touch(row); | |
| 508 | 536 | } | |
| 509 | 537 | } | |
| 538 | + | Event::Ringing(ring) => { | |
| 539 | + | self.in_flight.pop_front(); | |
| 540 | + | self.ringing = ring; | |
| 541 | + | self.link = Link::Live; | |
| 542 | + | } | |
| 510 | 543 | Event::HoldCycle(modes) => { | |
| 511 | 544 | self.in_flight.pop_front(); | |
| 512 | 545 | if let Some(touch) = &mut self.buds.touch { | |
| @@ -689,6 +722,11 @@ impl App { | |||
| 689 | 722 | self.help = false; | |
| 690 | 723 | return Task::none(); | |
| 691 | 724 | } | |
| 725 | + | if self.confirm_find { | |
| 726 | + | // Only Enter rings the buds; any other key says no. | |
| 727 | + | let ring = matches!(key, Key::Named(keyboard::key::Named::Enter)); | |
| 728 | + | return self.update(Message::ConfirmFind(ring)); | |
| 729 | + | } | |
| 692 | 730 | match action { | |
| 693 | 731 | Action::Quit => iced::exit(), | |
| 694 | 732 | Action::Help => { | |
| @@ -725,6 +763,7 @@ impl App { | |||
| 725 | 763 | Page::Overview if self.has_reading() => overview::items(&self.buds), | |
| 726 | 764 | Page::Equalizer if self.has_reading() => equalizer::items(&self.buds), | |
| 727 | 765 | Page::Gestures if self.has_reading() => gestures::items(&self.buds), | |
| 766 | + | Page::Earbuds if self.has_reading() => earbuds::items(), | |
| 728 | 767 | _ => Vec::new(), | |
| 729 | 768 | } | |
| 730 | 769 | } | |
| @@ -830,6 +869,7 @@ impl App { | |||
| 830 | 869 | save: true, | |
| 831 | 870 | }) | |
| 832 | 871 | } | |
| 872 | + | Item::Find => (action != Action::Left).then_some(Message::Find), | |
| 833 | 873 | Item::Cycle(mode) => { | |
| 834 | 874 | let touch = self.buds.touch.as_ref()?; | |
| 835 | 875 | let on = touch | |
| @@ -860,9 +900,13 @@ impl App { | |||
| 860 | 900 | fn refresh_palette(&mut self) { | |
| 861 | 901 | let palette = match self.palette.refresh() { | |
| 862 | 902 | None => return, | |
| 863 | − | Some(Ok(palette)) => palette, | |
| 903 | + | Some(Ok(palette)) => { | |
| 904 | + | self.palette_problem = None; | |
| 905 | + | palette | |
| 906 | + | } | |
| 864 | 907 | Some(Err(e)) => { | |
| 865 | 908 | eprintln!("budctl-gui: {e}; using the built-in colors"); | |
| 909 | + | self.palette_problem = Some(e); | |
| 866 | 910 | theme::BUILT_IN | |
| 867 | 911 | } | |
| 868 | 912 | }; | |
| @@ -916,6 +960,20 @@ impl App { | |||
| 916 | 960 | }; | |
| 917 | 961 | gestures::view(look, &self.buds, self.touch_draft.as_ref(), marks) | |
| 918 | 962 | } | |
| 963 | + | Page::Earbuds if self.has_reading() => { | |
| 964 | + | let marks = Marks { | |
| 965 | + | focus: self.focus(), | |
| 966 | + | pending: &pending, | |
| 967 | + | }; | |
| 968 | + | earbuds::view(look, &self.buds, self.ringing, marks) | |
| 969 | + | } | |
| 970 | + | Page::Settings => { | |
| 971 | + | let colors = settings::Colors { | |
| 972 | + | file: self.palette.file(), | |
| 973 | + | problem: self.palette_problem.as_deref(), | |
| 974 | + | }; | |
| 975 | + | settings::view(look, colors) | |
| 976 | + | } | |
| 919 | 977 | page => column![ | |
| 920 | 978 | ui::title(look, page.title()), | |
| 921 | 979 | text("This page comes in a later step.").size(14).color(look.p.dim) | |
| @@ -945,6 +1003,8 @@ impl App { | |||
| 945 | 1003 | let window = row![self.sidebar(), ui::divider(look), content]; | |
| 946 | 1004 | if self.help { | |
| 947 | 1005 | stack![window, self.help()].into() | |
| 1006 | + | } else if self.confirm_find { | |
| 1007 | + | stack![window, self.confirm_find()].into() | |
| 948 | 1008 | } else { | |
| 949 | 1009 | window.into() | |
| 950 | 1010 | } | |
| @@ -1068,6 +1128,33 @@ impl App { | |||
| 1068 | 1128 | .into() | |
| 1069 | 1129 | } | |
| 1070 | 1130 | ||
| 1131 | + | /// "Ring the buds?", over a dimmed window: the sound is loud enough to hurt in the ears. | |
| 1132 | + | fn confirm_find(&self) -> Element<'_, Message> { | |
| 1133 | + | let look = self.look; | |
| 1134 | + | let p = look.p; | |
| 1135 | + | let words = column![ | |
| 1136 | + | text("Ring your buds?").size(22).font(look.bold()).color(p.fg), | |
| 1137 | + | text("Take them out of your ears first: the sound is loud. They ring until you press Stop.") | |
| 1138 | + | .size(14) | |
| 1139 | + | .color(p.dim), | |
| 1140 | + | ] | |
| 1141 | + | .spacing(8); | |
| 1142 | + | let buttons = row![ | |
| 1143 | + | space::horizontal(), | |
| 1144 | + | ui::pill(look, "Cancel", false, Message::ConfirmFind(false)), | |
| 1145 | + | ui::pill(look, "Ring", true, Message::ConfirmFind(true)), | |
| 1146 | + | ] | |
| 1147 | + | .spacing(10); | |
| 1148 | + | let dialog = ui::panel(look, column![words, buttons].spacing(22).into()); | |
| 1149 | + | let backdrop = container(container(dialog).width(480)) | |
| 1150 | + | .center(Length::Fill) | |
| 1151 | + | .style(|_| container::Style { | |
| 1152 | + | background: Some(Color { a: 0.6, ..Color::BLACK }.into()), | |
| 1153 | + | ..container::Style::default() | |
| 1154 | + | }); | |
| 1155 | + | opaque(backdrop) | |
| 1156 | + | } | |
| 1157 | + | ||
| 1071 | 1158 | /// The list of keys, over a dimmed window. A key or a click closes it. | |
| 1072 | 1159 | fn help(&self) -> Element<'_, Message> { | |
| 1073 | 1160 | let look = self.look; | |
| @@ -1527,6 +1614,40 @@ mod tests { | |||
| 1527 | 1614 | assert_eq!(app.pending(), [Item::Gesture(1)]); | |
| 1528 | 1615 | } | |
| 1529 | 1616 | ||
| 1617 | + | #[test] | |
| 1618 | + | fn ringing_needs_a_yes() { | |
| 1619 | + | let mut app = app(); | |
| 1620 | + | press(&mut app, "4 j Enter"); | |
| 1621 | + | assert_eq!(app.page, Page::Earbuds); | |
| 1622 | + | assert!(app.confirm_find); | |
| 1623 | + | press(&mut app, "Esc"); | |
| 1624 | + | assert!(!app.confirm_find); | |
| 1625 | + | assert!(!app.ringing); | |
| 1626 | + | press(&mut app, "Enter j"); | |
| 1627 | + | assert!(!app.ringing, "only Enter says yes"); | |
| 1628 | + | press(&mut app, "Enter Enter"); | |
| 1629 | + | assert!(app.ringing); | |
| 1630 | + | // Stopping asks nothing. | |
| 1631 | + | press(&mut app, "Enter"); | |
| 1632 | + | assert!(!app.confirm_find); | |
| 1633 | + | assert!(!app.ringing); | |
| 1634 | + | } | |
| 1635 | + | ||
| 1636 | + | #[test] | |
| 1637 | + | fn live_ringing_waits_for_the_buds() { | |
| 1638 | + | let (mut app, commands) = live(); | |
| 1639 | + | let _ = commands.try_recv(); | |
| 1640 | + | let _ = app.update(Message::Worker(Event::Read(Box::new(reading())))); | |
| 1641 | + | let _ = app.update(Message::Find); | |
| 1642 | + | let _ = app.update(Message::ConfirmFind(true)); | |
| 1643 | + | assert!(matches!(commands.try_recv(), Ok(Command::Find(true)))); | |
| 1644 | + | assert!(!app.ringing); | |
| 1645 | + | let _ = app.update(Message::Worker(Event::Ringing(true))); | |
| 1646 | + | assert!(app.ringing); | |
| 1647 | + | let _ = app.update(Message::Find); | |
| 1648 | + | assert!(matches!(commands.try_recv(), Ok(Command::Find(false)))); | |
| 1649 | + | } | |
| 1650 | + | ||
| 1530 | 1651 | #[test] | |
| 1531 | 1652 | fn steps() { | |
| 1532 | 1653 | let abc = ['a', 'b', 'c']; | |
| @@ -1580,6 +1701,7 @@ mod tests { | |||
| 1580 | 1701 | shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?"))); | |
| 1581 | 1702 | shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k"))); | |
| 1582 | 1703 | shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j"))); | |
| 1704 | + | shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j Enter"))); | |
| 1583 | 1705 | let mut connecting = App::new(palette.clone(), font, None, false); | |
| 1584 | 1706 | connecting.link = Link::Connecting; | |
| 1585 | 1707 | shots.push(("connecting".to_owned(), 800.0, connecting)); | |
| @@ -0,0 +1,60 @@ | |||
| 1 | + | //! The App settings page: where the colors come from, the keys, and what budctl is. | |
| 2 | + | ||
| 3 | + | use std::path::Path; | |
| 4 | + | ||
| 5 | + | use iced::Element; | |
| 6 | + | use iced::widget::{column, space, text}; | |
| 7 | + | ||
| 8 | + | use crate::Message; | |
| 9 | + | use crate::icons::Icon; | |
| 10 | + | use crate::theme::Look; | |
| 11 | + | use crate::ui; | |
| 12 | + | ||
| 13 | + | /// Where the colors come from: the palette file followed, if any, and why it could not be used. | |
| 14 | + | pub struct Colors<'a> { | |
| 15 | + | pub file: Option<&'a Path>, | |
| 16 | + | pub problem: Option<&'a str>, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> { | |
| 20 | + | let p = look.p; | |
| 21 | + | let source = match (colors.file, colors.problem) { | |
| 22 | + | (_, Some(problem)) => format!("Built in: {problem}"), | |
| 23 | + | (Some(file), None) if file.exists() => format!("From {}", file.display()), | |
| 24 | + | _ => "Built in".to_owned(), | |
| 25 | + | }; | |
| 26 | + | let none = || -> Element<'a, Message> { space().into() }; | |
| 27 | + | let palette = ui::setting(look, Icon::Settings, "Colors", Some(source), none(), None, false); | |
| 28 | + | let how = text( | |
| 29 | + | "budctl follows a matugen palette with Aurora's keys (bg, fg, primary, surface…): the file given \ | |
| 30 | + | with --theme, else ~/.config/budctl/theme.json. It is read again whenever the window gets focus.", | |
| 31 | + | ) | |
| 32 | + | .size(13) | |
| 33 | + | .color(p.dim); | |
| 34 | + | let keys = ui::setting( | |
| 35 | + | look, | |
| 36 | + | Icon::Keys, | |
| 37 | + | "Keys", | |
| 38 | + | Some("Vim-like keys work on every page".to_owned()), | |
| 39 | + | ui::pill(look, "Show keys", false, Message::ShowHelp), | |
| 40 | + | None, | |
| 41 | + | false, | |
| 42 | + | ); | |
| 43 | + | let about = ui::setting( | |
| 44 | + | look, | |
| 45 | + | Icon::Info, | |
| 46 | + | concat!("budctl ", env!("CARGO_PKG_VERSION")), | |
| 47 | + | Some("GPL-3.0-or-later. No telemetry, no account: it talks to your buds over Bluetooth only.".to_owned()), | |
| 48 | + | none(), | |
| 49 | + | None, | |
| 50 | + | false, | |
| 51 | + | ); | |
| 52 | + | column![ | |
| 53 | + | ui::title(look, "App settings"), | |
| 54 | + | column![ui::card(look, vec![palette]), how].spacing(10), | |
| 55 | + | ui::card(look, vec![keys]), | |
| 56 | + | ui::card(look, vec![about]), | |
| 57 | + | ] | |
| 58 | + | .spacing(18) | |
| 59 | + | .into() | |
| 60 | + | } | |
| @@ -145,6 +145,11 @@ impl Source { | |||
| 145 | 145 | Source { file, stamp: None } | |
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | + | /// The palette file followed, if any. | |
| 149 | + | pub fn file(&self) -> Option<&Path> { | |
| 150 | + | self.file.as_deref() | |
| 151 | + | } | |
| 152 | + | ||
| 148 | 153 | /// The palette, if the file changed since the last call: built-in when the file is gone, an | |
| 149 | 154 | /// error when it cannot be used. `None` when nothing changed. | |
| 150 | 155 | pub fn refresh(&mut self) -> Option<Result<Palette, String>> { | |
| @@ -235,6 +235,30 @@ pub fn nav_item<'a>(look: Look, icon: Icon, label: &'a str, open: bool, on_press | |||
| 235 | 235 | .into() | |
| 236 | 236 | } | |
| 237 | 237 | ||
| 238 | + | /// A small rounded button: outlined, or `filled` with the accent. | |
| 239 | + | pub fn pill<'a>(look: Look, label: &'a str, filled: bool, on_press: Message) -> Element<'a, Message> { | |
| 240 | + | let p = look.p; | |
| 241 | + | let color = if filled { p.accent_fg } else { p.fg }; | |
| 242 | + | button(text(label).size(13.5).font(look.medium()).color(color)) | |
| 243 | + | .on_press(on_press) | |
| 244 | + | .padding([7, 16]) | |
| 245 | + | .style(move |_, status| button::Style { | |
| 246 | + | background: if filled { | |
| 247 | + | Some(p.accent.into()) | |
| 248 | + | } else { | |
| 249 | + | hovered(status).then_some(p.surface_high.into()) | |
| 250 | + | }, | |
| 251 | + | text_color: color, | |
| 252 | + | border: Border { | |
| 253 | + | color: if filled { p.accent } else { p.line }, | |
| 254 | + | width: 1.0, | |
| 255 | + | radius: 999.0.into(), | |
| 256 | + | }, | |
| 257 | + | ..button::Style::default() | |
| 258 | + | }) | |
| 259 | + | .into() | |
| 260 | + | } | |
| 261 | + | ||
| 238 | 262 | /// A wide button with an outline, like "Disconnect". | |
| 239 | 263 | pub fn outline_button<'a>(look: Look, label: &'a str, on_press: Message) -> Element<'a, Message> { | |
| 240 | 264 | let p = look.p; | |
| @@ -44,6 +44,8 @@ pub enum Command { | |||
| 44 | 44 | }, | |
| 45 | 45 | /// Set the noise-control modes a hold steps through. | |
| 46 | 46 | HoldCycle(Vec<String>), | |
| 47 | + | /// Start (`true`) or stop ringing the buds. | |
| 48 | + | Find(bool), | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | #[derive(Debug, Clone)] | |
| @@ -66,6 +68,8 @@ pub enum Event { | |||
| 66 | 68 | Touch(Vec<Gesture>), | |
| 67 | 69 | /// The answer to [`Command::HoldCycle`]: the modes, as read back. | |
| 68 | 70 | HoldCycle(Vec<String>), | |
| 71 | + | /// The answer to [`Command::Find`]: whether the buds were told to ring. | |
| 72 | + | Ringing(bool), | |
| 69 | 73 | /// The answer to a command that failed. The session is closed; the next command opens a | |
| 70 | 74 | /// new one. | |
| 71 | 75 | Failed { kind: Kind, message: String }, | |
| @@ -203,6 +207,10 @@ impl Worker { | |||
| 203 | 207 | .session() | |
| 204 | 208 | .and_then(|session| session.set_touch(&side, &gesture, &action, false)) | |
| 205 | 209 | .map(|change| Event::Touch(change.gestures)), | |
| 210 | + | Command::Find(ring) => self | |
| 211 | + | .session() | |
| 212 | + | .and_then(|session| session.find(ring)) | |
| 213 | + | .map(|()| Event::Ringing(ring)), | |
| 206 | 214 | Command::HoldCycle(modes) => { | |
| 207 | 215 | let modes: Vec<&str> = modes.iter().map(String::as_str).collect(); | |
| 208 | 216 | self.session() | |