diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 4793c79b8..8c14b4a10 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -20,6 +20,9 @@ pub const PANEL_H: f64 = 320.0; /// Logical size of the invisible strip that wakes the island when it is hidden. pub const STRIP_W: f64 = 240.0; pub const STRIP_H: f64 = 6.0; +/// Logical height the wake strip grows to while something is being dragged, so +/// a file only has to reach the top-centre of the screen, not a 6 px line. +const DROP_ZONE_H: f64 = 150.0; pub const WINDOW_LABEL: &str = "island"; @@ -300,6 +303,12 @@ pub fn screen_info(app: &AppHandle, pref: &str) -> ScreenInfo { /// Places and sizes the window. `collapsed` picks the wake strip instead of the panel. pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { + let (lw, lh) = if collapsed { (STRIP_W, STRIP_H) } else { (PANEL_W, PANEL_H) }; + place(app, pref, lw, lh); +} + +/// Centres a `lw` × `lh` logical window on the top edge of the island's monitor. +fn place(app: &AppHandle, pref: &str, lw: f64, lh: f64) { let Some(win) = window(app) else { return }; let Some(m) = target_monitor(app, pref) else { return }; @@ -307,7 +316,6 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let mp = *m.position(); let ms = *m.size(); - let (lw, lh) = if collapsed { (STRIP_W, STRIP_H) } else { (PANEL_W, PANEL_H) }; let pw = (lw * scale).round().max(1.0) as u32; let ph = (lh * scale).round().max(1.0) as u32; let x = mp.x + (ms.width as i32 - pw as i32) / 2; @@ -327,6 +335,85 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { // Moving across displays can rescale the window: re-assert the physical size. let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_always_on_top(true); + platform::raise_topmost(&win); +} + +/// Grows the hidden island's wake strip into a drop zone while the left button is +/// held, and shrinks it back on release. +/// +/// OLE only delivers a dragged file to the window under the cursor, and the +/// strip is a 6 px line at the very top of the screen: nobody drops a file +/// there. While the island is hidden and a drag may be in flight, the strip +/// becomes an invisible zone as wide as the panel; the file entering it wakes +/// the island exactly as the strip would have. One GetAsyncKeyState every 50 ms +/// while hidden; window moves and resizes are left alone. +pub fn spawn_drop_zone_watch(app: AppHandle, gate: Arc) { + if !platform::CURSOR_POLL { + return; // Linux: no global button state to watch. + } + std::thread::spawn(move || { + let mut zone_up = false; + let mut was_down = false; + // Set when the press began outside the zone: only such a press can be a + // drag *into* it. A click inside the zone must never have the zone pop + // up under it, or the button release would land on us and be lost. + let mut armed_at: Option<(f64, f64)> = None; + loop { + std::thread::sleep(Duration::from_millis(50)); + if !gate.collapsed.load(Ordering::Relaxed) { + zone_up = false; // the island took over + was_down = false; + armed_at = None; + continue; + } + let down = left_button_down(); + if !down && !was_down { + continue; // the common case: nothing held, nothing to do + } + let pref = app + .try_state::() + .map(|s| s.settings.lock().unwrap().screen.clone()) + .unwrap_or_else(|| "primary".into()); + + if down && !was_down { + armed_at = cursor_physical().filter(|&(x, y)| { + !drop_zone_rect(&app, &pref).is_some_and(|(l, t, r, b)| { + x >= l && x < r && y >= t && y < b + }) + }); + } + was_down = down; + + if down { + let moved = match (armed_at, cursor_physical()) { + (Some((ax, ay)), Some((cx, cy))) => (cx - ax).hypot(cy - ay) >= 8.0, + _ => false, + }; + if !zone_up && moved && !platform::moving_window() { + place(&app, &pref, PANEL_W, DROP_ZONE_H); + zone_up = true; + } + } else { + // Released without the file entering: back to the strip, unless + // the island opened in the meantime. + if zone_up && gate.collapsed.load(Ordering::Relaxed) { + apply_geometry(&app, &pref, true); + } + zone_up = false; + armed_at = None; + } + } + }); +} + +/// The drop zone in physical screen pixels: (left, top, right, bottom). +fn drop_zone_rect(app: &AppHandle, pref: &str) -> Option<(f64, f64, f64, f64)> { + let m = target_monitor(app, pref)?; + let scale = m.scale_factor(); + let (mp, ms) = (*m.position(), *m.size()); + let w = PANEL_W * scale; + let left = mp.x as f64 + (ms.width as f64 - w) / 2.0; + Some((left, mp.y as f64, left + w, mp.y as f64 + DROP_ZONE_H * scale)) } /// Position, size and scale of the monitor the island lives on. Any change here @@ -346,7 +433,6 @@ fn current_screen_key(app: &AppHandle) -> Option<(i32, i32, u32, u32, u64)> { /// visible. Parked on a condvar the rest of the time. pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { std::thread::spawn(move || { - let mut was_down = false; // Remembered across wakes so a display change while hidden is noticed the // moment the island comes back. let mut last_screen: Option<(i32, i32, u32, u32, u64)> = None; @@ -357,6 +443,7 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { loop { gate.wait_until_active(); let mut last = (f64::MIN, f64::MIN); + let mut was_down = false; let mut ticks: u32 = 0; while gate.is_active() { std::thread::sleep(Duration::from_millis(period)); @@ -388,6 +475,22 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { Ok(s) => (s.width as f64 / scale, s.height as f64 / scale), Err(_) => (PANEL_W, PANEL_H), }; + // A file drag the page saw leave can end anywhere, and HTML5 has + // no event for "dropped somewhere else". Tell the page when the + // button goes up — before the "cursor didn't move" shortcut, since + // a release needs no movement. + let down = left_button_down(); + if down && !was_down { + // A press may be the start of a drag: make sure the drop target is + // ours before the file arrives. + let handle = app.clone(); + let _ = app.run_on_main_thread(move || platform::unblock_webview_drops(&handle)); + } + if was_down && !down { + let _ = app.emit_to(WINDOW_LABEL, "pointer-released", ()); + } + was_down = down; + if (x - last.0).abs() < 1.0 && (y - last.1).abs() < 1.0 { continue; } @@ -410,14 +513,6 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { // registered destinations whatever ignoresMouseEvents says. So while // a button is held anywhere over the panel, the whole panel takes // the mouse, which also makes the drop zone as forgiving as the Mac's. - // A press may be the start of a drag: make sure the drop target is - // ours before the file arrives. - let down = left_button_down(); - if down && !was_down { - let handle = app.clone(); - let _ = app.run_on_main_thread(move || platform::unblock_webview_drops(&handle)); - } - was_down = down; let dragging = down && x >= 0.0 @@ -425,7 +520,13 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { && y >= 0.0 && y <= size.1; - let accept = on_island || dragging; + // The wake strip always takes the mouse (hover to wake, drop a + // file). Checked here and not only in set_collapsed: a tick that + // was asleep while the island collapsed would otherwise compute + // "not on the island" for the new strip and make it click-through + // again — invisible to hovers and to dragged files alike. + let collapsed = gate.collapsed.load(Ordering::Relaxed); + let accept = on_island || dragging || collapsed; if gate.ignoring.load(Ordering::Relaxed) == accept { gate.ignoring.store(!accept, Ordering::Relaxed); let _ = win.set_ignore_cursor_events(!accept); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 3667b5e88..e738d6374 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -142,9 +142,10 @@ fn set_collapsed(app: AppHandle, shared: State, collapsed: bool) { let pref = shared.settings.lock().unwrap().screen.clone(); shared.gate.collapsed.store(collapsed, Ordering::Relaxed); island::apply_geometry(&app, &pref, collapsed); + // Stop the cursor poll first so no in-flight tick can undo what follows. + shared.gate.set_active(!collapsed); // The wake strip must always take the mouse, and a resize invalidates the flag. island::refresh_click_through(&app, &shared.gate); - shared.gate.set_active(!collapsed); platform::set_pointer_watch(!collapsed); } @@ -714,6 +715,7 @@ pub fn run() { .setup(move |app| { let handle = app.handle().clone(); tray::build(&handle)?; + platform::keep_topmost(&handle); // Before the island: see create_settings_window. create_settings_window(&handle); // Same rule for Mochi's desktop window. @@ -741,6 +743,7 @@ pub fn run() { } gate.set_active(true); island::spawn_cursor_poll(handle.clone(), gate.clone()); + island::spawn_drop_zone_watch(handle.clone(), gate.clone()); log::line(format!("--- Coucou {} started ---", env!("CARGO_PKG_VERSION"))); hooks::ensure_hook_exe(&handle); diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index 38dff741d..6e7758549 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -779,6 +779,15 @@ pub fn layer_display(island: &WebviewWindow, mochi: &WebviewWindow) -> Option<(f Some((g.width() as f64, g.height() as f64)) } +pub fn moving_window() -> bool { + false +} + +/// Layer-shell keeps the island above the other surfaces already. +pub fn raise_topmost(_win: &WebviewWindow) {} + +pub fn keep_topmost(_app: &AppHandle) {} + #[cfg(test)] mod tests { use super::*; diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 8e7efc07c..2b6372dd9 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -316,6 +316,58 @@ unsafe extern "system" fn revoke_render_widget(hwnd: HWND, _: LPARAM) -> BOOL { true.into() } +/// True while the foreground window is being moved or resized by its title bar +/// or border — a held button that is not a drag the island should answer. +pub fn moving_window() -> bool { + use ::windows::Win32::UI::WindowsAndMessaging::{GetGUIThreadInfo, GUITHREADINFO, GUI_INMOVESIZE}; + let mut info = GUITHREADINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { GetGUIThreadInfo(0, &mut info).is_ok() && (info.flags.0 & GUI_INMOVESIZE.0) != 0 } +} + +/// Puts the island back on top of the other always-on-top windows. +/// +/// Every topmost window shares one band, and whichever was raised last wins. +/// A full-width status bar along the top of the screen is often topmost too, and +/// then covers the strip that wakes the island and takes dropped files. Raising the island +/// again hands that edge back to it. Never activates, never moves or resizes. +pub fn raise_topmost(win: &WebviewWindow) { + let Some(hwnd) = hwnd_of(win) else { return }; + raise_hwnd(hwnd); +} + +fn raise_hwnd(hwnd: HWND) { + use ::windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, HWND_TOPMOST, SWP_ASYNCWINDOWPOS, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + }; + unsafe { + let _ = SetWindowPos( + hwnd, + Some(HWND_TOPMOST), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS, + ); + } +} + +/// Such bars can raise themselves again later (on a redraw, or coming back from +/// a full-screen app), so the island checks back every couple of seconds. One +/// cheap call, no wake-up of the page. +pub fn keep_topmost(app: &AppHandle) { + let Some(win) = app.get_webview_window(crate::island::WINDOW_LABEL) else { return }; + let Some(hwnd) = hwnd_of(&win) else { return }; + let raw = hwnd.0 as isize; // HWND isn't Send; the handle itself is just a number + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + raise_hwnd(HWND(raw as *mut _)); + }); +} + /// WS_EX_NOACTIVATE keeps clicks from stealing focus; WS_EX_TOOLWINDOW keeps the /// island out of Alt-Tab. pub fn make_non_activating(win: &WebviewWindow) { diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 43279c29e..6fda810ac 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -2,7 +2,7 @@ // Mirrors IslandRootView.swift + IslandWindowController.swift. import { Tracked, Spring, clamp } from "../core/anim"; -import { Bridge, IS_TAURI, onDragDrop } from "../core/bridge"; +import { Bridge, IS_TAURI, onDragDrop, onEvent } from "../core/bridge"; import { EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, @@ -43,6 +43,8 @@ const modeOrder = (m: IslandMode) => (m === "hidden" ? 0 : m === "compact" ? 1 : export class Island { readonly fsm = new IslandStateMachine(); + /** A file drag entered the island since the mouse button last went up. */ + private fileDragSeen = false; /** Mochi on the desktop: his life cycle and the drag out of the island. */ readonly desktop: DesktopLink; @@ -504,7 +506,11 @@ export class Island { // enterZone must run before the island expands, so the sequence is // already active by the time the view becomes `upload`. UploadSeq.enterZone(State.mouseInIsland.x, State.mouseInIsland.y); + this.fileDragSeen = true; this.alert("upload"); + // Waking a hidden island passes through the home view, and leaving the + // drop views on the way switches the sequence off: switch it back on. + if (!UploadSeq.isActive) UploadSeq.enterZone(State.mouseInIsland.x, State.mouseInIsland.y); break; } case "leave": { @@ -530,6 +536,22 @@ export class Island { } } + /** + * The mouse button went up. If a file drag had already left the island + * without being dropped, it ended somewhere else: close the drop view rather + * than wait for a file that is never coming. + */ + private onPointerReleased() { + // Only a release that ends a file drag counts: the "+" tab opens the same + // view with a plain click, and that release must leave it open. + const wasFileDrag = this.fileDragSeen; + this.fileDragSeen = false; + if (!wasFileDrag || State.fileDragOver || State.view !== "upload" || UploadSeq.dropped) return; + void Bridge.log("drag ended outside the island"); + this.engine.animateMorph(0); + this.setView(State.defaultView()); + } + /** * Mochi eats the file. Nothing here waits on the file system: the copy into * the inbox runs in the background and swaps the path in when it lands, so a @@ -759,6 +781,7 @@ export class Island { }); void onDragDrop((e) => this.onDragDrop(e)); + void onEvent("pointer-released", () => this.onPointerReleased()); // Outside Tauri (plain browser) drive the cursor from DOM events so the // island can be inspected with `npm run dev`. diff --git a/windows/src/upload/sequence.ts b/windows/src/upload/sequence.ts index 1374c849d..63b375285 100644 --- a/windows/src/upload/sequence.ts +++ b/windows/src/upload/sequence.ts @@ -278,6 +278,9 @@ class UploadSequence { performDrop(uploadDuration: number) { this.uploadDuration = uploadDuration; this.dropWall = this.now(); + // A drop always runs the sequence, even if something switched it off while + // the file was hovering — otherwise the bar sits at 0 % for good. + this.isActive = true; // Restart the canonical post-drop timeline however long the user hovered. // Spring state (position and velocity) is deliberately preserved. this.t = USC.T_DROP;