Krolyxon
Commit 6d9ef16

Branches

Refs →

budctlcratesbudctl-guisrcgestures.rs

252 lines · 8.1 KB · Rust

1//! The Gestures page: what each tap and hold does on each bud, and what a hold steps through.
2
3use std::fmt;
4
5use budctl_core::status::TouchControls;
6use iced::widget::overlay::menu;
7use iced::widget::{column, pick_list};
8use iced::{Border, Element, Shadow};
9
10use crate::buds::{self, Buds};
11use crate::icons::Icon;
12use crate::theme::Look;
13use crate::ui;
14use crate::{Item, Marks, Message, TouchDraft};
15
16/// The buds, as the page shows them.
17const SIDES: [(&str, &str); 2] = [("left", "Left bud"), ("right", "Right bud")];
18
19/// Shown instead of a subtitle while a write is on its way.
20const SAVING: &str = "Saving…";
21
22/// One gesture of one bud, and the actions it can be given.
23pub 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.
31pub 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.
45pub 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.
57pub 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 · taps and holds on each bud");
60 let Some(touch) = &buds.touch else {
61 let none = ui::note(look, "These buds did not report their touch controls.");
62 return column![title, none].spacing(12).into();
63 };
64 let rows = rows(touch);
65 let mut page = column![title].spacing(18);
66 for (side, heading) in SIDES {
67 let gestures = rows
68 .iter()
69 .enumerate()
70 .filter(|(_, row)| row.side == side)
71 .filter_map(|(n, row)| Some((u8::try_from(n).ok()?, row)))
72 .map(|(n, row)| {
73 let shown = draft
74 .filter(|draft| draft.row == n)
75 .map(|draft| draft.action.as_str())
76 .or_else(|| buds.action(side, row.gesture))
77 .unwrap_or("none");
78 let options: Vec<Choice> = row.actions.iter().map(|a| Choice::new(a)).collect();
79 let picker = pick_list(options, Some(Choice::new(shown)), move |choice: Choice| {
80 Message::Action {
81 row: n,
82 action: choice.key,
83 save: true,
84 }
85 })
86 .width(230)
87 .padding([10, 18])
88 .text_size(14)
89 .font(look.medium())
90 .style(move |_, status| pick_list::Style {
91 text_color: p.fg,
92 placeholder_color: p.grey,
93 handle_color: p.grey,
94 background: if matches!(status, pick_list::Status::Hovered | pick_list::Status::Opened { .. }) {
95 p.mute
96 } else {
97 p.s2
98 }
99 .into(),
100 border: Border::default().rounded(999),
101 })
102 .menu_style(move |_| menu::Style {
103 background: p.s2.into(),
104 border: Border::default().rounded(20),
105 text_color: p.fg,
106 selected_text_color: p.fill_fg,
107 selected_background: p.fill.into(),
108 shadow: Shadow::default(),
109 });
110 let saving = marks.pending.contains(&Item::Gesture(n)).then(|| SAVING.to_owned());
111 ui::setting(
112 look,
113 gesture_icon(row.gesture),
114 gesture_name(row.gesture),
115 saving,
116 picker.into(),
117 None,
118 marks.focus == Some(Item::Gesture(n)),
119 )
120 })
121 .collect();
122 page = page.push(column![ui::heading(look, heading), ui::card(look, gestures)].spacing(10));
123 }
124 if let (Some(cycle), false) = (&touch.hold_cycle, touch.cycle_modes.is_empty()) {
125 let modes = touch
126 .cycle_modes
127 .iter()
128 .enumerate()
129 .filter_map(|(n, mode)| Some((u8::try_from(n).ok()?, mode)))
130 .map(|(n, mode)| {
131 let on = cycle.contains(mode);
132 let about = if marks.pending.contains(&Item::Cycle(n)) {
133 Some(SAVING.to_owned())
134 } else if on && cycle.len() <= 2 {
135 Some("A hold needs two modes to step through".to_owned())
136 } else {
137 None
138 };
139 ui::setting(
140 look,
141 mode_icon(mode),
142 mode_name(mode),
143 about,
144 ui::switch(look, on),
145 Some(Message::Cycle(n, !on)),
146 marks.focus == Some(Item::Cycle(n)),
147 )
148 })
149 .collect();
150 page = page.push(column![ui::heading(look, "A hold steps through"), ui::card(look, modes)].spacing(10));
151 }
152 page.into()
153}
154
155/// An action, for the dropdown: its name on the buds, and how it reads.
156#[derive(Debug, Clone, PartialEq)]
157struct Choice {
158 key: String,
159 label: String,
160}
161
162impl Choice {
163 fn new(key: &str) -> Self {
164 Choice {
165 key: key.to_owned(),
166 label: action_name(key),
167 }
168 }
169}
170
171impl fmt::Display for Choice {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 f.write_str(&self.label)
174 }
175}
176
177fn gesture_name(gesture: &str) -> String {
178 match gesture {
179 "hold" => "Touch and hold".to_owned(),
180 other => buds::words(other),
181 }
182}
183
184fn gesture_icon(gesture: &str) -> Icon {
185 match gesture {
186 "hold" => Icon::Earbud,
187 _ => Icon::Gestures,
188 }
189}
190
191pub fn action_name(action: &str) -> String {
192 match action {
193 "none" => "Nothing",
194 "play-pause" => "Play / pause",
195 "next" => "Next track",
196 "previous" => "Previous track",
197 "voice-assistant" => "Voice assistant",
198 "noise-cycle" => "Noise control",
199 other => return buds::words(other),
200 }
201 .to_owned()
202}
203
204fn mode_name(mode: &str) -> String {
205 match mode {
206 "anc" => "Noise cancelling".to_owned(),
207 other => buds::words(other),
208 }
209}
210
211fn mode_icon(mode: &str) -> Icon {
212 match mode {
213 "off" => Icon::NoiseOff,
214 "anc" => Icon::Anc,
215 _ => Icon::Transparency,
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn rows_go_bud_by_bud() {
225 let buds = buds::demo();
226 let touch = buds.touch.as_ref().unwrap();
227 let rows = rows(touch);
228 let names: Vec<(&str, &str)> = rows.iter().map(|r| (r.side, r.gesture)).collect();
229 assert_eq!(
230 names,
231 [
232 ("left", "double-tap"),
233 ("left", "triple-tap"),
234 ("left", "hold"),
235 ("right", "double-tap"),
236 ("right", "triple-tap"),
237 ("right", "hold"),
238 ]
239 );
240 let items = items(&buds);
241 assert_eq!(items.len(), 6 + 3);
242 assert_eq!(items.last(), Some(&Item::Cycle(2)));
243 }
244
245 #[test]
246 fn names_read_well() {
247 assert_eq!(action_name("play-pause"), "Play / pause");
248 assert_eq!(action_name("volume-up"), "Volume up");
249 assert_eq!(gesture_name("triple-tap"), "Triple tap");
250 assert_eq!(mode_name("anc"), "Noise cancelling");
251 }
252}