Krolyxon
Commit 47dc78a

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 23:55 +0530
Parent
e8c78b9
Commit
47dc78a41a740fb239f7e5ad70a5adb88bffe7c2

+632 −16

10 files changed

  1. M crates/budctl-core/src/devices/realme_t310/codec.rs +25 −1
  2. M crates/budctl-core/src/devices/realme_t310/mod.rs +19 −0
  3. M crates/budctl-core/src/status.rs +14 −1
  4. M crates/budctl-gui/src/buds.rs +57 −2
  5. M crates/budctl-gui/src/equalizer.rs +1 −1
  6. A crates/budctl-gui/src/gestures.rs +256 −0
  7. M crates/budctl-gui/src/main.rs +220 −3
  8. M crates/budctl-gui/src/overview.rs +2 −2
  9. M crates/budctl-gui/src/ui.rs +9 −4
  10. M crates/budctl-gui/src/worker.rs +29 −2
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -3,7 +3,9 @@
33
44 use crate::error::{Error, Result};
55 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+};
79
810 /// Spec: realme_t310.md#bitmap
911 pub(super) const HANDSHAKE: u16 = 0x0100;
@@ -641,6 +643,28 @@ const TOUCH_CHOICES: [(u8, &[u8]); 3] = [
641643 (0x04, &[0x00, 0x08, 0x03, 0x11]),
642644 ];
643645
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+
644668 /// A checked `touch <side> <gesture> <action>`: the bud ids, the act and the fn to write.
645669 #[derive(Debug, PartialEq, Eq)]
646670 pub(super) struct TouchTarget {
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -450,9 +450,23 @@ impl Session for T310Session {
450450 &codec::HOLD_CYCLE_QUERY,
451451 codec::decode_hold_cycle,
452452 )?;
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+ };
453465 Ok(TouchControls {
454466 gestures,
455467 hold_cycle,
468+ choices,
469+ cycle_modes,
456470 warnings: reads.warnings,
457471 })
458472 }
@@ -770,6 +784,11 @@ mod tests {
770784 script.assert_done();
771785 assert_eq!(touch.gestures.len(), 2);
772786 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"]);
773792 }
774793
775794 /// The owner's `budctl --debug touch` (HW1), byte for byte. The buds' own frames (slice
M crates/budctl-core/src/status.rs View
@@ -92,14 +92,27 @@ pub struct Switch {
9292 pub on: bool,
9393 }
9494
95−#[derive(Debug, Default, Serialize)]
95+#[derive(Debug, Default, Clone, Serialize)]
9696 pub struct TouchControls {
9797 pub gestures: Vec<Gesture>,
9898 /// Noise-control modes a hold steps through, when the device has that setting.
9999 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>,
100104 pub warnings: Vec<String>,
101105 }
102106
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+
103116 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104117 pub struct Gesture {
105118 /// `left`, `right` or `both`.
M crates/budctl-gui/src/buds.rs View
@@ -1,7 +1,9 @@
11 //! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported
22 //! at the last checkpoints), or an empty pair until the real buds answer.
33
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+};
57
68 /// The buds the window shows.
79 pub struct Buds {
@@ -10,6 +12,8 @@ pub struct Buds {
1012 pub status: Status,
1113 /// The EQ presets, when the buds list them.
1214 pub eq: Option<EqPresets>,
15+ /// The touch controls, when the buds report them.
16+ pub touch: Option<TouchControls>,
1317 }
1418
1519 impl Buds {
@@ -43,6 +47,16 @@ impl Buds {
4347 pub fn preset(&self, id: u8) -> Option<&EqPreset> {
4448 self.eq.as_ref()?.presets.iter().find(|p| p.id == id)
4549 }
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+ }
4660 }
4761
4862 /// The made-up buds of `--demo`.
@@ -57,6 +71,17 @@ pub fn demo() -> Buds {
5771 range: None,
5872 };
5973 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<_>>();
6085 Buds {
6186 name: "realme Buds T310".to_owned(),
6287 connected: true,
@@ -107,6 +132,35 @@ pub fn demo() -> Buds {
107132 ],
108133 warnings: Vec::new(),
109134 }),
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+ }),
110164 }
111165 }
112166
@@ -117,6 +171,7 @@ pub fn unknown() -> Buds {
117171 connected: false,
118172 status: Status::default(),
119173 eq: None,
174+ touch: None,
120175 }
121176 }
122177
@@ -151,7 +206,7 @@ pub fn eq_name(current: Option<&str>, presets: Option<&EqPresets>) -> String {
151206 }
152207
153208 /// `pure-bass` → "Pure bass".
154−fn words(name: &str) -> String {
209+pub fn words(name: &str) -> String {
155210 let words = name.replace('-', " ");
156211 let mut letters = words.chars();
157212 letters
M crates/budctl-gui/src/equalizer.rs View
@@ -59,7 +59,7 @@ pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Mar
5959 buds::shown_name(preset),
6060 Some(subtitle.to_owned()),
6161 mark,
62− Message::Preset(preset.id),
62+ Some(Message::Preset(preset.id)),
6363 marks.focus == Some(Item::Preset(preset.id)),
6464 )
6565 })
A crates/budctl-gui/src/gestures.rs View
@@ -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+}
M crates/budctl-gui/src/main.rs View
@@ -6,6 +6,7 @@
66
77 mod buds;
88 mod equalizer;
9+mod gestures;
910 mod icons;
1011 mod keys;
1112 mod overview;
@@ -181,6 +182,10 @@ enum Item {
181182 Preset(u8),
182183 /// One band of a custom EQ preset: the preset's id and the band's frequency.
183184 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),
184189 }
185190
186191 /// 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> {
191196 pending: &'a [Item],
192197 }
193198
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+
194207 /// Bands of a custom EQ preset moved on the screen and not saved on the buds yet.
195208 #[derive(Debug, Clone, PartialEq)]
196209 struct Draft {
@@ -230,6 +243,14 @@ enum Message {
230243 },
231244 /// Save the moved bands of a custom EQ preset.
232245 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),
233254 /// Read the buds again.
234255 Refresh,
235256 Worker(Event),
@@ -268,6 +289,8 @@ struct App {
268289 read_at: Option<Instant>,
269290 /// Bands moved and not saved yet.
270291 draft: Option<Draft>,
292+ /// A gesture's action picked and not written yet.
293+ touch_draft: Option<TouchDraft>,
271294 }
272295
273296 impl App {
@@ -300,6 +323,7 @@ impl App {
300323 in_flight: VecDeque::new(),
301324 read_at: None,
302325 draft: None,
326+ touch_draft: None,
303327 };
304328 app.refresh_palette();
305329 app
@@ -318,6 +342,13 @@ impl App {
318342 }
319343 }
320344 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),
321352 Message::Refresh => self.refresh(),
322353 Message::Worker(event) => self.answer(event),
323354 Message::Focused => {
@@ -352,6 +383,22 @@ impl App {
352383 }
353384 }
354385 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+ }
355402 Command::CustomEq(_, bands) => {
356403 if let Some(preset) = self.buds.preset(item_preset(item)) {
357404 let mut preset = preset.clone();
@@ -377,6 +424,7 @@ impl App {
377424 Link::Demo => {
378425 self.buds = buds::demo();
379426 self.draft = None;
427+ self.touch_draft = None;
380428 }
381429 _ if self.in_flight.contains(&None) => {}
382430 _ => {
@@ -448,12 +496,34 @@ impl App {
448496 _ => {}
449497 }
450498 }
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+ }
451517 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) => {
455521 self.draft = None;
456522 }
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+ _ => {}
457527 }
458528 if kind == Kind::NotFound {
459529 self.buds.connected = false;
@@ -465,6 +535,7 @@ impl App {
465535
466536 fn apply_reading(&mut self, reading: Reading) {
467537 self.buds.eq = reading.eq;
538+ self.buds.touch = reading.touch;
468539 if let Some(level) = reading
469540 .status
470541 .noise_control
@@ -540,6 +611,63 @@ impl App {
540611 }
541612 }
542613
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+
543671 /// The items with a write on its way.
544672 fn pending(&self) -> Vec<Item> {
545673 self.in_flight.iter().flatten().copied().collect()
@@ -596,6 +724,7 @@ impl App {
596724 match self.page {
597725 Page::Overview if self.has_reading() => overview::items(&self.buds),
598726 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds),
727+ Page::Gestures if self.has_reading() => gestures::items(&self.buds),
599728 _ => Vec::new(),
600729 }
601730 }
@@ -689,6 +818,31 @@ impl App {
689818 save: true,
690819 })
691820 }
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+ }
692846 }
693847 }
694848
@@ -755,6 +909,13 @@ impl App {
755909 };
756910 equalizer::view(look, &self.buds, self.draft.as_ref(), marks)
757911 }
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+ }
758919 page => column![
759920 ui::title(look, page.title()),
760921 text("This page comes in a later step.").size(14).color(look.p.dim)
@@ -981,6 +1142,7 @@ mod tests {
9811142 Reading {
9821143 status: buds::demo().status,
9831144 eq: buds::demo().eq,
1145+ touch: buds::demo().touch,
9841146 }
9851147 }
9861148
@@ -1311,6 +1473,60 @@ mod tests {
13111473 assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3));
13121474 }
13131475
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+
13141530 #[test]
13151531 fn steps() {
13161532 let abc = ['a', 'b', 'c'];
@@ -1363,6 +1579,7 @@ mod tests {
13631579 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
13641580 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
13651581 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")));
13661583 let mut connecting = App::new(palette.clone(), font, None, false);
13671584 connecting.link = Link::Connecting;
13681585 shots.push(("connecting".to_owned(), 800.0, connecting));
M crates/budctl-gui/src/overview.rs View
@@ -165,7 +165,7 @@ fn switches<'a>(look: Look, switches: &[Switch], marks: Marks) -> Element<'a, Me
165165 s.label,
166166 subtitle,
167167 ui::switch(look, s.on),
168− Message::Switch(s.key, !s.on),
168+ Some(Message::Switch(s.key, !s.on)),
169169 marks.focus == Some(Item::Switch(s.key)),
170170 )
171171 })
@@ -195,7 +195,7 @@ fn more<'a>(look: Look, buds: &Buds, focus: Option<Item>) -> Element<'a, Message
195195 title,
196196 Some(subtitle),
197197 ui::chevron(look),
198− Message::Open(page),
198+ Some(Message::Open(page)),
199199 focus == Some(Item::Open(page)),
200200 )
201201 })
M crates/budctl-gui/src/ui.rs View
@@ -8,6 +8,11 @@ use crate::Message;
88 use crate::icons::{self, Icon};
99 use crate::theme::Look;
1010
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+
1116 /// A page title.
1217 pub fn title<'a>(look: Look, title: &'a str) -> Element<'a, Message> {
1318 text(title).size(30).font(look.bold()).color(look.p.fg).into()
@@ -68,15 +73,15 @@ fn separator<'a>(look: Look) -> Element<'a, Message> {
6873 .into()
6974 }
7075
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.
7378 pub fn setting<'a>(
7479 look: Look,
7580 icon: Icon,
7681 title: impl text::IntoFragment<'a>,
7782 subtitle: Option<String>,
7883 trailing: Element<'a, Message>,
79− on_press: Message,
84+ on_press: Option<Message>,
8085 focused: bool,
8186 ) -> Element<'a, Message> {
8287 let p = look.p;
@@ -89,7 +94,7 @@ pub fn setting<'a>(
8994 .align_y(Alignment::Center)
9095 .padding([13, 16]);
9196 button(content)
92− .on_press(on_press)
97+ .on_press_maybe(on_press)
9398 .padding(0)
9499 .width(Length::Fill)
95100 .style(move |_, status| button::Style {
M crates/budctl-gui/src/worker.rs View
@@ -12,7 +12,9 @@ use std::time::{Duration, Instant};
1212
1313 use budctl_core::bt::BdAddr;
1414 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+};
1618 use budctl_core::{Error, Kind, Result};
1719 use iced::futures::channel::mpsc as channel;
1820 use iced::futures::{SinkExt, Stream, StreamExt};
@@ -34,6 +36,14 @@ pub enum Command {
3436 /// Save a custom EQ preset (`custom-<id>`) with these bands changed, which also selects it.
3537 /// With no bands, it only selects it.
3638 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>),
3747 }
3848
3949 #[derive(Debug, Clone)]
@@ -52,6 +62,10 @@ pub enum Event {
5262 Eq(String),
5363 /// The answer to [`Command::CustomEq`]: the preset, as read back.
5464 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>),
5569 /// The answer to a command that failed. The session is closed; the next command opens a
5670 /// new one.
5771 Failed { kind: Kind, message: String },
@@ -64,6 +78,8 @@ pub struct Reading {
6478 pub status: Status,
6579 /// The EQ presets, when the buds can list them: they name the custom ones.
6680 pub eq: Option<EqPresets>,
81+ /// The touch controls, when the buds report them.
82+ pub touch: Option<TouchControls>,
6783 }
6884
6985 /// The worker, as a stream of events for an iced subscription. `device` picks the buds, as
@@ -183,6 +199,16 @@ impl Worker {
183199 .session()
184200 .and_then(|session| session.set_custom_eq(&preset, &bands))
185201 .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+ }
186212 };
187213 match answer {
188214 Ok(event) => self.send(event),
@@ -200,7 +226,8 @@ impl Worker {
200226 let session = self.session()?;
201227 let status = session.status()?;
202228 let eq = session.eq_presets().ok();
203− Ok(Reading { status, eq })
229+ let touch = session.touch_controls().ok();
230+ Ok(Reading { status, eq, touch })
204231 }
205232
206233 /// The open session, opening one first if there is none.