| 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 | } |