From 0123836dd2000d18228a66208c47e84a2314bf4e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 19:12:38 -0300 Subject: [PATCH 1/6] fix(keys): the paste as card gets the keys and stays put, and combinations compare as the settings do --- app/src-tauri/src/keys.rs | 8 +++++++- app/src-tauri/src/keys_test.rs | 10 ++++++++++ app/src-tauri/src/settings.rs | 2 +- app/src/core.ts | 23 +++++++++++++++++++++++ app/src/tests/keys.test.ts | 12 +++++++++++- app/src/ui/Keys.tsx | 7 ++++--- crates/cp-panel/src/quick.rs | 10 +++++----- crates/cp-panel/src/quick_test.rs | 22 ++++++++++++++++++++-- 8 files changed, 81 insertions(+), 13 deletions(-) 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..1b7b4687 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -217,6 +217,29 @@ 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", +}; + +function keysOf(shortcut: string): string[] { + return shortcut + .split("+") + .map((one) => one.trim().toLowerCase()) + .filter((one) => one.length > 0) + .map((one) => ALIASES[one] ?? one) + .sort(); +} + +export function sameKeys(one: string, other: string): boolean { + const mine = keysOf(one); + const theirs = keysOf(other); + 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..61646874 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,13 @@ 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); + }); +}); diff --git a/app/src/ui/Keys.tsx b/app/src/ui/Keys.tsx index bb3b7ad7..e24d2c92 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"; @@ -101,8 +101,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((taken) => sameKeys(one, taken)); + const why = system ? "keysSystem" : repeated ? "keysTwice" : null; setRefused(why); if (why === null) { void change({ [field]: one }).finally(() => recheck(one)); diff --git a/crates/cp-panel/src/quick.rs b/crates/cp-panel/src/quick.rs index dde0fa2e..96a05d5a 100644 --- a/crates/cp-panel/src/quick.rs +++ b/crates/cp-panel/src/quick.rs @@ -167,7 +167,6 @@ impl Quick { if !self.fill(chooser) { self.back.set(was); } - place(chooser); } fn chosen(self: &Rc, chooser: &Chooser, key: &str) { @@ -240,7 +239,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) { @@ -266,7 +265,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,16 +273,17 @@ fn leave_when_left(chooser: &Chooser) { if let WindowEvent::Focused(false) = event && let Some(chooser) = weak.upgrade() { - left(&chooser); + left(&chooser, &ahead); } EventResult::Propagate }); } -fn left(chooser: &Chooser) { +fn left(chooser: &Chooser, ahead: &AtomicIsize) { if !chooser.window().is_visible() { return; } + ahead.store(0, Ordering::Relaxed); let later = chooser.as_weak(); slint::Timer::single_shot(Duration::ZERO, move || { if let Some(chooser) = later.upgrade() { diff --git a/crates/cp-panel/src/quick_test.rs b/crates/cp-panel/src/quick_test.rs index 6c6fcafe..12ec8024 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); assert!( chooser.window().is_visible(), "not in the middle of the event" ); + assert_eq!( + bench.ahead.load(Ordering::Relaxed), + 0, + "clicking elsewhere forgets where it was going" + ); tick(1); assert!(!chooser.window().is_visible()); - left(&chooser); + bench.ahead.store(42, Ordering::Relaxed); + left(&chooser, &bench.ahead); 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] From d11d841e89cacefeab4fd146c52784a808a4f986 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 19:43:56 -0300 Subject: [PATCH 2/6] fix(keys): the paste as card is drawn at start and comes forward at once, so it gets the keys from the first use --- crates/cp-panel/src/app.rs | 3 +++ crates/cp-panel/src/here.rs | 10 ++++++++-- crates/cp-panel/src/quick.rs | 9 +++++++-- crates/cp-panel/src/quick_test.rs | 22 ++++++++++++++++++++++ crates/cp-win-sys/src/frontmost.rs | 18 ++++++++++++++++++ crates/cp-win-sys/src/frontmost_test.rs | 15 +++++++++++++++ 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index bc6def97..c68c57ee 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.warm() { + 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..c4320080 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -225,9 +225,15 @@ 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::bring_forward(target.window) + || cp_win_sys::frontmost::pull_forward(target.window) + { + return; } + crate::note::note("Windows would not bring our window forward, so its keys stay behind"); } pub use cp_win_sys::dragging::Dragged; diff --git a/crates/cp-panel/src/quick.rs b/crates/cp-panel/src/quick.rs index 96a05d5a..1739f789 100644 --- a/crates/cp-panel/src/quick.rs +++ b/crates/cp-panel/src/quick.rs @@ -99,6 +99,10 @@ impl Quick { appear(chooser); } + pub fn warm(self: &Rc) -> bool { + self.chooser().is_some() + } + pub fn ground(&self) { if let Some(handle) = self .chooser @@ -249,8 +253,9 @@ 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(); } diff --git a/crates/cp-panel/src/quick_test.rs b/crates/cp-panel/src/quick_test.rs index 12ec8024..bfd3d70c 100644 --- a/crates/cp-panel/src/quick_test.rs +++ b/crates/cp-panel/src/quick_test.rs @@ -336,3 +336,25 @@ 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.warm()); + let chooser = card(&bench); + assert!(!chooser.window().is_visible(), "drawn, not shown"); + assert!(bench.quick.warm(), "warming 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.warm(); + bench.quick.choose(); + let chooser = card(&bench); + assert!(chooser.window().is_visible()); + assert_eq!(chooser.get_current(), 0, "ready for the keys right away"); +} diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index 81a89056..f6a75a8f 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -99,6 +99,24 @@ pub fn bring_forward(window: HWND) -> bool { unsafe { SetForegroundWindow(window) }.as_bool() } +pub fn pull_forward(window: HWND) -> bool { + if foreground() == Some(window) { + return true; + } + let Some(ahead) = foreground() else { + 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..d31dea40 100644 --- a/crates/cp-win-sys/src/frontmost_test.rs +++ b/crates/cp-win-sys/src/frontmost_test.rs @@ -72,3 +72,18 @@ 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_already_in_front_needs_no_pulling() { + let edit = crate::window::EditWindow::open("pulled").expect("a window to try it on"); + edit.pump(std::time::Duration::from_millis(50)); + if foreground() != Some(edit.window()) { + return; + } + assert!(pull_forward(edit.window())); +} + +#[test] +fn a_window_that_is_gone_is_never_pulled_forward() { + assert!(!pull_forward(HWND(std::ptr::null_mut()))); +} From 613bfbcd7b3ec80a198df95fd360f2d3215b758f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 19:55:51 -0300 Subject: [PATCH 3/6] fix(keys): the paste as card checks it holds the keys once shown and says where the focus went --- crates/cp-panel/src/here.rs | 22 +++++++++++++++++----- crates/cp-panel/src/quick.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index c4320080..ca6476b8 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, 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,14 @@ mod platform { cp_win_sys::frontmost::foreground().map_or(0, |window| window.0 as isize) } + 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); @@ -322,6 +330,10 @@ mod platform { 0 } + pub fn holds_front(_handle: RawWindowHandle) -> bool { + true + } + pub fn worn_sequence() -> Option { None } diff --git a/crates/cp-panel/src/quick.rs b/crates/cp-panel/src/quick.rs index 1739f789..3a71df6c 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, @@ -258,6 +259,30 @@ fn appear(chooser: &Chooser) { 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", + here::in_front().unwrap_or_else(|| "another app".to_owned()) + )); + here::forward(handle); + chooser.invoke_focus_keys(); } fn complain(chooser: &Chooser, said: &str) { @@ -289,6 +314,10 @@ fn left(chooser: &Chooser, ahead: &AtomicIsize) { return; } ahead.store(0, Ordering::Relaxed); + note(&format!( + "the paste as card was left for {}", + here::in_front().unwrap_or_else(|| "another app".to_owned()) + )); let later = chooser.as_weak(); slint::Timer::single_shot(Duration::ZERO, move || { if let Some(chooser) = later.upgrade() { From 76626b13ad3a1a63a18d837558abf7d2ffb57544 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 20:11:14 -0300 Subject: [PATCH 4/6] fix(keys): the card stays on its monitor as it grows and forward checks where the keys really went --- app/src/core.ts | 20 ++++++++++++----- app/src/tests/keys.test.ts | 5 +++++ app/src/ui/Keys.tsx | 2 +- crates/cp-panel/src/here.rs | 3 ++- crates/cp-panel/src/placing.rs | 7 ++++++ crates/cp-panel/src/placing_test.rs | 26 ++++++++++++++++++++++ crates/cp-panel/src/quick.rs | 29 +++++++++++++++---------- crates/cp-panel/src/quick_test.rs | 24 ++++++++++++++++---- crates/cp-panel/src/showing.rs | 25 +++++++++++++++++++++ crates/cp-win-sys/src/frontmost.rs | 3 +++ crates/cp-win-sys/src/frontmost_test.rs | 10 --------- 11 files changed, 122 insertions(+), 32 deletions(-) diff --git a/app/src/core.ts b/app/src/core.ts index 1b7b4687..bf443166 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -225,18 +225,28 @@ const ALIASES: Record = { meta: "cmd", }; -function keysOf(shortcut: string): string[] { +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) => ALIASES[one] ?? one) + .map((one) => canonical(one, mac)) .sort(); } -export function sameKeys(one: string, other: string): boolean { - const mine = keysOf(one); - const theirs = keysOf(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("+"); } diff --git a/app/src/tests/keys.test.ts b/app/src/tests/keys.test.ts index 61646874..607a2c0b 100644 --- a/app/src/tests/keys.test.ts +++ b/app/src/tests/keys.test.ts @@ -53,5 +53,10 @@ describe("two combinations", () => { 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 e24d2c92..9d0f613a 100644 --- a/app/src/ui/Keys.tsx +++ b/app/src/ui/Keys.tsx @@ -102,7 +102,7 @@ function Shortcut({ const take = (one: string) => { const others = (Object.keys(FIELD) as Role[]).filter((other) => other !== role); const repeated = others.some((other) => sameKeys(one, kept[FIELD[other]] ?? "")); - const system = SYSTEM.some((taken) => sameKeys(one, taken)); + const system = SYSTEM.some((reserved) => sameKeys(one, reserved)); const why = system ? "keysSystem" : repeated ? "keysTwice" : null; setRefused(why); if (why === null) { diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index ca6476b8..0c6c49db 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -236,7 +236,8 @@ mod platform { let Some(target) = cp_win_sys::frontmost::target_at(win32.hwnd.get()) else { return; }; - if cp_win_sys::frontmost::bring_forward(target.window) + let _ = cp_win_sys::frontmost::bring_forward(target.window); + if cp_win_sys::frontmost::foreground() == Some(target.window) || cp_win_sys::frontmost::pull_forward(target.window) { return; 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 3a71df6c..44f517f1 100644 --- a/crates/cp-panel/src/quick.rs +++ b/crates/cp-panel/src/quick.rs @@ -171,7 +171,9 @@ impl Quick { self.back.set(back); if !self.fill(chooser) { self.back.set(was); + return; } + crate::showing::kept_inside(chooser.window(), size_of(chooser)); } fn chosen(self: &Rc, chooser: &Chooser, key: &str) { @@ -303,23 +305,25 @@ fn leave_when_left(chooser: &Chooser, ahead: Arc) { if let WindowEvent::Focused(false) = event && let Some(chooser) = weak.upgrade() { - left(&chooser, &ahead); + left(&chooser, ahead.clone(), here::activated); } EventResult::Propagate }); } -fn left(chooser: &Chooser, ahead: &AtomicIsize) { +fn left(chooser: &Chooser, ahead: Arc, ours: impl Fn() -> bool + 'static) { if !chooser.window().is_visible() { return; } - ahead.store(0, Ordering::Relaxed); - note(&format!( - "the paste as card was left for {}", - here::in_front().unwrap_or_else(|| "another app".to_owned()) - )); 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 {}", + here::in_front().unwrap_or_else(|| "another app".to_owned()) + )); + } if let Some(chooser) = later.upgrade() { let _ = chooser.hide(); } @@ -343,13 +347,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 bfd3d70c..001a5454 100644 --- a/crates/cp-panel/src/quick_test.rs +++ b/crates/cp-panel/src/quick_test.rs @@ -305,20 +305,20 @@ fn losing_the_focus_puts_the_card_away_on_the_next_turn() { bench.quick.choose(); let chooser = card(&bench); bench.ahead.store(42, Ordering::Relaxed); - left(&chooser, &bench.ahead); + 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" + "clicking elsewhere forgets where it was going, once the switch is over" ); - tick(1); assert!(!chooser.window().is_visible()); bench.ahead.store(42, Ordering::Relaxed); - left(&chooser, &bench.ahead); + left(&chooser, bench.ahead.clone(), || false); tick(1); assert!(!chooser.window().is_visible()); assert_eq!( @@ -358,3 +358,19 @@ fn the_card_comes_forward_in_the_same_turn_it_shows() { 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..f9523c3a 100644 --- a/crates/cp-panel/src/showing.rs +++ b/crates/cp-panel/src/showing.rs @@ -24,6 +24,31 @@ pub fn place(panel: &Panel) { beside_pointer(panel.window(), size); } +pub fn kept_inside(window: &slint::Window, (width, height): (f64, f64)) { + use i_slint_backend_winit::WinitWindowAccessor; + let Some(Some(monitor)) = window.with_winit_window(|winit| winit.current_monitor()) else { + return; + }; + let (at, size, scale) = (monitor.position(), monitor.size(), monitor.scale_factor()); + let area = crate::placing::Area { + left: f64::from(at.x), + top: f64::from(at.y), + right: f64::from(at.x) + f64::from(size.width), + bottom: f64::from(at.y) + f64::from(size.height), + }; + let now = window.position(); + let (x, y) = crate::placing::kept_within( + (f64::from(now.x), f64::from(now.y)), + (width * scale, height * scale), + 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 f6a75a8f..24552466 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -106,6 +106,9 @@ pub fn pull_forward(window: HWND) -> bool { 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 { diff --git a/crates/cp-win-sys/src/frontmost_test.rs b/crates/cp-win-sys/src/frontmost_test.rs index d31dea40..f2be62f5 100644 --- a/crates/cp-win-sys/src/frontmost_test.rs +++ b/crates/cp-win-sys/src/frontmost_test.rs @@ -73,16 +73,6 @@ fn neither_the_panel_nor_its_host_is_ever_where_a_paste_goes() { assert!(!is_one_of(None, &[10, 20])); } -#[test] -fn a_window_already_in_front_needs_no_pulling() { - let edit = crate::window::EditWindow::open("pulled").expect("a window to try it on"); - edit.pump(std::time::Duration::from_millis(50)); - if foreground() != Some(edit.window()) { - return; - } - assert!(pull_forward(edit.window())); -} - #[test] fn a_window_that_is_gone_is_never_pulled_forward() { assert!(!pull_forward(HWND(std::ptr::null_mut()))); From b9d532260ec40e6054c21e8cb84a0bc81f8f635d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 20:12:04 -0300 Subject: [PATCH 5/6] fix(keys): shortcut keys sort with a real comparison and the refusal reads as one decision --- app/src/core.ts | 2 +- app/src/ui/Keys.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/core.ts b/app/src/core.ts index bf443166..957b92e3 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -241,7 +241,7 @@ function keysOf(shortcut: string, mac: boolean): string[] { .map((one) => one.trim().toLowerCase()) .filter((one) => one.length > 0) .map((one) => canonical(one, mac)) - .sort(); + .sort((one, other) => one.localeCompare(other)); } export function sameKeys(one: string, other: string, mac = onMac()): boolean { diff --git a/app/src/ui/Keys.tsx b/app/src/ui/Keys.tsx index 9d0f613a..b9b77e3a 100644 --- a/app/src/ui/Keys.tsx +++ b/app/src/ui/Keys.tsx @@ -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, @@ -103,7 +110,7 @@ function Shortcut({ const others = (Object.keys(FIELD) as Role[]).filter((other) => other !== role); const repeated = others.some((other) => sameKeys(one, kept[FIELD[other]] ?? "")); const system = SYSTEM.some((reserved) => sameKeys(one, reserved)); - const why = system ? "keysSystem" : repeated ? "keysTwice" : null; + const why = refusalOf(system, repeated); setRefused(why); if (why === null) { void change({ [field]: one }).finally(() => recheck(one)); From 7cc53b389009d519ffc29ddb68b7a182cbb83156 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 20:16:27 -0300 Subject: [PATCH 6/6] fix(keys): a growing card stays above the taskbar and coming forward is one checked call --- crates/cp-panel/src/app.rs | 2 +- crates/cp-panel/src/here.rs | 38 ++++++++++++++++++++------- crates/cp-panel/src/quick.rs | 10 ++++--- crates/cp-panel/src/quick_test.rs | 6 ++--- crates/cp-panel/src/showing.rs | 14 +++------- crates/cp-win-sys/src/frontmost.rs | 1 + crates/cp-win-sys/src/pointer.rs | 14 ++++++++-- crates/cp-win-sys/src/pointer_test.rs | 16 +++++++++++ 8 files changed, 72 insertions(+), 29 deletions(-) diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index c68c57ee..9a386321 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -337,7 +337,7 @@ impl App { }), self.state.borrow().ahead.clone(), ); - if !quick.warm() { + if !quick.draw() { note("the paste as card is drawn on its first use instead"); } let plain = quick.clone(); diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index 0c6c49db..1a5f4879 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -9,9 +9,9 @@ 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, 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, sequence, system_is_light, - text_in, thumb_of_file, thumbs_dir, to_clipboard, towards, unreadable, utc_offset_at, - watch_start, worn_sequence, + 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,24 @@ 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; @@ -236,13 +254,11 @@ mod platform { let Some(target) = cp_win_sys::frontmost::target_at(win32.hwnd.get()) else { return; }; - let _ = cp_win_sys::frontmost::bring_forward(target.window); - if cp_win_sys::frontmost::foreground() == Some(target.window) - || cp_win_sys::frontmost::pull_forward(target.window) - { - 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", + ); } - crate::note::note("Windows would not bring our window forward, so its keys stay behind"); } pub use cp_win_sys::dragging::Dragged; @@ -335,6 +351,10 @@ mod platform { true } + pub fn screen_of(_handle: RawWindowHandle) -> Option { + None + } + pub fn worn_sequence() -> Option { None } diff --git a/crates/cp-panel/src/quick.rs b/crates/cp-panel/src/quick.rs index 44f517f1..5b821902 100644 --- a/crates/cp-panel/src/quick.rs +++ b/crates/cp-panel/src/quick.rs @@ -100,7 +100,7 @@ impl Quick { appear(chooser); } - pub fn warm(self: &Rc) -> bool { + pub fn draw(self: &Rc) -> bool { self.chooser().is_some() } @@ -281,7 +281,7 @@ fn still_ahead(chooser: &Chooser) { } note(&format!( "the paste as card showed but {} kept the keys, so it is brought forward again", - here::in_front().unwrap_or_else(|| "another app".to_owned()) + whoever_is_in_front() )); here::forward(handle); chooser.invoke_focus_keys(); @@ -321,7 +321,7 @@ fn left(chooser: &Chooser, ahead: Arc, ours: impl Fn() -> bool + 's ahead.store(0, Ordering::Relaxed); note(&format!( "the paste as card was left for {}", - here::in_front().unwrap_or_else(|| "another app".to_owned()) + whoever_is_in_front() )); } if let Some(chooser) = later.upgrade() { @@ -330,6 +330,10 @@ fn left(chooser: &Chooser, ahead: Arc, ours: impl Fn() -> bool + 's }); } +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() } diff --git a/crates/cp-panel/src/quick_test.rs b/crates/cp-panel/src/quick_test.rs index 001a5454..3143efd5 100644 --- a/crates/cp-panel/src/quick_test.rs +++ b/crates/cp-panel/src/quick_test.rs @@ -341,10 +341,10 @@ fn putting_the_card_down_on_quitting_is_safe_drawn_or_not() { 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.warm()); + assert!(bench.quick.draw()); let chooser = card(&bench); assert!(!chooser.window().is_visible(), "drawn, not shown"); - assert!(bench.quick.warm(), "warming twice keeps the same card"); + assert!(bench.quick.draw(), "drawing twice keeps the same card"); bench.quick.choose(); assert!(card(&bench).window().is_visible()); } @@ -352,7 +352,7 @@ fn the_card_is_drawn_before_it_is_ever_asked_for_and_stays_hidden() { #[test] fn the_card_comes_forward_in_the_same_turn_it_shows() { let bench = bench(&["newest"]); - bench.quick.warm(); + bench.quick.draw(); bench.quick.choose(); let chooser = card(&bench); assert!(chooser.window().is_visible()); diff --git a/crates/cp-panel/src/showing.rs b/crates/cp-panel/src/showing.rs index f9523c3a..71786b01 100644 --- a/crates/cp-panel/src/showing.rs +++ b/crates/cp-panel/src/showing.rs @@ -25,22 +25,14 @@ pub fn place(panel: &Panel) { } pub fn kept_inside(window: &slint::Window, (width, height): (f64, f64)) { - use i_slint_backend_winit::WinitWindowAccessor; - let Some(Some(monitor)) = window.with_winit_window(|winit| winit.current_monitor()) else { + let Some(screen) = crate::app::handle_of(window).and_then(crate::here::screen_of) else { return; }; - let (at, size, scale) = (monitor.position(), monitor.size(), monitor.scale_factor()); - let area = crate::placing::Area { - left: f64::from(at.x), - top: f64::from(at.y), - right: f64::from(at.x) + f64::from(size.width), - bottom: f64::from(at.y) + f64::from(size.height), - }; let now = window.position(); let (x, y) = crate::placing::kept_within( (f64::from(now.x), f64::from(now.y)), - (width * scale, height * scale), - area, + (width * screen.scale, height * screen.scale), + screen.area, ); #[allow(clippy::cast_possible_truncation)] window.set_position(slint::PhysicalPosition::new( diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index 24552466..59325640 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -100,6 +100,7 @@ pub fn bring_forward(window: HWND) -> bool { } pub fn pull_forward(window: HWND) -> bool { + let _ = bring_forward(window); if foreground() == Some(window) { return true; } 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()); +}