diff --git a/app/src-tauri/src/keys.rs b/app/src-tauri/src/keys.rs index 3863e5ad..d7d8dc94 100644 --- a/app/src-tauri/src/keys.rs +++ b/app/src-tauri/src/keys.rs @@ -33,6 +33,10 @@ impl Role { } } + pub fn masks_the_menu(self) -> bool { + self == Role::Plain + } + fn spare(self) -> &'static [&'static str] { match self { Role::Panel => SPARE, @@ -116,7 +120,9 @@ pub fn bind(app: &AppHandle, role: Role, said: &str) -> Result<() app.global_shortcut() .on_shortcut(wanted, move |_app, _shortcut, event| { if event.state() == ShortcutState::Pressed { - unmenued(); + if role.masks_the_menu() { + unmenued(); + } role.answer(&handle); } }) diff --git a/app/src-tauri/src/keys_test.rs b/app/src-tauri/src/keys_test.rs index 2f107783..948cb8c6 100644 --- a/app/src-tauri/src/keys_test.rs +++ b/app/src-tauri/src/keys_test.rs @@ -142,3 +142,13 @@ fn settings_that_cannot_be_read_take_no_combination_beyond_the_panel() { assert_eq!(unread.plain_shortcut, ""); assert_eq!(unread.forms_shortcut, ""); } + +#[test] +fn only_the_shortcut_that_opens_no_window_keeps_the_menu_closed_by_hand() { + assert!(Role::Plain.masks_the_menu()); + assert!( + !Role::Panel.masks_the_menu(), + "a window of ours takes the keys, and a typed mask would cost it the front" + ); + assert!(!Role::Forms.masks_the_menu()); +} diff --git a/app/src-tauri/src/settings.rs b/app/src-tauri/src/settings.rs index 13c6ec2a..f045c1aa 100644 --- a/app/src-tauri/src/settings.rs +++ b/app/src-tauri/src/settings.rs @@ -142,8 +142,8 @@ fn written(app: &tauri::AppHandle, config: Config) -> Result { }; crate::note::note(&format!("the new shortcut could not be taken: {why}")); let mut back = landed; + let before = was.unwrap_or_else(crate::keys::unread); for role in &moved { - let before = was.clone().unwrap_or_else(crate::keys::unread); role.set(&mut back, role.of(&before).to_owned()); } if let Err(twice) = rebound(app, &moved, &back) { diff --git a/app/src/core.ts b/app/src/core.ts index 1eb66ecd..957b92e3 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -217,6 +217,39 @@ export function nearLimit(usedBytes: number, quotaMb: number) { return quotaMb > 0 && usedBytes * 10 >= quotaMb * A_MEGABYTE * 7; } +const ALIASES: Record = { + control: "ctrl", + option: "alt", + command: "cmd", + super: "cmd", + meta: "cmd", +}; + +const EITHER = ["cmdorctrl", "commandorcontrol", "cmdorcontrol", "commandorctrl"]; + +function canonical(key: string, mac: boolean): string { + const bare = /^(key|digit)(.)$/.exec(key)?.[2] ?? key; + if (EITHER.includes(bare)) { + return mac ? "cmd" : "ctrl"; + } + return ALIASES[bare] ?? bare; +} + +function keysOf(shortcut: string, mac: boolean): string[] { + return shortcut + .split("+") + .map((one) => one.trim().toLowerCase()) + .filter((one) => one.length > 0) + .map((one) => canonical(one, mac)) + .sort((one, other) => one.localeCompare(other)); +} + +export function sameKeys(one: string, other: string, mac = onMac()): boolean { + const mine = keysOf(one, mac); + const theirs = keysOf(other, mac); + return mine.length > 0 && mine.join("+") === theirs.join("+"); +} + export function combination(press: { ctrlKey: boolean; altKey: boolean; diff --git a/app/src/tests/keys.test.ts b/app/src/tests/keys.test.ts index 915589df..607a2c0b 100644 --- a/app/src/tests/keys.test.ts +++ b/app/src/tests/keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { combination } from "../core"; +import { combination, sameKeys } from "../core"; function press(code: string, held: Partial>) { return { @@ -45,3 +45,18 @@ describe("the combination the user presses", () => { expect(combination(press("AltLeft", { ctrl: true, alt: true }))).toBeNull(); }); }); + +describe("two combinations", () => { + it("are the same however they are spelled or ordered", () => { + expect(sameKeys("Shift+Alt+V", "Alt+Shift+V")).toBe(true); + expect(sameKeys("Control+Alt+P", "ctrl + alt + p")).toBe(true); + expect(sameKeys("Option+Super+1", "Alt+Cmd+1")).toBe(true); + expect(sameKeys("Ctrl+Alt+V", "Ctrl+Alt+Shift+V")).toBe(false); + expect(sameKeys("", "")).toBe(false); + expect(sameKeys("Ctrl+Alt+KeyV", "Ctrl+Alt+V")).toBe(true); + expect(sameKeys("Ctrl+Digit1", "Ctrl+1")).toBe(true); + expect(sameKeys("CommandOrControl+Alt+V", "Ctrl+Alt+V", false)).toBe(true); + expect(sameKeys("CmdOrCtrl+Alt+V", "Cmd+Alt+V", true)).toBe(true); + expect(sameKeys("CmdOrCtrl+Alt+V", "Cmd+Alt+V", false)).toBe(false); + }); +}); diff --git a/app/src/ui/Keys.tsx b/app/src/ui/Keys.tsx index bb3b7ad7..b9b77e3a 100644 --- a/app/src/ui/Keys.tsx +++ b/app/src/ui/Keys.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { asKeys, combination, type Kept, onMac, type Role, useKeys } from "../core"; +import { asKeys, combination, type Kept, onMac, type Role, sameKeys, useKeys } from "../core"; import { panelKeys, t } from "../locales"; import { Band, Line } from "./Bits"; @@ -78,6 +78,13 @@ export default function Keys({ ); } +function refusalOf(system: boolean, repeated: boolean): "keysSystem" | "keysTwice" | null { + if (system) { + return "keysSystem"; + } + return repeated ? "keysTwice" : null; +} + function Shortcut({ which: role, says, @@ -101,8 +108,9 @@ function Shortcut({ const taken = !asking && !off && keys !== null && !keys.bound; const take = (one: string) => { const others = (Object.keys(FIELD) as Role[]).filter((other) => other !== role); - const repeated = one !== "" && others.some((other) => kept[FIELD[other]] === one); - const why = SYSTEM.includes(one) ? "keysSystem" : repeated ? "keysTwice" : null; + const repeated = others.some((other) => sameKeys(one, kept[FIELD[other]] ?? "")); + const system = SYSTEM.some((reserved) => sameKeys(one, reserved)); + const why = refusalOf(system, repeated); setRefused(why); if (why === null) { void change({ [field]: one }).finally(() => recheck(one)); diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index bc6def97..9a386321 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -337,6 +337,9 @@ impl App { }), self.state.borrow().ahead.clone(), ); + if !quick.draw() { + note("the paste as card is drawn on its first use instead"); + } let plain = quick.clone(); panel.on_plain_asked(move || plain.plain()); let forms = quick.clone(); diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index e1ea83db..1a5f4879 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -7,11 +7,11 @@ use std::path::{Path, PathBuf}; pub use platform::{ Dragged, Listening, THUMBNAILS_FILES, Watching, activated, ahead_now, capture_insisting, - content_of, data_dir, drag_out, dress, forward, front, ground, hand_back, host, in_front, - keep_awake, media_of, ocr_available, on_foreground, open_link, open_path, ours_up_front, - paste_into, pointer, read_stuck, rebuilds_whole, sequence, system_is_light, text_in, - thumb_of_file, thumbs_dir, to_clipboard, towards, unreadable, utc_offset_at, watch_start, - worn_sequence, + content_of, data_dir, drag_out, dress, forward, front, ground, hand_back, holds_front, host, + in_front, keep_awake, media_of, ocr_available, on_foreground, open_link, open_path, + ours_up_front, paste_into, pointer, read_stuck, rebuilds_whole, screen_of, sequence, + system_is_light, text_in, thumb_of_file, thumbs_dir, to_clipboard, towards, unreadable, + utc_offset_at, watch_start, worn_sequence, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -56,6 +56,32 @@ mod platform { cp_win_sys::frontmost::foreground().map_or(0, |window| window.0 as isize) } + pub fn screen_of(handle: RawWindowHandle) -> Option { + let RawWindowHandle::Win32(win32) = handle else { + return None; + }; + let spot = cp_win_sys::pointer::spot_of(win32.hwnd.get())?; + Some(super::Pointer { + at: (f64::from(spot.x), f64::from(spot.y)), + area: crate::placing::Area { + left: f64::from(spot.left), + top: f64::from(spot.top), + right: f64::from(spot.right), + bottom: f64::from(spot.bottom), + }, + physical: true, + scale: spot.scale, + }) + } + + pub fn holds_front(handle: RawWindowHandle) -> bool { + let RawWindowHandle::Win32(win32) = handle else { + return true; + }; + cp_win_sys::frontmost::target_at(win32.hwnd.get()) + .is_some_and(|ours| cp_win_sys::frontmost::foreground() == Some(ours.window)) + } + pub fn worn_sequence() -> Option { let sequence = cp_win_sys::clipboard::sequence()?; let ours = cp_win_sys::clipboard::holds(cp_win::restore::WORN); @@ -225,8 +251,13 @@ mod platform { let RawWindowHandle::Win32(win32) = handle else { return; }; - if let Some(target) = cp_win_sys::frontmost::target_at(win32.hwnd.get()) { - cp_win_sys::frontmost::bring_forward(target.window); + let Some(target) = cp_win_sys::frontmost::target_at(win32.hwnd.get()) else { + return; + }; + if !cp_win_sys::frontmost::pull_forward(target.window) { + crate::note::note( + "Windows would not bring our window forward, so its keys stay behind", + ); } } @@ -316,6 +347,14 @@ mod platform { 0 } + pub fn holds_front(_handle: RawWindowHandle) -> bool { + true + } + + pub fn screen_of(_handle: RawWindowHandle) -> Option { + None + } + pub fn worn_sequence() -> Option { None } diff --git a/crates/cp-panel/src/placing.rs b/crates/cp-panel/src/placing.rs index 54d63085..977b038c 100644 --- a/crates/cp-panel/src/placing.rs +++ b/crates/cp-panel/src/placing.rs @@ -28,6 +28,13 @@ pub fn beside(pointer: (f64, f64), area: Area, size: (f64, f64), unit: f64) -> ( ) } +pub fn kept_within(at: (f64, f64), size: (f64, f64), area: Area) -> (f64, f64) { + ( + at.0.min(area.right - size.0).max(area.left), + at.1.min(area.bottom - size.1).max(area.top), + ) +} + #[cfg(test)] #[path = "placing_test.rs"] mod tests; diff --git a/crates/cp-panel/src/placing_test.rs b/crates/cp-panel/src/placing_test.rs index 918ce3cc..72c9055e 100644 --- a/crates/cp-panel/src/placing_test.rs +++ b/crates/cp-panel/src/placing_test.rs @@ -64,3 +64,29 @@ fn a_panel_taller_than_the_screen_starts_at_its_top() { }; assert_eq!(beside((500.0, 200.0), short, PANEL, 1.0).1, 0.0); } + +#[test] +fn a_window_that_grew_is_pushed_back_inside_without_moving_otherwise() { + let area = Area { + left: 0.0, + top: 0.0, + right: 1000.0, + bottom: 800.0, + }; + assert_eq!( + kept_within((100.0, 200.0), (300.0, 200.0), area), + (100.0, 200.0) + ); + assert_eq!( + kept_within((100.0, 700.0), (300.0, 200.0), area), + (100.0, 600.0) + ); + assert_eq!( + kept_within((900.0, 50.0), (300.0, 200.0), area), + (700.0, 50.0) + ); + assert_eq!( + kept_within((-20.0, -10.0), (300.0, 200.0), area), + (0.0, 0.0) + ); +} diff --git a/crates/cp-panel/src/quick.rs b/crates/cp-panel/src/quick.rs index dde0fa2e..5b821902 100644 --- a/crates/cp-panel/src/quick.rs +++ b/crates/cp-panel/src/quick.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicIsize, Ordering}; use std::time::Duration; const COMPLAINS_FOR: Duration = Duration::from_millis(2_200); +const LOOKS_AGAIN: Duration = Duration::from_millis(150); pub struct Aim { pub store: Rc, @@ -99,6 +100,10 @@ impl Quick { appear(chooser); } + pub fn draw(self: &Rc) -> bool { + self.chooser().is_some() + } + pub fn ground(&self) { if let Some(handle) = self .chooser @@ -166,8 +171,9 @@ impl Quick { self.back.set(back); if !self.fill(chooser) { self.back.set(was); + return; } - place(chooser); + crate::showing::kept_inside(chooser.window(), size_of(chooser)); } fn chosen(self: &Rc, chooser: &Chooser, key: &str) { @@ -240,7 +246,7 @@ fn wire(quick: &Rc, chooser: &Chooser) { here::hand_back(quick.ahead.swap(0, Ordering::Relaxed)); } }); - leave_when_left(chooser); + leave_when_left(chooser, quick.ahead.clone()); } fn appear(chooser: &Chooser) { @@ -250,9 +256,34 @@ fn appear(chooser: &Chooser) { } crate::showing::rescaled(chooser.window(), kept_zoom(chooser)); place(chooser); - if let Some(handle) = crate::app::handle_of(chooser.window()) { - here::forward(handle); + match crate::app::handle_of(chooser.window()) { + Some(handle) => here::forward(handle), + None => note("the paste as card had no window yet to bring forward"), + } + chooser.invoke_focus_keys(); + let later = chooser.as_weak(); + slint::Timer::single_shot(LOOKS_AGAIN, move || { + if let Some(chooser) = later.upgrade() { + still_ahead(&chooser); + } + }); +} + +fn still_ahead(chooser: &Chooser) { + if !chooser.window().is_visible() { + return; } + let Some(handle) = crate::app::handle_of(chooser.window()) else { + return; + }; + if here::holds_front(handle) { + return; + } + note(&format!( + "the paste as card showed but {} kept the keys, so it is brought forward again", + whoever_is_in_front() + )); + here::forward(handle); chooser.invoke_focus_keys(); } @@ -266,7 +297,7 @@ fn complain(chooser: &Chooser, said: &str) { }); } -fn leave_when_left(chooser: &Chooser) { +fn leave_when_left(chooser: &Chooser, ahead: Arc) { use i_slint_backend_winit::winit::event::WindowEvent; use i_slint_backend_winit::{EventResult, WinitWindowAccessor}; let weak = chooser.as_weak(); @@ -274,24 +305,35 @@ fn leave_when_left(chooser: &Chooser) { if let WindowEvent::Focused(false) = event && let Some(chooser) = weak.upgrade() { - left(&chooser); + left(&chooser, ahead.clone(), here::activated); } EventResult::Propagate }); } -fn left(chooser: &Chooser) { +fn left(chooser: &Chooser, ahead: Arc, ours: impl Fn() -> bool + 'static) { if !chooser.window().is_visible() { return; } let later = chooser.as_weak(); slint::Timer::single_shot(Duration::ZERO, move || { + if !ours() { + ahead.store(0, Ordering::Relaxed); + note(&format!( + "the paste as card was left for {}", + whoever_is_in_front() + )); + } if let Some(chooser) = later.upgrade() { let _ = chooser.hide(); } }); } +fn whoever_is_in_front() -> String { + here::in_front().unwrap_or_else(|| "another app".to_owned()) +} + fn kept_zoom(chooser: &Chooser) -> f32 { chooser.global::().get_zoom() } @@ -309,13 +351,16 @@ fn subject_of(row: &Listed) -> String { } } -fn place(chooser: &Chooser) { +fn size_of(chooser: &Chooser) -> (f64, f64) { let zoom = f64::from(kept_zoom(chooser)); - let size = ( + ( f64::from(chooser.get_wide()) * zoom, f64::from(chooser.get_tall()) * zoom, - ); - crate::showing::beside_pointer(chooser.window(), size); + ) +} + +fn place(chooser: &Chooser) { + crate::showing::beside_pointer(chooser.window(), size_of(chooser)); } #[cfg(test)] diff --git a/crates/cp-panel/src/quick_test.rs b/crates/cp-panel/src/quick_test.rs index 6c6fcafe..3143efd5 100644 --- a/crates/cp-panel/src/quick_test.rs +++ b/crates/cp-panel/src/quick_test.rs @@ -144,8 +144,14 @@ fn the_arrows_walk_back_through_the_history_and_stop_at_both_ends() { let bench = bench(&["older", "newest"]); bench.quick.choose(); let chooser = card(&bench); + let at = chooser.window().position(); press(&chooser, Key::LeftArrow); assert_eq!(chooser.get_subject(), "older"); + assert_eq!( + chooser.window().position(), + at, + "the card stays where it opened" + ); assert!(chooser.get_newer()); assert!(!chooser.get_older()); press(&chooser, Key::LeftArrow); @@ -298,16 +304,28 @@ fn losing_the_focus_puts_the_card_away_on_the_next_turn() { let bench = bench(&["newest"]); bench.quick.choose(); let chooser = card(&bench); - left(&chooser); + bench.ahead.store(42, Ordering::Relaxed); + left(&chooser, bench.ahead.clone(), || false); assert!( chooser.window().is_visible(), "not in the middle of the event" ); tick(1); + assert_eq!( + bench.ahead.load(Ordering::Relaxed), + 0, + "clicking elsewhere forgets where it was going, once the switch is over" + ); assert!(!chooser.window().is_visible()); - left(&chooser); + bench.ahead.store(42, Ordering::Relaxed); + left(&chooser, bench.ahead.clone(), || false); tick(1); assert!(!chooser.window().is_visible()); + assert_eq!( + bench.ahead.load(Ordering::Relaxed), + 42, + "a card already put away by us keeps the target for the paste in flight" + ); } #[test] @@ -318,3 +336,41 @@ fn putting_the_card_down_on_quitting_is_safe_drawn_or_not() { drawn.quick.choose(); drawn.quick.ground(); } + +#[test] +fn the_card_is_drawn_before_it_is_ever_asked_for_and_stays_hidden() { + let bench = bench(&["newest"]); + assert!(bench.quick.chooser.get().is_none()); + assert!(bench.quick.draw()); + let chooser = card(&bench); + assert!(!chooser.window().is_visible(), "drawn, not shown"); + assert!(bench.quick.draw(), "drawing twice keeps the same card"); + bench.quick.choose(); + assert!(card(&bench).window().is_visible()); +} + +#[test] +fn the_card_comes_forward_in_the_same_turn_it_shows() { + let bench = bench(&["newest"]); + bench.quick.draw(); + bench.quick.choose(); + let chooser = card(&bench); + assert!(chooser.window().is_visible()); + assert_eq!(chooser.get_current(), 0, "ready for the keys right away"); +} + +#[test] +fn leaving_the_card_for_a_window_of_our_own_keeps_where_it_was_going() { + let bench = bench(&["newest"]); + bench.quick.choose(); + let chooser = card(&bench); + bench.ahead.store(42, Ordering::Relaxed); + left(&chooser, bench.ahead.clone(), || true); + assert_eq!( + bench.ahead.load(Ordering::Relaxed), + 42, + "the panel opened over the card still pastes where the user was" + ); + tick(1); + assert!(!chooser.window().is_visible()); +} diff --git a/crates/cp-panel/src/showing.rs b/crates/cp-panel/src/showing.rs index 87f5f2bf..71786b01 100644 --- a/crates/cp-panel/src/showing.rs +++ b/crates/cp-panel/src/showing.rs @@ -24,6 +24,23 @@ pub fn place(panel: &Panel) { beside_pointer(panel.window(), size); } +pub fn kept_inside(window: &slint::Window, (width, height): (f64, f64)) { + let Some(screen) = crate::app::handle_of(window).and_then(crate::here::screen_of) else { + return; + }; + let now = window.position(); + let (x, y) = crate::placing::kept_within( + (f64::from(now.x), f64::from(now.y)), + (width * screen.scale, height * screen.scale), + screen.area, + ); + #[allow(clippy::cast_possible_truncation)] + window.set_position(slint::PhysicalPosition::new( + x.round() as i32, + y.round() as i32, + )); +} + pub fn beside_pointer(window: &slint::Window, (width, height): (f64, f64)) { let Some(pointer) = crate::here::pointer() else { return; diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index 81a89056..59325640 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -99,6 +99,28 @@ pub fn bring_forward(window: HWND) -> bool { unsafe { SetForegroundWindow(window) }.as_bool() } +pub fn pull_forward(window: HWND) -> bool { + let _ = bring_forward(window); + if foreground() == Some(window) { + return true; + } + let Some(ahead) = foreground() else { + return false; + }; + if unsafe { windows::Win32::UI::WindowsAndMessaging::IsHungAppWindow(ahead) }.as_bool() { + return false; + } + let thread = + unsafe { windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(ahead, None) }; + let Some(attached) = Attached::to(thread) else { + return false; + }; + let _ = bring_forward(window); + let _ = attached.focus_on(window); + drop(attached); + foreground() == Some(window) +} + pub fn answers(window: HWND, patience_ms: u32) -> bool { let mut ignored = 0usize; diff --git a/crates/cp-win-sys/src/frontmost_test.rs b/crates/cp-win-sys/src/frontmost_test.rs index 50ea1c50..f2be62f5 100644 --- a/crates/cp-win-sys/src/frontmost_test.rs +++ b/crates/cp-win-sys/src/frontmost_test.rs @@ -72,3 +72,8 @@ fn neither_the_panel_nor_its_host_is_ever_where_a_paste_goes() { assert!(!is_one_of(Some(30), &[10, 20])); assert!(!is_one_of(None, &[10, 20])); } + +#[test] +fn a_window_that_is_gone_is_never_pulled_forward() { + assert!(!pull_forward(HWND(std::ptr::null_mut()))); +} diff --git a/crates/cp-win-sys/src/pointer.rs b/crates/cp-win-sys/src/pointer.rs index 0f5e3eed..506ffe07 100644 --- a/crates/cp-win-sys/src/pointer.rs +++ b/crates/cp-win-sys/src/pointer.rs @@ -1,6 +1,7 @@ -use windows::Win32::Foundation::POINT; +use windows::Win32::Foundation::{HWND, POINT}; use windows::Win32::Graphics::Gdi::{ - GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, + GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, + MonitorFromWindow, }; use windows::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; @@ -22,6 +23,15 @@ pub fn spot() -> Option { let mut at = POINT::default(); unsafe { GetCursorPos(&raw mut at) }.ok()?; let monitor = unsafe { MonitorFromPoint(at, MONITOR_DEFAULTTONEAREST) }; + spot_on(monitor, at) +} + +pub fn spot_of(window: isize) -> Option { + let monitor = unsafe { MonitorFromWindow(HWND(window as *mut _), MONITOR_DEFAULTTONEAREST) }; + spot_on(monitor, POINT::default()) +} + +fn spot_on(monitor: HMONITOR, at: POINT) -> Option { let mut info = MONITORINFO { cbSize: u32::try_from(size_of::()).ok()?, ..Default::default() diff --git a/crates/cp-win-sys/src/pointer_test.rs b/crates/cp-win-sys/src/pointer_test.rs index afbbd96a..d2f55f9d 100644 --- a/crates/cp-win-sys/src/pointer_test.rs +++ b/crates/cp-win-sys/src/pointer_test.rs @@ -9,3 +9,19 @@ fn the_pointer_sits_on_a_work_area_that_has_room() { assert!(spot.bottom > spot.top, "{spot:?}"); assert!(spot.scale >= 1.0, "{spot:?}"); } + +#[test] +fn a_window_knows_the_work_area_of_the_screen_it_is_on() { + let edit = crate::window::EditWindow::open("where am i").expect("a window to ask about"); + let Some(spot) = spot_of(edit.window().0 as isize) else { + return; + }; + assert!(spot.right > spot.left && spot.bottom > spot.top); + assert!(spot.scale >= 1.0); +} + +#[test] +fn a_window_that_is_not_there_still_falls_on_some_screen() { + let nearest = spot_of(0); + assert_eq!(nearest.is_some(), spot().is_some()); +}