From 80abd221910ca7f32a953858ce4867633e05ddfe Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 21 Sep 2026 10:10:21 -0300 Subject: [PATCH 1/3] fix: the picker comes forward on Windows, and a picker nobody answered is replaced --- crates/linkunbound-mac/src/native.rs | 2 + crates/linkunbound-shell/src/main.rs | 168 ++++++++++++++++++++---- crates/linkunbound-shell/ui/shell.slint | 1 + crates/linkunbound-win/src/lib.rs | 6 +- crates/linkunbound-win/src/native.rs | 64 ++++++--- 5 files changed, 196 insertions(+), 45 deletions(-) diff --git a/crates/linkunbound-mac/src/native.rs b/crates/linkunbound-mac/src/native.rs index 3108d1e..0a31f76 100644 --- a/crates/linkunbound-mac/src/native.rs +++ b/crates/linkunbound-mac/src/native.rs @@ -121,6 +121,8 @@ pub fn take_the_keyboard(view: isize) { }; let app = NSApplication::sharedApplication(mtm); app.setActivationPolicy(NSApplicationActivationPolicy::Regular); + // In front of its level even when the activation is refused or still on its way. + window.orderFrontRegardless(); #[allow(deprecated)] app.activateIgnoringOtherApps(true); window.makeKeyAndOrderFront(None); diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index d2fc980..1a0a124 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -78,6 +78,10 @@ mod host { linkunbound_win::front_window() } + pub fn last_input() -> Option { + linkunbound_win::last_input() + } + pub fn never_activates(window: isize) { linkunbound_win::never_activates(window); } @@ -153,6 +157,11 @@ mod host { linkunbound_mac::front_application() } + /// Launch Services brings the picker forward itself, link by link; nothing is taken back. + pub fn last_input() -> Option { + None + } + pub fn never_activates(_window: isize) {} pub fn shift_is_down() -> bool { @@ -224,6 +233,10 @@ mod host { 0 } + pub fn last_input() -> Option { + None + } + pub fn never_activates(_window: isize) {} pub fn shift_is_down() -> bool { @@ -452,19 +465,22 @@ struct Shown { /// Read when the link arrived, not when the user picks: by then the picker /// itself is the foreground window, and the rule would bind to us. source: Option, - /// Links that arrived while one was already on screen. Redressing the window - /// under the user would open the wrong one, and dropping them would lose a - /// click they already made. + /// Links that arrived while one was on screen and being answered. Redressing + /// the window under the user would open the wrong one, and dropping them + /// would lose a click they already made. waiting: std::collections::VecDeque, } -/// A link that arrives while one is already on screen waits its turn. Redressing -/// the window under the user opens the wrong one — they aimed at what they could -/// see — and discarding it loses a click they already made. +/// A link that arrives while one is on screen and attended waits its turn. Redressing the +/// window under the user opens the wrong one — they aimed at what they could see — and +/// discarding it loses a click they already made. One arriving over a picker nobody is +/// answering — refused the front, or left behind by the very click that sent the link — takes +/// its place instead: a picker that never answered was clicked past, and queueing behind it +/// is how a dozen clicks came to show nothing at all. /// /// Kept apart from the window so the decision can be checked without one. -fn claims_the_window(shown: &mut Shown, url: String, occupied: bool) -> Option { - if occupied { +fn claims_the_window(shown: &mut Shown, url: String, attended: bool) -> Option { + if attended { // A program that retries the same link every second fills the queue with one click; a // link already waiting, or the one on screen, is that click. let already = shown.url.as_deref() == Some(url.as_str()) @@ -483,10 +499,16 @@ fn claims_the_window(shown: &mut Shown, url: String, occupied: bool) -> Option>, url: String) { - let occupied = picker.window().is_visible(); - let Some(url) = claims_the_window(&mut shown.borrow_mut(), url, occupied) else { - return; +/// Only a picker that holds the front is being looked at; one that is up without it is being +/// clicked past. Shown means Slint's flag, which a window behind another still carries. +fn attended(picker: &Picker) -> bool { + picker.window().is_visible() && native_handle(picker.window()).is_some_and(host::is_in_front) +} + +/// Whether the link went on screen, rather than behind one already being answered. +fn present(picker: &Picker, words: &Strings, shown: &Rc>, url: String) -> bool { + let Some(url) = claims_the_window(&mut shown.borrow_mut(), url, attended(picker)) else { + return false; }; let listed = rows(physical_icon_side(picker)); if listed.is_empty() { @@ -502,7 +524,7 @@ fn present(picker: &Picker, words: &Strings, shown: &Rc>, url: St host::keep_off_the_taskbar(handle, corner_of(picker)); host::take_the_keyboard(handle); } - return; + return true; } let source = host::clicked_in(); dress(picker, words, &url, source.as_deref(), &listed); @@ -539,6 +561,7 @@ fn present(picker: &Picker, words: &Strings, shown: &Rc>, url: St ui.refresh_strip(); } } + true } /// The picker is summoned by a click and answered with the keyboard, so it has @@ -556,13 +579,13 @@ fn native_handle(window: &slint::Window) -> Option { /// clicked is silently dropped. fn next_in_line(picker: &Picker, words: &Strings, shown: &Rc>) { let queued = shown.borrow_mut().waiting.pop_front(); - if let Some(url) = queued { - present(picker, words, shown, url); - if let Some(ui) = ui() { - // The next link needs its own settle: carrying the previous one's - // state would dismiss it on the tick after it appeared. - ui.watch_focus(); - } + if let Some(url) = queued + && present(picker, words, shown, url) + && let Some(ui) = ui() + { + // The next link needs its own settle: carrying the previous one's + // state would dismiss it on the tick after it appeared. + ui.watch_focus(); } } @@ -642,6 +665,10 @@ struct Ui { taskbar_seen: Cell, /// The window the picker was shown over when it could not take the front. shown_over: Cell>, + /// The moment the picker was last put up, and the last input then: losing the front in that + /// first moment, to the application that sent the link and with nobody having touched + /// anything since, is answered by taking it back rather than by putting the picker away. + put_up: Cell>, #[cfg(target_os = "macos")] launch_decided: Cell, } @@ -649,6 +676,28 @@ struct Ui { /// How long a browser gets to bring its window up after being launched. const BROWSER_ARRIVES_WITHIN: Duration = Duration::from_millis(1500); +/// How long the picker keeps taking the front back after it was put up. +const FRONT_SETTLES_WITHIN: Duration = Duration::from_millis(500); + +#[derive(Clone, Copy)] +struct PutUp { + at: std::time::Instant, + last_input: Option, +} + +/// Some applications pull their window forward again right after handing a link over. That is +/// not the person walking away, and the front is taken back — only in the picker's first +/// moment, and only while nobody has touched mouse or keyboard since it came up: wherever the +/// person went themselves is where they meant to be. A system with no clock of the last input +/// never takes it back. +fn still_settling(put_up: Option, last_input: Option) -> bool { + put_up.is_some_and(|up| { + up.at.elapsed() < FRONT_SETTLES_WITHIN + && last_input.is_some() + && last_input == up.last_input + }) +} + thread_local! { static UI: RefCell>> = const { RefCell::new(None) }; } @@ -824,6 +873,10 @@ impl Ui { fn watch_focus(self: &Rc) { self.held_focus.set(false); self.shown_over.set(None); + self.put_up.set(Some(PutUp { + at: std::time::Instant::now(), + last_input: host::last_input(), + })); let weak = Rc::downgrade(self); self.watch.start( slint::TimerMode::Repeated, @@ -846,6 +899,10 @@ impl Ui { .set_private_on(ui.picker.get_pinned_private() || host::shift_is_down()); if host::is_in_front(ours) { ui.held_focus.set(true); + } else if still_settling(ui.put_up.get(), host::last_input()) + && host::clicked_in() == ui.shown.borrow().source + { + host::take_the_keyboard(ours); } else if !ui.held_focus.get() { // An elevated window in front keeps a plain process out of its input, so // the picker sits there unfocused; the window the person was in is @@ -931,8 +988,11 @@ fn arrived(raw: String) { } return; } - present(&ui.picker, &ui.words.get(), &ui.shown, url); - ui.watch_focus(); + // A link that only queued leaves the picker's settle alone: restarting it would have the + // picker take the front back from wherever the person had just gone. + if present(&ui.picker, &ui.words.get(), &ui.shown, url) { + ui.watch_focus(); + } } /// Links the socket took before the loop could: the copy that handed them over believes they @@ -1283,6 +1343,7 @@ fn main() -> Result<(), slint::PlatformError> { light_seen: Cell::new(false), taskbar_seen: Cell::new(light_taskbar), shown_over: Cell::new(None), + put_up: Cell::new(None), #[cfg(target_os = "macos")] launch_decided: Cell::new(false), }); @@ -1337,7 +1398,8 @@ fn main() -> Result<(), slint::PlatformError> { #[cfg(test)] mod tests { use super::{ - Listed, Shown, WAITING_ROOM, claims_the_window, link_from, physical, rule_for, with_icons, + FRONT_SETTLES_WITHIN, Listed, PutUp, Shown, WAITING_ROOM, claims_the_window, link_from, + physical, rule_for, still_settling, with_icons, }; use linkunbound_core::Scope; use linkunbound_core::normalise; @@ -1415,6 +1477,40 @@ mod tests { } } + /// The front is taken back only in the picker's first moment and only while the person has + /// touched nothing since; past the moment, or after any input, losing the front is the + /// person walking away. Never put up, or no clock of the input to ask, nothing is taken. + #[test] + fn the_front_is_taken_back_for_a_moment_while_nothing_was_touched() { + let now = std::time::Instant::now(); + let fresh = PutUp { + at: now, + last_input: Some(7), + }; + assert!(still_settling(Some(fresh), Some(7))); + assert!(!still_settling(Some(fresh), Some(8)), "a key was pressed"); + assert!(!still_settling(Some(fresh), None), "no clock of the input"); + let stale = PutUp { + at: now + .checked_sub(FRONT_SETTLES_WITHIN * 2) + .expect("uptime beyond a second"), + last_input: Some(7), + }; + assert!( + !still_settling(Some(stale), Some(7)), + "the moment has passed" + ); + let unclocked = PutUp { + at: now, + last_input: None, + }; + assert!( + !still_settling(Some(unclocked), None), + "the Mac never takes it back" + ); + assert!(!still_settling(None, Some(7))); + } + /// The screen is measured in physical pixels and the window is described in logical ones, so /// on a 150% display the untouched number asks for two thirds of the room it needs and the /// picker opens clipped. @@ -1515,9 +1611,29 @@ mod tests { ); } - /// A link arriving while one is on screen must not redress the window: the - /// user aimed at what they could see. And it must not be dropped either — - /// that click already happened. + /// A link arriving over a picker that never got the front, or lost it to the click that sent + /// the link, takes its place: the person clicked past a window that was not answering, and + /// the same link clicked again is that person trying once more, not a program retrying. + #[test] + fn a_link_arriving_over_a_picker_nobody_is_answering_takes_its_place() { + let mut shown = Shown { + url: Some("https://behind.test/".to_owned()), + ..Shown::default() + }; + let next = claims_the_window(&mut shown, "https://next.test/".to_owned(), false); + assert_eq!(next.as_deref(), Some("https://next.test/")); + assert!( + shown.waiting.is_empty(), + "nothing queues behind an unanswered picker" + ); + + let again = claims_the_window(&mut shown, "https://behind.test/".to_owned(), false); + assert_eq!(again.as_deref(), Some("https://behind.test/")); + } + + /// A link arriving while one is on screen and attended must not redress the + /// window: the user aimed at what they could see. And it must not be dropped + /// either — that click already happened. #[test] fn a_link_arriving_over_a_shown_one_waits_instead_of_replacing_it() { let mut shown = Shown::default(); diff --git a/crates/linkunbound-shell/ui/shell.slint b/crates/linkunbound-shell/ui/shell.slint index 546aa41..644a945 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -413,6 +413,7 @@ export component Picker inherits Window { title: "LinkUnbound"; icon: @image-url("../../../app/src-tauri/icons/128x128.png"); no-frame: true; + always-on-top: true; background: transparent; width: root.wanted-width; height: root.wanted-height; diff --git a/crates/linkunbound-win/src/lib.rs b/crates/linkunbound-win/src/lib.rs index a6f4c2c..a8ed138 100644 --- a/crates/linkunbound-win/src/lib.rs +++ b/crates/linkunbound-win/src/lib.rs @@ -16,9 +16,9 @@ pub use icons::cached_or_extract as icon_for; #[cfg(windows)] pub use native::{ attach_parent_console, copy_text, current_user_sid, cursor, digit_behind, front_is_ours, - front_window, is_in_front, keep_off_the_taskbar, let_whoever_opens_next_come_forward, - never_activates, notify_associations_changed, shift_is_down, source_app, take_the_keyboard, - work_area_at, write_line_to_pipe, + front_window, is_in_front, keep_off_the_taskbar, last_input, + let_whoever_opens_next_come_forward, never_activates, notify_associations_changed, + shift_is_down, source_app, take_the_keyboard, work_area_at, write_line_to_pipe, }; #[cfg(windows)] pub use registration::{ diff --git a/crates/linkunbound-win/src/native.rs b/crates/linkunbound-win/src/native.rs index 06500de..5730b30 100644 --- a/crates/linkunbound-win/src/native.rs +++ b/crates/linkunbound-win/src/native.rs @@ -26,18 +26,21 @@ use windows::Win32::System::DataExchange::{ }; use windows::Win32::System::Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock}; use windows::Win32::System::Ole::CF_UNICODETEXT; -use windows::Win32::System::Pipes::WaitNamedPipeW; +use windows::Win32::System::Pipes::{GetNamedPipeServerProcessId, WaitNamedPipeW}; use windows::Win32::System::Threading::{ AttachThreadInput, GetCurrentProcess, GetCurrentThreadId, OpenProcess, OpenProcessToken, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, }; -use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, SetFocus, VK_SHIFT, VkKeyScanW}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyState, GetLastInputInfo, LASTINPUTINFO, SetFocus, VK_SHIFT, VkKeyScanW, +}; use windows::Win32::UI::Shell::{SHCNE_ASSOCCHANGED, SHCNF_IDLIST, SHChangeNotify}; use windows::Win32::UI::Shell::{SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGetFileInfoW}; use windows::Win32::UI::WindowsAndMessaging::{ ASFW_ANY, AllowSetForegroundWindow, GWL_EXSTYLE, GetCursorPos, GetForegroundWindow, - GetWindowLongPtrW, GetWindowThreadProcessId, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, - SetForegroundWindow, SetWindowLongPtrW, SetWindowPos, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + GetWindowLongPtrW, GetWindowThreadProcessId, HWND_TOPMOST, IsHungAppWindow, SWP_NOACTIVATE, + SWP_NOMOVE, SWP_NOSIZE, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, }; use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, HICON, ICONINFO}; @@ -85,7 +88,8 @@ pub fn work_area_at(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> { /// A tool window is kept out of the taskbar and the alt-tab list. The picker is /// summoned by a click and dismissed by one: an entry standing there outlives -/// the window it names, with no icon of its own to show. +/// the window it names, with no icon of its own to show. Raised without being +/// activated: the front is asked for separately, once, by `take_the_keyboard`. pub fn keep_off_the_taskbar(window: isize) { let hwnd = HWND(window as *mut std::ffi::c_void); let style = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }; @@ -100,7 +104,7 @@ pub fn keep_off_the_taskbar(window: isize) { 0, 0, 0, - SWP_NOMOVE | SWP_NOSIZE, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, ) }; } @@ -154,6 +158,19 @@ pub fn front_window() -> isize { unsafe { GetForegroundWindow() }.0 as isize } +/// The tick of the last key or mouse event on this desktop, whoever it went to. Unchanged +/// since a moment ago, the person has touched nothing since. +#[must_use] +pub fn last_input() -> Option { + let mut info = LASTINPUTINFO { + cbSize: size_of::() as u32, + dwTime: 0, + }; + unsafe { GetLastInputInfo(&raw mut info) } + .as_bool() + .then_some(info.dwTime) +} + /// The account this process runs as, spelled the way a security descriptor string reads it. #[must_use] pub fn current_user_sid() -> Option { @@ -189,6 +206,10 @@ pub fn current_user_sid() -> Option { /// One line down a named pipe, opened for identification only: a server squatting the name /// before the resident took it would otherwise be handed this process's token to impersonate. /// True when the line was written; false when nobody answers at that name. +/// +/// The right to come forward belongs to the process the click started and dies with it, so the +/// server is handed it before the line, when this copy holds it at all: a launch from a process +/// that was not in front holds nothing to hand on, and the resident is left to its handshake. #[must_use] pub fn write_line_to_pipe(path: &str, line: &str) -> bool { let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); @@ -216,6 +237,10 @@ pub fn write_line_to_pipe(path: &str, line: &str) -> bool { } Err(_) => return false, }; + let mut resident = 0u32; + if unsafe { GetNamedPipeServerProcessId(handle, &raw mut resident) }.is_ok() { + let _ = unsafe { AllowSetForegroundWindow(resident) }; + } let body = format!("{line}\n"); let mut written = 0u32; let wrote = unsafe { WriteFile(handle, Some(body.as_bytes()), Some(&raw mut written), None) }; @@ -239,24 +264,31 @@ pub fn never_activates(window: isize) { } } -/// Windows refuses `SetForegroundWindow` to a process that is not already in -/// front. Attaching to the input queue of the window that is lifts the refusal; -/// this is the handshake every launcher performs. +/// Windows refuses `SetForegroundWindow` to a process that is not already in front, unless one +/// that is handed it the right — the copy the click started does, along with the link. When the +/// plain call is still refused, attaching to the input queue of the window in front lifts the +/// refusal; that is the handshake every launcher performs, and it fails against an elevated one. pub fn take_the_keyboard(window: isize) { let hwnd = HWND(window as *mut std::ffi::c_void); + let _ = unsafe { SetForegroundWindow(hwnd) }; + let _ = unsafe { SetFocus(Some(hwnd)) }; + if is_in_front(window) { + return; + } let front = unsafe { GetForegroundWindow() }; let ours = unsafe { GetCurrentThreadId() }; let theirs = unsafe { GetWindowThreadProcessId(front, None) }; - - if theirs != 0 && theirs != ours { - let _ = unsafe { AttachThreadInput(ours, theirs, true) }; + // Joined queues turn the other window's deactivation into a message waited on: against one + // that no longer pumps, the resident would hang with it. + if theirs == 0 || theirs == ours || unsafe { IsHungAppWindow(front) }.as_bool() { + return; + } + if unsafe { AttachThreadInput(ours, theirs, true) }.as_bool() { let _ = unsafe { SetForegroundWindow(hwnd) }; - let _ = unsafe { SetFocus(Some(hwnd)) }; let _ = unsafe { AttachThreadInput(ours, theirs, false) }; - } else { - let _ = unsafe { SetForegroundWindow(hwnd) }; - let _ = unsafe { SetFocus(Some(hwnd)) }; } + // Parting the queues can leave this thread's focus on nothing until the next click. + let _ = unsafe { SetFocus(Some(hwnd)) }; } /// Asked at the moment of the click rather than remembered from a key event: a modifier held From bd0e1592263286367f23919f9c27df4b421982e9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 21 Sep 2026 10:54:43 -0300 Subject: [PATCH 2/3] fix: the picker takes the front back for its first moment whatever the mouse did --- crates/linkunbound-shell/src/main.rs | 87 ++++++++-------------------- crates/linkunbound-win/src/lib.rs | 6 +- crates/linkunbound-win/src/native.rs | 17 +----- 3 files changed, 27 insertions(+), 83 deletions(-) diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index 1a0a124..f18d3d3 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -78,8 +78,8 @@ mod host { linkunbound_win::front_window() } - pub fn last_input() -> Option { - linkunbound_win::last_input() + pub fn takes_the_front_back() -> bool { + true } pub fn never_activates(window: isize) { @@ -157,9 +157,8 @@ mod host { linkunbound_mac::front_application() } - /// Launch Services brings the picker forward itself, link by link; nothing is taken back. - pub fn last_input() -> Option { - None + pub fn takes_the_front_back() -> bool { + false } pub fn never_activates(_window: isize) {} @@ -233,8 +232,8 @@ mod host { 0 } - pub fn last_input() -> Option { - None + pub fn takes_the_front_back() -> bool { + false } pub fn never_activates(_window: isize) {} @@ -665,10 +664,7 @@ struct Ui { taskbar_seen: Cell, /// The window the picker was shown over when it could not take the front. shown_over: Cell>, - /// The moment the picker was last put up, and the last input then: losing the front in that - /// first moment, to the application that sent the link and with nobody having touched - /// anything since, is answered by taking it back rather than by putting the picker away. - put_up: Cell>, + put_up: Cell>, #[cfg(target_os = "macos")] launch_decided: Cell, } @@ -676,26 +672,10 @@ struct Ui { /// How long a browser gets to bring its window up after being launched. const BROWSER_ARRIVES_WITHIN: Duration = Duration::from_millis(1500); -/// How long the picker keeps taking the front back after it was put up. const FRONT_SETTLES_WITHIN: Duration = Duration::from_millis(500); -#[derive(Clone, Copy)] -struct PutUp { - at: std::time::Instant, - last_input: Option, -} - -/// Some applications pull their window forward again right after handing a link over. That is -/// not the person walking away, and the front is taken back — only in the picker's first -/// moment, and only while nobody has touched mouse or keyboard since it came up: wherever the -/// person went themselves is where they meant to be. A system with no clock of the last input -/// never takes it back. -fn still_settling(put_up: Option, last_input: Option) -> bool { - put_up.is_some_and(|up| { - up.at.elapsed() < FRONT_SETTLES_WITHIN - && last_input.is_some() - && last_input == up.last_input - }) +fn still_settling(put_up: Option) -> bool { + put_up.is_some_and(|at| at.elapsed() < FRONT_SETTLES_WITHIN) } thread_local! { @@ -873,10 +853,8 @@ impl Ui { fn watch_focus(self: &Rc) { self.held_focus.set(false); self.shown_over.set(None); - self.put_up.set(Some(PutUp { - at: std::time::Instant::now(), - last_input: host::last_input(), - })); + self.put_up + .set(host::takes_the_front_back().then(std::time::Instant::now)); let weak = Rc::downgrade(self); self.watch.start( slint::TimerMode::Repeated, @@ -899,9 +877,10 @@ impl Ui { .set_private_on(ui.picker.get_pinned_private() || host::shift_is_down()); if host::is_in_front(ours) { ui.held_focus.set(true); - } else if still_settling(ui.put_up.get(), host::last_input()) + } else if still_settling(ui.put_up.get()) && host::clicked_in() == ui.shown.borrow().source { + ui.held_focus.set(false); host::take_the_keyboard(ours); } else if !ui.held_focus.get() { // An elevated window in front keeps a plain process out of its input, so @@ -1398,8 +1377,8 @@ fn main() -> Result<(), slint::PlatformError> { #[cfg(test)] mod tests { use super::{ - FRONT_SETTLES_WITHIN, Listed, PutUp, Shown, WAITING_ROOM, claims_the_window, link_from, - physical, rule_for, still_settling, with_icons, + FRONT_SETTLES_WITHIN, Listed, Shown, WAITING_ROOM, claims_the_window, link_from, physical, + rule_for, still_settling, with_icons, }; use linkunbound_core::Scope; use linkunbound_core::normalise; @@ -1477,38 +1456,18 @@ mod tests { } } - /// The front is taken back only in the picker's first moment and only while the person has - /// touched nothing since; past the moment, or after any input, losing the front is the - /// person walking away. Never put up, or no clock of the input to ask, nothing is taken. #[test] - fn the_front_is_taken_back_for_a_moment_while_nothing_was_touched() { + fn the_front_is_taken_back_only_in_the_first_moment() { let now = std::time::Instant::now(); - let fresh = PutUp { - at: now, - last_input: Some(7), - }; - assert!(still_settling(Some(fresh), Some(7))); - assert!(!still_settling(Some(fresh), Some(8)), "a key was pressed"); - assert!(!still_settling(Some(fresh), None), "no clock of the input"); - let stale = PutUp { - at: now - .checked_sub(FRONT_SETTLES_WITHIN * 2) - .expect("uptime beyond a second"), - last_input: Some(7), - }; - assert!( - !still_settling(Some(stale), Some(7)), - "the moment has passed" - ); - let unclocked = PutUp { - at: now, - last_input: None, - }; + assert!(still_settling(Some(now))); + let stale = now + .checked_sub(FRONT_SETTLES_WITHIN * 2) + .expect("uptime beyond a second"); + assert!(!still_settling(Some(stale)), "the moment has passed"); assert!( - !still_settling(Some(unclocked), None), - "the Mac never takes it back" + !still_settling(None), + "never put up, or a system that never takes it back" ); - assert!(!still_settling(None, Some(7))); } /// The screen is measured in physical pixels and the window is described in logical ones, so diff --git a/crates/linkunbound-win/src/lib.rs b/crates/linkunbound-win/src/lib.rs index a8ed138..a6f4c2c 100644 --- a/crates/linkunbound-win/src/lib.rs +++ b/crates/linkunbound-win/src/lib.rs @@ -16,9 +16,9 @@ pub use icons::cached_or_extract as icon_for; #[cfg(windows)] pub use native::{ attach_parent_console, copy_text, current_user_sid, cursor, digit_behind, front_is_ours, - front_window, is_in_front, keep_off_the_taskbar, last_input, - let_whoever_opens_next_come_forward, never_activates, notify_associations_changed, - shift_is_down, source_app, take_the_keyboard, work_area_at, write_line_to_pipe, + front_window, is_in_front, keep_off_the_taskbar, let_whoever_opens_next_come_forward, + never_activates, notify_associations_changed, shift_is_down, source_app, take_the_keyboard, + work_area_at, write_line_to_pipe, }; #[cfg(windows)] pub use registration::{ diff --git a/crates/linkunbound-win/src/native.rs b/crates/linkunbound-win/src/native.rs index 5730b30..4334a11 100644 --- a/crates/linkunbound-win/src/native.rs +++ b/crates/linkunbound-win/src/native.rs @@ -31,9 +31,7 @@ use windows::Win32::System::Threading::{ AttachThreadInput, GetCurrentProcess, GetCurrentThreadId, OpenProcess, OpenProcessToken, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, }; -use windows::Win32::UI::Input::KeyboardAndMouse::{ - GetKeyState, GetLastInputInfo, LASTINPUTINFO, SetFocus, VK_SHIFT, VkKeyScanW, -}; +use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, SetFocus, VK_SHIFT, VkKeyScanW}; use windows::Win32::UI::Shell::{SHCNE_ASSOCCHANGED, SHCNF_IDLIST, SHChangeNotify}; use windows::Win32::UI::Shell::{SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGetFileInfoW}; use windows::Win32::UI::WindowsAndMessaging::{ @@ -158,19 +156,6 @@ pub fn front_window() -> isize { unsafe { GetForegroundWindow() }.0 as isize } -/// The tick of the last key or mouse event on this desktop, whoever it went to. Unchanged -/// since a moment ago, the person has touched nothing since. -#[must_use] -pub fn last_input() -> Option { - let mut info = LASTINPUTINFO { - cbSize: size_of::() as u32, - dwTime: 0, - }; - unsafe { GetLastInputInfo(&raw mut info) } - .as_bool() - .then_some(info.dwTime) -} - /// The account this process runs as, spelled the way a security descriptor string reads it. #[must_use] pub fn current_user_sid() -> Option { From 0479d82dc8111af484d36bf7a16e3340b16ef033 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 21 Sep 2026 11:12:26 -0300 Subject: [PATCH 3/3] test: a link over a picker nobody answers takes its place, and the queue follows --- crates/linkunbound-shell/src/main.rs | 78 ++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index f18d3d3..12e6887 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -1377,12 +1377,15 @@ fn main() -> Result<(), slint::PlatformError> { #[cfg(test)] mod tests { use super::{ - FRONT_SETTLES_WITHIN, Listed, Shown, WAITING_ROOM, claims_the_window, link_from, physical, - rule_for, still_settling, with_icons, + FRONT_SETTLES_WITHIN, Listed, Shown, UI, Ui, WAITING_ROOM, claims_the_window, host, + link_from, next_in_line, physical, present, rule_for, still_settling, with_icons, }; use linkunbound_core::Scope; - use linkunbound_core::normalise; - use linkunbound_shell::Reaches; + use linkunbound_core::{Language, normalise}; + use linkunbound_shell::{Notice, Picker, Reaches}; + use slint::ComponentHandle; + use std::cell::{Cell, RefCell}; + use std::rc::Rc; #[cfg(target_os = "macos")] mod launch_services { @@ -1456,6 +1459,73 @@ mod tests { } } + fn headless_ui() -> Rc { + i_slint_backend_testing::init_no_event_loop(); + let ui = Rc::new(Ui { + picker: Picker::new().expect("a window"), + notice: Notice::new().expect("a notice"), + shown: Rc::new(RefCell::new(Shown::default())), + words: Cell::new(Language::English.strings()), + firing: RefCell::new(None), + tray: None, + watch: slint::Timer::default(), + countdown: slint::Timer::default(), + progress_watch: slint::Timer::default(), + looker: slint::Timer::default(), + put_away: RefCell::new(None), + prefs_seen: Cell::new(None), + held_focus: Cell::new(false), + launched_at: Cell::new(None), + light_seen: Cell::new(false), + taskbar_seen: Cell::new(false), + shown_over: Cell::new(None), + put_up: Cell::new(None), + #[cfg(target_os = "macos")] + launch_decided: Cell::new(false), + }); + UI.with_borrow_mut(|slot| *slot = Some(Rc::clone(&ui))); + ui + } + + #[test] + fn a_link_over_a_picker_nobody_answers_takes_its_place_and_the_queue_follows() { + let ui = headless_ui(); + let words = ui.words.get(); + + assert!(present( + &ui.picker, + &words, + &ui.shown, + "https://one.test/".to_owned() + )); + ui.watch_focus(); + assert!(ui.picker.window().is_visible()); + assert_eq!(ui.shown.borrow().url.as_deref(), Some("https://one.test/")); + assert_eq!(ui.put_up.get().is_some(), host::takes_the_front_back()); + + assert!(present( + &ui.picker, + &words, + &ui.shown, + "https://two.test/".to_owned() + )); + assert_eq!(ui.shown.borrow().url.as_deref(), Some("https://two.test/")); + assert!(ui.shown.borrow().waiting.is_empty()); + + let _ = ui.picker.hide(); + ui.shown + .borrow_mut() + .waiting + .push_back("https://three.test/".to_owned()); + next_in_line(&ui.picker, &words, &ui.shown); + assert!(ui.picker.window().is_visible()); + assert_eq!( + ui.shown.borrow().url.as_deref(), + Some("https://three.test/") + ); + assert!(ui.shown.borrow().waiting.is_empty()); + } + #[test] fn the_front_is_taken_back_only_in_the_first_moment() { let now = std::time::Instant::now();