1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum UiInput {
22 Forward,
23 Select,
24 Backward,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum MenuItem {
33 Status,
35 Stats,
38 CheckIn,
40 StartPairing,
42 ClearBonds,
44}
45
46impl MenuItem {
47 pub const ALL: [MenuItem; 5] = [
49 MenuItem::Status,
50 MenuItem::Stats,
51 MenuItem::CheckIn,
52 MenuItem::StartPairing,
53 MenuItem::ClearBonds,
54 ];
55
56 const fn bit(self) -> u8 {
57 1 << self as u8
58 }
59
60 const fn index(self) -> usize {
61 self as usize
62 }
63
64 pub const fn requires_confirmation(self) -> bool {
70 matches!(self, MenuItem::ClearBonds)
71 }
72
73 pub const fn effect(self) -> Option<UiEffect> {
76 match self {
77 MenuItem::Status | MenuItem::Stats => None,
78 MenuItem::CheckIn => Some(UiEffect::CheckIn),
79 MenuItem::StartPairing => Some(UiEffect::StartPairing),
80 MenuItem::ClearBonds => Some(UiEffect::ClearBonds),
81 }
82 }
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct MenuItems(u8);
91
92impl MenuItems {
93 pub const fn new() -> Self {
95 Self(MenuItem::Status.bit())
96 }
97
98 pub const fn all() -> Self {
100 Self(
101 MenuItem::Status.bit()
102 | MenuItem::Stats.bit()
103 | MenuItem::CheckIn.bit()
104 | MenuItem::StartPairing.bit()
105 | MenuItem::ClearBonds.bit(),
106 )
107 }
108
109 pub const fn with(self, item: MenuItem) -> Self {
111 Self(self.0 | item.bit())
112 }
113
114 pub const fn contains(self, item: MenuItem) -> bool {
115 self.0 & item.bit() != 0
116 }
117
118 pub const fn len(self) -> u32 {
120 self.0.count_ones()
121 }
122
123 pub const fn is_empty(self) -> bool {
124 false
125 }
126
127 fn step(self, from: MenuItem, step: isize) -> MenuItem {
130 let n = MenuItem::ALL.len();
131 let mut index = from.index();
132 for _ in 0..n {
135 index = (index as isize + step).rem_euclid(n as isize) as usize;
136 let candidate = MenuItem::ALL[index];
137 if self.contains(candidate) {
138 return candidate;
139 }
140 }
141 MenuItem::Status
142 }
143}
144
145impl Default for MenuItems {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum Page {
154 Menu(MenuItem),
156 Confirm {
159 item: MenuItem,
160 confirm_selected: bool,
161 },
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum UiEffect {
167 CheckIn,
168 StartPairing,
169 ClearBonds,
170}
171
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub enum UiNotice {
175 CheckInRequested,
176 PairingStarted,
177 PairingUnavailable,
178 BondsCleared,
179 ClearFailed,
180}
181
182#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub struct UiModel {
189 items: MenuItems,
190 page: Page,
191 notice: Option<UiNotice>,
192}
193
194impl UiModel {
195 pub const fn new(items: MenuItems) -> Self {
196 Self {
197 items,
198 page: Page::Menu(MenuItem::Status),
199 notice: None,
200 }
201 }
202
203 pub const fn page(&self) -> Page {
204 self.page
205 }
206
207 pub const fn notice(&self) -> Option<UiNotice> {
208 self.notice
209 }
210
211 pub const fn items(&self) -> MenuItems {
212 self.items
213 }
214
215 pub const fn is_home(&self) -> bool {
219 matches!(self.page, Page::Menu(MenuItem::Status)) && self.notice.is_none()
220 }
221
222 pub fn set_notice(&mut self, notice: UiNotice) {
224 self.page = Page::Menu(MenuItem::Status);
225 self.notice = Some(notice);
226 }
227
228 pub fn clear_notice(&mut self) {
229 self.notice = None;
230 }
231
232 pub fn go_home(&mut self) {
238 self.page = Page::Menu(MenuItem::Status);
239 self.notice = None;
240 }
241
242 pub fn apply(&mut self, input: UiInput) -> Option<UiEffect> {
248 self.notice = None;
249 match (self.page, input) {
250 (Page::Menu(item), UiInput::Forward) => {
251 self.page = Page::Menu(self.items.step(item, 1));
252 None
253 }
254 (Page::Menu(item), UiInput::Backward) => {
255 self.page = Page::Menu(self.items.step(item, -1));
256 None
257 }
258 (Page::Menu(item), UiInput::Select) => {
259 if item.requires_confirmation() {
260 self.page = Page::Confirm {
261 item,
262 confirm_selected: false,
263 };
264 return None;
265 }
266 let effect = item.effect();
267 if effect.is_some() {
268 self.page = Page::Menu(MenuItem::Status);
269 }
270 effect
271 }
272 (
273 Page::Confirm {
274 item,
275 confirm_selected,
276 },
277 UiInput::Forward | UiInput::Backward,
278 ) => {
279 self.page = Page::Confirm {
280 item,
281 confirm_selected: !confirm_selected,
282 };
283 None
284 }
285 (
286 Page::Confirm {
287 item,
288 confirm_selected: false,
289 },
290 UiInput::Select,
291 ) => {
292 self.page = Page::Menu(item);
293 None
294 }
295 (
296 Page::Confirm {
297 item,
298 confirm_selected: true,
299 },
300 UiInput::Select,
301 ) => {
302 self.page = Page::Menu(MenuItem::Status);
303 item.effect()
304 }
305 }
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn full() -> UiModel {
314 UiModel::new(MenuItems::all())
315 }
316
317 #[test]
318 fn forward_and_backward_wrap_menu_items() {
319 let mut ui = full();
320 ui.apply(UiInput::Forward);
321 assert_eq!(ui.page(), Page::Menu(MenuItem::Stats));
322 ui.apply(UiInput::Forward);
323 assert_eq!(ui.page(), Page::Menu(MenuItem::CheckIn));
324 ui.apply(UiInput::Forward);
325 assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
326 ui.apply(UiInput::Forward);
327 assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
328 ui.apply(UiInput::Forward);
329 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
330 ui.apply(UiInput::Backward);
331 assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
332 }
333
334 #[test]
335 fn navigation_skips_items_the_board_does_not_enable() {
336 let items = MenuItems::new().with(MenuItem::StartPairing);
338 let mut ui = UiModel::new(items);
339 ui.apply(UiInput::Forward);
340 assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
341 ui.apply(UiInput::Forward);
342 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
343 ui.apply(UiInput::Backward);
344 assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
345 }
346
347 #[test]
348 fn status_only_menu_stays_put() {
349 let mut ui = UiModel::new(MenuItems::new());
350 ui.apply(UiInput::Forward);
351 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
352 assert_eq!(ui.apply(UiInput::Select), None);
353 }
354
355 #[test]
356 fn safe_items_activate_without_confirmation() {
357 let mut ui = full();
358 ui.apply(UiInput::Forward);
359 ui.apply(UiInput::Forward);
360 assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::CheckIn));
361 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
362
363 ui.apply(UiInput::Forward);
364 ui.apply(UiInput::Forward);
365 ui.apply(UiInput::Forward);
366 assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::StartPairing));
367 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
368 }
369
370 #[test]
371 fn status_select_is_inert() {
372 let mut ui = full();
373 assert_eq!(ui.apply(UiInput::Select), None);
374 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
375 }
376
377 #[test]
378 fn clear_defaults_to_cancel_and_requires_visible_confirmation() {
379 let mut ui = full();
380 ui.apply(UiInput::Backward);
381 assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
382 assert_eq!(ui.apply(UiInput::Select), None);
383 assert_eq!(
384 ui.page(),
385 Page::Confirm {
386 item: MenuItem::ClearBonds,
387 confirm_selected: false,
388 }
389 );
390
391 assert_eq!(ui.apply(UiInput::Select), None);
393 assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
394
395 ui.apply(UiInput::Select);
397 ui.apply(UiInput::Forward);
398 assert_eq!(
399 ui.page(),
400 Page::Confirm {
401 item: MenuItem::ClearBonds,
402 confirm_selected: true,
403 }
404 );
405 assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::ClearBonds));
406 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
407 }
408
409 #[test]
410 fn backward_also_toggles_the_confirmation() {
411 let mut ui = full();
412 ui.apply(UiInput::Backward);
413 ui.apply(UiInput::Select);
414 ui.apply(UiInput::Backward);
415 assert_eq!(
416 ui.page(),
417 Page::Confirm {
418 item: MenuItem::ClearBonds,
419 confirm_selected: true,
420 }
421 );
422 }
423
424 #[test]
425 fn notice_returns_to_status_and_clears_on_input() {
426 let mut ui = full();
427 ui.apply(UiInput::Backward);
428 ui.set_notice(UiNotice::BondsCleared);
429 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
430 assert_eq!(ui.notice(), Some(UiNotice::BondsCleared));
431
432 ui.apply(UiInput::Forward);
433 assert_eq!(ui.notice(), None);
434 assert_eq!(ui.page(), Page::Menu(MenuItem::Stats));
435 }
436
437 #[test]
438 fn go_home_drops_a_pending_confirmation() {
439 let mut ui = full();
440 ui.apply(UiInput::Backward);
441 ui.apply(UiInput::Select);
442 assert!(!ui.is_home());
443
444 ui.go_home();
445 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
446 assert_eq!(ui.notice(), None);
447 assert!(ui.is_home());
448 }
449
450 #[test]
451 fn go_home_drops_a_stale_notice() {
452 let mut ui = full();
453 ui.set_notice(UiNotice::PairingStarted);
454 assert!(!ui.is_home());
455 ui.go_home();
456 assert!(ui.is_home());
457 }
458
459 #[test]
460 fn status_is_always_enabled() {
461 assert!(MenuItems::new().contains(MenuItem::Status));
462 assert!(MenuItems::all().contains(MenuItem::Status));
463 assert_eq!(MenuItems::new().len(), 1);
464 assert_eq!(MenuItems::all().len(), 5);
465 }
466}