diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 995b24ae..ffb70e78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,7 +164,7 @@ jobs: - run: cargo llvm-cov --no-report --workspace - run: cargo llvm-cov --no-report run -p cp-mac --example mac_probe - - run: cargo llvm-cov report --lcov --output-path lcov-rust.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching)\.rs|_test\.rs$|src-tauri[/\]src[/\]' + - run: cargo llvm-cov report --lcov --output-path lcov-rust.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching|showing)\.rs|_test\.rs$|src-tauri[/\]src[/\]' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: @@ -193,7 +193,7 @@ jobs: retention-days: 1 - name: The threshold is asked after the measuring, so a dip still ships the report if: always() - run: cargo llvm-cov report --fail-under-lines 85 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching)\.rs|_test\.rs$|src-tauri[/\]src[/\]' + run: cargo llvm-cov report --fail-under-lines 85 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching|showing)\.rs|_test\.rs$|src-tauri[/\]src[/\]' coverage-windows: name: coverage / the Windows crates @@ -220,7 +220,7 @@ jobs: shell: pwsh run: Set-Clipboard -Value "cp-probe" - run: cargo llvm-cov --no-report run -p cp-win --example win_probe - - run: cargo llvm-cov report --lcov --output-path lcov-win.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching)\.rs|_test\.rs$|src-tauri[/\]src[/\]' + - run: cargo llvm-cov report --lcov --output-path lcov-win.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching|showing)\.rs|_test\.rs$|src-tauri[/\]src[/\]' - name: Every path is relative to the repository, which is what Sonar reads if: always() shell: bash @@ -234,7 +234,7 @@ jobs: retention-days: 1 - name: The threshold is asked after the measuring, so a dip still ships the report if: always() - run: cargo llvm-cov report --fail-under-lines 85 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching)\.rs|_test\.rs$|src-tauri[/\]src[/\]' + run: cargo llvm-cov report --fail-under-lines 85 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure|reaching|showing)\.rs|_test\.rs$|src-tauri[/\]src[/\]' sonar: name: sonarcloud @@ -285,6 +285,6 @@ jobs: -Dsonar.javascript.lcov.reportPaths=app/coverage/lcov.info -Dsonar.rust.clippy.enabled=false -Dsonar.cpd.exclusions=app/src/locales.ts - -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/**,crates/cp-panel/src/main.rs,crates/cp-panel/src/app.rs,crates/cp-panel/src/measure.rs,crates/cp-panel/src/reaching.rs + -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/**,crates/cp-panel/src/main.rs,crates/cp-panel/src/app.rs,crates/cp-panel/src/showing.rs,crates/cp-panel/src/measure.rs,crates/cp-panel/src/reaching.rs -Dsonar.qualitygate.wait=${{ github.event_name == 'pull_request' }} -Dsonar.qualitygate.timeout=300 diff --git a/Cargo.toml b/Cargo.toml index cecd43d0..f8254d66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ windows = { version = "0.62", features = [ "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", + "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_UI_Input_KeyboardAndMouse", "Win32_Security", diff --git a/PRIVACY.md b/PRIVACY.md index 3eadc3c5..54fa2f86 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -95,7 +95,7 @@ CopyPaste makes **one type of network request** for update checking: - Only when you press «Update» does the app also read `latest.json` (or `candidate.json` for test versions) from the same branch, which says where the installer lives and carries its signature - The installer is **cryptographically signed**, and the signature is verified against a public key built into the app before anything is installed. The download address is also checked against our own release hosts before a single byte is fetched - **Microsoft Store version:** nothing is checked and nothing is offered. The Store delivers its own updates -- **Homebrew** (`copypaste`, or `copypaste-beta` for test versions): you are told the `brew upgrade` command for your cask. Nothing is downloaded or installed behind Homebrew's back +- **Homebrew** (`copypaste`, or `copypaste-beta` for test versions): the same as a standalone build, «Update» downloads and installs it. The cask tells Homebrew that CopyPaste updates itself, so `brew upgrade --cask` keeps working too - **Standalone builds (Windows / macOS):** if you press «Update», and only then, the installer is downloaded and run, and CopyPaste restarts itself. Nothing is downloaded or installed without you asking for it ### User-Initiated Browser Navigation diff --git a/RELEASING.md b/RELEASING.md index 596eb22a..a63133bf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -66,9 +66,10 @@ copy that only knew that channel is not left behind. What it does with an offer depends on where the copy came from, which it reads off its own path: a copy under `WindowsApps` is the Store's to update and is -never offered anything; one under `Caskroom/copypaste-beta` or -`Caskroom/copypaste` is told its own `brew` command rather than handed an -installer; anything else installs its own update. +never offered anything; anything else, a Homebrew copy included, installs its +own update with one click, as Tisty does. The casks say `auto_updates true`, so +Homebrew expects that; its own `brew` command is shown only when the copy cannot +replace itself, such as when it runs from the mounted `.dmg`. Four things guard the install, all of them borrowed from Tisty: the download address must be this repository's releases on `github.com` (or diff --git a/app/src-tauri/src/update.rs b/app/src-tauri/src/update.rs index 156f5fdb..e2b48e42 100644 --- a/app/src-tauri/src/update.rs +++ b/app/src-tauri/src/update.rs @@ -69,7 +69,7 @@ struct Kept { } pub const fn self_installs(route: Route) -> bool { - matches!(route, Route::Download) + matches!(route, Route::Brew | Route::BrewBeta | Route::Download) } pub fn ours(url: &str) -> bool { diff --git a/app/src-tauri/src/update_test.rs b/app/src-tauri/src/update_test.rs index 3ed2cd45..1a83ae61 100644 --- a/app/src-tauri/src/update_test.rs +++ b/app/src-tauri/src/update_test.rs @@ -32,7 +32,10 @@ fn the_cask_moves_the_bundle_out_of_the_caskroom_and_the_receipt_still_tells() { Route::Download, "no receipt, no brew" ); - assert!(!self_installs(Route::Brew)); + assert!( + self_installs(Route::Brew), + "the cask says auto_updates, so a brewed copy replaces itself like Tisty does" + ); } #[test] @@ -112,7 +115,7 @@ fn a_copy_from_the_beta_cask_is_told_its_own_command() { if cfg!(target_os = "macos") { assert_eq!(chosen(Some(at), beta), Route::BrewBeta); } - assert!(!self_installs(Route::BrewBeta)); + assert!(self_installs(Route::BrewBeta)); } #[test] diff --git a/app/src/ui/About.tsx b/app/src/ui/About.tsx index d632bbe4..fe5157fd 100644 --- a/app/src/ui/About.tsx +++ b/app/src/ui/About.tsx @@ -31,10 +31,10 @@ function Newer() { : t("updateLook"); const why = ready - ? brew - ? t(seen?.route === "brewBeta" ? "updateBrewBeta" : "updateBrew") - : ready.installs - ? t("updateTake") + ? ready.installs + ? t("updateTake") + : brew + ? t(seen?.route === "brewBeta" ? "updateBrewBeta" : "updateBrew") : t("updateMove") : t("updateWhen"); diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index 8f80b903..8aed53ca 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -14,6 +14,7 @@ pub mod ocr; pub mod pasteboard; pub mod paths; pub mod permissions; +pub mod pointer; pub mod processes; pub mod runloop; pub mod theme; diff --git a/crates/cp-mac-sys/src/pointer.rs b/crates/cp-mac-sys/src/pointer.rs new file mode 100644 index 00000000..60eab61b --- /dev/null +++ b/crates/cp-mac-sys/src/pointer.rs @@ -0,0 +1,46 @@ +use objc2_app_kit::{NSEvent, NSScreen}; +use objc2_foundation::{MainThreadMarker, NSPoint, NSRect}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Spot { + pub x: f64, + pub y: f64, + pub left: f64, + pub top: f64, + pub right: f64, + pub bottom: f64, +} + +pub fn spot() -> Option { + let mtm = MainThreadMarker::new()?; + let at = NSEvent::mouseLocation(); + let screens = NSScreen::screens(mtm); + let primary = screens.firstObject()?.frame(); + let under = screens + .iter() + .find(|screen| holds(screen.frame(), at)) + .or_else(|| NSScreen::mainScreen(mtm))?; + Some(flipped(at, under.visibleFrame(), primary.size.height)) +} + +fn holds(frame: NSRect, at: NSPoint) -> bool { + at.x >= frame.origin.x + && at.x <= frame.origin.x + frame.size.width + && at.y >= frame.origin.y + && at.y <= frame.origin.y + frame.size.height +} + +pub fn flipped(at: NSPoint, visible: NSRect, primary_height: f64) -> Spot { + Spot { + x: at.x, + y: primary_height - at.y, + left: visible.origin.x, + top: primary_height - (visible.origin.y + visible.size.height), + right: visible.origin.x + visible.size.width, + bottom: primary_height - visible.origin.y, + } +} + +#[cfg(test)] +#[path = "pointer_test.rs"] +mod tests; diff --git a/crates/cp-mac-sys/src/pointer_test.rs b/crates/cp-mac-sys/src/pointer_test.rs new file mode 100644 index 00000000..432c11fd --- /dev/null +++ b/crates/cp-mac-sys/src/pointer_test.rs @@ -0,0 +1,35 @@ +use super::*; +use objc2_foundation::NSSize; + +#[test] +fn appkit_counts_from_the_bottom_and_the_panel_from_the_top() { + let visible = NSRect::new(NSPoint::new(0.0, 80.0), NSSize::new(1440.0, 795.0)); + let spot = flipped(NSPoint::new(700.0, 600.0), visible, 900.0); + assert_eq!( + spot, + Spot { + x: 700.0, + y: 300.0, + left: 0.0, + top: 25.0, + right: 1440.0, + bottom: 820.0, + } + ); +} + +#[test] +fn a_screen_above_the_main_one_has_negative_tops() { + let visible = NSRect::new(NSPoint::new(0.0, 900.0), NSSize::new(1920.0, 1055.0)); + let spot = flipped(NSPoint::new(100.0, 1500.0), visible, 900.0); + assert_eq!(spot.y, -600.0); + assert_eq!(spot.top, -1055.0); + assert_eq!(spot.bottom, 0.0); +} + +#[test] +fn off_the_main_thread_there_is_no_spot() { + if MainThreadMarker::new().is_none() { + assert_eq!(spot(), None); + } +} diff --git a/crates/cp-mac/examples/probe/panel.rs b/crates/cp-mac/examples/probe/panel.rs index 4bfba319..67c270fe 100644 --- a/crates/cp-mac/examples/probe/panel.rs +++ b/crates/cp-mac/examples/probe/panel.rs @@ -73,4 +73,16 @@ fn the_panel_and_its_windows(b: &mut Battery) { Ok(()) }, ); + + b.case( + "S6", + "the pointer sits on a screen whose visible part holds it", + || { + let spot = cp_mac_sys::pointer::spot().ok_or("this needs the main thread and a screen")?; + if spot.right <= spot.left || spot.bottom <= spot.top { + return Err(format!("the visible part of the screen is empty: {spot:?}")); + } + Ok(()) + }, + ); } diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index dd3ab16f..abb7cf7f 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -4,6 +4,7 @@ use crate::model::{Metrics, Rows, reveal}; use crate::note::note; use crate::opening::Reached; use crate::reaching::reach_for; +use crate::showing::{NEXT_FRAME, appear, place, vanish}; use crate::view::{AS_IS, as_is_label, label_of_form, shorthand_of}; use crate::view::{chips_of, compact, count_text, empty_of, form_of, harvest, label_of, sweeten}; use crate::{Chip, FormRow, Options, Panel}; @@ -18,8 +19,6 @@ use std::time::{Duration, Instant}; const CHIP_STEP: f32 = 78.0; const KEPT_IN_VIEW: usize = 2; -const OUT: Duration = Duration::from_millis(130); -const NEXT_FRAME: Duration = Duration::from_millis(16); const SETTLES: Duration = Duration::from_millis(70); const RESTS: Duration = Duration::from_millis(110); const LOOKS: Duration = Duration::from_millis(250); @@ -245,12 +244,9 @@ impl App { }); let ui = self.ui.clone(); panel.on_nudge(move |dx, dy| { - let Some(ui) = ui.upgrade() else { - return; - }; - let window = ui.window(); - let at = window.position().to_logical(window.scale_factor()); - window.set_position(slint::LogicalPosition::new(at.x + dx, at.y + dy)); + if let Some(ui) = ui.upgrade() { + crate::showing::nudge(&ui, dx, dy); + } }); } @@ -1116,38 +1112,6 @@ fn arm_sheet(state: &Rc>, ui: &Panel) { }); } -thread_local! { - static CURTAIN: slint::Timer = slint::Timer::default(); -} - -fn appear(ui: &Panel) { - crate::note::tell("shown"); - ui.set_shown(0.0); - let weak = ui.as_weak(); - CURTAIN.with(|timer| { - timer.stop(); - timer.start(slint::TimerMode::SingleShot, NEXT_FRAME, move || { - if let Some(ui) = weak.upgrade() { - ui.set_shown(1.0); - } - }); - }); -} - -fn vanish(ui: &Panel) { - ui.set_sheet_open(false); - ui.set_shown(0.0); - let weak = ui.as_weak(); - CURTAIN.with(|timer| { - timer.stop(); - timer.start(slint::TimerMode::SingleShot, OUT, move || { - if let Some(ui) = weak.upgrade() { - let _ = ui.hide(); - } - }); - }); -} - fn busy() -> &'static str { if crate::here::read_stuck() { return crate::say::pick( @@ -1440,9 +1404,11 @@ fn listen(ui: slint::Weak, ahead: Arc, backdrop: String) { crate::say::adopt_what_was_kept(); let dressed = backdrop.clone(); let _ = ui.upgrade_in_event_loop(move |panel| { + place(&panel); if panel.show().is_err() { return; } + place(&panel); crate::view::dress_words(&panel); dress(&panel, &dressed); forward(&panel); diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index d8753cda..ea13616b 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -8,10 +8,18 @@ use std::path::{Path, PathBuf}; pub use platform::{ Dragged, THUMBNAILS_FILES, Watching, ahead_now, capture_insisting, content_of, data_dir, drag_out, dress, forward, ground, in_front, media_of, ocr_available, open_link, open_path, - ours_up_front, paste_into, read_stuck, stay_out_of_the_dock, system_is_light, text_in, + ours_up_front, paste_into, pointer, read_stuck, stay_out_of_the_dock, system_is_light, text_in, thumb_of_file, thumbs_dir, to_clipboard, towards, utc_offset_at, watch_start, }; +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Pointer { + pub at: (f64, f64), + pub area: crate::placing::Area, + pub physical: bool, + pub scale: f64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Landed { Nothing, @@ -195,6 +203,21 @@ mod platform { } pub fn ground(_handle: RawWindowHandle) {} + + pub fn pointer() -> Option { + let spot = cp_win_sys::pointer::spot()?; + 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, + }) + } } #[cfg(target_os = "macos")] @@ -387,6 +410,21 @@ mod platform { } } + pub fn pointer() -> Option { + let spot = cp_mac_sys::pointer::spot()?; + Some(super::Pointer { + at: (spot.x, spot.y), + area: crate::placing::Area { + left: spot.left, + top: spot.top, + right: spot.right, + bottom: spot.bottom, + }, + physical: false, + scale: 1.0, + }) + } + pub fn ground(handle: RawWindowHandle) { if let RawWindowHandle::AppKit(appkit) = handle { cp_mac_sys::floating::grounded(appkit.ns_view); diff --git a/crates/cp-panel/src/here_test.rs b/crates/cp-panel/src/here_test.rs index 1a9cf77f..aa1f290c 100644 --- a/crates/cp-panel/src/here_test.rs +++ b/crates/cp-panel/src/here_test.rs @@ -209,3 +209,16 @@ fn a_paste_that_fails_tells_the_panel_why_exactly_once() { ); assert_eq!(asked.get(), 3, "once it is done nothing is asked again"); } + +#[test] +fn the_pointer_lands_on_a_work_area_that_holds_it() { + let Some(pointer) = pointer() else { + return; + }; + let area = pointer.area; + assert!( + area.right > area.left && area.bottom > area.top, + "{pointer:?}" + ); + assert!(pointer.scale >= 1.0, "{pointer:?}"); +} diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 2264fc4e..504043c2 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -18,9 +18,11 @@ mod note; mod opening; mod paired; mod papers; +mod placing; mod reaching; mod say; mod shape; +mod showing; mod tags; mod token; mod view; diff --git a/crates/cp-panel/src/placing.rs b/crates/cp-panel/src/placing.rs new file mode 100644 index 00000000..54d63085 --- /dev/null +++ b/crates/cp-panel/src/placing.rs @@ -0,0 +1,33 @@ +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Area { + pub left: f64, + pub top: f64, + pub right: f64, + pub bottom: f64, +} + +const GAP: f64 = 12.0; +const EDGE: f64 = 8.0; + +pub fn beside(pointer: (f64, f64), area: Area, size: (f64, f64), unit: f64) -> (f64, f64) { + let (width, height) = size; + let (gap, edge) = (GAP * unit, EDGE * unit); + let x = if pointer.0 + gap + width <= area.right { + pointer.0 + gap + } else if pointer.0 - gap - width >= area.left { + pointer.0 - gap - width + } else { + area.right - width - gap + }; + let y = (pointer.1 - height / 2.0) + .max(area.top + edge) + .min(area.bottom - edge - height); + ( + x.min(area.right - width).max(area.left), + y.min(area.bottom - height).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 new file mode 100644 index 00000000..918ce3cc --- /dev/null +++ b/crates/cp-panel/src/placing_test.rs @@ -0,0 +1,66 @@ +use super::*; + +const SCREEN: Area = Area { + left: 0.0, + top: 0.0, + right: 1920.0, + bottom: 1040.0, +}; +const PANEL: (f64, f64) = (480.0, 620.0); + +#[test] +fn the_panel_opens_to_the_right_of_the_pointer_and_centred_on_it() { + assert_eq!(beside((500.0, 500.0), SCREEN, PANEL, 1.0), (512.0, 190.0)); +} + +#[test] +fn near_the_right_edge_it_opens_to_the_left_of_the_pointer() { + assert_eq!(beside((1800.0, 500.0), SCREEN, PANEL, 1.0), (1308.0, 190.0)); +} + +#[test] +fn near_the_top_or_the_bottom_it_stays_inside_the_work_area() { + assert_eq!(beside((500.0, 20.0), SCREEN, PANEL, 1.0).1, 8.0); + assert_eq!( + beside((500.0, 1030.0), SCREEN, PANEL, 1.0).1, + 1040.0 - 8.0 - 620.0 + ); +} + +#[test] +fn a_screen_narrower_than_both_sides_pins_it_to_the_right_edge() { + let narrow = Area { + right: 700.0, + ..SCREEN + }; + let (x, _) = beside((350.0, 500.0), narrow, PANEL, 1.0); + assert_eq!(x, 700.0 - 480.0 - 12.0); +} + +#[test] +fn a_second_monitor_to_the_left_keeps_its_negative_coordinates() { + let left = Area { + left: -1920.0, + top: 0.0, + right: 0.0, + bottom: 1040.0, + }; + assert_eq!(beside((-1500.0, 500.0), left, PANEL, 1.0), (-1488.0, 190.0)); +} + +#[test] +fn the_gaps_grow_with_the_scale_of_the_screen() { + assert_eq!( + beside((500.0, 500.0), SCREEN, (960.0, 800.0), 2.0), + (524.0, 100.0) + ); +} + +#[test] +fn a_panel_taller_than_the_screen_starts_at_its_top() { + let short = Area { + bottom: 400.0, + ..SCREEN + }; + assert_eq!(beside((500.0, 200.0), short, PANEL, 1.0).1, 0.0); +} diff --git a/crates/cp-panel/src/showing.rs b/crates/cp-panel/src/showing.rs new file mode 100644 index 00000000..10cdb1ce --- /dev/null +++ b/crates/cp-panel/src/showing.rs @@ -0,0 +1,72 @@ +use crate::Panel; +use slint::ComponentHandle; +use std::time::Duration; + +pub const NEXT_FRAME: Duration = Duration::from_millis(16); +const OUT: Duration = Duration::from_millis(130); + +thread_local! { + static CURTAIN: slint::Timer = slint::Timer::default(); +} + +pub fn place(panel: &Panel) { + let Some(pointer) = crate::here::pointer() else { + return; + }; + let theme = panel.global::(); + let width = f64::from(theme.get_width() + theme.get_margin() * 2.0); + let height = f64::from(theme.get_height() + theme.get_margin() * 2.0); + let window = panel.window(); + let scale = pointer.scale; + if pointer.physical { + let (x, y) = crate::placing::beside( + pointer.at, + pointer.area, + (width * scale, height * scale), + scale, + ); + #[allow(clippy::cast_possible_truncation)] + window.set_position(slint::PhysicalPosition::new( + x.round() as i32, + y.round() as i32, + )); + } else { + let (x, y) = crate::placing::beside(pointer.at, pointer.area, (width, height), 1.0); + #[allow(clippy::cast_possible_truncation)] + window.set_position(slint::LogicalPosition::new(x as f32, y as f32)); + } +} + +pub fn nudge(panel: &Panel, dx: f32, dy: f32) { + let window = panel.window(); + let at = window.position().to_logical(window.scale_factor()); + window.set_position(slint::LogicalPosition::new(at.x + dx, at.y + dy)); +} + +pub fn appear(ui: &Panel) { + crate::note::tell("shown"); + ui.set_shown(0.0); + let weak = ui.as_weak(); + CURTAIN.with(|timer| { + timer.stop(); + timer.start(slint::TimerMode::SingleShot, NEXT_FRAME, move || { + if let Some(ui) = weak.upgrade() { + ui.set_shown(1.0); + } + }); + }); +} + +pub fn vanish(ui: &Panel) { + ui.set_sheet_open(false); + ui.set_shown(0.0); + let weak = ui.as_weak(); + CURTAIN.with(|timer| { + timer.stop(); + timer.start(slint::TimerMode::SingleShot, OUT, move || { + if let Some(ui) = weak.upgrade() { + let _ = ui.hide(); + } + }); + }); +} diff --git a/crates/cp-win-sys/src/lib.rs b/crates/cp-win-sys/src/lib.rs index a759dd16..f6e2a51d 100644 --- a/crates/cp-win-sys/src/lib.rs +++ b/crates/cp-win-sys/src/lib.rs @@ -14,6 +14,7 @@ pub mod ocr; pub mod ole; pub mod paths; pub mod permissions; +pub mod pointer; pub mod reading; pub mod source; pub mod theme; diff --git a/crates/cp-win-sys/src/pointer.rs b/crates/cp-win-sys/src/pointer.rs new file mode 100644 index 00000000..0f5e3eed --- /dev/null +++ b/crates/cp-win-sys/src/pointer.rs @@ -0,0 +1,52 @@ +use windows::Win32::Foundation::POINT; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, +}; +use windows::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; +use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; + +const PLAIN_DPI: f64 = 96.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Spot { + pub x: i32, + pub y: i32, + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, + pub scale: f64, +} + +pub fn spot() -> Option { + let mut at = POINT::default(); + unsafe { GetCursorPos(&raw mut at) }.ok()?; + let monitor = unsafe { MonitorFromPoint(at, MONITOR_DEFAULTTONEAREST) }; + let mut info = MONITORINFO { + cbSize: u32::try_from(size_of::()).ok()?, + ..Default::default() + }; + if !unsafe { GetMonitorInfoW(monitor, &raw mut info) }.as_bool() { + return None; + } + let (mut dpi, mut unused) = (0_u32, 0_u32); + let scale = + unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &raw mut dpi, &raw mut unused) } + .ok() + .filter(|()| dpi > 0) + .map_or(1.0, |()| f64::from(dpi) / PLAIN_DPI); + let work = info.rcWork; + Some(Spot { + x: at.x, + y: at.y, + left: work.left, + top: work.top, + right: work.right, + bottom: work.bottom, + scale, + }) +} + +#[cfg(test)] +#[path = "pointer_test.rs"] +mod tests; diff --git a/crates/cp-win-sys/src/pointer_test.rs b/crates/cp-win-sys/src/pointer_test.rs new file mode 100644 index 00000000..afbbd96a --- /dev/null +++ b/crates/cp-win-sys/src/pointer_test.rs @@ -0,0 +1,11 @@ +use super::*; + +#[test] +fn the_pointer_sits_on_a_work_area_that_has_room() { + let Some(spot) = spot() else { + return; + }; + assert!(spot.right > spot.left, "{spot:?}"); + assert!(spot.bottom > spot.top, "{spot:?}"); + assert!(spot.scale >= 1.0, "{spot:?}"); +}