diff --git a/site/src/content/docs/using/home-menu.md b/site/src/content/docs/using/home-menu.md index 1356378c..cffed9f3 100644 --- a/site/src/content/docs/using/home-menu.md +++ b/site/src/content/docs/using/home-menu.md @@ -27,3 +27,5 @@ Switchify PC asks Windows or macOS to shut down or restart and lets open apps cl In Settings you can choose whether Select opens Home every time, or returns to the last mode you used. Home and the menus it opens have a slim bar along their top, with the Switchify PC name on the left and a **Settings** button on the right. Click the button with a mouse to open the Switchify PC window on its Settings page. The menu stays where it is while the pointer is over the bar, even when it is set to move out of the pointer's way. The bar itself never takes focus; the Switchify PC window opens in front of your work. + +Nothing else is written across a menu: the highlight shows what Select will choose. When something needs your attention, such as a menu waiting or paused or an action that failed, a short message appears beside the menu and goes away when you deal with it. diff --git a/site/src/content/docs/using/keyboard.md b/site/src/content/docs/using/keyboard.md index 5a25c102..803030ac 100644 --- a/site/src/content/docs/using/keyboard.md +++ b/site/src/content/docs/using/keyboard.md @@ -46,4 +46,6 @@ The keyboard opens on the display the pointer is on and stays on that display wh The slim bar along the top of the keyboard shows the Switchify PC name and a **Settings** button. Click the button with a mouse to open the Switchify PC window on its Settings page. The keyboard stays put while the pointer is over the bar, even when it is set to move out of the pointer's way. The bar itself never takes focus from the app you are typing into; the Switchify PC window opens in front of it. +Nothing else is written across the keyboard: the highlight shows what Select will choose. When something needs your attention, such as the keyboard waiting or paused, a key that could not be sent, or a key repeating until you stop it, a short message appears just above the keyboard, or below it when the keyboard is at the top of the display, and goes away when you deal with it. + Word prediction has its own page: [Word prediction](/using/word-prediction/). diff --git a/site/src/content/docs/using/mouse-panel.md b/site/src/content/docs/using/mouse-panel.md index 4e4c0d03..a6968526 100644 --- a/site/src/content/docs/using/mouse-panel.md +++ b/site/src/content/docs/using/mouse-panel.md @@ -17,6 +17,8 @@ Choose **Mouse** from [Home](/using/home-menu/), or give a switch the **Open mou - **Keyboard** to open the scanning [keyboard](/using/keyboard/). - **Switch to Point** to change to [point scanning](/using/point-scanning/). +The panel has a slim bar along its top with the Switchify PC name and a **Settings** button, then the buttons. Nothing else is written on it: the highlight shows what Select will choose. When something needs your attention, such as the panel waiting or paused, an action that failed, or the pointer moving until you stop it, a short message appears just above the panel, or below it when the panel is at the top of the display, and goes away when you deal with it. + ## Speed and repeat Pointer speed has ten levels plus fine tuning. The panel, the Mouse settings page and the Android app all show the same level. Hold a switch on a movement or scroll button and it repeats, with a repeat interval and gradual acceleration you can set. Repeat stops when you release the switch, or on the next press, whichever you choose in Settings. diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index df58b7f6..cfffeebe 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -1879,11 +1879,10 @@ mod tests { workflow .mouse .frame(screen, 1.0, Default::default(), None) - .tiles - .last() - .unwrap() - .text, - "Pointer unavailable · Try again" + .label + .map(|label| label.text) + .as_deref(), + Some("Pointer unavailable · Try again") ); workflow.set_pointer(Some((640.0, 360.0))); @@ -2369,12 +2368,14 @@ mod tests { height: 720.0, }; let mut session = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), true); - let status = |session: &Session| { - let mut frame = session.technique.frame(); - frame.tiles.pop().unwrap().text - }; + // The message beside the panel; nothing while scanning normally. + let message = + |session: &Session| session.technique.frame().label.map(|label| label.text); session.action(Action::OpenMouse); - assert_eq!(status(&session), "Mouse waiting · Select to continue"); + assert_eq!( + message(&session).as_deref(), + Some("Mouse waiting · Select to continue") + ); for action in [ Action::Select, Action::Next, @@ -2390,10 +2391,23 @@ mod tests { Some(Request::MouseMove { dy: 0, .. }) )); assert!(session.technique.stop_mouse_repeat()); - assert_eq!(status(&session), "Mouse waiting · Select to continue"); + assert_eq!( + message(&session).as_deref(), + Some("Mouse waiting · Select to continue") + ); session.tick(5000, false); assert_eq!(session.action(Action::Select), None); - assert_eq!(status(&session), "Movement · Select →"); + assert_eq!(message(&session), None); + // Scanning resumed at the arrow the user chose. + let selected: Vec<_> = session + .technique + .frame() + .tiles + .into_iter() + .filter(|tile| tile.selected) + .map(|tile| tile.text) + .collect(); + assert_eq!(selected, ["→"]); // A move that fails starts again from the top. assert!(session.action(Action::Select).is_some()); session.technique.stop_mouse_repeat(); @@ -2401,9 +2415,17 @@ mod tests { session.technique.mouse_repeat_enabled = false; assert!(session.action(Action::Select).is_some()); session.technique.execution_failed(String::new()); - assert_eq!(status(&session), "Mouse action failed · Select to resume"); + assert_eq!( + message(&session).as_deref(), + Some("Mouse action failed · Select to resume") + ); session.action(Action::Select); - assert_eq!(status(&session), "Movement · Select a row"); + assert_eq!(message(&session), None); + // Back to scanning rows from the top. + let frame = session.technique.frame(); + let selected: Vec<_> = frame.tiles.iter().filter(|tile| tile.selected).collect(); + assert!(selected.len() > 1); + assert!(selected.iter().all(|tile| tile.style.unwrap().row_scan)); } #[test] fn panels_wait_on_opening_where_the_user_chose_to() { @@ -2455,10 +2477,10 @@ mod tests { s.tick(5000, false); assert_eq!(s.technique.phase(), keyboard); if waits { - let mut frame = s.technique.frame(); + let frame = s.technique.frame(); assert_eq!( - frame.tiles.pop().unwrap().text, - "Keyboard waiting · Select to start" + frame.label.map(|label| label.text).as_deref(), + Some("Keyboard waiting · Select to start") ); // Select starts the scan without typing. assert_eq!(s.action(Action::Select), None); @@ -2728,8 +2750,8 @@ mod tests { let frame = session.technique.frame(); assert!(frame.tiles.iter().all(|tile| !tile.selected)); assert_eq!( - frame.tiles.last().unwrap().text, - "Moving pointer · Press and release a switch to stop" + frame.label.map(|label| label.text).as_deref(), + Some("Moving pointer · Press and release a switch to stop") ); session.tick(1000, true); assert!( @@ -3148,8 +3170,8 @@ mod tests { height: 720.0, }; let background = |workflow: &Workflow| workflow.frame().tiles[0].rect; - let bottom = Dock::default().rect(screen, (568.0, 420.0)); - let top = Dock { column: 1, row: 0 }.rect(screen, (568.0, 420.0)); + let bottom = Dock::default().rect(screen, (568.0, 372.0)); + let top = Dock { column: 1, row: 0 }.rect(screen, (568.0, 372.0)); let over_bottom = Some((640.0, 700.0)); let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.open_mouse(true); @@ -3482,8 +3504,8 @@ mod tests { ); assert!(!s.technique.return_to_mouse); assert_eq!( - s.frame().tiles.last().unwrap().text, - "Mouse action failed · Select to resume" + s.frame().label.map(|label| label.text).as_deref(), + Some("Mouse action failed · Select to resume") ); } #[test] @@ -3624,10 +3646,10 @@ mod tests { s.technique.phase(), Phase::Workflow(WorkflowPhase::KeyboardSuspended) ); - let mut frame = s.technique.frame(); + let frame = s.technique.frame(); assert_eq!( - frame.tiles.pop().unwrap().text, - "Keyboard waiting · Select to start" + frame.label.map(|label| label.text).as_deref(), + Some("Keyboard waiting · Select to start") ); } #[test] @@ -3702,8 +3724,8 @@ mod tests { let frame = s.frame(); assert!(frame.tiles.iter().all(|tile| !tile.selected)); assert_eq!( - frame.tiles.last().unwrap().text, - format!("Repeating Backspace · {}", edge.instruction()) + frame.label.map(|label| label.text), + Some(format!("Repeating Backspace · {}", edge.instruction())) ); s.tick(250, false); @@ -3750,7 +3772,7 @@ mod tests { assert!(typed(s.technique.choose_key(Key::Named(name)), name)); s.technique.execution_succeeded(); assert!(s.technique.mouse_repeating(), "{name}"); - let text = s.frame().tiles.last().unwrap().text.clone(); + let text = s.frame().label.unwrap().text; assert!(text.starts_with("Repeating "), "{text}"); assert!(!text.contains("Arrow"), "{text}"); } @@ -3813,8 +3835,7 @@ mod tests { assert!(s.technique.mouse_repeating()); assert!(s .frame() - .tiles - .last() + .label .unwrap() .text .starts_with("Repeating Shift+Left arrow · ")); @@ -4092,10 +4113,6 @@ mod tests { assert!(session.take_selection().is_some()); session.execution_failed("Cleanup failed".into()); let frame = session.frame(); - assert!(frame - .tiles - .iter() - .any(|tile| tile.text.ends_with("Action failed · Select to return"))); assert!(frame.label.unwrap().text.starts_with("Cleanup failed")); assert_eq!(session.action(Action::Select), None); assert_eq!( @@ -4301,18 +4318,12 @@ mod tests { w.set_dock(dock); w.stage = Stage::Menu; w.execution_failed(message.into()); - // The status stays one short line, and the message is shown clear of the panel. + // The message is shown clear of the panel, which has no status line. let frame = w.frame(); - let status = frame - .tiles - .iter() - .find(|tile| { - tile.style - .is_some_and(|style| style.role == crate::scanning::TileRole::Status) - }) - .unwrap(); - assert_eq!(status.text, "Actions · Action failed · Select to return"); assert!(frame.tiles.iter().all(|tile| !tile.selected)); + assert!(frame.tiles.iter().all(|tile| { + tile.style.map(|style| style.role) != Some(crate::scanning::TileRole::Status) + })); let label = frame.label.unwrap(); assert_eq!(label.text, format!("{message}\nSelect to return")); assert!(label.hud.is_some()); @@ -4353,8 +4364,8 @@ mod tests { for (column, right, count) in [(0, false, 1), (1, true, 1), (2, false, 2)] { let mut s = session(false); open(&mut s); - // The background, the title and Settings, eleven actions and the status header. - assert_eq!(s.frame().tiles.len(), 15); + // The background, the title and Settings, and eleven actions. + assert_eq!(s.frame().tiles.len(), 14); assert_eq!( choose(&mut s, 0, column), Some(Request::Click { diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 2c43e7c1..3ebaae81 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -1057,40 +1057,27 @@ impl Keyboard { ) -> Frame { let escaping = self.scan.nav.escaping(); let highlight_row = self.scan.row_scan() || self.scan.group_scan() || escaping; - let (active_row, active_column) = self.scan.position(&self.rows); - let page = match self.page { - _ if self.positioning => crate::scan_panel::POSITION_PAGE, - Page::Letters => match self.layout { - crate::point_scan::KeyboardLayout::Qwerty => "Letters · QWERTY", - crate::point_scan::KeyboardLayout::SimpleQwerty => "Letters · Simple QWERTY", - crate::point_scan::KeyboardLayout::CommonLetters => { - "Letters · Common letters first" - } - }, - Page::Functions => "Navigation", - Page::Numbers => "Numbers", - Page::Symbols => "Symbols", - }; - let status = if let Some((stroke, instruction)) = repeating { - format!("Repeating {} · {instruction}", self.repeat_label(stroke)) + let message = if let Some((stroke, instruction)) = repeating { + Some(format!( + "Repeating {} · {instruction}", + self.repeat_label(stroke) + )) } else if self.error { - "Input failed · Select to try again".to_owned() + Some("Input failed · Select to try again".to_owned()) } else if self.waiting_after_typing { - "Press Select to continue typing.".to_owned() + Some("Press Select to continue typing.".to_owned()) } else if self.scan.waiting() { - "Keyboard waiting · Select to start".to_owned() + Some("Keyboard waiting · Select to start".to_owned()) } else if self.scan.suspended { - "Keyboard paused · Select to resume".to_owned() + Some("Keyboard paused · Select to resume".to_owned()) } else if escaping { - crate::scan_panel::back_status(&self.scan).to_owned() + Some(crate::scan_panel::back_status(&self.scan).to_owned()) } else if self.disabled() { - "Suggestions updating · Select to continue".to_owned() + Some("Suggestions updating · Select to continue".to_owned()) + } else if self.layout_save_error { + Some("Could not save layout".to_owned()) } else { - crate::scan_panel::item_status( - page, - &self.scan, - &self.label(self.rows[active_row][active_column.unwrap_or(0)]), - ) + None }; let empty_suggestions = self.rows.first().is_some_and(|row| { row.iter() @@ -1121,15 +1108,8 @@ impl Keyboard { .collect() }) .collect(), - status, + message, // With no suggestions to show, their row says why instead. - note: if self.layout_save_error { - Some("Could not save layout".into()) - } else if empty_suggestions { - None - } else { - self.prediction_note() - }, row_label: empty_suggestions .then(|| self.prediction_note()) .flatten() @@ -1275,23 +1255,25 @@ mod tests { width: 1920.0, height: 1080.0, }; - // The note, and whether it is drawn across the empty suggestion row - // rather than beside the prompt. + // The note is drawn across the suggestion row only while that row is + // empty; it never floats as a message and never sits beside the keys. let badge = |keyboard: &Keyboard| { let frame = keyboard.frame(screen, 1.0, ScannerColor::default()); - let prompt = frame.tiles.last().unwrap(); - let badge = &frame.tiles[frame.tiles.len() - 2]; - assert!(prompt.text.contains("Select")); - assert!(!badge.selected); - let in_row = badge.rect.y >= prompt.rect.y + prompt.rect.height; - if !in_row { - assert!(badge.rect.x >= prompt.rect.x + prompt.rect.width); - } - (badge.text.clone(), in_row) + assert!(frame.label.is_none()); + let last = frame.tiles.last().unwrap(); + (last.style.map(|style| style.role) == Some(crate::scanning::TileRole::Status)).then( + || { + assert!(!last.selected); + assert!( + last.rect.y > frame.tiles[0].rect.y + crate::scan_panel::MENU_BAR_HEIGHT + ); + last.text.clone() + }, + ) }; - assert_eq!(badge(&keyboard), ("Prediction loading".into(), true)); + assert_eq!(badge(&keyboard), Some("Prediction loading".into())); keyboard.predictions(None, false); - assert_eq!(badge(&keyboard), ("Type for suggestions".into(), true)); + assert_eq!(badge(&keyboard), Some("Type for suggestions".into())); keyboard.predictions( Some(crate::prediction::worker::Batch { refined: false, @@ -1300,7 +1282,7 @@ mod tests { }), false, ); - assert_eq!(badge(&keyboard), ("No suggestions".into(), true)); + assert_eq!(badge(&keyboard), Some("No suggestions".into())); keyboard.predictions( Some(crate::prediction::worker::Batch { refined: false, @@ -1309,9 +1291,9 @@ mod tests { }), false, ); - assert_eq!(badge(&keyboard), ("1 suggestion".into(), false)); + assert_eq!(badge(&keyboard), None, "suggestions speak for themselves"); keyboard.prediction_tracking(false); - assert_eq!(badge(&keyboard), ("Prediction paused".into(), false)); + assert_eq!(badge(&keyboard), None); } #[test] @@ -1369,10 +1351,6 @@ mod tests { key != Key::Modifier(0), "only Shift stays at the utility size" ); - assert_eq!( - crate::scan_panel::scanning_status("Letters", false, &keyboard.label(key)), - format!("Letters · Select {name}") - ); } for arrow in ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] { let text = keyboard.display(Key::Named(arrow)); @@ -1712,11 +1690,13 @@ mod tests { // A failure starts again from the top. k.failed(); k.handle(Action::Select); - let status = |k: &Keyboard| { - let mut frame = k.frame(screen, 1.0, ScannerColor::default()); - frame.tiles.pop().unwrap().text + // Nothing floats beside the keyboard while it scans normally. + let message = |k: &Keyboard| { + k.frame(screen, 1.0, ScannerColor::default()) + .label + .map(|label| label.text) }; - assert_eq!(status(&k), "Letters · QWERTY · Select a row"); + assert_eq!(message(&k), None); // So does a suggestion, whose row is replaced. k.predictions( Some(crate::prediction::worker::Batch { @@ -1736,7 +1716,7 @@ mod tests { if wait { k.handle(Action::Select); } - assert_eq!(status(&k), "Letters · QWERTY · Select a row"); + assert_eq!(message(&k), None); } } #[test] @@ -1824,10 +1804,10 @@ mod tests { 1.0, ScannerColor::Blue, ); - assert!(frame - .tiles - .iter() - .any(|t| t.text == "Press Select to continue typing.")); + assert_eq!( + frame.label.as_ref().map(|label| label.text.as_str()), + Some("Press Select to continue typing.") + ); assert!(!frame.tiles.iter().any(|t| t.selected)); } assert!(k.handle(Action::Select).is_none()); @@ -2100,10 +2080,9 @@ mod tests { .iter() .all(|tile| tile.rect.y == selected[0].rect.y)); assert!(selected.iter().all(|tile| tile.style.unwrap().row_scan)); - assert!(!escaping.tiles.last().unwrap().selected); assert_eq!( - escaping.tiles.last().unwrap().text, - crate::scan_panel::BACK_TO_ROWS + escaping.label.as_ref().map(|label| label.text.as_str()), + Some(crate::scan_panel::BACK_TO_ROWS) ); k.handle(Action::Select); assert!(k.scan.nav.path().is_empty()); @@ -2146,7 +2125,11 @@ mod tests { assert!(selected .iter() .all(|tile| tile.rect.y == selected[0].rect.y)); - assert!(!frame.tiles.last().unwrap().selected); + assert_eq!( + frame.label.as_ref().map(|label| label.text.as_str()), + Some(crate::scan_panel::BACK_TO_ROWS), + "{direction:?} {page:?}" + ); keyboard.handle(Action::Select); assert_eq!(keyboard.scan.position(&keyboard.rows), (row, None)); } @@ -2162,7 +2145,10 @@ mod tests { assert!(!linear.scan.nav.escaping()); let frame = linear.frame(screen, 1.0, ScannerColor::default()); assert_eq!(frame.tiles.iter().filter(|tile| tile.selected).count(), 1); - assert!(!frame.tiles.last().unwrap().selected); + assert!( + frame.label.is_none(), + "no going back offered, so no message" + ); } #[test] fn qwerty_layout_and_platform_keys() { @@ -2472,20 +2458,22 @@ mod tests { keyboard.layout_change_failed(); assert!(!keyboard.suspended()); assert_eq!(keyboard.layout, KeyboardLayout::Qwerty); - assert!(keyboard - .frame( - Rect { - x: 0.0, - y: 0.0, - width: 1280.0, - height: 720.0 - }, - 1.0, - ScannerColor::default() - ) - .tiles - .iter() - .any(|tile| tile.text == "Could not save layout")); + assert_eq!( + keyboard + .frame( + Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0 + }, + 1.0, + ScannerColor::default() + ) + .label + .map(|label| label.text), + Some("Could not save layout".into()) + ); keyboard.handle(Action::Next); assert!(!keyboard.layout_save_error); } @@ -2586,9 +2574,10 @@ mod tests { ); let before = keyboard.frame(screen, 1.0, ScannerColor::default()); assert_eq!(empty.tiles[0].rect, before.tiles[0].rect); + // The empty row carries one label in place of its slots. assert_eq!( before.tiles.len() - empty.tiles.len(), - crate::prediction::SLOTS + crate::prediction::SLOTS - 1 ); keyboard.predictions( Some(crate::prediction::worker::Batch { @@ -2651,7 +2640,7 @@ mod tests { height: 1080.0, }; let frame = k.frame(screen, 1.0, ScannerColor::default()); - assert_eq!(frame.tiles.last().unwrap().text, "Position · Select a row"); + assert!(frame.label.is_none()); assert_eq!( frame.tiles[3].rect.width, 128.0, "position labels use utility cells rather than character cells" @@ -2740,15 +2729,16 @@ mod tests { .iter() .filter(|tile| tile.text == "Type for suggestions") .collect::>(); - // In the row instead of the header, and never highlighted. + // Across the row under the menu bar, never highlighted, and not a + // floating message. let [label] = labels.as_slice() else { panic!("expected one label, found {}", labels.len()); }; assert!(!label.selected); let background = frame.tiles[0].rect; - let status = frame.tiles.last().unwrap(); - assert!(label.rect.y > status.rect.y + status.rect.height); + assert!(label.rect.y > background.y + crate::scan_panel::MENU_BAR_HEIGHT); assert!(label.rect.width > background.width * 0.9); + assert!(frame.label.is_none()); // An empty batch says so too. k.predictions( Some(crate::prediction::worker::Batch { @@ -2760,7 +2750,7 @@ mod tests { ); let frame = k.frame(screen, 1.0, ScannerColor::Blue); assert!(frame.tiles.iter().any(|tile| tile.text == "No suggestions")); - // With suggestions, the note stays in the header and the row has none. + // With suggestions, the row shows them and nothing counts them. k.restart(); k.handle(Action::Next); k.predictions( @@ -2772,12 +2762,9 @@ mod tests { false, ); let frame = k.frame(screen, 1.0, ScannerColor::Blue); - let note = frame - .tiles - .iter() - .find(|tile| tile.text == "1 suggestion") - .unwrap(); - assert!(note.rect.y < frame.tiles.last().unwrap().rect.y + 1.0); + assert!(frame.tiles.iter().any(|tile| tile.text == "hello")); + assert!(!frame.tiles.iter().any(|tile| tile.text == "1 suggestion")); + assert!(frame.label.is_none()); // Without prediction there is no suggestion row to label. k.enable_predictions(false); let frame = k.frame(screen, 1.0, ScannerColor::Blue); @@ -2798,16 +2785,10 @@ mod tests { }; k.scan.suspended = true; let frame = k.frame(screen, 1.0, ScannerColor::Blue); - assert!(frame - .tiles - .iter() - .any(|tile| tile.text.contains("Select to resume"))); + assert!(frame.label.unwrap().text.contains("Select to resume")); k.failed(); let frame = k.frame(screen, 1.0, ScannerColor::Blue); - assert!(frame - .tiles - .iter() - .any(|tile| tile.text.contains("Select to try again"))); + assert!(frame.label.unwrap().text.contains("Select to try again")); } #[test] fn arriving_predictions_preserve_reverse_origin_and_full_pass() { @@ -2942,41 +2923,41 @@ mod tests { ..Default::default() }, ); + // The floating message, if any, and how many keys are highlighted. let frame = |k: &Keyboard| { - let mut frame = k.frame(screen, 1.0, ScannerColor::default()); - let status = frame.tiles.pop().unwrap().text; + let frame = k.frame(screen, 1.0, ScannerColor::default()); + let message = frame.label.map(|label| label.text); let selected = frame.tiles.iter().filter(|tile| tile.selected).count(); - (status, selected) + (message, selected) }; let groups = crate::scan_items::row_groups(k.rows[0].len()); assert!(groups.len() > 1); - assert_eq!( - frame(&k), - ("Letters · QWERTY · Select a row".into(), k.rows[0].len()) - ); + assert_eq!(frame(&k), (None, k.rows[0].len())); k.handle(Action::Select); - assert_eq!( - frame(&k), - ("Letters · QWERTY · Select a group".into(), groups[0].len()) - ); + assert_eq!(frame(&k), (None, groups[0].len())); k.handle(Action::Select); - let first = k.label(k.rows[0][0]).replace('\n', " "); - assert_eq!(frame(&k), (format!("Letters · QWERTY · Select {first}"), 1)); + assert_eq!(frame(&k), (None, 1)); for _ in 0..groups[0].len() { k.handle(Action::Next); } assert_eq!( frame(&k), - (crate::scan_panel::BACK_TO_GROUPS.into(), groups[0].len()) + ( + Some(crate::scan_panel::BACK_TO_GROUPS.into()), + groups[0].len() + ) ); k.handle(Action::Select); - assert_eq!(frame(&k).0, "Letters · QWERTY · Select a group"); + assert_eq!(frame(&k), (None, groups[0].len())); for _ in 0..groups.len() { k.handle(Action::Next); } assert_eq!( frame(&k), - (crate::scan_panel::BACK_TO_ROWS.into(), k.rows[0].len()) + ( + Some(crate::scan_panel::BACK_TO_ROWS.into()), + k.rows[0].len() + ) ); } diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index a5f85737..b9d6ce4c 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -172,26 +172,22 @@ impl Menu { self.scan.handle(action) } - /// Draws the menu as a docked panel. While an action has `failed`, the status - /// stays short so it fits one line; the workflow shows the message beside it. + /// Draws the menu as a docked panel. While an action has `failed`, nothing + /// is highlighted and no message is added: the workflow shows the error + /// itself, with what went wrong, beside the panel. pub fn frame(&self, screen: Rect, units: f64, dock: Dock, failed: bool) -> Frame { - let (row, column) = self.scan.position(&self.rows); let escaping = self.scan.nav.escaping(); let title = self.kind.label(); - let status = if failed { - format!("{title} · Action failed · Select to return") + let message = if failed { + None } else if self.scan.waiting() { - format!("{title} · Select to continue") + Some(format!("{title} · Select to continue")) } else if self.suspended() { - format!("{title} paused · Select to resume") + Some(format!("{title} paused · Select to resume")) } else if escaping { - crate::scan_panel::back_status(&self.scan).to_owned() + Some(crate::scan_panel::back_status(&self.scan).to_owned()) } else { - crate::scan_panel::item_status( - title, - &self.scan, - self.rows[row][column.unwrap_or(0)].label(), - ) + None }; let options = self.scan.options; Panel { @@ -213,8 +209,7 @@ impl Menu { .collect() }) .collect(), - status, - note: None, + message, dock, // Nothing can be chosen while an error or pause waits for Select. selected: (!failed && !self.suspended()).then(|| self.scan.highlight(&self.rows)), @@ -571,18 +566,9 @@ mod tests { use super::*; use crate::scanning::FrameTile; - fn is_status(tile: &FrameTile) -> bool { - tile.style - .is_some_and(|style| style.role == TileRole::Status) - } - fn status(frame: &Frame) -> String { - frame - .tiles - .iter() - .find(|tile| is_status(tile)) - .unwrap() - .text - .clone() + /// The message floating beside the panel, if there is one. + fn message(frame: &Frame) -> Option { + frame.label.as_ref().map(|label| label.text.clone()) } #[test] @@ -620,11 +606,12 @@ mod tests { for item in kind.rows().into_iter().flatten() { assert!(texts.contains(&item.label()), "{kind:?} {item:?}"); } + // The background, the title, Settings and the items; no status line. assert_eq!( frame.tiles.len(), - kind.rows().iter().map(Vec::len).sum::() + 4 + kind.rows().iter().map(Vec::len).sum::() + 3 ); - assert!(status(&frame).starts_with(kind.label())); + assert_eq!(message(&frame), None); } } } @@ -916,7 +903,7 @@ mod tests { c.validate().unwrap(); } #[test] - fn panel_highlights_the_current_row_or_item_with_panel_status() { + fn panel_highlights_the_current_row_or_item_without_a_status_line() { let screen = Rect { x: 0.0, y: 0.0, @@ -929,7 +916,6 @@ mod tests { .iter() .filter(|tile| { !tile.is_panel_background() - && !is_status(tile) && !tile.is_menu_bar() && tile.style.map(|s| s.role) != Some(TileRole::Title) }) @@ -939,7 +925,7 @@ mod tests { let mut menu = Menu::new(Kind::Actions, 250); let row = menu.frame(screen, 1.0, Dock::default(), false); assert!(row.tiles[0].is_panel_background()); - assert_eq!(status(&row), "Actions · Select a row"); + assert_eq!(message(&row), None, "the highlight says it all"); let selected: Vec<_> = keys(&row).into_iter().filter(|t| t.selected).collect(); assert_eq!( selected.iter().map(|t| t.text.as_str()).collect::>(), @@ -948,7 +934,7 @@ mod tests { assert!(selected.iter().all(|t| t.style.unwrap().row_scan)); menu.handle(Action::Select); let item = menu.frame(screen, 1.0, Dock::default(), false); - assert_eq!(status(&item), "Actions · Select Left click"); + assert_eq!(message(&item), None); let selected: Vec<_> = keys(&item).into_iter().filter(|t| t.selected).collect(); assert_eq!(selected.len(), 1); assert_eq!(selected[0].text, "Left click"); @@ -962,7 +948,7 @@ mod tests { assert_eq!(close.style.unwrap().role, TileRole::Toolbar); } #[test] - fn errors_pauses_and_waits_show_in_the_status_without_a_highlight() { + fn errors_pauses_and_waits_float_beside_the_panel_without_a_highlight() { let screen = Rect { x: 0.0, y: 0.0, @@ -970,14 +956,24 @@ mod tests { height: 720.0, }; let mut menu = Menu::new(Kind::Home, 250); + // A failed action leaves the error to the workflow, which knows what went wrong. let error = menu.frame(screen, 1.0, Dock::default(), true); - assert_eq!(status(&error), "Home · Action failed · Select to return"); + assert_eq!(message(&error), None); assert!(error.tiles.iter().all(|tile| !tile.selected)); assert!(error.tiles.iter().any(|tile| tile.text == "Close menu")); menu.suspend(); let paused = menu.frame(screen, 1.0, Dock::default(), false); - assert_eq!(status(&paused), "Home paused · Select to resume"); + assert_eq!( + message(&paused).as_deref(), + Some("Home paused · Select to resume") + ); assert!(paused.tiles.iter().all(|tile| !tile.selected)); + // The message is drawn on the HUD beside the panel, never on a tile. + let label = paused.label.unwrap(); + assert!(label.hud.is_some()); + let panel = paused.tiles[0].rect; + assert!(label.rect.y + label.rect.height <= panel.y); + assert!(!paused.tiles.iter().any(|tile| tile.text.contains("paused"))); } #[test] fn rounded_panel_has_clear_corners_and_insets_all_keys() { @@ -1003,16 +999,14 @@ mod tests { assert!(tile.rect.x + tile.rect.width < panel.rect.x + panel.rect.width); assert!(tile.rect.y + tile.rect.height < panel.rect.y + panel.rect.height); } - // The status header sits above every key, with equal side margins. - let header = content.iter().find(|tile| is_status(tile)).unwrap(); + // Every key sits below the menu bar, with equal side margins. + let bar = crate::scan_panel::menu_bar(&frame).unwrap(); assert!(content .iter() .filter(|tile| { - !is_status(tile) - && !tile.is_menu_bar() - && tile.style.map(|s| s.role) != Some(TileRole::Title) + !tile.is_menu_bar() && tile.style.map(|s| s.role) != Some(TileRole::Title) }) - .all(|tile| header.rect.y + header.rect.height < tile.rect.y)); + .all(|tile| bar.y + bar.height < tile.rect.y)); let left = content .iter() .map(|t| t.rect.x) diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 71906b6e..e53253aa 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -242,38 +242,30 @@ impl MousePanel { color: ScannerColor, repeating: Option, ) -> Frame { - let (active_row, active_column) = self.scan.position(&self.rows); let escaping = self.scan.nav.escaping(); let highlight_row = self.scan.row_scan() || self.scan.group_scan() || escaping; - let status = if let Some(repeating) = repeating { + let message = if let Some(repeating) = repeating { let action = match repeating { RepeatPrompt::Moving => "Moving pointer", RepeatPrompt::Scrolling(dy) if dy > 0 => "Scrolling up", RepeatPrompt::Scrolling(_) => "Scrolling down", }; - format!("{action} · {}", self.repeat_stop_edge.instruction()) + Some(format!( + "{action} · {}", + self.repeat_stop_edge.instruction() + )) } else if self.error { - "Mouse action failed · Select to resume".to_owned() + Some("Mouse action failed · Select to resume".to_owned()) } else if let Some(notice) = self.notice { - notice.to_owned() + Some(notice.to_owned()) } else if self.scan.waiting() { - "Mouse waiting · Select to continue".to_owned() + Some("Mouse waiting · Select to continue".to_owned()) } else if self.scan.suspended { - "Mouse paused · Select to resume".to_owned() + Some("Mouse paused · Select to resume".to_owned()) } else if escaping { - crate::scan_panel::back_status(&self.scan).to_owned() + Some(crate::scan_panel::back_status(&self.scan).to_owned()) } else { - crate::scan_panel::item_status( - if self.positioning { - crate::scan_panel::POSITION_PAGE - } else if self.more { - "More controls" - } else { - "Movement" - }, - &self.scan, - &self.label(self.rows[active_row][active_column.unwrap_or(0)]), - ) + None }; let scanning = repeating.is_none() && !self.scan.suspended; Panel { @@ -304,8 +296,7 @@ impl MousePanel { .collect() }) .collect(), - status, - note: None, + message, dock: self.dock, selected: scanning.then(|| self.scan.highlight(&self.rows)), row_scan: highlight_row, @@ -347,9 +338,9 @@ mod tests { let bounds = frame.tiles[0].rect; let scale = (units * size.scale()) .min(width / 568.0) - .min(height / 420.0); + .min(height / 372.0); assert!((bounds.width - 568.0 * scale).abs() < 1e-8); - assert!((bounds.height - 420.0 * scale).abs() < 1e-8); + assert!((bounds.height - 372.0 * scale).abs() < 1e-8); for tile in &frame.tiles { assert!( tile.rect.x >= screen.x - 1e-8 @@ -426,7 +417,7 @@ mod tests { let frame = panel.frame(screen, 1.0, color, None); let current = frame.tiles.iter().find(|tile| tile.text == "Bottom •"); assert!(current.is_some_and(|tile| tile.style.unwrap().active)); - assert_eq!(frame.tiles.last().unwrap().text, "Position · Select a row"); + assert!(frame.label.is_none()); panel.choose(Key::Back); assert!(panel.more && panel.rows[0][0] == Key::Speed(-1)); @@ -452,9 +443,12 @@ mod tests { width: 1920.0, height: 1080.0, }; - let status = |panel: &MousePanel| { - let mut frame = panel.frame(screen, 1.0, ScannerColor::default(), None); - frame.tiles.pop().unwrap().text + // The message floating beside the panel; nothing while scanning normally. + let message = |panel: &MousePanel| { + panel + .frame(screen, 1.0, ScannerColor::default(), None) + .label + .map(|label| label.text) }; let mut panel = MousePanel::new( Resolved { @@ -470,30 +464,41 @@ mod tests { assert_eq!(panel.handle(Action::Select), Some(Key::RightClick)); panel.choose(Key::RightClick); assert!(panel.suspended()); - assert_eq!(status(&panel), "Mouse waiting · Select to continue"); + assert_eq!( + message(&panel).as_deref(), + Some("Mouse waiting · Select to continue") + ); // The monitor keys appearing does not end the wait. panel.set_displays(2); panel.set_displays(1); - assert_eq!(status(&panel), "Mouse waiting · Select to continue"); + assert_eq!( + message(&panel).as_deref(), + Some("Mouse waiting · Select to continue") + ); panel.advance(1000, 1000); assert_eq!(panel.handle(Action::Select), None); - assert_eq!(status(&panel), "Movement · Select Right click"); + assert_eq!(message(&panel), None); + assert_eq!(panel.scan.position(&panel.rows), (0, Some(1))); assert_eq!(panel.handle(Action::Select), Some(Key::RightClick)); // A failure is not a choice, and starts again from the top. panel.choose(Key::RightClick); panel.failed(); - assert_eq!(status(&panel), "Mouse action failed · Select to resume"); + assert_eq!( + message(&panel).as_deref(), + Some("Mouse action failed · Select to resume") + ); panel.handle(Action::Select); - assert_eq!(status(&panel), "Movement · Select a row"); + assert_eq!(message(&panel), None); + assert_eq!(panel.scan.position(&panel.rows), (0, None)); // Changing page is not an action. panel.choose(Key::More); assert!(!panel.suspended()); - assert_eq!(status(&panel), "More controls · Select a row"); + assert_eq!(message(&panel), None); // Nor is leaving the panel. panel.handle(Action::Next); panel.choose(Key::Keyboard); assert!(!panel.suspended()); - assert_eq!(status(&panel), "More controls · Select a row"); + assert_eq!(message(&panel), None); } #[test] fn panel_renders_with_selected_tiles_inside_the_screen() { @@ -552,10 +557,9 @@ mod tests { .iter() .all(|tile| tile.rect.y == selected[0].rect.y)); assert!(selected.iter().all(|tile| tile.style.unwrap().row_scan)); - assert!(!frame.tiles.last().unwrap().selected); assert_eq!( - frame.tiles.last().unwrap().text, - crate::scan_panel::BACK_TO_ROWS + frame.label.as_ref().map(|label| label.text.as_str()), + Some(crate::scan_panel::BACK_TO_ROWS) ); panel.handle(Action::Select); assert_eq!(panel.scan.position(&panel.rows), (row, None)); @@ -573,7 +577,10 @@ mod tests { assert!(!linear.scan.nav.escaping()); let frame = linear.frame(screen, 1.0, ScannerColor::default(), None); assert_eq!(frame.tiles.iter().filter(|tile| tile.selected).count(), 1); - assert!(!frame.tiles.last().unwrap().selected); + assert!( + frame.label.is_none(), + "no going back offered, so no message" + ); } #[test] @@ -598,7 +605,7 @@ mod tests { assert_eq!(tile(&frame, "←").width, tile(&frame, "↖").width); assert_eq!(tile(&frame, "→").x, tile(&frame, "↗").x); assert_eq!(tile(&frame, "→").width, tile(&frame, "↗").width); - assert_eq!(frame.tiles.len(), 1 + 2 + 19 + 1, "spacers draw no tiles"); + assert_eq!(frame.tiles.len(), 1 + 2 + 19, "spacers draw no tiles"); assert_eq!(tile(&frame, "Radar").x, tile(&frame, "↑").x); panel.handle(Action::Next); @@ -628,29 +635,43 @@ mod tests { let keyboard = crate::scan_keyboard::Keyboard::new(false).frame(screen, 1.0, color); assert!(frame.tiles[0].rect.width < keyboard.tiles[0].rect.width); assert_eq!(frame.tiles[0].rect.width, 568.0); - assert!(frame.label.is_none()); - let status = frame.tiles.last().unwrap(); - assert_eq!(status.style.unwrap().role, TileRole::Status); - assert_eq!(status.text, "Movement · Select a row"); - assert_eq!(status.rect.x, frame.tiles[0].rect.x + 16.0); - let close = &frame.tiles[frame.tiles.len() - 2]; + assert!( + frame.label.is_none(), + "nothing to say while scanning normally" + ); + assert!(frame + .tiles + .iter() + .all(|tile| tile.style.map(|style| style.role) != Some(TileRole::Status))); + let close = frame.tiles.last().unwrap(); assert_eq!(close.text, "Switch to Point"); assert_eq!(close.style.unwrap().role, TileRole::Toolbar); + // The highlight alone shows what Select will choose. + let highlighted = |panel: &MousePanel| { + let frame = panel.frame(screen, 1.0, color, None); + assert!(frame.label.is_none()); + let selected: Vec<_> = frame + .tiles + .into_iter() + .filter(|tile| tile.selected) + .map(|tile| tile.text) + .collect(); + selected + }; panel.handle(Action::Select); - let status = panel.frame(screen, 1.0, color, None).tiles.pop().unwrap(); - assert_eq!(status.text, "Movement · Select Left click"); + assert_eq!(highlighted(&panel), ["Left click"]); panel.choose(Key::More); panel.handle(Action::Select); - let status = panel.frame(screen, 1.0, color, None).tiles.pop().unwrap(); - assert_eq!(status.text, "More controls · Select Slower Level 5.0"); + assert_eq!(highlighted(&panel), ["Slower\nLevel 5.0"]); + // States that need the user's attention float beside the panel. let moving = panel.frame(screen, 1.0, color, Some(RepeatPrompt::Moving)); assert!(moving.tiles.iter().all(|tile| !tile.selected)); assert_eq!( - moving.tiles.last().unwrap().text, - "Moving pointer · Press and release a switch to stop" + moving.label.map(|label| label.text).as_deref(), + Some("Moving pointer · Press and release a switch to stop") ); for (direction, expected) in [ (1, "Scrolling up · Press and release a switch to stop"), @@ -659,15 +680,18 @@ mod tests { let scrolling = panel.frame(screen, 1.0, color, Some(RepeatPrompt::Scrolling(direction))); assert!(scrolling.tiles.iter().all(|tile| !tile.selected)); - assert_eq!(scrolling.tiles.last().unwrap().text, expected); + assert_eq!( + scrolling.label.map(|label| label.text).as_deref(), + Some(expected) + ); } panel.failed(); let failed = panel.frame(screen, 1.0, color, None); assert!(failed.tiles.iter().all(|tile| !tile.selected)); assert_eq!( - failed.tiles.last().unwrap().text, - "Mouse action failed · Select to resume" + failed.label.map(|label| label.text).as_deref(), + Some("Mouse action failed · Select to resume") ); } } diff --git a/src-tauri/src/scan_panel.rs b/src-tauri/src/scan_panel.rs index df45402b..9bb42068 100644 --- a/src-tauri/src/scan_panel.rs +++ b/src-tauri/src/scan_panel.rs @@ -1,9 +1,11 @@ -//! Shared layout for switch-scanned panels: a docked background, a status tile -//! and weighted rows of keys. Panels supply content; geometry lives here. +//! Shared layout for switch-scanned panels: a docked background, a slim menu +//! bar and weighted rows of keys. Panels supply content; geometry lives here. use crate::{ scan_items::ItemScanner, scan_preferences::{PanelSize, Thickness}, - scanning::{Frame, FrameTile, Rect, ScannerColor, TileRole, TileStyle}, + scanning::{ + Frame, FrameLabel, FrameTile, HudPresentation, Rect, ScannerColor, TileRole, TileStyle, + }, }; /// One scale for the complete panel, including its text. @@ -192,6 +194,11 @@ pub fn move_frame(frame: &mut Frame, to: Dock, screen: Rect) { tile.rect.x += to.x - from.x; tile.rect.y += to.y - from.y; } + // A message floats beside the panel, so it moves with it. + if let Some(label) = &mut frame.label { + label.rect.x += to.x - from.x; + label.rect.y += to.y - from.y; + } } /// Position page shared by panels: the nine docks as a 3×3 grid, then a back key. @@ -219,9 +226,11 @@ pub struct Panel { pub key_width: f64, pub size: PanelSize, pub rows: Vec>, - pub status: String, - /// Passive feedback beside the scanning prompt; never a scan target. - pub note: Option, + /// Something that needs the user's attention, such as a failed action, a + /// pause or a repeating move with its stop instruction. It floats beside + /// the panel while it applies. `None` while scanning normally: the + /// highlight shows what Select will choose, so nothing is written out. + pub message: Option, pub dock: Dock, /// Row and keys to highlight, all of the row when `None`, or `None` when no /// key may be chosen. @@ -246,7 +255,6 @@ impl Panel { + 32.0; let preferred_height = 32.0 + MENU_BAR_HEIGHT - + 48.0 + self.rows.len() as f64 * 56.0 + self.rows.len().saturating_sub(1) as f64 * 8.0; let scale = fit_scale(screen, units, self.size, preferred_width, preferred_height); @@ -262,7 +270,6 @@ impl Panel { let content_width = width - padding * 2.0; let gap = 8.0 * scale; let row_height = 64.0 * scale; - let header_height = 48.0 * scale; let menu_bar_height = MENU_BAR_HEIGHT * scale; let mut frame = Frame::default(); frame.tiles.push(FrameTile::panel_background( @@ -347,7 +354,7 @@ impl Panel { }), rect: Rect { x: left, - y: y + header_height + r as f64 * row_height, + y: y + r as f64 * row_height, width: key_width, height: (row_height - gap).max(1.0), }, @@ -372,7 +379,7 @@ impl Panel { }), rect: Rect { x, - y: y + header_height + *row as f64 * row_height, + y: y + *row as f64 * row_height, width, height: (row_height - gap).max(1.0), }, @@ -380,66 +387,42 @@ impl Panel { selected: false, }); } - let note_width = self.note.as_ref().map_or(0.0, |_| width * 0.28); - if let Some(note) = &self.note { - frame.tiles.push(FrameTile { - thickness: self.thickness, - color, - text: note.clone(), - style: Some(TileStyle { - role: TileRole::Status, - active: false, - row_scan: false, - }), - rect: Rect { - x: x + width - note_width, - y, - width: note_width, - height: (header_height - gap).max(1.0), - }, - scale: scale * 0.8, - selected: false, - }); + if let Some(message) = &self.message { + frame.label = Some(message_label(message, frame.tiles[0].rect, screen, scale)); } - frame.tiles.push(FrameTile { - thickness: self.thickness, - color, - text: self.status.clone(), - style: Some(TileStyle { - role: TileRole::Status, - active: false, - row_scan: false, - }), - rect: Rect { - x, - y, - width: width - note_width - if note_width > 0.0 { gap } else { 0.0 }, - height: (header_height - gap).max(1.0), - }, - scale, - selected: false, - }); frame } } -/// Status wording shared by panels once error and pause states are handled. -pub fn scanning_status(page: &str, row_scan: bool, key: &str) -> String { - if row_scan { - format!("{page} · Select a row") - } else { - format!("{page} · Select {}", key.replace('\n', " ")) - } -} -/// `scanning_status` for an item scanner, which may be choosing a group. -pub fn item_status(page: &str, scan: &ItemScanner, key: &str) -> String { - if scan.group_scan() { - format!("{page} · Select a group") +/// A message floats just above the panel, or below it when the panel is +/// docked at the top, as wide as the panel, and is drawn on the HUD so it +/// never takes a scan position. +fn message_label(text: &str, panel: Rect, screen: Rect, scale: f64) -> FrameLabel { + let height = (MESSAGE_HEIGHT * scale).min(screen.height); + let margin = MESSAGE_MARGIN * scale; + let above = panel.y - height - margin; + let y = if above >= screen.y { + above } else { - scanning_status(page, scan.row_scan(), key) + (panel.y + panel.height + margin).min(screen.y + screen.height - height) + }; + FrameLabel { + text: text.to_owned(), + rect: Rect { + x: panel.x, + y, + width: panel.width, + height, + }, + scale, + hud: Some(HudPresentation { screen, scale }), } } -/// The status while an item scanner offers to go back. +/// One line of HUD text, the height the overlay hosts draw a prompt at. +pub const MESSAGE_HEIGHT: f64 = 64.0; +pub const MESSAGE_MARGIN: f64 = 8.0; + +/// The message while an item scanner offers to go back. pub fn back_status(scan: &ItemScanner) -> &'static str { if scan.escaping_to_groups() { BACK_TO_GROUPS @@ -473,8 +456,7 @@ mod tests { key_width: 380.0, size: PanelSize::Medium, rows: vec![vec![key("a", 1.0), hidden, key("c", 1.0)]], - status: "Page · Select a key".into(), - note: None, + message: None, dock: Dock::default(), selected: Some((0, Some(2..3))), row_scan: false, @@ -493,20 +475,20 @@ mod tests { ); let a = frame.tiles.iter().find(|t| t.text == "a").unwrap(); let c = frame.tiles.iter().find(|t| t.text == "c").unwrap(); - // The background, the title, Settings, the two drawn keys and the status line. - assert_eq!(frame.tiles.len(), 6); + // The background, the title, Settings and the two drawn keys; nothing else. + assert_eq!(frame.tiles.len(), 5); + assert!(frame.label.is_none()); assert!(c.selected && !a.selected); assert!(c.rect.x - a.rect.x > 2.0 * a.rect.width); } #[test] - fn rows_fill_the_padded_width_with_even_gaps_below_the_status_tile() { + fn rows_fill_the_padded_width_with_even_gaps_below_the_menu_bar() { let panel = Panel { key_width: 380.0, size: PanelSize::Medium, rows: vec![vec![key("a", 1.0), key("b", 2.0)], vec![key("c", 1.0)]], - status: "Page · Select a row".into(), - note: None, + message: None, dock: Dock::default(), selected: Some((1, None)), row_scan: true, @@ -520,8 +502,8 @@ mod tests { height: 1080.0, }; let frame = panel.frame(screen, 1.0, ScannerColor::default()); - let [background, title, menu_bar, a, b, c, status] = frame.tiles.as_slice() else { - panic!("expected background, title, Settings, three keys and a status tile"); + let [background, title, menu_bar, a, b, c] = frame.tiles.as_slice() else { + panic!("expected background, title, Settings and three keys"); }; assert!(background.is_panel_background()); assert_eq!(background.rect.width, 1180.0); @@ -553,13 +535,13 @@ mod tests { assert!((c.rect.x + c.rect.width - right).abs() < 1e-9); assert!((b.rect.width - 2.0 * a.rect.width).abs() < 1e-9); assert!((b.rect.x - (a.rect.x + a.rect.width) - 8.0).abs() < 1e-9); - assert_eq!(status.style.unwrap().role, TileRole::Status); - assert_eq!( - (status.rect.x, status.rect.y), - (left, background.rect.y + 16.0 + MENU_BAR_HEIGHT) + // The first row sits straight under the menu bar; no status line between. + assert_eq!(a.rect.y, background.rect.y + MENU_BAR_HEIGHT + 16.0); + assert!(menu_bar.rect.y + menu_bar.rect.height < a.rect.y); + assert!( + frame.label.is_none(), + "nothing to say while scanning normally" ); - assert!(menu_bar.rect.y + menu_bar.rect.height < status.rect.y); - assert!(status.rect.y + status.rect.height < a.rect.y); assert_eq!( frame.tiles.iter().filter(|tile| tile.selected).count(), 1, @@ -574,8 +556,7 @@ mod tests { key_width: 380.0, size: PanelSize::Medium, rows: vec![vec![key("a", 1.0)]], - status: BACK_TO_ROWS.into(), - note: None, + message: Some(BACK_TO_ROWS.into()), dock: Dock { column: 1, row: 0 }, selected: Some((0, None)), row_scan: true, @@ -594,15 +575,48 @@ mod tests { assert!(!frame.tiles[1].selected, "title"); assert!(frame.tiles[2].is_menu_bar() && !frame.tiles[2].selected); assert!(frame.tiles[3].selected); - assert!(!frame.tiles[4].selected); - assert_eq!( - scanning_status("Movement", false, "Caps lock\nOn"), - "Movement · Select Caps lock On" - ); - assert_eq!( - scanning_status("Movement", true, "x"), - "Movement · Select a row" - ); + assert_eq!(frame.tiles.len(), 4); + // The message floats below a panel docked at the top, as wide as the + // panel, on the HUD, and never among the tiles. + let label = frame.label.unwrap(); + assert_eq!(label.text, BACK_TO_ROWS); + let panel = frame.tiles[0].rect; + assert_eq!((label.rect.x, label.rect.width), (panel.x, panel.width)); + assert_eq!(label.rect.y, panel.y + panel.height + MESSAGE_MARGIN); + assert_eq!(label.rect.height, MESSAGE_HEIGHT); + assert_eq!(label.hud, Some(HudPresentation { screen, scale: 1.0 })); + } + + #[test] + fn a_message_floats_above_a_panel_that_has_room_and_moves_with_it() { + let screen = Rect { + x: -1920.0, + y: 40.0, + width: 1920.0, + height: 1040.0, + }; + let mut frame = Panel { + key_width: 380.0, + size: PanelSize::Medium, + rows: vec![vec![key("a", 1.0)]], + message: Some("Mouse paused · Select to resume".into()), + dock: Dock::default(), + selected: None, + row_scan: false, + row_label: None, + thickness: Thickness::default(), + } + .frame(screen, 1.0, ScannerColor::default()); + let panel = frame.tiles[0].rect; + let label = frame.label.clone().unwrap(); + assert_eq!(label.text, "Mouse paused · Select to resume"); + assert_eq!(label.rect.y + label.rect.height + MESSAGE_MARGIN, panel.y); + assert_eq!((label.rect.x, label.rect.width), (panel.x, panel.width)); + move_frame(&mut frame, Dock { column: 0, row: 1 }, screen); + let moved = frame.tiles[0].rect; + let label = frame.label.unwrap(); + assert_eq!(label.rect.x, moved.x); + assert_eq!(label.rect.y + label.rect.height + MESSAGE_MARGIN, moved.y); } #[test] @@ -742,8 +756,7 @@ mod tests { key_width: 380.0, size: PanelSize::Medium, rows: vec![vec![key("a", 1.0)]], - status: String::new(), - note: None, + message: None, dock: Dock::default(), selected: None, row_scan: false, @@ -775,8 +788,7 @@ mod tests { key_width: 380.0, size: PanelSize::Medium, rows: vec![vec![key("a", 1.0)]], - status: String::new(), - note: None, + message: None, dock, selected: None, row_scan: false, diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index bbd221e5..98766d34 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -1471,12 +1471,7 @@ mod repeat_stop_tests { for id in ["one", "remote:1"] { let mut session = repeating(edge, scroll); let frame = session.technique.frame(); - assert!(frame - .tiles - .last() - .unwrap() - .text - .ends_with(edge.instruction())); + assert!(frame.label.unwrap().text.ends_with(edge.instruction())); let mut stop = RepeatStop::default(); let mut gestures = Gestures::default(); gestures.pressed("previous", 0, &switches());