diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index b460d91b..9f7395a4 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -112,6 +112,8 @@ pub struct Workflow { keyboard_area: Rect, mouse: crate::scan_mouse::MousePanel, mouse_area: Rect, + /// Shared by the keyboard and mouse panels and kept when either is rebuilt. + dock: crate::scan_panel::Dock, return_to_mouse: bool, move_repeat: Option, scroll_repeat: Option, @@ -143,6 +145,7 @@ impl Workflow { keyboard_area: screen, mouse: crate::scan_mouse::MousePanel::new(mouse_options, 1, 100), mouse_area: screen, + dock: Default::default(), return_to_mouse: false, move_repeat: None, scroll_repeat: None, @@ -161,6 +164,29 @@ impl Workflow { error: None, }) } + fn new_keyboard(&self) -> crate::scan_keyboard::Keyboard { + let mut keyboard = crate::scan_keyboard::Keyboard::configured( + cfg!(target_os = "macos"), + self.point.config.keyboard_scan, + ) + .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); + keyboard.dock = self.dock; + keyboard + } + fn new_mouse(&self) -> crate::scan_mouse::MousePanel { + let mut mouse = crate::scan_mouse::MousePanel::new( + self.point.config.mouse_scan, + 1, + self.mouse.speed_percent, + ); + mouse.dock = self.dock; + mouse + } + fn set_dock(&mut self, dock: crate::scan_panel::Dock) { + self.dock = dock; + self.keyboard.dock = dock; + self.mouse.dock = dock; + } fn active_preferences(&self) -> crate::scan_preferences::Resolved { match self.stage { Stage::Keyboard | Stage::KeyboardOpening => self.point.config.keyboard_scan, @@ -184,16 +210,8 @@ impl Workflow { menu.set_period(self.point.config.menu_scan.interval_ms); } if restart { - self.keyboard = crate::scan_keyboard::Keyboard::configured( - cfg!(target_os = "macos"), - self.point.config.keyboard_scan, - ) - .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); - self.mouse = crate::scan_mouse::MousePanel::new( - self.point.config.mouse_scan, - 1, - self.mouse.speed_percent, - ); + self.keyboard = self.new_keyboard(); + self.mouse = self.new_mouse(); self.return_to_mouse = false; self.move_repeat = None; self.scroll_repeat = None; @@ -305,11 +323,7 @@ impl Workflow { self.stage = Stage::Mouse; self.move_repeat = None; self.scroll_repeat = None; - self.mouse = crate::scan_mouse::MousePanel::new( - self.point.config.mouse_scan, - 1, - self.mouse.speed_percent, - ); + self.mouse = self.new_mouse(); self.pending = None; self.parent_menu.clear(); self.elapsed = 0; @@ -327,6 +341,7 @@ impl Workflow { fn mouse_key(&mut self, key: crate::scan_mouse::Key) -> Option { use crate::scan_mouse::Key; self.mouse.choose(key); + self.set_dock(self.mouse.dock); match key { Key::Move(dx, dy) => { let delta = (i32::from(dx) * 12, i32::from(dy) * 12); @@ -386,15 +401,11 @@ impl Workflow { self.return_to_mouse = true; self.mouse.dragging = false; self.stage = Stage::KeyboardOpening; - self.keyboard = crate::scan_keyboard::Keyboard::configured( - cfg!(target_os = "macos"), - self.point.config.keyboard_scan, - ) - .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); + self.keyboard = self.new_keyboard(); Some(Request::OpenKeyboard) } Key::Close => self.open_point(), - Key::More | Key::Movement | Key::Dock => None, + Key::More | Key::Movement | Key::Dock | Key::Position(_) | Key::Back => None, } } fn keyboard_closed(&mut self) { @@ -414,11 +425,7 @@ impl Workflow { self.mouse.dragging = false; } self.stage = Stage::KeyboardOpening; - self.keyboard = crate::scan_keyboard::Keyboard::configured( - cfg!(target_os = "macos"), - self.point.config.keyboard_scan, - ) - .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); + self.keyboard = self.new_keyboard(); self.pending = None; self.parent_menu.clear(); self.elapsed = 0; @@ -552,16 +559,8 @@ impl Technique for Workflow { } } fn reset(&mut self) { - self.keyboard = crate::scan_keyboard::Keyboard::configured( - cfg!(target_os = "macos"), - self.point.config.keyboard_scan, - ) - .with_wait_after_typing(self.point.config.keyboard_wait_after_typing); - self.mouse = crate::scan_mouse::MousePanel::new( - self.point.config.mouse_scan, - 1, - self.mouse.speed_percent, - ); + self.keyboard = self.new_keyboard(); + self.mouse = self.new_mouse(); self.return_to_mouse = false; self.move_repeat = None; self.scroll_repeat = None; @@ -617,22 +616,25 @@ impl Technique for Workflow { return self.selected(Item::Keyboard); } match self.stage { - Stage::Keyboard => match self - .keyboard - .handle_with_context(action, crate::prediction::keyboard_input_context()) - { - Some(crate::scan_keyboard::Output::Stroke(stroke)) => { - return Some(Request::Keyboard(stroke)) - } - Some(crate::scan_keyboard::Output::Punctuation(punctuation)) => { - return Some(Request::KeyboardPunctuation(punctuation)) - } - Some(crate::scan_keyboard::Output::Prediction { token, index }) => { - return Some(Request::Prediction { token, index }) + Stage::Keyboard => { + let output = self + .keyboard + .handle_with_context(action, crate::prediction::keyboard_input_context()); + self.set_dock(self.keyboard.dock); + match output { + Some(crate::scan_keyboard::Output::Stroke(stroke)) => { + return Some(Request::Keyboard(stroke)) + } + Some(crate::scan_keyboard::Output::Punctuation(punctuation)) => { + return Some(Request::KeyboardPunctuation(punctuation)) + } + Some(crate::scan_keyboard::Output::Prediction { token, index }) => { + return Some(Request::Prediction { token, index }) + } + Some(crate::scan_keyboard::Output::Close) => self.keyboard_closed(), + None => {} } - Some(crate::scan_keyboard::Output::Close) => self.keyboard_closed(), - None => {} - }, + } Stage::KeyboardOpening => {} Stage::Mouse => { if let Some(key) = self.mouse.handle(action) { @@ -1137,6 +1139,37 @@ mod tests { assert_eq!(workflow.mouse_feedback(), None); } + #[test] + fn panel_position_is_shared_and_survives_rebuilding_panels() { + use crate::{scan_mouse::Key, scan_panel::Dock}; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + workflow.open_mouse(); + let dock = Dock { column: 2, row: 1 }; + assert_eq!(workflow.mouse_key(Key::Dock), None); + assert_eq!(workflow.mouse_key(Key::Position(dock)), None); + assert_eq!(workflow.mouse.dock, dock); + assert_eq!( + workflow.mouse_key(Key::Keyboard), + Some(Request::OpenKeyboard) + ); + assert_eq!(workflow.keyboard.dock, dock); + workflow.execution_succeeded(); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Keyboard)); + let dock = Dock { column: 0, row: 0 }; + workflow.keyboard.dock = dock; + workflow.handle(Action::Next); + workflow.keyboard_closed(); + assert_eq!(workflow.mouse.dock, dock, "keyboard choices move the mouse"); + workflow.reset(); + workflow.open_mouse(); + assert_eq!((workflow.keyboard.dock, workflow.mouse.dock), (dock, dock)); + } #[test] fn mouse_keyboard_return_and_drag_state() { let screen = Rect { diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 341963fe..c1094d38 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -42,6 +42,8 @@ pub enum Key { Caps, Page(Page), Dock, + Position(crate::scan_panel::Dock), + Back, Close, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -191,8 +193,9 @@ pub struct Keyboard { pub page: Page, pub modifiers: [Modifier; 4], pub caps: bool, - pub top: bool, + pub dock: crate::scan_panel::Dock, pub error: bool, + positioning: bool, mac: bool, rows: Vec>, scan: ItemScanner, @@ -222,8 +225,9 @@ impl Keyboard { page: Page::Letters, modifiers: [Modifier::Off; 4], caps: false, - top: false, + dock: Default::default(), error: false, + positioning: false, mac, rows, scan, @@ -254,8 +258,12 @@ impl Keyboard { self.rebuild_rows(); } fn rebuild_rows(&mut self) { - self.rows = rows(self.page, self.mac); - if self.prediction_enabled && self.page == Page::Letters { + self.rows = if self.positioning { + crate::scan_panel::position_rows(Key::Position, Key::Back) + } else { + rows(self.page, self.mac) + }; + if self.prediction_enabled && self.page == Page::Letters && !self.positioning { self.rows.insert(0, (0..5).map(Key::Prediction).collect()); } self.scan.replace(ItemScanner::nodes(&self.rows)); @@ -266,6 +274,7 @@ impl Keyboard { fn prediction_row_active(&self) -> bool { self.prediction_enabled && self.page == Page::Letters + && !self.positioning && self.scan.position(&self.rows).0 == 0 } pub fn predictions(&mut self, batch: Option, failed: bool) { @@ -288,6 +297,7 @@ impl Keyboard { } else { self.predictions = batch; if self.prefer_predictions + && !self.positioning && self.scan.options.direction == crate::scan_preferences::Direction::Forward && self .predictions @@ -452,11 +462,19 @@ impl Keyboard { self.caps = !self.caps; self.reconcile_auto_shift(); } - Key::Dock => self.top = !self.top, Key::Page(page) => { self.page = page; self.rebuild_rows(); } + Key::Dock | Key::Back => { + self.positioning = key == Key::Dock; + self.rebuild_rows(); + } + Key::Position(dock) => { + self.dock = dock; + self.positioning = false; + self.rebuild_rows(); + } Key::Close => return Some(Output::Close), Key::Character(..) | Key::Named(_) => { let stroke = self.stroke(key); @@ -652,7 +670,9 @@ impl Keyboard { }, if page == self.page { " •" } else { "" } ), - Key::Dock => if self.top { "Dock bottom" } else { "Dock top" }.into(), + Key::Dock => crate::scan_panel::POSITION_PAGE.into(), + Key::Position(dock) => crate::scan_panel::position_label(dock, self.dock), + Key::Back => "Back".into(), Key::Close => "Close keyboard".into(), } } @@ -669,13 +689,14 @@ impl Keyboard { TileStyle { role: match key { Key::Character(..) => TileRole::Character, - Key::Page(_) | Key::Dock | Key::Close => TileRole::Toolbar, + Key::Page(_) | Key::Dock | Key::Back | Key::Close => TileRole::Toolbar, _ => TileRole::Utility, }, active: match key { Key::Modifier(i) => self.modifiers[i] != Modifier::Off, Key::Caps => self.caps, Key::Page(page) => self.page == page, + Key::Position(dock) => self.dock == dock, _ => false, }, row_scan, @@ -685,6 +706,7 @@ impl Keyboard { let row_scan = self.scan.row_scan(); 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 => "Letters", Page::Functions => "Navigation", Page::Numbers => "Numbers", @@ -728,7 +750,7 @@ impl Keyboard { }) .collect(), status, - top: self.top, + dock: self.dock, selected: (!self.scan.suspended && !self.disabled() && !self.scan.nav.escaping()) .then_some((active_row, active_column)), status_selected: !self.scan.suspended && self.scan.nav.escaping(), @@ -1318,6 +1340,7 @@ mod tests { k.choose(Key::Modifier(1)); k.choose(Key::Page(Page::Numbers)); k.choose(Key::Dock); + k.choose(Key::Position(crate::scan_panel::Dock { column: 0, row: 1 })); assert_eq!( k.modifiers, [ @@ -1376,13 +1399,14 @@ mod tests { assert!(!k.suspended()); } #[test] - fn geometry_fits_work_area_at_both_docks_and_scales() { + fn geometry_fits_work_area_at_every_dock_and_scale() { + let docks = crate::scan_panel::position_rows(|dock| dock, Default::default()); for units in [0.75, 1.0, 1.5, 2.0, 3.0] { - for top in [false, true] { + for &dock in docks.iter().flatten() { for page in [Page::Letters, Page::Functions, Page::Numbers] { let mut k = Keyboard::new(false); k.choose(Key::Page(page)); - k.top = top; + k.dock = dock; let screen = Rect { x: -1600.0, y: -200.0, @@ -1399,6 +1423,38 @@ mod tests { } } #[test] + fn position_page_replaces_predictions_and_returns_to_the_page() { + use crate::scan_panel::Dock; + let mut k = Keyboard::new(false); + k.enable_predictions(true); + k.choose(Key::Page(Page::Numbers)); + k.choose(Key::Page(Page::Letters)); + k.choose(Key::Dock); + assert_eq!(k.rows.len(), 4); + assert_eq!(k.rows[1][1], Key::Position(Dock { column: 1, row: 1 })); + assert!(!k.prediction_row_active()); + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + 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.tiles.iter().any(|tile| tile.text == "Bottom •")); + + k.choose(Key::Back); + assert_eq!(k.rows[0][0], Key::Prediction(0)); + assert_eq!(k.dock, Dock::default()); + + k.choose(Key::Dock); + k.choose(Key::Position(Dock { column: 0, row: 1 })); + assert_eq!(k.rows[0][0], Key::Prediction(0)); + let background = k.frame(screen, 1.0, ScannerColor::default()).tiles[0].rect; + assert_eq!(background.x, 0.0); + assert!(background.y > 0.0 && background.y + background.height < 1080.0); + } + #[test] fn reverse_scanning_counts_passes_across_empty_prediction_slots() { use crate::scan_preferences::{Direction, Pattern, Resolved}; for pattern in [Pattern::Grouped, Pattern::Linear] { diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 4fa9c31f..e7cbc4e0 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -1,7 +1,7 @@ //! Switch-owned mouse panel. Native input stays in the scanning adapter. use crate::{ scan_items::{ItemScanner, Policy}, - scan_panel::{Panel, PanelKey}, + scan_panel::{Dock, Panel, PanelKey}, scan_preferences::Resolved, scanning::{Action, Frame, Rect, ScannerColor, TileRole}, }; @@ -20,6 +20,8 @@ pub enum Key { Movement, Keyboard, Dock, + Position(Dock), + Back, Close, } @@ -31,10 +33,11 @@ pub enum RepeatPrompt { pub struct MousePanel { pub more: bool, - pub top: bool, + pub dock: Dock, pub dragging: bool, pub speed_percent: u16, pub error: bool, + positioning: bool, displays: usize, rows: Vec>, scan: ItemScanner, @@ -42,22 +45,25 @@ pub struct MousePanel { impl MousePanel { pub fn new(options: Resolved, displays: usize, speed_percent: u16) -> Self { - let rows = Self::rows(false, displays); + let rows = Self::rows(false, false, displays); let scan = ItemScanner::configured_rows(&rows, Policy::KEYBOARD, options); Self { more: false, - top: false, + dock: Dock::default(), dragging: false, speed_percent, error: false, + positioning: false, displays, rows, scan, } } - fn rows(more: bool, displays: usize) -> Vec> { + fn rows(more: bool, positioning: bool, displays: usize) -> Vec> { use Key::*; - if !more { + if positioning { + crate::scan_panel::position_rows(Position, Back) + } else if !more { vec![ vec![Click, RightClick, DoubleClick, Drag], vec![Move(-1, -1), Move(0, -1), Move(1, -1), Scroll(1)], @@ -83,17 +89,24 @@ impl MousePanel { match key { Key::More | Key::Movement => { self.more = key == Key::More; - self.rows = Self::rows(self.more, self.displays); - self.scan = - ItemScanner::configured_rows(&self.rows, Policy::KEYBOARD, self.scan.options); + self.rebuild_rows(); } - Key::Dock => { - self.top = !self.top; - self.scan.restart(); + Key::Dock | Key::Back => { + self.positioning = key == Key::Dock; + self.rebuild_rows(); + } + Key::Position(dock) => { + self.dock = dock; + self.positioning = false; + self.rebuild_rows(); } _ => self.scan.restart(), } } + fn rebuild_rows(&mut self) { + self.rows = Self::rows(self.more, self.positioning, self.displays); + self.scan = ItemScanner::configured_rows(&self.rows, Policy::KEYBOARD, self.scan.options); + } pub fn handle(&mut self, action: Action) -> Option { if self.scan.suspended { if action == Action::Select { @@ -120,9 +133,9 @@ impl MousePanel { pub fn set_displays(&mut self, displays: usize) { if self.displays != displays { self.displays = displays; - self.rows = Self::rows(self.more, displays); - self.scan = - ItemScanner::configured_rows(&self.rows, Policy::KEYBOARD, self.scan.options); + if !self.positioning { + self.rebuild_rows(); + } } } fn label(&self, key: Key) -> String { @@ -158,14 +171,18 @@ impl MousePanel { More => "More controls".into(), Movement => "Movement".into(), Keyboard => "Keyboard".into(), - Dock => if self.top { "Dock bottom" } else { "Dock top" }.into(), + Dock => crate::scan_panel::POSITION_PAGE.into(), + Position(dock) => crate::scan_panel::position_label(dock, self.dock), + Back => "Back".into(), Close => "Switch to Point".into(), } } fn role(key: Key) -> TileRole { match key { Key::Move(..) => TileRole::Character, - Key::More | Key::Movement | Key::Keyboard | Key::Dock | Key::Close => TileRole::Toolbar, + Key::More | Key::Movement | Key::Keyboard | Key::Dock | Key::Back | Key::Close => { + TileRole::Toolbar + } _ => TileRole::Utility, } } @@ -195,7 +212,9 @@ impl MousePanel { crate::scan_panel::BACK_TO_ROWS.to_owned() } else { crate::scan_panel::scanning_status( - if self.more { + if self.positioning { + crate::scan_panel::POSITION_PAGE + } else if self.more { "More controls" } else { "Movement" @@ -216,7 +235,11 @@ impl MousePanel { text: self.label(key), weight: 1.0, role: Self::role(key), - active: key == Key::Drag && self.dragging, + active: match key { + Key::Drag => self.dragging, + Key::Position(dock) => dock == self.dock, + _ => false, + }, blank: false, }; // Keep ← and → under the diagonal arrows. @@ -227,7 +250,7 @@ impl MousePanel { }) .collect(), status, - top: self.top, + dock: self.dock, selected: (scanning && !escaping).then_some((active_row, active_column)), status_selected: scanning && escaping, row_scan, @@ -267,6 +290,42 @@ mod tests { assert_eq!(panel.rows[0][0], Key::Click); } + #[test] + fn position_page_docks_the_panel_and_returns_to_the_previous_page() { + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let color = ScannerColor::default(); + let mut panel = MousePanel::new(Resolved::default(), 1, 100); + panel.choose(Key::More); + panel.choose(Key::Dock); + assert_eq!(panel.rows[0][0], Key::Position(Dock { column: 0, row: 0 })); + assert_eq!(panel.rows[3], [Key::Back]); + panel.set_displays(2); + assert_eq!(panel.rows[3], [Key::Back], "position page ignores displays"); + 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"); + + panel.choose(Key::Back); + assert!(panel.more && panel.rows[0][0] == Key::Speed(-1)); + assert_eq!(panel.dock, Dock::default()); + + panel.choose(Key::Dock); + panel.choose(Key::Position(Dock { column: 2, row: 0 })); + assert_eq!(panel.dock, Dock { column: 2, row: 0 }); + assert_eq!(panel.rows[0][0], Key::Speed(-1)); + let background = panel.frame(screen, 1.0, color, None).tiles[0].rect; + assert_eq!( + (background.x + background.width, background.y), + (1920.0, 0.0) + ); + } + #[test] fn panel_renders_with_selected_tiles_inside_the_screen() { let panel = MousePanel::new(Resolved::default(), 2, 100); diff --git a/src-tauri/src/scan_panel.rs b/src-tauri/src/scan_panel.rs index 46d52e86..c11ea939 100644 --- a/src-tauri/src/scan_panel.rs +++ b/src-tauri/src/scan_panel.rs @@ -27,10 +27,59 @@ impl PanelKey { } } +/// One of nine screen positions. `column` and `row` are 0 (start), 1 (middle) or 2 (end). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Dock { + pub column: u8, + pub row: u8, +} + +impl Default for Dock { + fn default() -> Self { + Self { column: 1, row: 2 } + } +} + +impl Dock { + pub fn label(self) -> &'static str { + const LABELS: [[&str; 3]; 3] = [ + ["Top left", "Top", "Top right"], + ["Left", "Middle", "Right"], + ["Bottom left", "Bottom", "Bottom right"], + ]; + LABELS[usize::from(self.row.min(2))][usize::from(self.column.min(2))] + } + /// Start of a `size` span placed at `slot` within `length` from `start`. + fn place(slot: u8, start: f64, length: f64, size: f64) -> f64 { + start + (length - size) * f64::from(slot.min(2)) / 2.0 + } +} + +/// Position page shared by panels: the nine docks as a 3×3 grid, then a back key. +pub const POSITION_PAGE: &str = "Position"; +pub fn position_rows(position: impl Fn(Dock) -> K, back: K) -> Vec> { + let mut rows: Vec> = (0..3) + .map(|row| { + (0..3) + .map(|column| position(Dock { column, row })) + .collect() + }) + .collect(); + rows.push(vec![back]); + rows +} +pub fn position_label(dock: Dock, current: Dock) -> String { + format!( + "{}{}", + dock.label(), + if dock == current { " •" } else { "" } + ) +} + pub struct Panel { pub rows: Vec>, pub status: String, - pub top: bool, + pub dock: Dock, /// Scanner position to highlight, or `None` when no key may be chosen. pub selected: Option<(usize, Option)>, /// Highlights the status tile, used while the scan offers to go back to rows. @@ -43,12 +92,8 @@ impl Panel { pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor) -> Frame { let width = (1180.0 * units).min((screen.width - 24.0 * units).max(1.0)); let height = (460.0 * units).min(screen.height * 0.55).max(1.0); - let x = screen.x + (screen.width - width) / 2.0; - let y = if self.top { - screen.y - } else { - screen.y + screen.height - height - }; + let x = Dock::place(self.dock.column, screen.x, screen.width, width); + let y = Dock::place(self.dock.row, screen.y, screen.height, height); let outer_scale = units.min(height / 460.0).min(width / 1180.0); let padding = 16.0 * outer_scale; let content_width = (width - padding * 2.0).max(1.0); @@ -159,7 +204,7 @@ mod tests { let panel = Panel { rows: vec![vec![key("a", 1.0), key("b", 2.0)], vec![key("c", 1.0)]], status: "Page · Select a row".into(), - top: false, + dock: Dock::default(), selected: Some((1, None)), status_selected: false, row_scan: true, @@ -203,7 +248,7 @@ mod tests { let panel = Panel { rows: vec![vec![key("a", 1.0)]], status: BACK_TO_ROWS.into(), - top: true, + dock: Dock { column: 1, row: 0 }, selected: None, status_selected: true, row_scan: false, @@ -229,4 +274,46 @@ mod tests { "Movement · Select a row" ); } + + #[test] + fn every_dock_anchors_the_panel_to_its_screen_position() { + let screen = Rect { + x: -1920.0, + y: 40.0, + width: 1920.0, + height: 1040.0, + }; + let rows = position_rows(|dock| dock, Dock::default()); + assert_eq!(rows.iter().map(Vec::len).collect::>(), [3, 3, 3, 1]); + let mut labels = std::collections::HashSet::new(); + for dock in rows[..3].iter().flatten().copied() { + assert!(labels.insert(dock.label())); + let panel = Panel { + rows: vec![vec![key("a", 1.0)]], + status: String::new(), + dock, + selected: None, + status_selected: false, + row_scan: false, + thickness: Thickness::default(), + }; + let rect = panel.frame(screen, 1.0, ScannerColor::default()).tiles[0].rect; + let left = rect.x - screen.x; + let right = screen.x + screen.width - (rect.x + rect.width); + let above = rect.y - screen.y; + let below = screen.y + screen.height - (rect.y + rect.height); + let expect = |slot: u8, before: f64, after: f64| match slot { + 0 => assert_eq!(before, 0.0), + 1 => assert!((before - after).abs() < 1e-9 && before > 0.0), + _ => assert!(after.abs() < 1e-9), + }; + expect(dock.column, left, right); + expect(dock.row, above, below); + } + assert_eq!(position_label(Dock::default(), Dock::default()), "Bottom •"); + assert_eq!( + position_label(Dock { column: 0, row: 0 }, Dock::default()), + "Top left" + ); + } }