budctl47dc78a
budctl-gui: the Gestures page
A card per bud lists its gestures, each with a dropdown of the actions the buds accept for it; h/l step through them, Enter goes round. As on the Equalizer page, a pick made while a write is on its way goes out when that is answered, so the latest pick wins, and a failure drops it. A last card has the hold cycle's modes as switches; the last two stay, since a hold needs two modes to step through. The reading now includes the touch controls. The worker gained Command::Touch (set_touch) and Command::HoldCycle (set_hold_cycle). Setting rows can be plain rows (no click target) for the dropdowns. Core: TouchControls lists the actions each gesture can take (`choices`) and the modes the hold cycle can include (`cycle_modes`), when the buds take those writes, so front ends offer only what the device declares. JSON gains both keys. TouchControls derives Clone. No new dependencies.
+632 −16
10 files changed
- M crates/budctl-core/src/devices/realme_t310/codec.rs +25 −1
- M crates/budctl-core/src/devices/realme_t310/mod.rs +19 −0
- M crates/budctl-core/src/status.rs +14 −1
- M crates/budctl-gui/src/buds.rs +57 −2
- M crates/budctl-gui/src/equalizer.rs +1 −1
- A crates/budctl-gui/src/gestures.rs +256 −0
- M crates/budctl-gui/src/main.rs +220 −3
- M crates/budctl-gui/src/overview.rs +2 −2
- M crates/budctl-gui/src/ui.rs +9 −4
- M crates/budctl-gui/src/worker.rs +29 −2
| @@ -3,7 +3,9 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use crate::error::{Error, Result}; | |
| 5 | 5 | use crate::reader::Reader; | |
| 6 | − | use crate::status::{AncLevel, Battery, Charge, EqBand, EqPreset, Gesture, NoiseControl, NoiseMode, Switch}; | |
| 6 | + | use crate::status::{ | |
| 7 | + | AncLevel, Battery, Charge, EqBand, EqPreset, Gesture, GestureChoice, NoiseControl, NoiseMode, Switch, | |
| 8 | + | }; | |
| 7 | 9 | ||
| 8 | 10 | /// Spec: realme_t310.md#bitmap | |
| 9 | 11 | pub(super) const HANDSHAKE: u16 = 0x0100; | |
| @@ -641,6 +643,28 @@ const TOUCH_CHOICES: [(u8, &[u8]); 3] = [ | |||
| 641 | 643 | (0x04, &[0x00, 0x08, 0x03, 0x11]), | |
| 642 | 644 | ]; | |
| 643 | 645 | ||
| 646 | + | /// The actions each gesture can be given, by name, in realme Link's order. | |
| 647 | + | /// Spec: realme_t310.md#gesture-choices | |
| 648 | + | pub(super) fn touch_choices() -> Vec<GestureChoice> { | |
| 649 | + | TOUCH_CHOICES | |
| 650 | + | .iter() | |
| 651 | + | .filter_map(|&(act, functions)| { | |
| 652 | + | Some(GestureChoice { | |
| 653 | + | gesture: name_of(&ACTS, act)?.to_owned(), | |
| 654 | + | actions: functions | |
| 655 | + | .iter() | |
| 656 | + | .filter_map(|&f| name_of(&FUNCTIONS, f).map(str::to_owned)) | |
| 657 | + | .collect(), | |
| 658 | + | }) | |
| 659 | + | }) | |
| 660 | + | .collect() | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | /// The modes a hold cycle can include, by name. Spec: realme_t310.md#hold-cycle-write | |
| 664 | + | pub(super) fn cycle_modes() -> Vec<String> { | |
| 665 | + | CYCLE_MODES.iter().map(|&(_, name)| name.to_owned()).collect() | |
| 666 | + | } | |
| 667 | + | ||
| 644 | 668 | /// A checked `touch <side> <gesture> <action>`: the bud ids, the act and the fn to write. | |
| 645 | 669 | #[derive(Debug, PartialEq, Eq)] | |
| 646 | 670 | pub(super) struct TouchTarget { | |
| @@ -450,9 +450,23 @@ impl Session for T310Session { | |||
| 450 | 450 | &codec::HOLD_CYCLE_QUERY, | |
| 451 | 451 | codec::decode_hold_cycle, | |
| 452 | 452 | )?; | |
| 453 | + | // The choices only when the buds take writes: the GUI offers what it shows here. | |
| 454 | + | let writable = |cmd| self.commands.allows(cmd); | |
| 455 | + | let choices = if writable(codec::GESTURE_WRITE) { | |
| 456 | + | codec::touch_choices() | |
| 457 | + | } else { | |
| 458 | + | Vec::new() | |
| 459 | + | }; | |
| 460 | + | let cycle_modes = if hold_cycle.is_some() && writable(codec::NOISE_CONTROL_WRITE) { | |
| 461 | + | codec::cycle_modes() | |
| 462 | + | } else { | |
| 463 | + | Vec::new() | |
| 464 | + | }; | |
| 453 | 465 | Ok(TouchControls { | |
| 454 | 466 | gestures, | |
| 455 | 467 | hold_cycle, | |
| 468 | + | choices, | |
| 469 | + | cycle_modes, | |
| 456 | 470 | warnings: reads.warnings, | |
| 457 | 471 | }) | |
| 458 | 472 | } | |
| @@ -770,6 +784,11 @@ mod tests { | |||
| 770 | 784 | script.assert_done(); | |
| 771 | 785 | assert_eq!(touch.gestures.len(), 2); | |
| 772 | 786 | assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]); | |
| 787 | + | let gestures: Vec<&str> = touch.choices.iter().map(|c| c.gesture.as_str()).collect(); | |
| 788 | + | assert_eq!(gestures, ["double-tap", "triple-tap", "hold"]); | |
| 789 | + | let hold = touch.choices.iter().find(|c| c.gesture == "hold").unwrap(); | |
| 790 | + | assert_eq!(hold.actions, ["none", "noise-cycle", "voice-assistant", "game-mode"]); | |
| 791 | + | assert_eq!(touch.cycle_modes, ["off", "transparency", "anc"]); | |
| 773 | 792 | } | |
| 774 | 793 | ||
| 775 | 794 | /// The owner's `budctl --debug touch` (HW1), byte for byte. The buds' own frames (slice | |
| @@ -92,14 +92,27 @@ pub struct Switch { | |||
| 92 | 92 | pub on: bool, | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | − | #[derive(Debug, Default, Serialize)] | |
| 95 | + | #[derive(Debug, Default, Clone, Serialize)] | |
| 96 | 96 | pub struct TouchControls { | |
| 97 | 97 | pub gestures: Vec<Gesture>, | |
| 98 | 98 | /// Noise-control modes a hold steps through, when the device has that setting. | |
| 99 | 99 | pub hold_cycle: Option<Vec<String>>, | |
| 100 | + | /// The actions each gesture can be given, when the device can change them. | |
| 101 | + | pub choices: Vec<GestureChoice>, | |
| 102 | + | /// The modes the hold cycle can include, when the device can change it. | |
| 103 | + | pub cycle_modes: Vec<String>, | |
| 100 | 104 | pub warnings: Vec<String>, | |
| 101 | 105 | } | |
| 102 | 106 | ||
| 107 | + | /// The actions one gesture can be given, as the device offers them, in its order. | |
| 108 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | |
| 109 | + | pub struct GestureChoice { | |
| 110 | + | /// `double-tap`, `triple-tap`, `hold`… | |
| 111 | + | pub gesture: String, | |
| 112 | + | /// `none`, `play-pause`… | |
| 113 | + | pub actions: Vec<String>, | |
| 114 | + | } | |
| 115 | + | ||
| 103 | 116 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | |
| 104 | 117 | pub struct Gesture { | |
| 105 | 118 | /// `left`, `right` or `both`. | |
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | //! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported | |
| 2 | 2 | //! at the last checkpoints), or an empty pair until the real buds answer. | |
| 3 | 3 | ||
| 4 | − | use budctl_core::status::{Battery, Charge, EqBand, EqPreset, EqPresets, NoiseControl, Status, Switch}; | |
| 4 | + | use budctl_core::status::{ | |
| 5 | + | Battery, Charge, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status, Switch, TouchControls, | |
| 6 | + | }; | |
| 5 | 7 | ||
| 6 | 8 | /// The buds the window shows. | |
| 7 | 9 | pub struct Buds { | |
| @@ -10,6 +12,8 @@ pub struct Buds { | |||
| 10 | 12 | pub status: Status, | |
| 11 | 13 | /// The EQ presets, when the buds list them. | |
| 12 | 14 | pub eq: Option<EqPresets>, | |
| 15 | + | /// The touch controls, when the buds report them. | |
| 16 | + | pub touch: Option<TouchControls>, | |
| 13 | 17 | } | |
| 14 | 18 | ||
| 15 | 19 | impl Buds { | |
| @@ -43,6 +47,16 @@ impl Buds { | |||
| 43 | 47 | pub fn preset(&self, id: u8) -> Option<&EqPreset> { | |
| 44 | 48 | self.eq.as_ref()?.presets.iter().find(|p| p.id == id) | |
| 45 | 49 | } | |
| 50 | + | ||
| 51 | + | /// What a gesture of a bud (`left`, `right`) does now. | |
| 52 | + | pub fn action(&self, side: &str, gesture: &str) -> Option<&str> { | |
| 53 | + | self.touch | |
| 54 | + | .as_ref()? | |
| 55 | + | .gestures | |
| 56 | + | .iter() | |
| 57 | + | .find(|g| g.side == side && g.gesture == gesture && g.group.is_none()) | |
| 58 | + | .map(|g| g.action.as_str()) | |
| 59 | + | } | |
| 46 | 60 | } | |
| 47 | 61 | ||
| 48 | 62 | /// The made-up buds of `--demo`. | |
| @@ -57,6 +71,17 @@ pub fn demo() -> Buds { | |||
| 57 | 71 | range: None, | |
| 58 | 72 | }; | |
| 59 | 73 | let band = |hz, gain| EqBand { hz, gain }; | |
| 74 | + | let gesture = |side: &str, gesture: &str, action: &str| Gesture { | |
| 75 | + | side: side.to_owned(), | |
| 76 | + | gesture: gesture.to_owned(), | |
| 77 | + | action: action.to_owned(), | |
| 78 | + | group: None, | |
| 79 | + | }; | |
| 80 | + | let choice = |gesture: &str, actions: &[&str]| GestureChoice { | |
| 81 | + | gesture: gesture.to_owned(), | |
| 82 | + | actions: actions.iter().map(|&a| a.to_owned()).collect(), | |
| 83 | + | }; | |
| 84 | + | let names = |names: &[&str]| names.iter().map(|&n| n.to_owned()).collect::<Vec<_>>(); | |
| 60 | 85 | Buds { | |
| 61 | 86 | name: "realme Buds T310".to_owned(), | |
| 62 | 87 | connected: true, | |
| @@ -107,6 +132,35 @@ pub fn demo() -> Buds { | |||
| 107 | 132 | ], | |
| 108 | 133 | warnings: Vec::new(), | |
| 109 | 134 | }), | |
| 135 | + | touch: Some(TouchControls { | |
| 136 | + | gestures: vec![ | |
| 137 | + | gesture("left", "double-tap", "play-pause"), | |
| 138 | + | gesture("left", "triple-tap", "previous"), | |
| 139 | + | gesture("left", "hold", "noise-cycle"), | |
| 140 | + | gesture("right", "double-tap", "play-pause"), | |
| 141 | + | gesture("right", "triple-tap", "next"), | |
| 142 | + | gesture("right", "hold", "noise-cycle"), | |
| 143 | + | ], | |
| 144 | + | hold_cycle: Some(names(&["off", "anc"])), | |
| 145 | + | choices: vec![ | |
| 146 | + | choice("double-tap", &["none", "play-pause", "next", "voice-assistant"]), | |
| 147 | + | choice( | |
| 148 | + | "triple-tap", | |
| 149 | + | &[ | |
| 150 | + | "none", | |
| 151 | + | "previous", | |
| 152 | + | "next", | |
| 153 | + | "voice-assistant", | |
| 154 | + | "volume-up", | |
| 155 | + | "volume-down", | |
| 156 | + | "game-mode", | |
| 157 | + | ], | |
| 158 | + | ), | |
| 159 | + | choice("hold", &["none", "noise-cycle", "voice-assistant", "game-mode"]), | |
| 160 | + | ], | |
| 161 | + | cycle_modes: names(&["off", "transparency", "anc"]), | |
| 162 | + | warnings: Vec::new(), | |
| 163 | + | }), | |
| 110 | 164 | } | |
| 111 | 165 | } | |
| 112 | 166 | ||
| @@ -117,6 +171,7 @@ pub fn unknown() -> Buds { | |||
| 117 | 171 | connected: false, | |
| 118 | 172 | status: Status::default(), | |
| 119 | 173 | eq: None, | |
| 174 | + | touch: None, | |
| 120 | 175 | } | |
| 121 | 176 | } | |
| 122 | 177 | ||
| @@ -151,7 +206,7 @@ pub fn eq_name(current: Option<&str>, presets: Option<&EqPresets>) -> String { | |||
| 151 | 206 | } | |
| 152 | 207 | ||
| 153 | 208 | /// `pure-bass` → "Pure bass". | |
| 154 | − | fn words(name: &str) -> String { | |
| 209 | + | pub fn words(name: &str) -> String { | |
| 155 | 210 | let words = name.replace('-', " "); | |
| 156 | 211 | let mut letters = words.chars(); | |
| 157 | 212 | letters | |
| @@ -59,7 +59,7 @@ pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Mar | |||
| 59 | 59 | buds::shown_name(preset), | |
| 60 | 60 | Some(subtitle.to_owned()), | |
| 61 | 61 | mark, | |
| 62 | − | Message::Preset(preset.id), | |
| 62 | + | Some(Message::Preset(preset.id)), | |
| 63 | 63 | marks.focus == Some(Item::Preset(preset.id)), | |
| 64 | 64 | ) | |
| 65 | 65 | }) | |
| @@ -0,0 +1,256 @@ | |||
| 1 | + | //! The Gestures page: what each tap and hold does on each bud, and what a hold steps through. | |
| 2 | + | ||
| 3 | + | use std::fmt; | |
| 4 | + | ||
| 5 | + | use budctl_core::status::TouchControls; | |
| 6 | + | use iced::widget::overlay::menu; | |
| 7 | + | use iced::widget::{column, pick_list, text}; | |
| 8 | + | use iced::{Border, Element, Shadow}; | |
| 9 | + | ||
| 10 | + | use crate::buds::{self, Buds}; | |
| 11 | + | use crate::icons::Icon; | |
| 12 | + | use crate::theme::Look; | |
| 13 | + | use crate::ui; | |
| 14 | + | use crate::{Item, Marks, Message, TouchDraft}; | |
| 15 | + | ||
| 16 | + | /// The buds, as the page shows them. | |
| 17 | + | const SIDES: [(&str, &str); 2] = [("left", "Left bud"), ("right", "Right bud")]; | |
| 18 | + | ||
| 19 | + | /// Shown instead of a subtitle while a write is on its way. | |
| 20 | + | const SAVING: &str = "Saving…"; | |
| 21 | + | ||
| 22 | + | /// One gesture of one bud, and the actions it can be given. | |
| 23 | + | pub struct Row<'a> { | |
| 24 | + | pub side: &'static str, | |
| 25 | + | pub gesture: &'a str, | |
| 26 | + | pub actions: &'a [String], | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// The page's gesture rows, in order: each bud's gestures, in the order the buds offer them. | |
| 30 | + | /// [`Item::Gesture`] counts in this list. | |
| 31 | + | pub fn rows(touch: &TouchControls) -> Vec<Row<'_>> { | |
| 32 | + | SIDES | |
| 33 | + | .iter() | |
| 34 | + | .flat_map(|&(side, _)| { | |
| 35 | + | touch.choices.iter().map(move |choice| Row { | |
| 36 | + | side, | |
| 37 | + | gesture: &choice.gesture, | |
| 38 | + | actions: &choice.actions, | |
| 39 | + | }) | |
| 40 | + | }) | |
| 41 | + | .collect() | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// What the keyboard cursor can rest on, top to bottom: the gestures, then the hold-cycle modes. | |
| 45 | + | pub fn items(buds: &Buds) -> Vec<Item> { | |
| 46 | + | let Some(touch) = &buds.touch else { | |
| 47 | + | return Vec::new(); | |
| 48 | + | }; | |
| 49 | + | let count = |n: usize| (0..n).filter_map(|i| u8::try_from(i).ok()); | |
| 50 | + | count(rows(touch).len()) | |
| 51 | + | .map(Item::Gesture) | |
| 52 | + | .chain(count(touch.cycle_modes.len()).map(Item::Cycle)) | |
| 53 | + | .collect() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// `draft`: an action picked on the screen and not on the buds yet. | |
| 57 | + | pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a TouchDraft>, marks: Marks) -> Element<'a, Message> { | |
| 58 | + | let p = look.p; | |
| 59 | + | let title = ui::title(look, "Gestures"); | |
| 60 | + | let Some(touch) = &buds.touch else { | |
| 61 | + | let none = text("These buds did not report their touch controls.") | |
| 62 | + | .size(14) | |
| 63 | + | .color(p.dim); | |
| 64 | + | return column![title, none].spacing(12).into(); | |
| 65 | + | }; | |
| 66 | + | let rows = rows(touch); | |
| 67 | + | let mut page = column![title].spacing(18); | |
| 68 | + | for (side, heading) in SIDES { | |
| 69 | + | let gestures = rows | |
| 70 | + | .iter() | |
| 71 | + | .enumerate() | |
| 72 | + | .filter(|(_, row)| row.side == side) | |
| 73 | + | .filter_map(|(n, row)| Some((u8::try_from(n).ok()?, row))) | |
| 74 | + | .map(|(n, row)| { | |
| 75 | + | let shown = draft | |
| 76 | + | .filter(|draft| draft.row == n) | |
| 77 | + | .map(|draft| draft.action.as_str()) | |
| 78 | + | .or_else(|| buds.action(side, row.gesture)) | |
| 79 | + | .unwrap_or("none"); | |
| 80 | + | let options: Vec<Choice> = row.actions.iter().map(|a| Choice::new(a)).collect(); | |
| 81 | + | let picker = pick_list(options, Some(Choice::new(shown)), move |choice: Choice| { | |
| 82 | + | Message::Action { | |
| 83 | + | row: n, | |
| 84 | + | action: choice.key, | |
| 85 | + | save: true, | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | .width(220) | |
| 89 | + | .padding([8, 12]) | |
| 90 | + | .text_size(14) | |
| 91 | + | .style(move |_, _| pick_list::Style { | |
| 92 | + | text_color: p.fg, | |
| 93 | + | placeholder_color: p.dim, | |
| 94 | + | handle_color: p.dim, | |
| 95 | + | background: p.surface_high.into(), | |
| 96 | + | border: Border { | |
| 97 | + | color: p.line, | |
| 98 | + | width: 1.0, | |
| 99 | + | radius: 10.0.into(), | |
| 100 | + | }, | |
| 101 | + | }) | |
| 102 | + | .menu_style(move |_| menu::Style { | |
| 103 | + | background: p.surface_high.into(), | |
| 104 | + | border: Border { | |
| 105 | + | color: p.line, | |
| 106 | + | width: 1.0, | |
| 107 | + | radius: 10.0.into(), | |
| 108 | + | }, | |
| 109 | + | text_color: p.fg, | |
| 110 | + | selected_text_color: p.accent_fg, | |
| 111 | + | selected_background: p.accent.into(), | |
| 112 | + | shadow: Shadow::default(), | |
| 113 | + | }); | |
| 114 | + | let saving = marks.pending.contains(&Item::Gesture(n)).then(|| SAVING.to_owned()); | |
| 115 | + | ui::setting( | |
| 116 | + | look, | |
| 117 | + | gesture_icon(row.gesture), | |
| 118 | + | gesture_name(row.gesture), | |
| 119 | + | saving, | |
| 120 | + | picker.into(), | |
| 121 | + | None, | |
| 122 | + | marks.focus == Some(Item::Gesture(n)), | |
| 123 | + | ) | |
| 124 | + | }) | |
| 125 | + | .collect(); | |
| 126 | + | page = page.push(column![ui::heading(look, heading), ui::card(look, gestures)].spacing(10)); | |
| 127 | + | } | |
| 128 | + | if let (Some(cycle), false) = (&touch.hold_cycle, touch.cycle_modes.is_empty()) { | |
| 129 | + | let modes = touch | |
| 130 | + | .cycle_modes | |
| 131 | + | .iter() | |
| 132 | + | .enumerate() | |
| 133 | + | .filter_map(|(n, mode)| Some((u8::try_from(n).ok()?, mode))) | |
| 134 | + | .map(|(n, mode)| { | |
| 135 | + | let on = cycle.contains(mode); | |
| 136 | + | let about = if marks.pending.contains(&Item::Cycle(n)) { | |
| 137 | + | Some(SAVING.to_owned()) | |
| 138 | + | } else if on && cycle.len() <= 2 { | |
| 139 | + | Some("A hold needs two modes to step through".to_owned()) | |
| 140 | + | } else { | |
| 141 | + | None | |
| 142 | + | }; | |
| 143 | + | ui::setting( | |
| 144 | + | look, | |
| 145 | + | mode_icon(mode), | |
| 146 | + | mode_name(mode), | |
| 147 | + | about, | |
| 148 | + | ui::switch(look, on), | |
| 149 | + | Some(Message::Cycle(n, !on)), | |
| 150 | + | marks.focus == Some(Item::Cycle(n)), | |
| 151 | + | ) | |
| 152 | + | }) | |
| 153 | + | .collect(); | |
| 154 | + | page = page.push(column![ui::heading(look, "A hold steps through"), ui::card(look, modes)].spacing(10)); | |
| 155 | + | } | |
| 156 | + | page.into() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// An action, for the dropdown: its name on the buds, and how it reads. | |
| 160 | + | #[derive(Debug, Clone, PartialEq)] | |
| 161 | + | struct Choice { | |
| 162 | + | key: String, | |
| 163 | + | label: String, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | impl Choice { | |
| 167 | + | fn new(key: &str) -> Self { | |
| 168 | + | Choice { | |
| 169 | + | key: key.to_owned(), | |
| 170 | + | label: action_name(key), | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | impl fmt::Display for Choice { | |
| 176 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 177 | + | f.write_str(&self.label) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | fn gesture_name(gesture: &str) -> String { | |
| 182 | + | match gesture { | |
| 183 | + | "hold" => "Touch and hold".to_owned(), | |
| 184 | + | other => buds::words(other), | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | fn gesture_icon(gesture: &str) -> Icon { | |
| 189 | + | match gesture { | |
| 190 | + | "hold" => Icon::Earbud, | |
| 191 | + | _ => Icon::Gestures, | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | pub fn action_name(action: &str) -> String { | |
| 196 | + | match action { | |
| 197 | + | "none" => "Nothing", | |
| 198 | + | "play-pause" => "Play / pause", | |
| 199 | + | "next" => "Next track", | |
| 200 | + | "previous" => "Previous track", | |
| 201 | + | "voice-assistant" => "Voice assistant", | |
| 202 | + | "noise-cycle" => "Noise control", | |
| 203 | + | other => return buds::words(other), | |
| 204 | + | } | |
| 205 | + | .to_owned() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | fn mode_name(mode: &str) -> String { | |
| 209 | + | match mode { | |
| 210 | + | "anc" => "Noise cancelling".to_owned(), | |
| 211 | + | other => buds::words(other), | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | fn mode_icon(mode: &str) -> Icon { | |
| 216 | + | match mode { | |
| 217 | + | "off" => Icon::NoiseOff, | |
| 218 | + | "anc" => Icon::Anc, | |
| 219 | + | _ => Icon::Transparency, | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[cfg(test)] | |
| 224 | + | mod tests { | |
| 225 | + | use super::*; | |
| 226 | + | ||
| 227 | + | #[test] | |
| 228 | + | fn rows_go_bud_by_bud() { | |
| 229 | + | let buds = buds::demo(); | |
| 230 | + | let touch = buds.touch.as_ref().unwrap(); | |
| 231 | + | let rows = rows(touch); | |
| 232 | + | let names: Vec<(&str, &str)> = rows.iter().map(|r| (r.side, r.gesture)).collect(); | |
| 233 | + | assert_eq!( | |
| 234 | + | names, | |
| 235 | + | [ | |
| 236 | + | ("left", "double-tap"), | |
| 237 | + | ("left", "triple-tap"), | |
| 238 | + | ("left", "hold"), | |
| 239 | + | ("right", "double-tap"), | |
| 240 | + | ("right", "triple-tap"), | |
| 241 | + | ("right", "hold"), | |
| 242 | + | ] | |
| 243 | + | ); | |
| 244 | + | let items = items(&buds); | |
| 245 | + | assert_eq!(items.len(), 6 + 3); | |
| 246 | + | assert_eq!(items.last(), Some(&Item::Cycle(2))); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[test] | |
| 250 | + | fn names_read_well() { | |
| 251 | + | assert_eq!(action_name("play-pause"), "Play / pause"); | |
| 252 | + | assert_eq!(action_name("volume-up"), "Volume up"); | |
| 253 | + | assert_eq!(gesture_name("triple-tap"), "Triple tap"); | |
| 254 | + | assert_eq!(mode_name("anc"), "Noise cancelling"); | |
| 255 | + | } | |
| 256 | + | } | |
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | 7 | mod buds; | |
| 8 | 8 | mod equalizer; | |
| 9 | + | mod gestures; | |
| 9 | 10 | mod icons; | |
| 10 | 11 | mod keys; | |
| 11 | 12 | mod overview; | |
| @@ -181,6 +182,10 @@ enum Item { | |||
| 181 | 182 | Preset(u8), | |
| 182 | 183 | /// One band of a custom EQ preset: the preset's id and the band's frequency. | |
| 183 | 184 | Band(u8, u16), | |
| 185 | + | /// A bud's gesture, by its place among the Gestures page's rows. | |
| 186 | + | Gesture(u8), | |
| 187 | + | /// A mode of the hold cycle, by its place among the modes the buds offer. | |
| 188 | + | Cycle(u8), | |
| 184 | 189 | } | |
| 185 | 190 | ||
| 186 | 191 | /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the | |
| @@ -191,6 +196,14 @@ struct Marks<'a> { | |||
| 191 | 196 | pending: &'a [Item], | |
| 192 | 197 | } | |
| 193 | 198 | ||
| 199 | + | /// A gesture's action picked on the screen and not on the buds yet. | |
| 200 | + | #[derive(Debug, Clone, PartialEq)] | |
| 201 | + | struct TouchDraft { | |
| 202 | + | /// The gesture, by its place among the Gestures page's rows. | |
| 203 | + | row: u8, | |
| 204 | + | action: String, | |
| 205 | + | } | |
| 206 | + | ||
| 194 | 207 | /// Bands of a custom EQ preset moved on the screen and not saved on the buds yet. | |
| 195 | 208 | #[derive(Debug, Clone, PartialEq)] | |
| 196 | 209 | struct Draft { | |
| @@ -230,6 +243,14 @@ enum Message { | |||
| 230 | 243 | }, | |
| 231 | 244 | /// Save the moved bands of a custom EQ preset. | |
| 232 | 245 | SaveBands(u8), | |
| 246 | + | /// Give a gesture (by its row) an action. With `save`, write it right away. | |
| 247 | + | Action { | |
| 248 | + | row: u8, | |
| 249 | + | action: String, | |
| 250 | + | save: bool, | |
| 251 | + | }, | |
| 252 | + | /// Put a mode (by its place) in the hold cycle, or take it out. | |
| 253 | + | Cycle(u8, bool), | |
| 233 | 254 | /// Read the buds again. | |
| 234 | 255 | Refresh, | |
| 235 | 256 | Worker(Event), | |
| @@ -268,6 +289,8 @@ struct App { | |||
| 268 | 289 | read_at: Option<Instant>, | |
| 269 | 290 | /// Bands moved and not saved yet. | |
| 270 | 291 | draft: Option<Draft>, | |
| 292 | + | /// A gesture's action picked and not written yet. | |
| 293 | + | touch_draft: Option<TouchDraft>, | |
| 271 | 294 | } | |
| 272 | 295 | ||
| 273 | 296 | impl App { | |
| @@ -300,6 +323,7 @@ impl App { | |||
| 300 | 323 | in_flight: VecDeque::new(), | |
| 301 | 324 | read_at: None, | |
| 302 | 325 | draft: None, | |
| 326 | + | touch_draft: None, | |
| 303 | 327 | }; | |
| 304 | 328 | app.refresh_palette(); | |
| 305 | 329 | app | |
| @@ -318,6 +342,13 @@ impl App { | |||
| 318 | 342 | } | |
| 319 | 343 | } | |
| 320 | 344 | Message::SaveBands(preset) => self.save_bands(preset), | |
| 345 | + | Message::Action { row, action, save } => { | |
| 346 | + | self.touch_draft = Some(TouchDraft { row, action }); | |
| 347 | + | if save { | |
| 348 | + | self.save_touch(row); | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | Message::Cycle(mode, on) => self.cycle(mode, on), | |
| 321 | 352 | Message::Refresh => self.refresh(), | |
| 322 | 353 | Message::Worker(event) => self.answer(event), | |
| 323 | 354 | Message::Focused => { | |
| @@ -352,6 +383,22 @@ impl App { | |||
| 352 | 383 | } | |
| 353 | 384 | } | |
| 354 | 385 | Command::Eq(preset) => self.buds.set_eq(preset), | |
| 386 | + | Command::Touch { side, gesture, action } => { | |
| 387 | + | if let Some(entry) = self.buds.touch.as_mut().and_then(|touch| { | |
| 388 | + | touch | |
| 389 | + | .gestures | |
| 390 | + | .iter_mut() | |
| 391 | + | .find(|g| g.side == side && g.gesture == gesture && g.group.is_none()) | |
| 392 | + | }) { | |
| 393 | + | entry.action = action; | |
| 394 | + | } | |
| 395 | + | self.touch_draft = None; | |
| 396 | + | } | |
| 397 | + | Command::HoldCycle(modes) => { | |
| 398 | + | if let Some(touch) = &mut self.buds.touch { | |
| 399 | + | touch.hold_cycle = Some(modes); | |
| 400 | + | } | |
| 401 | + | } | |
| 355 | 402 | Command::CustomEq(_, bands) => { | |
| 356 | 403 | if let Some(preset) = self.buds.preset(item_preset(item)) { | |
| 357 | 404 | let mut preset = preset.clone(); | |
| @@ -377,6 +424,7 @@ impl App { | |||
| 377 | 424 | Link::Demo => { | |
| 378 | 425 | self.buds = buds::demo(); | |
| 379 | 426 | self.draft = None; | |
| 427 | + | self.touch_draft = None; | |
| 380 | 428 | } | |
| 381 | 429 | _ if self.in_flight.contains(&None) => {} | |
| 382 | 430 | _ => { | |
| @@ -448,12 +496,34 @@ impl App { | |||
| 448 | 496 | _ => {} | |
| 449 | 497 | } | |
| 450 | 498 | } | |
| 499 | + | Event::Touch(gestures) => { | |
| 500 | + | let done = self.in_flight.pop_front(); | |
| 501 | + | if let Some(touch) = &mut self.buds.touch { | |
| 502 | + | touch.gestures = gestures; | |
| 503 | + | } | |
| 504 | + | self.link = Link::Live; | |
| 505 | + | // An action picked while this write was on its way goes out now. | |
| 506 | + | if let Some(Some(Item::Gesture(row))) = done { | |
| 507 | + | self.save_touch(row); | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | Event::HoldCycle(modes) => { | |
| 511 | + | self.in_flight.pop_front(); | |
| 512 | + | if let Some(touch) = &mut self.buds.touch { | |
| 513 | + | touch.hold_cycle = Some(modes); | |
| 514 | + | } | |
| 515 | + | self.link = Link::Live; | |
| 516 | + | } | |
| 451 | 517 | Event::Failed { kind, message } => { | |
| 452 | − | if let Some(Some(Item::Preset(id))) = self.in_flight.pop_front() { | |
| 453 | − | // The buds keep what they had: show that, not the moved bands. | |
| 454 | − | if self.draft.as_ref().is_some_and(|draft| draft.preset == id) { | |
| 518 | + | // The buds keep what they had: show that, not what was picked. | |
| 519 | + | match self.in_flight.pop_front() { | |
| 520 | + | Some(Some(Item::Preset(id))) if self.draft.as_ref().is_some_and(|d| d.preset == id) => { | |
| 455 | 521 | self.draft = None; | |
| 456 | 522 | } | |
| 523 | + | Some(Some(Item::Gesture(row))) if self.touch_draft.as_ref().is_some_and(|d| d.row == row) => { | |
| 524 | + | self.touch_draft = None; | |
| 525 | + | } | |
| 526 | + | _ => {} | |
| 457 | 527 | } | |
| 458 | 528 | if kind == Kind::NotFound { | |
| 459 | 529 | self.buds.connected = false; | |
| @@ -465,6 +535,7 @@ impl App { | |||
| 465 | 535 | ||
| 466 | 536 | fn apply_reading(&mut self, reading: Reading) { | |
| 467 | 537 | self.buds.eq = reading.eq; | |
| 538 | + | self.buds.touch = reading.touch; | |
| 468 | 539 | if let Some(level) = reading | |
| 469 | 540 | .status | |
| 470 | 541 | .noise_control | |
| @@ -540,6 +611,63 @@ impl App { | |||
| 540 | 611 | } | |
| 541 | 612 | } | |
| 542 | 613 | ||
| 614 | + | /// A gesture row: the bud, the gesture, and the actions it takes. | |
| 615 | + | fn gesture_row(&self, row: u8) -> Option<(&'static str, String, Vec<String>)> { | |
| 616 | + | let touch = self.buds.touch.as_ref()?; | |
| 617 | + | let rows = gestures::rows(touch); | |
| 618 | + | let found = rows.get(usize::from(row))?; | |
| 619 | + | Some((found.side, found.gesture.to_owned(), found.actions.to_vec())) | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | /// The action a gesture row has: the one picked on the screen, else the buds' own. | |
| 623 | + | fn gesture_action(&self, row: u8) -> Option<String> { | |
| 624 | + | if let Some(draft) = self.touch_draft.as_ref().filter(|draft| draft.row == row) { | |
| 625 | + | return Some(draft.action.clone()); | |
| 626 | + | } | |
| 627 | + | let (side, gesture, _) = self.gesture_row(row)?; | |
| 628 | + | self.buds.action(side, &gesture).map(str::to_owned) | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | /// Writes the action picked for a gesture row. If a write to it is on its way, the pick | |
| 632 | + | /// goes out when that is answered: the latest pick wins. | |
| 633 | + | fn save_touch(&mut self, row: u8) { | |
| 634 | + | let Some(draft) = self.touch_draft.clone().filter(|draft| draft.row == row) else { | |
| 635 | + | return; | |
| 636 | + | }; | |
| 637 | + | let Some((side, gesture, _)) = self.gesture_row(row) else { | |
| 638 | + | return; | |
| 639 | + | }; | |
| 640 | + | if self.buds.action(side, &gesture) == Some(draft.action.as_str()) { | |
| 641 | + | self.touch_draft = None; | |
| 642 | + | } else if !self.pending().contains(&Item::Gesture(row)) { | |
| 643 | + | let command = Command::Touch { | |
| 644 | + | side: side.to_owned(), | |
| 645 | + | gesture, | |
| 646 | + | action: draft.action, | |
| 647 | + | }; | |
| 648 | + | self.write(Item::Gesture(row), command); | |
| 649 | + | } | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | /// Puts a mode in the hold cycle or takes it out. The buds need two modes to step | |
| 653 | + | /// through, so the last two stay. | |
| 654 | + | fn cycle(&mut self, mode: u8, on: bool) { | |
| 655 | + | let Some(touch) = &self.buds.touch else { | |
| 656 | + | return; | |
| 657 | + | }; | |
| 658 | + | let (Some(name), Some(now)) = (touch.cycle_modes.get(usize::from(mode)), &touch.hold_cycle) else { | |
| 659 | + | return; | |
| 660 | + | }; | |
| 661 | + | let mut modes: Vec<String> = now.iter().filter(|m| *m != name).cloned().collect(); | |
| 662 | + | if on { | |
| 663 | + | modes.push(name.clone()); | |
| 664 | + | } | |
| 665 | + | let busy = self.pending().iter().any(|item| matches!(item, Item::Cycle(_))); | |
| 666 | + | if modes.len() >= 2 && !busy { | |
| 667 | + | self.write(Item::Cycle(mode), Command::HoldCycle(modes)); | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | ||
| 543 | 671 | /// The items with a write on its way. | |
| 544 | 672 | fn pending(&self) -> Vec<Item> { | |
| 545 | 673 | self.in_flight.iter().flatten().copied().collect() | |
| @@ -596,6 +724,7 @@ impl App { | |||
| 596 | 724 | match self.page { | |
| 597 | 725 | Page::Overview if self.has_reading() => overview::items(&self.buds), | |
| 598 | 726 | Page::Equalizer if self.has_reading() => equalizer::items(&self.buds), | |
| 727 | + | Page::Gestures if self.has_reading() => gestures::items(&self.buds), | |
| 599 | 728 | _ => Vec::new(), | |
| 600 | 729 | } | |
| 601 | 730 | } | |
| @@ -689,6 +818,31 @@ impl App { | |||
| 689 | 818 | save: true, | |
| 690 | 819 | }) | |
| 691 | 820 | } | |
| 821 | + | Item::Gesture(row) => { | |
| 822 | + | let (_, _, actions) = self.gesture_row(row)?; | |
| 823 | + | let now = self.gesture_action(row)?; | |
| 824 | + | let places: Vec<usize> = (0..actions.len()).collect(); | |
| 825 | + | let at = actions.iter().position(|a| *a == now); | |
| 826 | + | let to = step(&places, at, action)?; | |
| 827 | + | actions.get(to).map(|action| Message::Action { | |
| 828 | + | row, | |
| 829 | + | action: action.clone(), | |
| 830 | + | save: true, | |
| 831 | + | }) | |
| 832 | + | } | |
| 833 | + | Item::Cycle(mode) => { | |
| 834 | + | let touch = self.buds.touch.as_ref()?; | |
| 835 | + | let on = touch | |
| 836 | + | .hold_cycle | |
| 837 | + | .as_ref()? | |
| 838 | + | .contains(touch.cycle_modes.get(usize::from(mode))?); | |
| 839 | + | let want = match action { | |
| 840 | + | Action::Left => false, | |
| 841 | + | Action::Right => true, | |
| 842 | + | _ => !on, | |
| 843 | + | }; | |
| 844 | + | (want != on).then_some(Message::Cycle(mode, want)) | |
| 845 | + | } | |
| 692 | 846 | } | |
| 693 | 847 | } | |
| 694 | 848 | ||
| @@ -755,6 +909,13 @@ impl App { | |||
| 755 | 909 | }; | |
| 756 | 910 | equalizer::view(look, &self.buds, self.draft.as_ref(), marks) | |
| 757 | 911 | } | |
| 912 | + | Page::Gestures if self.has_reading() => { | |
| 913 | + | let marks = Marks { | |
| 914 | + | focus: self.focus(), | |
| 915 | + | pending: &pending, | |
| 916 | + | }; | |
| 917 | + | gestures::view(look, &self.buds, self.touch_draft.as_ref(), marks) | |
| 918 | + | } | |
| 758 | 919 | page => column![ | |
| 759 | 920 | ui::title(look, page.title()), | |
| 760 | 921 | text("This page comes in a later step.").size(14).color(look.p.dim) | |
| @@ -981,6 +1142,7 @@ mod tests { | |||
| 981 | 1142 | Reading { | |
| 982 | 1143 | status: buds::demo().status, | |
| 983 | 1144 | eq: buds::demo().eq, | |
| 1145 | + | touch: buds::demo().touch, | |
| 984 | 1146 | } | |
| 985 | 1147 | } | |
| 986 | 1148 | ||
| @@ -1311,6 +1473,60 @@ mod tests { | |||
| 1311 | 1473 | assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3)); | |
| 1312 | 1474 | } | |
| 1313 | 1475 | ||
| 1476 | + | fn cycle(app: &App) -> Vec<String> { | |
| 1477 | + | app.buds.touch.as_ref().unwrap().hold_cycle.clone().unwrap() | |
| 1478 | + | } | |
| 1479 | + | ||
| 1480 | + | #[test] | |
| 1481 | + | fn gestures_and_the_hold_cycle_by_key() { | |
| 1482 | + | let mut app = app(); | |
| 1483 | + | press(&mut app, "3 j j"); | |
| 1484 | + | assert_eq!(app.page, Page::Gestures); | |
| 1485 | + | assert_eq!(app.focus(), Some(Item::Gesture(1))); | |
| 1486 | + | press(&mut app, "l"); | |
| 1487 | + | assert_eq!(app.buds.action("left", "triple-tap"), Some("next")); | |
| 1488 | + | press(&mut app, "h h h"); | |
| 1489 | + | assert_eq!( | |
| 1490 | + | app.buds.action("left", "triple-tap"), | |
| 1491 | + | Some("none"), | |
| 1492 | + | "it stops at the first" | |
| 1493 | + | ); | |
| 1494 | + | ||
| 1495 | + | press(&mut app, "G k l"); | |
| 1496 | + | assert_eq!(app.focus(), Some(Item::Cycle(1))); | |
| 1497 | + | assert_eq!(cycle(&app), ["off", "anc", "transparency"]); | |
| 1498 | + | press(&mut app, "h"); | |
| 1499 | + | assert_eq!(cycle(&app), ["off", "anc"]); | |
| 1500 | + | press(&mut app, "j h"); | |
| 1501 | + | assert_eq!(cycle(&app), ["off", "anc"], "a hold needs two modes"); | |
| 1502 | + | } | |
| 1503 | + | ||
| 1504 | + | #[test] | |
| 1505 | + | fn gesture_picks_wait_for_the_write_before_them() { | |
| 1506 | + | let (mut app, commands) = live(); | |
| 1507 | + | let _ = commands.try_recv(); | |
| 1508 | + | let _ = app.update(Message::Worker(Event::Read(Box::new(reading())))); | |
| 1509 | + | press(&mut app, "3 j j l"); | |
| 1510 | + | assert!(matches!( | |
| 1511 | + | commands.try_recv(), | |
| 1512 | + | Ok(Command::Touch { side, gesture, action }) | |
| 1513 | + | if side == "left" && gesture == "triple-tap" && action == "next" | |
| 1514 | + | )); | |
| 1515 | + | // While that write is on its way, picks only change the screen. | |
| 1516 | + | press(&mut app, "l l"); | |
| 1517 | + | assert!(commands.try_recv().is_err()); | |
| 1518 | + | assert_eq!(app.gesture_action(1).as_deref(), Some("volume-up")); | |
| 1519 | + | // The answer comes: the latest pick goes out at once. | |
| 1520 | + | let mut table = reading().touch.unwrap().gestures; | |
| 1521 | + | table[1].action = "next".to_owned(); | |
| 1522 | + | let _ = app.update(Message::Worker(Event::Touch(table))); | |
| 1523 | + | assert!(matches!( | |
| 1524 | + | commands.try_recv(), | |
| 1525 | + | Ok(Command::Touch { action, .. }) if action == "volume-up" | |
| 1526 | + | )); | |
| 1527 | + | assert_eq!(app.pending(), [Item::Gesture(1)]); | |
| 1528 | + | } | |
| 1529 | + | ||
| 1314 | 1530 | #[test] | |
| 1315 | 1531 | fn steps() { | |
| 1316 | 1532 | let abc = ['a', 'b', 'c']; | |
| @@ -1363,6 +1579,7 @@ mod tests { | |||
| 1363 | 1579 | shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j"))); | |
| 1364 | 1580 | shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?"))); | |
| 1365 | 1581 | shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k"))); | |
| 1582 | + | shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j"))); | |
| 1366 | 1583 | let mut connecting = App::new(palette.clone(), font, None, false); | |
| 1367 | 1584 | connecting.link = Link::Connecting; | |
| 1368 | 1585 | shots.push(("connecting".to_owned(), 800.0, connecting)); | |
| @@ -165,7 +165,7 @@ fn switches<'a>(look: Look, switches: &[Switch], marks: Marks) -> Element<'a, Me | |||
| 165 | 165 | s.label, | |
| 166 | 166 | subtitle, | |
| 167 | 167 | ui::switch(look, s.on), | |
| 168 | − | Message::Switch(s.key, !s.on), | |
| 168 | + | Some(Message::Switch(s.key, !s.on)), | |
| 169 | 169 | marks.focus == Some(Item::Switch(s.key)), | |
| 170 | 170 | ) | |
| 171 | 171 | }) | |
| @@ -195,7 +195,7 @@ fn more<'a>(look: Look, buds: &Buds, focus: Option<Item>) -> Element<'a, Message | |||
| 195 | 195 | title, | |
| 196 | 196 | Some(subtitle), | |
| 197 | 197 | ui::chevron(look), | |
| 198 | − | Message::Open(page), | |
| 198 | + | Some(Message::Open(page)), | |
| 199 | 199 | focus == Some(Item::Open(page)), | |
| 200 | 200 | ) | |
| 201 | 201 | }) | |
| @@ -8,6 +8,11 @@ use crate::Message; | |||
| 8 | 8 | use crate::icons::{self, Icon}; | |
| 9 | 9 | use crate::theme::Look; | |
| 10 | 10 | ||
| 11 | + | /// A heading above a card. | |
| 12 | + | pub fn heading<'a>(look: Look, words: &'a str) -> Element<'a, Message> { | |
| 13 | + | text(words).size(15).font(look.medium()).color(look.p.dim).into() | |
| 14 | + | } | |
| 15 | + | ||
| 11 | 16 | /// A page title. | |
| 12 | 17 | pub fn title<'a>(look: Look, title: &'a str) -> Element<'a, Message> { | |
| 13 | 18 | text(title).size(30).font(look.bold()).color(look.p.fg).into() | |
| @@ -68,15 +73,15 @@ fn separator<'a>(look: Look) -> Element<'a, Message> { | |||
| 68 | 73 | .into() | |
| 69 | 74 | } | |
| 70 | 75 | ||
| 71 | − | /// A setting row: icon, title, an optional subtitle, and something on the right. The whole row | |
| 72 | − | /// is the click target. `focused`: the keyboard cursor is on it. | |
| 76 | + | /// A setting row: icon, title, an optional subtitle, and something on the right. With | |
| 77 | + | /// `on_press`, the whole row is the click target. `focused`: the keyboard cursor is on it. | |
| 73 | 78 | pub fn setting<'a>( | |
| 74 | 79 | look: Look, | |
| 75 | 80 | icon: Icon, | |
| 76 | 81 | title: impl text::IntoFragment<'a>, | |
| 77 | 82 | subtitle: Option<String>, | |
| 78 | 83 | trailing: Element<'a, Message>, | |
| 79 | − | on_press: Message, | |
| 84 | + | on_press: Option<Message>, | |
| 80 | 85 | focused: bool, | |
| 81 | 86 | ) -> Element<'a, Message> { | |
| 82 | 87 | let p = look.p; | |
| @@ -89,7 +94,7 @@ pub fn setting<'a>( | |||
| 89 | 94 | .align_y(Alignment::Center) | |
| 90 | 95 | .padding([13, 16]); | |
| 91 | 96 | button(content) | |
| 92 | − | .on_press(on_press) | |
| 97 | + | .on_press_maybe(on_press) | |
| 93 | 98 | .padding(0) | |
| 94 | 99 | .width(Length::Fill) | |
| 95 | 100 | .style(move |_, status| button::Style { | |
| @@ -12,7 +12,9 @@ use std::time::{Duration, Instant}; | |||
| 12 | 12 | ||
| 13 | 13 | use budctl_core::bt::BdAddr; | |
| 14 | 14 | use budctl_core::devices::Session; | |
| 15 | − | use budctl_core::status::{EqBand, EqPreset, EqPresets, NoiseControl, NoiseMode, Status, Switch}; | |
| 15 | + | use budctl_core::status::{ | |
| 16 | + | EqBand, EqPreset, EqPresets, Gesture, NoiseControl, NoiseMode, Status, Switch, TouchControls, | |
| 17 | + | }; | |
| 16 | 18 | use budctl_core::{Error, Kind, Result}; | |
| 17 | 19 | use iced::futures::channel::mpsc as channel; | |
| 18 | 20 | use iced::futures::{SinkExt, Stream, StreamExt}; | |
| @@ -34,6 +36,14 @@ pub enum Command { | |||
| 34 | 36 | /// Save a custom EQ preset (`custom-<id>`) with these bands changed, which also selects it. | |
| 35 | 37 | /// With no bands, it only selects it. | |
| 36 | 38 | CustomEq(String, Vec<EqBand>), | |
| 39 | + | /// Give a bud's gesture an action, by name: `left`, `double-tap`, `play-pause`. | |
| 40 | + | Touch { | |
| 41 | + | side: String, | |
| 42 | + | gesture: String, | |
| 43 | + | action: String, | |
| 44 | + | }, | |
| 45 | + | /// Set the noise-control modes a hold steps through. | |
| 46 | + | HoldCycle(Vec<String>), | |
| 37 | 47 | } | |
| 38 | 48 | ||
| 39 | 49 | #[derive(Debug, Clone)] | |
| @@ -52,6 +62,10 @@ pub enum Event { | |||
| 52 | 62 | Eq(String), | |
| 53 | 63 | /// The answer to [`Command::CustomEq`]: the preset, as read back. | |
| 54 | 64 | CustomEq(EqPreset), | |
| 65 | + | /// The answer to [`Command::Touch`]: the whole gesture table, as read back. | |
| 66 | + | Touch(Vec<Gesture>), | |
| 67 | + | /// The answer to [`Command::HoldCycle`]: the modes, as read back. | |
| 68 | + | HoldCycle(Vec<String>), | |
| 55 | 69 | /// The answer to a command that failed. The session is closed; the next command opens a | |
| 56 | 70 | /// new one. | |
| 57 | 71 | Failed { kind: Kind, message: String }, | |
| @@ -64,6 +78,8 @@ pub struct Reading { | |||
| 64 | 78 | pub status: Status, | |
| 65 | 79 | /// The EQ presets, when the buds can list them: they name the custom ones. | |
| 66 | 80 | pub eq: Option<EqPresets>, | |
| 81 | + | /// The touch controls, when the buds report them. | |
| 82 | + | pub touch: Option<TouchControls>, | |
| 67 | 83 | } | |
| 68 | 84 | ||
| 69 | 85 | /// The worker, as a stream of events for an iced subscription. `device` picks the buds, as | |
| @@ -183,6 +199,16 @@ impl Worker { | |||
| 183 | 199 | .session() | |
| 184 | 200 | .and_then(|session| session.set_custom_eq(&preset, &bands)) | |
| 185 | 201 | .map(Event::CustomEq), | |
| 202 | + | Command::Touch { side, gesture, action } => self | |
| 203 | + | .session() | |
| 204 | + | .and_then(|session| session.set_touch(&side, &gesture, &action, false)) | |
| 205 | + | .map(|change| Event::Touch(change.gestures)), | |
| 206 | + | Command::HoldCycle(modes) => { | |
| 207 | + | let modes: Vec<&str> = modes.iter().map(String::as_str).collect(); | |
| 208 | + | self.session() | |
| 209 | + | .and_then(|session| session.set_hold_cycle(&modes)) | |
| 210 | + | .map(Event::HoldCycle) | |
| 211 | + | } | |
| 186 | 212 | }; | |
| 187 | 213 | match answer { | |
| 188 | 214 | Ok(event) => self.send(event), | |
| @@ -200,7 +226,8 @@ impl Worker { | |||
| 200 | 226 | let session = self.session()?; | |
| 201 | 227 | let status = session.status()?; | |
| 202 | 228 | let eq = session.eq_presets().ok(); | |
| 203 | − | Ok(Reading { status, eq }) | |
| 229 | + | let touch = session.touch_controls().ok(); | |
| 230 | + | Ok(Reading { status, eq, touch }) | |
| 204 | 231 | } | |
| 205 | 232 | ||
| 206 | 233 | /// The open session, opening one first if there is none. | |