Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/panel-sizing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Scanning panel sizing

One shared Panel size preference in scanning Appearance controls mouse, keyboard, Home and action menus. Small, Medium and Large use 0.8, 1.0 and 1.2. Missing or unknown saved values use Medium; per-area overrides are not supported. Point lines, pointer rings and application settings windows are unaffected.

Mouse keys use 128 logical pixels per weight unit; keyboard character pages use 68. The keyboard Position page uses 128 for its text labels. Rows use 56-pixel controls, 8-pixel gaps, 16-pixel outer padding and a 48-pixel status area. Width comes from the widest weighted row, including reserved blanks. Medium mouse Movement is 568 by 392; keyboard dimensions depend on layout and whether its six prediction slots are enabled. Menus keep their existing 180-pixel grid pitch at Medium.

The preset and display scale are applied once to all geometry and text. A uniform fit factor keeps the complete panel within its display bounds. Status text, highlights and prediction contents cannot resize a panel. Page/layout changes may resize it. Docking, relocation and pointer avoidance use measured bounds.

## Validation

Geometry tests cover presets, fractional DPI, small and negative-coordinate displays, nine docks, directional alignment, all menus and stable keyboard prediction bounds. Existing fake-input scanning and prediction acceptance tests remain in the suite. UI and serialization tests cover old settings and the shared control.

On Windows, export synthetic images with the actual GDI text renderer, without displaying a window or injecting input:

```powershell
$env:SWITCHIFY_PANEL_FIXTURES = Join-Path $PWD '.cache/panel-fixtures'
cargo test --manifest-path src-tauri/Cargo.toml export_panel_size_fixtures -- --ignored --nocapture
```

Windows Small and Medium mouse and Medium keyboard fixtures were visually checked for labels, spacing, direction alignment and reserved prediction slots. macOS visual inspection is not available on this Windows machine; cross-platform compilation and tests are covered by CI. This change does not modify platform focus or click-through behavior.
103 changes: 103 additions & 0 deletions src-tauri/src/modifier_overlay_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,12 @@ fn rounded_rect(rect: Rect, radius: f32) -> Result<tiny_skia::Path, String> {
.ok_or_else(|| "the modifier overlay chip path is invalid".into())
}

// Captures only synthetic fixtures on the exporting test's thread, never desktop pixels.
#[cfg(test)]
thread_local! {
static PANEL_FIXTURE: std::cell::RefCell<Option<Pixmap>> = const { std::cell::RefCell::new(None) };
}

fn present_pixmap_with_text(
window: HWND,
labels: &[String],
Expand Down Expand Up @@ -566,6 +572,34 @@ fn present_pixmap_with_text(
);
}

#[cfg(test)]
if PANEL_FIXTURE.with(|fixture| fixture.borrow().is_some()) {
let mut pixels = vec![0; output.len()];
copy_rgba_to_bgra(output, &mut pixels)?;
// GDI clears alpha on text pixels; the panel underneath is opaque.
for pixel in pixels.chunks_exact_mut(4) {
if pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0 {
pixel[3] = 255;
}
}
let tile = Pixmap::from_vec(
pixels,
tiny_skia::IntSize::from_wh(layout.width as u32, layout.height as u32).unwrap(),
)
.unwrap();
PANEL_FIXTURE.with(|fixture| {
fixture.borrow_mut().as_mut().unwrap().draw_pixmap(
layout.x,
layout.y,
tile.as_ref(),
&Default::default(),
Transform::identity(),
None,
)
});
return Ok(());
}

let destination = POINT {
x: layout.x,
y: layout.y,
Expand Down Expand Up @@ -873,6 +907,75 @@ mod tests {
thread.join().unwrap();
}

#[test]
#[ignore = "Exports synthetic panels through the native Windows text renderer; no input or desktop capture"]
fn export_panel_size_fixtures() {
use crate::{
scan_panel::Dock,
scan_preferences::{PanelSize, Resolved},
scanning::{Rect, ScannerColor},
};
let directory =
std::env::var("SWITCHIFY_PANEL_FIXTURES").expect("fixture output directory");
std::fs::create_dir_all(&directory).unwrap();
let screen = Rect {
x: 0.0,
y: 0.0,
width: 1920.0,
height: 1080.0,
};
for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] {
let mut mouse = crate::scan_mouse::MousePanel::new(
Resolved {
panel_size: size,
..Default::default()
},
2,
100,
);
mouse.dock = Dock { row: 0, column: 0 };
let mut keyboard = crate::scan_keyboard::Keyboard::new(false);
keyboard.dock = mouse.dock;
keyboard.set_panel_size(size);
keyboard.enable_predictions(true);
let keyboard_frame = keyboard.frame(screen, 1.0, ScannerColor::default());
keyboard.fixture_page(crate::scan_keyboard::Key::Dock);
for (name, frame) in [
(
"mouse",
mouse.frame(screen, 1.0, ScannerColor::default(), None),
),
("keyboard", keyboard_frame),
(
"keyboard-position",
keyboard.frame(screen, 1.0, ScannerColor::default()),
),
] {
let bounds = frame.tiles[0].rect;
super::PANEL_FIXTURE.with(|fixture| {
*fixture.borrow_mut() = tiny_skia::Pixmap::new(
bounds.width.ceil() as u32,
bounds.height.ceil() as u32,
)
});
for tile in &frame.tiles {
super::present_scan_tile(windows::Win32::Foundation::HWND::default(), tile)
.unwrap();
}
super::PANEL_FIXTURE.with(|fixture| {
fixture
.borrow_mut()
.take()
.unwrap()
.save_png(
std::path::Path::new(&directory).join(format!("{name}-{size:?}.png")),
)
.unwrap()
});
}
}
}

#[test]
fn native_window_render_smoke_becomes_visible_and_hides() {
let mut host = NativeHost::new().unwrap();
Expand Down
85 changes: 74 additions & 11 deletions src-tauri/src/point_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ impl Workflow {
self.open(Kind::Actions);
}
self.point.config = config;
self.keyboard
.set_panel_size(self.point.config.keyboard_scan.panel_size);
self.mouse
.set_panel_size(self.point.config.mouse_scan.panel_size);
self.menu
.set_panel_size(self.point.config.menu_scan.panel_size);
for menu in &mut self.parent_menu {
menu.set_panel_size(self.point.config.menu_scan.panel_size);
}

self.keyboard.set_layout(self.point.config.keyboard_layout);
self.menu
.set_period(self.point.config.menu_scan.interval_ms);
Expand Down Expand Up @@ -405,10 +415,29 @@ impl Workflow {
if !self.point.config.panel_avoids_pointer {
return None;
}
// Use the same measured frame as rendering, never a keyboard-sized proxy.
let frame = match self.stage {
Stage::Keyboard => self.keyboard.frame(
area,
self.point.units_per_logical_pixel,
self.point.config.scanner_color,
),
_ => self.mouse.frame(
area,
self.point.units_per_logical_pixel,
self.point.config.mouse_scan.color,
None,
),
};
let bounds = frame
.tiles
.iter()
.find(|tile| tile.is_panel_background())?
.rect;
self.dock.avoiding(
self.pointer?,
area,
self.point.units_per_logical_pixel,
(bounds.width, bounds.height),
self.moved,
)
}
Expand Down Expand Up @@ -1110,13 +1139,7 @@ impl Technique for Workflow {
_ => Frame::default(),
};
if let Some(moved) = self.avoiding() {
crate::scan_panel::move_frame(
&mut frame,
self.dock,
moved,
panel_area,
self.point.units_per_logical_pixel,
);
crate::scan_panel::move_frame(&mut frame, moved, panel_area);
}
if let Some(error) = &self.error {
frame.tiles.clear();
Expand Down Expand Up @@ -2100,6 +2123,42 @@ mod tests {
assert_eq!((workflow.keyboard.dock, workflow.mouse.dock), (dock, dock));
}

#[test]
fn size_changes_update_open_panels_without_resetting_the_scan() {
use crate::scan_preferences::PanelSize;
let screen = Rect {
x: 0.0,
y: 0.0,
width: 1920.0,
height: 1080.0,
};
let mut config = last_mode();
let mut workflow = Workflow::new(config.point(), screen, 1.0).unwrap();
workflow.open_mouse();
workflow.handle(Action::Next);
let selected = workflow
.frame()
.tiles
.iter()
.filter(|tile| tile.selected)
.map(|tile| tile.text.clone())
.collect::<Vec<_>>();
config.scan_preferences.panel_size = PanelSize::Large;
workflow.apply_config(config.point(), false);
let frame = workflow.frame();
assert!((frame.tiles[0].rect.width - 568.0 * 1.2).abs() < 1e-8);
assert_eq!(
selected,
frame
.tiles
.iter()
.filter(|tile| tile.selected)
.map(|tile| tile.text.clone())
.collect::<Vec<_>>()
);
assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse));
}

#[test]
fn open_panels_move_out_of_the_pointers_way_only_when_enabled() {
use crate::scan_panel::Dock;
Expand All @@ -2110,8 +2169,8 @@ mod tests {
height: 720.0,
};
let background = |workflow: &Workflow| workflow.frame().tiles[0].rect;
let bottom = Dock::default().rect(screen, 1.0);
let top = Dock { column: 1, row: 0 }.rect(screen, 1.0);
let bottom = Dock::default().rect(screen, (568.0, 392.0));
let top = Dock { column: 1, row: 0 }.rect(screen, (568.0, 392.0));
let over_bottom = Some((640.0, 700.0));
let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap();
workflow.open_mouse();
Expand Down Expand Up @@ -2146,6 +2205,10 @@ mod tests {
workflow.mouse_key(crate::scan_mouse::Key::Keyboard);
workflow.execution_succeeded();
assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Keyboard));
let keyboard_bounds = background(&workflow);
let size = (keyboard_bounds.width, keyboard_bounds.height);
let top = Dock { column: 1, row: 0 }.rect(screen, size);
let bottom = Dock::default().rect(screen, size);
workflow.set_pointer(over_bottom);
assert_eq!(background(&workflow), top, "the keyboard also moves");
workflow.set_pointer(None);
Expand All @@ -2154,7 +2217,7 @@ mod tests {
// A closed panel forgets where it moved, so a middle dock chooses afresh when reopened.
let middle = Dock { column: 1, row: 1 };
workflow.set_dock(middle);
let area = middle.rect(screen, 1.0);
let area = middle.rect(screen, size);
workflow.set_pointer(Some((640.0, area.y + 10.0)));
assert_eq!(workflow.moved, Some(Dock::default()));
workflow.keyboard_closed();
Expand Down
54 changes: 54 additions & 0 deletions src-tauri/src/scan_keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,15 @@ pub struct Keyboard {
pending_typed: Option<PendingTyped>,
}
impl Keyboard {
#[cfg(all(test, target_os = "windows"))]
pub(crate) fn fixture_page(&mut self, key: Key) {
self.choose(key);
}

pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) {
self.scan.options.panel_size = size;
}

#[cfg(test)]
pub fn new(mac: bool) -> Self {
Self::configured(mac, crate::scan_preferences::Resolved::default())
Expand Down Expand Up @@ -1004,6 +1013,8 @@ impl Keyboard {
)
};
crate::scan_panel::Panel {
key_width: if self.positioning { 128.0 } else { 68.0 },
size: self.scan.options.panel_size,
rows: self
.rows
.iter()
Expand Down Expand Up @@ -2271,6 +2282,45 @@ mod tests {
assert!(k.handle(Action::Select).is_none());
assert!(!k.suspended());
}
#[test]
fn layouts_and_predictions_keep_bounds_independent_of_text() {
use crate::{point_scan::KeyboardLayout, scan_preferences::PanelSize};
let screen = Rect {
x: -1920.0,
y: -100.0,
width: 1920.0,
height: 1080.0,
};
for layout in [
KeyboardLayout::Qwerty,
KeyboardLayout::SimpleQwerty,
KeyboardLayout::CommonLetters,
] {
for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] {
let mut keyboard = Keyboard::new(false);
keyboard.set_layout(layout);
keyboard.set_panel_size(size);
keyboard.enable_predictions(true);
let before = keyboard.frame(screen, 1.0, ScannerColor::default());
keyboard.predictions(
Some(crate::prediction::worker::Batch {
token: 42,
refined: true,
words: std::array::from_fn(|_| Some("extraordinary".into())),
}),
false,
);
let after = keyboard.frame(screen, 1.0, ScannerColor::default());
assert_eq!(before.tiles[0].rect, after.tiles[0].rect);
assert_eq!(
before.tiles.iter().map(|t| t.rect).collect::<Vec<_>>(),
after.tiles.iter().map(|t| t.rect).collect::<Vec<_>>()
);
assert_eq!(keyboard.rows[0].len(), 6);
}
}
}

#[test]
fn geometry_fits_work_area_at_every_dock_and_scale() {
let docks = crate::scan_panel::position_rows(|dock| dock, Default::default());
Expand Down Expand Up @@ -2314,6 +2364,10 @@ mod tests {
};
let frame = k.frame(screen, 1.0, ScannerColor::default());
assert_eq!(frame.tiles.last().unwrap().text, "Position · Select a row");
assert_eq!(
frame.tiles[1].rect.width, 128.0,
"position labels use utility cells rather than character cells"
);
assert!(frame.tiles.iter().any(|tile| tile.text == "Bottom •"));

k.choose(Key::Back);
Expand Down
Loading
Loading