From a5ef9a3a2602e83639b329f947b584acab267693 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 20:55:10 +0530 Subject: [PATCH 01/28] Windows: scaffold Rust port with mouse hook, layered overlay, laser dot and ink --- windows/.gitignore | 1 + windows/Cargo.lock | 304 +++++++++++++++++++++++++++++++++++++++ windows/Cargo.toml | 8 ++ windows/src/cursor.rs | 39 ++++++ windows/src/hook.rs | 73 ++++++++++ windows/src/main.rs | 312 +++++++++++++++++++++++++++++++++++++++++ windows/src/scene.rs | 132 +++++++++++++++++ windows/src/surface.rs | 63 +++++++++ 8 files changed, 932 insertions(+) create mode 100644 windows/.gitignore create mode 100644 windows/Cargo.lock create mode 100644 windows/Cargo.toml create mode 100644 windows/src/cursor.rs create mode 100644 windows/src/hook.rs create mode 100644 windows/src/main.rs create mode 100644 windows/src/scene.rs create mode 100644 windows/src/surface.rs diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1 @@ +/target diff --git a/windows/Cargo.lock b/windows/Cargo.lock new file mode 100644 index 0000000..33b8e51 --- /dev/null +++ b/windows/Cargo.lock @@ -0,0 +1,304 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyhand" +version = "0.1.0" +dependencies = [ + "tiny-skia", + "windows", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/windows/Cargo.toml b/windows/Cargo.toml new file mode 100644 index 0000000..1666a8e --- /dev/null +++ b/windows/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "tinyhand" +version = "0.1.0" +edition = "2024" + +[dependencies] +tiny-skia = "0.12.0" +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi"] } diff --git a/windows/src/cursor.rs b/windows/src/cursor.rs new file mode 100644 index 0000000..6d1c6bc --- /dev/null +++ b/windows/src/cursor.rs @@ -0,0 +1,39 @@ +use windows::Win32::Foundation::HINSTANCE; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; + +const IDS: [SYSTEM_CURSOR_ID; 13] = [ + OCR_NORMAL, + OCR_IBEAM, + OCR_WAIT, + OCR_CROSS, + OCR_UP, + OCR_SIZENWSE, + OCR_SIZENESW, + OCR_SIZEWE, + OCR_SIZENS, + OCR_SIZEALL, + OCR_NO, + OCR_HAND, + OCR_APPSTARTING, +]; + +pub fn hide() { + let and = [0xFFu8; 128]; + let xor = [0u8; 128]; + unsafe { + let hinst = GetModuleHandleW(None).map(|m| HINSTANCE(m.0)).ok(); + for id in IDS { + if let Ok(c) = CreateCursor(hinst, 0, 0, 32, 32, and.as_ptr().cast(), xor.as_ptr().cast()) { + let _ = SetSystemCursor(c, id); + } + } + } +} + +/// Reloads the user's cursors from settings. Safe to call at any time. +pub fn restore() { + unsafe { + let _ = SystemParametersInfoW(SPI_SETCURSORS, 0, None, SPIF_SENDCHANGE); + } +} diff --git a/windows/src/hook.rs b/windows/src/hook.rs new file mode 100644 index 0000000..26a6af6 --- /dev/null +++ b/windows/src/hook.rs @@ -0,0 +1,73 @@ +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, Ordering::Relaxed}; + +use windows::Win32::Foundation::*; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; + +pub const WM_TH_DOWN: u32 = WM_APP + 1; +pub const WM_TH_UP: u32 = WM_APP + 2; + +pub static ACTIVE: AtomicBool = AtomicBool::new(false); +static TARGET: AtomicIsize = AtomicIsize::new(0); +static SWALLOWED: AtomicBool = AtomicBool::new(false); +static PASS: [AtomicI32; 4] = [const { AtomicI32::new(0) }; 4]; + +pub fn set_passthrough(r: RECT) { + for (slot, v) in PASS.iter().zip([r.left, r.top, r.right, r.bottom]) { + slot.store(v, Relaxed); + } +} + +fn in_passthrough(p: POINT) -> bool { + p.x >= PASS[0].load(Relaxed) && p.y >= PASS[1].load(Relaxed) && p.x < PASS[2].load(Relaxed) && p.y < PASS[3].load(Relaxed) +} + +fn post(msg: u32) { + let hwnd = HWND(TARGET.load(Relaxed) as *mut c_void); + unsafe { + let _ = PostMessageW(Some(hwnd), msg, WPARAM(0), LPARAM(0)); + } +} + +unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { + if code == HC_ACTION as i32 && ACTIVE.load(Relaxed) { + let info = unsafe { &*(lp.0 as *const MSLLHOOKSTRUCT) }; + if info.flags & LLMHF_INJECTED == 0 { + match wp.0 as u32 { + WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN => { + if !in_passthrough(info.pt) { + SWALLOWED.store(true, Relaxed); + post(WM_TH_DOWN); + return LRESULT(1); + } + SWALLOWED.store(false, Relaxed); + } + WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP => { + if SWALLOWED.swap(false, Relaxed) { + post(WM_TH_UP); + return LRESULT(1); + } + } + _ => {} + } + } + } + unsafe { CallNextHookEx(None, code, wp, lp) } +} + +/// Runs the hook on its own thread so the callback never waits on rendering. +pub fn start(target: HWND) { + TARGET.store(target.0 as isize, Relaxed); + std::thread::spawn(|| unsafe { + let hinst = GetModuleHandleW(None).map(|m| HINSTANCE(m.0)).ok(); + if SetWindowsHookExW(WH_MOUSE_LL, Some(proc), hinst, 0).is_err() { + return; + } + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + }); +} diff --git a/windows/src/main.rs b/windows/src/main.rs new file mode 100644 index 0000000..17a3989 --- /dev/null +++ b/windows/src/main.rs @@ -0,0 +1,312 @@ +#![windows_subsystem = "windows"] + +mod cursor; +mod hook; +mod scene; +mod surface; + +use std::cell::RefCell; +use std::sync::atomic::Ordering::Relaxed; +use std::time::Instant; + +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::UpdateWindow; +use windows::Win32::Media::timeBeginPeriod; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::HiDpi::*; +use windows::Win32::UI::Input::KeyboardAndMouse::*; +use windows::Win32::UI::Shell::*; +use windows::Win32::UI::WindowsAndMessaging::*; +use windows::core::{PCWSTR, w}; + +use scene::{Ink, draw_laser}; +use surface::Surface; + +const TIMER: usize = 1; +const FRAME_MS: u32 = 8; +const HK_TOGGLE: i32 = 1; +const HK_CLEAR: i32 = 2; +const HK_ESC: i32 = 3; +const WM_TRAY: u32 = WM_APP + 10; +const CMD_TOGGLE: usize = 1; +const CMD_QUIT: usize = 2; +const DRAG_THRESHOLD: f32 = 4.0; + +struct App { + hwnd: HWND, + surface: Surface, + ink: Ink, + active: bool, + held: bool, + down_at: (f32, f32), + cursor: (f32, f32), + press: f32, + dirty: bool, + last_frame: Instant, + last_taskbar: Instant, +} + +thread_local! { + static APP: RefCell> = const { RefCell::new(None) }; +} + +impl App { + fn new(hwnd: HWND, w: i32, h: i32) -> Self { + let mut ink = Ink::new(); + ink.width = (h as f32 * 0.0055).max(3.0); + Self { + hwnd, + surface: Surface::new(w, h), + ink, + active: false, + held: false, + down_at: (0.0, 0.0), + cursor: (0.0, 0.0), + press: 0.0, + dirty: true, + last_frame: Instant::now(), + last_taskbar: Instant::now() - std::time::Duration::from_secs(10), + } + } + + fn toggle(&mut self) { + if self.active { + self.deactivate(); + } else { + self.activate(); + } + } + + fn activate(&mut self) { + self.active = true; + self.refresh_taskbar(); + hook::ACTIVE.store(true, Relaxed); + cursor::hide(); + unsafe { + let _ = RegisterHotKey(Some(self.hwnd), HK_ESC, MOD_NOREPEAT, VK_ESCAPE.0 as u32); + let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); + SetTimer(Some(self.hwnd), TIMER, FRAME_MS, None); + } + self.last_frame = Instant::now(); + self.dirty = true; + self.tick(); + } + + fn deactivate(&mut self) { + self.active = false; + self.held = false; + hook::ACTIVE.store(false, Relaxed); + self.ink.clear(); + cursor::restore(); + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER); + let _ = UnregisterHotKey(Some(self.hwnd), HK_ESC); + let _ = ShowWindow(self.hwnd, SW_HIDE); + } + } + + fn refresh_taskbar(&mut self) { + self.last_taskbar = Instant::now(); + let mut r = RECT::default(); + unsafe { + if let Ok(bar) = FindWindowW(w!("Shell_TrayWnd"), PCWSTR::null()) { + let _ = GetWindowRect(bar, &mut r); + } + } + hook::set_passthrough(r); + } + + fn tick(&mut self) { + if !self.active { + return; + } + if self.last_taskbar.elapsed().as_millis() > 500 { + self.refresh_taskbar(); + } + let dt = self.last_frame.elapsed().as_secs_f32(); + self.last_frame = Instant::now(); + + let mut p = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut p); + } + let at = (p.x as f32, p.y as f32); + if at != self.cursor { + self.cursor = at; + self.dirty = true; + } + + if self.held { + if self.ink.is_drawing() { + self.ink.extend(at); + } else if (at.0 - self.down_at.0).hypot(at.1 - self.down_at.1) >= DRAG_THRESHOLD { + self.ink.begin(self.down_at); + self.ink.extend(at); + } + } else if self.press > 0.002 { + self.press *= 0.82f32.powf(dt / 0.016); + self.dirty = true; + } + + if self.dirty || self.ink.alive() { + self.render(); + self.dirty = false; + } + } + + fn render(&mut self) { + let pix = &mut self.surface.pix; + pix.fill(tiny_skia::Color::TRANSPARENT); + self.ink.draw(pix); + let h = self.surface.size().1 as f32; + let r = (h * 0.0038).max(4.0) * (1.0 + 0.2 * self.press); + draw_laser(&mut self.surface.pix, self.cursor, r); + self.surface.present(self.hwnd); + } + + fn on_message(&mut self, hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> Option { + match msg { + WM_TIMER => self.tick(), + hook::WM_TH_DOWN => { + self.held = true; + self.press = 1.0; + self.down_at = self.cursor; + self.dirty = true; + } + hook::WM_TH_UP => { + self.held = false; + self.ink.end(); + self.dirty = true; + } + WM_HOTKEY => match wp.0 as i32 { + HK_TOGGLE => self.toggle(), + HK_CLEAR => { + self.ink.clear(); + self.dirty = true; + } + HK_ESC => self.deactivate(), + _ => {} + }, + WM_TRAY => match (lp.0 as u32) & 0xFFFF { + WM_LBUTTONUP => self.toggle(), + WM_RBUTTONUP => self.tray_menu(hwnd), + _ => {} + }, + WM_COMMAND => match wp.0 & 0xFFFF { + CMD_TOGGLE => self.toggle(), + CMD_QUIT => unsafe { PostQuitMessage(0) }, + _ => {} + }, + _ => return None, + } + Some(LRESULT(0)) + } + + fn tray_menu(&self, hwnd: HWND) { + unsafe { + let Ok(menu) = CreatePopupMenu() else { return }; + let label = if self.active { w!("Turn pointer off") } else { w!("Turn pointer on") }; + let _ = AppendMenuW(menu, MF_STRING, CMD_TOGGLE, label); + let _ = AppendMenuW(menu, MF_STRING, CMD_QUIT, w!("Quit TinyHand")); + let mut p = POINT::default(); + let _ = GetCursorPos(&mut p); + let _ = SetForegroundWindow(hwnd); + let _ = TrackPopupMenu(menu, TPM_RIGHTBUTTON, p.x, p.y, Some(0), hwnd, None); + let _ = DestroyMenu(menu); + } + } +} + +unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + // try_borrow_mut: ShowWindow and friends re-enter this proc. + let handled = APP.with(|cell| { + let mut slot = cell.try_borrow_mut().ok()?; + slot.as_mut()?.on_message(hwnd, msg, wp, lp) + }); + handled.unwrap_or_else(|| unsafe { DefWindowProcW(hwnd, msg, wp, lp) }) +} + +fn add_tray(hwnd: HWND) { + let mut nid = NOTIFYICONDATAW { + cbSize: size_of::() as u32, + hWnd: hwnd, + uID: 1, + uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP, + uCallbackMessage: WM_TRAY, + hIcon: unsafe { LoadIconW(None, IDI_APPLICATION).unwrap_or_default() }, + ..Default::default() + }; + for (d, s) in nid.szTip.iter_mut().zip("TinyHand".encode_utf16()) { + *d = s; + } + unsafe { + let _ = Shell_NotifyIconW(NIM_ADD, &nid); + } +} + +fn remove_tray(hwnd: HWND) { + let nid = NOTIFYICONDATAW { cbSize: size_of::() as u32, hWnd: hwnd, uID: 1, ..Default::default() }; + unsafe { + let _ = Shell_NotifyIconW(NIM_DELETE, &nid); + } +} + +fn main() { + cursor::restore(); + let default_panic = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + cursor::restore(); + default_panic(info); + })); + + unsafe { + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + timeBeginPeriod(1); + + let hinst = HINSTANCE(GetModuleHandleW(None).expect("module").0); + let class = w!("TinyHandOverlay"); + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: class, + ..Default::default() + }); + + let (w, h) = (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)); + let hwnd = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + class, + w!("TinyHand"), + WS_POPUP, + 0, + 0, + w, + h, + None, + None, + Some(hinst), + None, + ) + .expect("overlay window"); + let _ = UpdateWindow(hwnd); + + APP.with(|a| *a.borrow_mut() = Some(App::new(hwnd, w, h))); + hook::start(hwnd); + add_tray(hwnd); + + let mods = MOD_CONTROL | MOD_ALT | MOD_NOREPEAT; + let _ = RegisterHotKey(Some(hwnd), HK_TOGGLE, mods, 'H' as u32); + let _ = RegisterHotKey(Some(hwnd), HK_CLEAR, mods, 'X' as u32); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + hook::ACTIVE.store(false, Relaxed); + cursor::restore(); + remove_tray(hwnd); + } +} diff --git a/windows/src/scene.rs b/windows/src/scene.rs new file mode 100644 index 0000000..ae00234 --- /dev/null +++ b/windows/src/scene.rs @@ -0,0 +1,132 @@ +use std::time::Instant; + +use tiny_skia::*; + +const FADE_DELAY: f32 = 3.0; +const FADE_FOR: f32 = 0.6; + +struct Stroke { + pts: Vec<(f32, f32)>, + ended: Option, +} + +impl Stroke { + fn alpha(&self) -> f32 { + match self.ended { + None => 1.0, + Some(t) => 1.0 - ((t.elapsed().as_secs_f32() - FADE_DELAY) / FADE_FOR).clamp(0.0, 1.0), + } + } + + fn path(&self) -> Option { + let mut pb = PathBuilder::new(); + let first = self.pts.first()?; + pb.move_to(first.0, first.1); + if self.pts.len() == 1 { + pb.line_to(first.0, first.1); + } + for w in self.pts.windows(2) { + let (a, b) = (w[0], w[1]); + pb.quad_to(a.0, a.1, (a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + } + if self.pts.len() > 1 { + let l = self.pts[self.pts.len() - 1]; + pb.line_to(l.0, l.1); + } + pb.finish() + } +} + +pub struct Ink { + strokes: Vec, + drawing: bool, + pub color: Color, + pub width: f32, +} + +impl Ink { + pub fn new() -> Self { + Self { strokes: vec![], drawing: false, color: Color::from_rgba8(255, 41, 31, 255), width: 4.0 } + } + + pub fn is_drawing(&self) -> bool { + self.drawing + } + + pub fn alive(&self) -> bool { + !self.strokes.is_empty() + } + + pub fn begin(&mut self, p: (f32, f32)) { + self.strokes.push(Stroke { pts: vec![p], ended: None }); + self.drawing = true; + } + + pub fn extend(&mut self, p: (f32, f32)) { + let Some(s) = self.strokes.last_mut().filter(|_| self.drawing) else { return }; + let l = s.pts[s.pts.len() - 1]; + if (p.0 - l.0).hypot(p.1 - l.1) >= 1.5 { + s.pts.push(p); + } + } + + pub fn end(&mut self) { + if let Some(s) = self.strokes.last_mut().filter(|_| self.drawing) { + s.ended = Some(Instant::now()); + } + self.drawing = false; + } + + pub fn clear(&mut self) { + self.strokes.clear(); + self.drawing = false; + } + + pub fn draw(&mut self, pix: &mut Pixmap) { + self.strokes.retain(|s| s.alpha() > 0.0); + let stroke = tiny_skia::Stroke { width: self.width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + for s in &self.strokes { + let Some(path) = s.path() else { continue }; + let mut paint = Paint::default(); + let mut c = self.color; + c.set_alpha(s.alpha()); + paint.set_color(c); + paint.anti_alias = true; + pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } +} + +pub fn draw_laser(pix: &mut Pixmap, at: (f32, f32), r: f32) { + let red = Color::from_rgba8(255, 41, 31, 255); + let glow = RadialGradient::new( + Point::from_xy(at.0, at.1), + 0.0, + Point::from_xy(at.0, at.1), + r * 3.2, + vec![ + GradientStop::new(0.0, Color::from_rgba8(255, 41, 31, 150)), + GradientStop::new(1.0, Color::from_rgba8(255, 41, 31, 0)), + ], + SpreadMode::Pad, + Transform::identity(), + ); + let mut paint = Paint::default(); + paint.anti_alias = true; + if let Some(shader) = glow { + paint.shader = shader; + if let Some(rect) = Rect::from_xywh(at.0 - r * 3.2, at.1 - r * 3.2, r * 6.4, r * 6.4) { + pix.fill_rect(rect, &paint, Transform::identity(), None); + } + } + let mut dot = |radius: f32, color: Color| { + let mut p = Paint::default(); + p.set_color(color); + p.anti_alias = true; + if let Some(path) = PathBuilder::from_circle(at.0, at.1, radius) { + pix.fill_path(&path, &p, FillRule::Winding, Transform::identity(), None); + } + }; + dot(r, red); + dot(r * 0.45, Color::from_rgba8(255, 220, 209, 242)); +} diff --git a/windows/src/surface.rs b/windows/src/surface.rs new file mode 100644 index 0000000..ae7cbe2 --- /dev/null +++ b/windows/src/surface.rs @@ -0,0 +1,63 @@ +use std::ffi::c_void; +use std::ptr::null_mut; + +use tiny_skia::Pixmap; +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::UI::WindowsAndMessaging::*; + +/// Software canvas blitted to a layered window with per-pixel alpha. +pub struct Surface { + pub pix: Pixmap, + w: i32, + h: i32, + dc: HDC, + bits: *mut u32, +} + +impl Surface { + pub fn new(w: i32, h: i32) -> Self { + unsafe { + let screen = GetDC(None); + let dc = CreateCompatibleDC(Some(screen)); + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: w, + biHeight: -h, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits: *mut c_void = null_mut(); + let bmp = CreateDIBSection(Some(screen), &bmi, DIB_RGB_COLORS, &mut bits, None, 0).expect("DIB section"); + SelectObject(dc, bmp.into()); + ReleaseDC(None, screen); + Self { pix: Pixmap::new(w as u32, h as u32).expect("pixmap"), w, h, dc, bits: bits.cast() } + } + } + + pub fn size(&self) -> (i32, i32) { + (self.w, self.h) + } + + pub fn present(&mut self, hwnd: HWND) { + let src = self.pix.data(); + let dst = unsafe { std::slice::from_raw_parts_mut(self.bits.cast::(), src.len()) }; + for (d, s) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { + d[0] = s[2]; + d[1] = s[1]; + d[2] = s[0]; + d[3] = s[3]; + } + let blend = BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: 255, AlphaFormat: AC_SRC_ALPHA as u8 }; + let origin = POINT::default(); + let size = SIZE { cx: self.w, cy: self.h }; + unsafe { + let _ = UpdateLayeredWindow(hwnd, None, Some(&origin), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&blend), ULW_ALPHA); + } + } +} From d666e50a460831f8a8a853fd5ab0b6dd5504aa00 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:04:04 +0530 Subject: [PATCH 02/28] Windows: vsync render thread, dirty-rect updates, persistent ink layer, no cursor broadcast --- windows/Cargo.toml | 2 +- windows/src/cursor.rs | 4 +- windows/src/hook.rs | 69 ++++++++++---- windows/src/main.rs | 173 +++++++++++------------------------ windows/src/renderer.rs | 198 ++++++++++++++++++++++++++++++++++++++++ windows/src/scene.rs | 181 +++++++++++++++++++++++++++--------- windows/src/surface.rs | 59 ++++++++---- 7 files changed, 481 insertions(+), 205 deletions(-) create mode 100644 windows/src/renderer.rs diff --git a/windows/Cargo.toml b/windows/Cargo.toml index 1666a8e..ae11ba6 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -5,4 +5,4 @@ edition = "2024" [dependencies] tiny-skia = "0.12.0" -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security"] } diff --git a/windows/src/cursor.rs b/windows/src/cursor.rs index 6d1c6bc..822df03 100644 --- a/windows/src/cursor.rs +++ b/windows/src/cursor.rs @@ -31,9 +31,9 @@ pub fn hide() { } } -/// Reloads the user's cursors from settings. Safe to call at any time. +/// Reloads the user's cursors. No broadcast: SPIF_SENDCHANGE makes the shell redraw. pub fn restore() { unsafe { - let _ = SystemParametersInfoW(SPI_SETCURSORS, 0, None, SPIF_SENDCHANGE); + let _ = SystemParametersInfoW(SPI_SETCURSORS, 0, None, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0)); } } diff --git a/windows/src/hook.rs b/windows/src/hook.rs index 26a6af6..b2acf5e 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -1,18 +1,46 @@ use std::ffi::c_void; -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, Ordering::Relaxed}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, AtomicU32, Ordering::Relaxed}; use windows::Win32::Foundation::*; use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Threading::*; use windows::Win32::UI::WindowsAndMessaging::*; - -pub const WM_TH_DOWN: u32 = WM_APP + 1; -pub const WM_TH_UP: u32 = WM_APP + 2; +use windows::core::PCWSTR; pub static ACTIVE: AtomicBool = AtomicBool::new(false); -static TARGET: AtomicIsize = AtomicIsize::new(0); +pub static HELD: AtomicBool = AtomicBool::new(false); +/// Set by the render thread while it sleeps. The hook wakes it on mouse moves. +pub static IDLE: AtomicBool = AtomicBool::new(false); +pub static DOWN_SEQ: AtomicU32 = AtomicU32::new(0); +pub static UP_SEQ: AtomicU32 = AtomicU32::new(0); +pub static DOWN_X: AtomicI32 = AtomicI32::new(0); +pub static DOWN_Y: AtomicI32 = AtomicI32::new(0); + +static WAKE: AtomicIsize = AtomicIsize::new(0); static SWALLOWED: AtomicBool = AtomicBool::new(false); static PASS: [AtomicI32; 4] = [const { AtomicI32::new(0) }; 4]; +pub fn init() { + let event = unsafe { CreateEventW(None, false, false, PCWSTR::null()) }.expect("wake event"); + WAKE.store(event.0 as isize, Relaxed); +} + +fn handle() -> HANDLE { + HANDLE(WAKE.load(Relaxed) as *mut c_void) +} + +pub fn wake() { + unsafe { + let _ = SetEvent(handle()); + } +} + +pub fn wait(ms: u32) { + unsafe { + WaitForSingleObject(handle(), ms); + } +} + pub fn set_passthrough(r: RECT) { for (slot, v) in PASS.iter().zip([r.left, r.top, r.right, r.bottom]) { slot.store(v, Relaxed); @@ -23,29 +51,33 @@ fn in_passthrough(p: POINT) -> bool { p.x >= PASS[0].load(Relaxed) && p.y >= PASS[1].load(Relaxed) && p.x < PASS[2].load(Relaxed) && p.y < PASS[3].load(Relaxed) } -fn post(msg: u32) { - let hwnd = HWND(TARGET.load(Relaxed) as *mut c_void); - unsafe { - let _ = PostMessageW(Some(hwnd), msg, WPARAM(0), LPARAM(0)); - } -} - unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { if code == HC_ACTION as i32 && ACTIVE.load(Relaxed) { let info = unsafe { &*(lp.0 as *const MSLLHOOKSTRUCT) }; - if info.flags & LLMHF_INJECTED == 0 { - match wp.0 as u32 { + let msg = wp.0 as u32; + if msg == WM_MOUSEMOVE { + if IDLE.swap(false, Relaxed) { + wake(); + } + } else if info.flags & LLMHF_INJECTED == 0 { + match msg { WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN => { if !in_passthrough(info.pt) { SWALLOWED.store(true, Relaxed); - post(WM_TH_DOWN); + DOWN_X.store(info.pt.x, Relaxed); + DOWN_Y.store(info.pt.y, Relaxed); + HELD.store(true, Relaxed); + DOWN_SEQ.fetch_add(1, Relaxed); + wake(); return LRESULT(1); } SWALLOWED.store(false, Relaxed); } WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP => { if SWALLOWED.swap(false, Relaxed) { - post(WM_TH_UP); + HELD.store(false, Relaxed); + UP_SEQ.fetch_add(1, Relaxed); + wake(); return LRESULT(1); } } @@ -56,9 +88,8 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { unsafe { CallNextHookEx(None, code, wp, lp) } } -/// Runs the hook on its own thread so the callback never waits on rendering. -pub fn start(target: HWND) { - TARGET.store(target.0 as isize, Relaxed); +/// Own thread, so the callback never waits on rendering. +pub fn start() { std::thread::spawn(|| unsafe { let hinst = GetModuleHandleW(None).map(|m| HINSTANCE(m.0)).ok(); if SetWindowsHookExW(WH_MOUSE_LL, Some(proc), hinst, 0).is_err() { diff --git a/windows/src/main.rs b/windows/src/main.rs index 17a3989..478ee6e 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -1,49 +1,43 @@ -#![windows_subsystem = "windows"] +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod cursor; mod hook; +mod renderer; mod scene; mod surface; use std::cell::RefCell; -use std::sync::atomic::Ordering::Relaxed; -use std::time::Instant; +use std::ffi::c_void; +use std::sync::atomic::{AtomicIsize, Ordering::Relaxed}; +use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::*; -use windows::Win32::Graphics::Gdi::UpdateWindow; +use windows::Win32::Graphics::Dwm::*; use windows::Win32::Media::timeBeginPeriod; +use windows::Win32::System::Console::SetConsoleCtrlHandler; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::UI::HiDpi::*; use windows::Win32::UI::Input::KeyboardAndMouse::*; use windows::Win32::UI::Shell::*; use windows::Win32::UI::WindowsAndMessaging::*; -use windows::core::{PCWSTR, w}; +use windows::core::{BOOL, PCWSTR, w}; -use scene::{Ink, draw_laser}; -use surface::Surface; +use renderer::{Renderer, lock}; -const TIMER: usize = 1; -const FRAME_MS: u32 = 8; +const TIMER_TASKBAR: usize = 1; const HK_TOGGLE: i32 = 1; const HK_CLEAR: i32 = 2; const HK_ESC: i32 = 3; const WM_TRAY: u32 = WM_APP + 10; const CMD_TOGGLE: usize = 1; const CMD_QUIT: usize = 2; -const DRAG_THRESHOLD: f32 = 4.0; + +static HWND_RAW: AtomicIsize = AtomicIsize::new(0); struct App { hwnd: HWND, - surface: Surface, - ink: Ink, + renderer: Arc>, active: bool, - held: bool, - down_at: (f32, f32), - cursor: (f32, f32), - press: f32, - dirty: bool, - last_frame: Instant, - last_taskbar: Instant, } thread_local! { @@ -51,24 +45,6 @@ thread_local! { } impl App { - fn new(hwnd: HWND, w: i32, h: i32) -> Self { - let mut ink = Ink::new(); - ink.width = (h as f32 * 0.0055).max(3.0); - Self { - hwnd, - surface: Surface::new(w, h), - ink, - active: false, - held: false, - down_at: (0.0, 0.0), - cursor: (0.0, 0.0), - press: 0.0, - dirty: true, - last_frame: Instant::now(), - last_taskbar: Instant::now() - std::time::Duration::from_secs(10), - } - } - fn toggle(&mut self) { if self.active { self.deactivate(); @@ -79,34 +55,33 @@ impl App { fn activate(&mut self) { self.active = true; + lock(&self.renderer).reset(); self.refresh_taskbar(); + hook::HELD.store(false, Relaxed); hook::ACTIVE.store(true, Relaxed); cursor::hide(); unsafe { let _ = RegisterHotKey(Some(self.hwnd), HK_ESC, MOD_NOREPEAT, VK_ESCAPE.0 as u32); + SetTimer(Some(self.hwnd), TIMER_TASKBAR, 500, None); let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); - SetTimer(Some(self.hwnd), TIMER, FRAME_MS, None); } - self.last_frame = Instant::now(); - self.dirty = true; - self.tick(); + hook::wake(); } fn deactivate(&mut self) { self.active = false; - self.held = false; hook::ACTIVE.store(false, Relaxed); - self.ink.clear(); + hook::HELD.store(false, Relaxed); cursor::restore(); + lock(&self.renderer).reset(); unsafe { - let _ = KillTimer(Some(self.hwnd), TIMER); + let _ = KillTimer(Some(self.hwnd), TIMER_TASKBAR); let _ = UnregisterHotKey(Some(self.hwnd), HK_ESC); let _ = ShowWindow(self.hwnd, SW_HIDE); } } - fn refresh_taskbar(&mut self) { - self.last_taskbar = Instant::now(); + fn refresh_taskbar(&self) { let mut r = RECT::default(); unsafe { if let Ok(bar) = FindWindowW(w!("Shell_TrayWnd"), PCWSTR::null()) { @@ -116,73 +91,14 @@ impl App { hook::set_passthrough(r); } - fn tick(&mut self) { - if !self.active { - return; - } - if self.last_taskbar.elapsed().as_millis() > 500 { - self.refresh_taskbar(); - } - let dt = self.last_frame.elapsed().as_secs_f32(); - self.last_frame = Instant::now(); - - let mut p = POINT::default(); - unsafe { - let _ = GetCursorPos(&mut p); - } - let at = (p.x as f32, p.y as f32); - if at != self.cursor { - self.cursor = at; - self.dirty = true; - } - - if self.held { - if self.ink.is_drawing() { - self.ink.extend(at); - } else if (at.0 - self.down_at.0).hypot(at.1 - self.down_at.1) >= DRAG_THRESHOLD { - self.ink.begin(self.down_at); - self.ink.extend(at); - } - } else if self.press > 0.002 { - self.press *= 0.82f32.powf(dt / 0.016); - self.dirty = true; - } - - if self.dirty || self.ink.alive() { - self.render(); - self.dirty = false; - } - } - - fn render(&mut self) { - let pix = &mut self.surface.pix; - pix.fill(tiny_skia::Color::TRANSPARENT); - self.ink.draw(pix); - let h = self.surface.size().1 as f32; - let r = (h * 0.0038).max(4.0) * (1.0 + 0.2 * self.press); - draw_laser(&mut self.surface.pix, self.cursor, r); - self.surface.present(self.hwnd); - } - fn on_message(&mut self, hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> Option { match msg { - WM_TIMER => self.tick(), - hook::WM_TH_DOWN => { - self.held = true; - self.press = 1.0; - self.down_at = self.cursor; - self.dirty = true; - } - hook::WM_TH_UP => { - self.held = false; - self.ink.end(); - self.dirty = true; - } + WM_TIMER => self.refresh_taskbar(), WM_HOTKEY => match wp.0 as i32 { HK_TOGGLE => self.toggle(), HK_CLEAR => { - self.ink.clear(); - self.dirty = true; + lock(&self.renderer).clear_ink(); + hook::wake(); } HK_ESC => self.deactivate(), _ => {} @@ -226,16 +142,15 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) handled.unwrap_or_else(|| unsafe { DefWindowProcW(hwnd, msg, wp, lp) }) } +fn tray_data(hwnd: HWND) -> NOTIFYICONDATAW { + NOTIFYICONDATAW { cbSize: size_of::() as u32, hWnd: hwnd, uID: 1, ..Default::default() } +} + fn add_tray(hwnd: HWND) { - let mut nid = NOTIFYICONDATAW { - cbSize: size_of::() as u32, - hWnd: hwnd, - uID: 1, - uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP, - uCallbackMessage: WM_TRAY, - hIcon: unsafe { LoadIconW(None, IDI_APPLICATION).unwrap_or_default() }, - ..Default::default() - }; + let mut nid = tray_data(hwnd); + nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + nid.uCallbackMessage = WM_TRAY; + nid.hIcon = unsafe { LoadIconW(None, IDI_APPLICATION).unwrap_or_default() }; for (d, s) in nid.szTip.iter_mut().zip("TinyHand".encode_utf16()) { *d = s; } @@ -245,12 +160,18 @@ fn add_tray(hwnd: HWND) { } fn remove_tray(hwnd: HWND) { - let nid = NOTIFYICONDATAW { cbSize: size_of::() as u32, hWnd: hwnd, uID: 1, ..Default::default() }; unsafe { - let _ = Shell_NotifyIconW(NIM_DELETE, &nid); + let _ = Shell_NotifyIconW(NIM_DELETE, &tray_data(hwnd)); } } +/// Ctrl+C in a dev console: put the cursors back before dying. +unsafe extern "system" fn on_ctrl(_: u32) -> BOOL { + cursor::restore(); + remove_tray(HWND(HWND_RAW.load(Relaxed) as *mut c_void)); + std::process::exit(0) +} + fn main() { cursor::restore(); let default_panic = std::panic::take_hook(); @@ -289,11 +210,19 @@ fn main() { None, ) .expect("overlay window"); - let _ = UpdateWindow(hwnd); + HWND_RAW.store(hwnd.0 as isize, Relaxed); + + // Without this the shell fades the window out on hide, leaving old ink on screen. + let off = BOOL(1); + let _ = DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); - APP.with(|a| *a.borrow_mut() = Some(App::new(hwnd, w, h))); - hook::start(hwnd); + let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h))); + APP.with(|a| *a.borrow_mut() = Some(App { hwnd, renderer: renderer.clone(), active: false })); + hook::init(); + hook::start(); + std::thread::spawn(move || renderer::run(renderer)); add_tray(hwnd); + let _ = SetConsoleCtrlHandler(Some(on_ctrl), true); let mods = MOD_CONTROL | MOD_ALT | MOD_NOREPEAT; let _ = RegisterHotKey(Some(hwnd), HK_TOGGLE, mods, 'H' as u32); diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs new file mode 100644 index 0000000..e8cc9f9 --- /dev/null +++ b/windows/src/renderer.rs @@ -0,0 +1,198 @@ +use std::sync::atomic::Ordering::Relaxed; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; + +use tiny_skia::PixmapMut; +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Dwm::DwmFlush; +use windows::Win32::System::Threading::*; +use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; + +use crate::hook; +use crate::scene::{Ink, Pending, Rc, draw_laser, union_opt}; +use crate::surface::Surface; + +const DRAG_THRESHOLD: f32 = 4.0; + +pub enum Next { + Frame, + Sleep(Option), +} + +pub struct Renderer { + hwnd: HWND, + surface: Surface, + ink: Ink, + cursor: (f32, f32), + dot: Option<(f32, f32, f32)>, + down_at: (f32, f32), + press: f32, + held: bool, + down_seq: u32, + up_seq: u32, + last: Instant, +} + +// The raw HWND and DIB pointer are only used behind the mutex. +unsafe impl Send for Renderer {} + +fn cursor_pos() -> (f32, f32) { + let mut p = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut p); + } + (p.x as f32, p.y as f32) +} + +impl Renderer { + pub fn new(hwnd: HWND, w: i32, h: i32) -> Self { + Self { + hwnd, + surface: Surface::new(w, h), + ink: Ink::new(w, h), + cursor: (0.0, 0.0), + dot: None, + down_at: (0.0, 0.0), + press: 0.0, + held: false, + down_seq: 0, + up_seq: 0, + last: Instant::now(), + } + } + + /// Blank slate and an empty frame. Window thread only. + pub fn reset(&mut self) { + self.surface.clear(); + self.ink.clear(); + self.ink.take_dirty(); + self.dot = None; + self.press = 0.0; + self.held = false; + self.cursor = cursor_pos(); + self.down_seq = hook::DOWN_SEQ.load(Relaxed); + self.up_seq = hook::UP_SEQ.load(Relaxed); + self.last = Instant::now(); + self.surface.present_full(self.hwnd); + } + + pub fn clear_ink(&mut self) { + self.ink.clear(); + } + + pub fn cursor_moved(&self) -> bool { + cursor_pos() != self.cursor + } + + pub fn frame(&mut self) -> Next { + let dt = self.last.elapsed().as_secs_f32(); + self.last = Instant::now(); + let at = cursor_pos(); + let mut busy = at != self.cursor; + self.cursor = at; + + let down = hook::DOWN_SEQ.load(Relaxed); + let up = hook::UP_SEQ.load(Relaxed); + if up != self.up_seq { + self.up_seq = up; + self.ink.end(); + } + if down != self.down_seq { + self.down_seq = down; + self.press = 1.0; + self.down_at = (hook::DOWN_X.load(Relaxed) as f32, hook::DOWN_Y.load(Relaxed) as f32); + } + self.held = hook::HELD.load(Relaxed); + + if self.held { + busy = true; + if self.ink.is_drawing() { + self.ink.extend(at); + } else if (at.0 - self.down_at.0).hypot(at.1 - self.down_at.1) >= DRAG_THRESHOLD { + self.ink.begin(self.down_at); + self.ink.extend(at); + } + } else if self.press > 0.002 { + self.press *= 0.82f32.powf(dt / 0.016); + busy = true; + } else { + self.press = 0.0; + } + busy |= self.ink.tick(); + + self.draw(at); + + if busy { + return Next::Frame; + } + match self.ink.pending() { + Pending::Fading => Next::Frame, + Pending::After(d) => Next::Sleep(Some(d.as_millis() as u32 + 1)), + Pending::Nothing => Next::Sleep(None), + } + } + + fn draw(&mut self, at: (f32, f32)) { + let (w, h) = (self.surface.w, self.surface.h); + let r = (h as f32 * 0.0038).max(4.0) * (1.0 + 0.2 * self.press); + let dot = (at.0, at.1, r); + let dot_moved = self.dot != Some(dot); + let ink_dirty = self.ink.take_dirty(); + if !dot_moved && ink_dirty.is_none() { + return; + } + + let reach = r * 3.2 + 3.0; + let now = Rc::around(at.0, at.1, reach); + let mut dirty = union_opt(ink_dirty, now); + if let Some(p) = self.dot { + dirty = dirty.union(Rc::around(p.0, p.1, p.2 * 3.2 + 3.0)); + } + self.dot = Some(dot); + let Some(d) = dirty.clip(w, h) else { return }; + + let stride = w as usize * 4; + let src = self.ink.layer.data(); + let dst = self.surface.bytes_mut(); + for y in d.t as usize..d.b as usize { + let range = y * stride + d.l as usize * 4..y * stride + d.r as usize * 4; + dst[range.clone()].copy_from_slice(&src[range]); + } + if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { + draw_laser(&mut pix, at, r); + } + self.surface.present_rect(self.hwnd, RECT { left: d.l, top: d.t, right: d.r, bottom: d.b }); + } +} + +pub fn lock(r: &Mutex) -> MutexGuard<'_, Renderer> { + r.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Frames follow the compositor's vsync. Idle blocks on an event, so CPU is zero. +pub fn run(shared: Arc>) { + unsafe { + let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL); + } + loop { + if !hook::ACTIVE.load(Relaxed) { + hook::IDLE.store(true, Relaxed); + hook::wait(INFINITE); + continue; + } + match lock(&shared).frame() { + Next::Frame => unsafe { + let _ = DwmFlush(); + }, + Next::Sleep(ms) => { + hook::IDLE.store(true, Relaxed); + if lock(&shared).cursor_moved() { + hook::IDLE.store(false, Relaxed); + continue; + } + hook::wait(ms.unwrap_or(INFINITE)); + hook::IDLE.store(false, Relaxed); + } + } + } +} diff --git a/windows/src/scene.rs b/windows/src/scene.rs index ae00234..201db2f 100644 --- a/windows/src/scene.rs +++ b/windows/src/scene.rs @@ -1,21 +1,58 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use tiny_skia::*; const FADE_DELAY: f32 = 3.0; const FADE_FOR: f32 = 0.6; +/// Every buffer here is BGRA, so colours swap red and blue. +pub fn px(r: u8, g: u8, b: u8, a: u8) -> Color { + Color::from_rgba8(b, g, r, a) +} + +#[derive(Clone, Copy)] +pub struct Rc { + pub l: i32, + pub t: i32, + pub r: i32, + pub b: i32, +} + +impl Rc { + pub fn around(x: f32, y: f32, rad: f32) -> Self { + Self { l: (x - rad).floor() as i32, t: (y - rad).floor() as i32, r: (x + rad).ceil() as i32, b: (y + rad).ceil() as i32 } + } + + pub fn union(self, o: Rc) -> Rc { + Rc { l: self.l.min(o.l), t: self.t.min(o.t), r: self.r.max(o.r), b: self.b.max(o.b) } + } + + pub fn clip(self, w: i32, h: i32) -> Option { + let c = Rc { l: self.l.max(0), t: self.t.max(0), r: self.r.min(w), b: self.b.min(h) }; + (c.l < c.r && c.t < c.b).then_some(c) + } +} + +pub fn union_opt(a: Option, b: Rc) -> Rc { + a.map_or(b, |a| a.union(b)) +} + struct Stroke { pts: Vec<(f32, f32)>, ended: Option, } impl Stroke { + fn age(&self) -> Option { + self.ended.map(|t| t.elapsed().as_secs_f32()) + } + fn alpha(&self) -> f32 { - match self.ended { - None => 1.0, - Some(t) => 1.0 - ((t.elapsed().as_secs_f32() - FADE_DELAY) / FADE_FOR).clamp(0.0, 1.0), - } + self.age().map_or(1.0, |a| 1.0 - ((a - FADE_DELAY) / FADE_FOR).clamp(0.0, 1.0)) + } + + fn fading(&self) -> bool { + self.age().is_some_and(|a| a > FADE_DELAY) } fn path(&self) -> Option { @@ -29,44 +66,95 @@ impl Stroke { let (a, b) = (w[0], w[1]); pb.quad_to(a.0, a.1, (a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); } - if self.pts.len() > 1 { - let l = self.pts[self.pts.len() - 1]; + if let (true, Some(l)) = (self.pts.len() > 1, self.pts.last()) { pb.line_to(l.0, l.1); } pb.finish() } } +pub enum Pending { + Fading, + After(Duration), + Nothing, +} + +/// Strokes are painted once into a persistent layer. Only fading rebuilds it. pub struct Ink { strokes: Vec, + pub layer: Pixmap, drawing: bool, - pub color: Color, - pub width: f32, + color: Color, + width: f32, + dirty: Option, } impl Ink { - pub fn new() -> Self { - Self { strokes: vec![], drawing: false, color: Color::from_rgba8(255, 41, 31, 255), width: 4.0 } + pub fn new(w: i32, h: i32) -> Self { + Self { + strokes: vec![], + layer: Pixmap::new(w as u32, h as u32).expect("ink layer"), + drawing: false, + color: px(255, 41, 31, 255), + width: (h as f32 * 0.0055).max(3.0), + dirty: None, + } } pub fn is_drawing(&self) -> bool { self.drawing } - pub fn alive(&self) -> bool { - !self.strokes.is_empty() + pub fn take_dirty(&mut self) -> Option { + self.dirty.take() + } + + fn paint(&mut self, path: &Path, alpha: f32) { + let mut c = self.color; + c.set_alpha(alpha); + let mut paint = Paint::default(); + paint.set_color(c); + paint.anti_alias = true; + let stroke = tiny_skia::Stroke { width: self.width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + self.layer.stroke_path(path, &paint, &stroke, Transform::identity(), None); + let b = path.bounds(); + let pad = self.width / 2.0 + 2.0; + let r = Rc { l: (b.left() - pad).floor() as i32, t: (b.top() - pad).floor() as i32, r: (b.right() + pad).ceil() as i32, b: (b.bottom() + pad).ceil() as i32 }; + self.dirty = Some(union_opt(self.dirty, r)); } pub fn begin(&mut self, p: (f32, f32)) { self.strokes.push(Stroke { pts: vec![p], ended: None }); self.drawing = true; + let mut pb = PathBuilder::new(); + pb.move_to(p.0, p.1); + pb.line_to(p.0, p.1); + if let Some(path) = pb.finish() { + self.paint(&path, 1.0); + } } pub fn extend(&mut self, p: (f32, f32)) { let Some(s) = self.strokes.last_mut().filter(|_| self.drawing) else { return }; let l = s.pts[s.pts.len() - 1]; - if (p.0 - l.0).hypot(p.1 - l.1) >= 1.5 { - s.pts.push(p); + if (p.0 - l.0).hypot(p.1 - l.1) < 1.5 { + return; + } + s.pts.push(p); + let n = s.pts.len(); + let mid = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let mut pb = PathBuilder::new(); + if n == 2 { + pb.move_to(s.pts[0].0, s.pts[0].1); + } else { + let m = mid(s.pts[n - 3], s.pts[n - 2]); + let e = mid(s.pts[n - 2], s.pts[n - 1]); + pb.move_to(m.0, m.1); + pb.quad_to(s.pts[n - 2].0, s.pts[n - 2].1, e.0, e.1); + } + pb.line_to(p.0, p.1); + if let Some(path) = pb.finish() { + self.paint(&path, 1.0); } } @@ -80,53 +168,64 @@ impl Ink { pub fn clear(&mut self) { self.strokes.clear(); self.drawing = false; + self.layer.fill(Color::TRANSPARENT); + self.dirty = Some(self.full()); + } + + fn full(&self) -> Rc { + Rc { l: 0, t: 0, r: self.layer.width() as i32, b: self.layer.height() as i32 } } - pub fn draw(&mut self, pix: &mut Pixmap) { + /// Returns true while strokes are fading. + pub fn tick(&mut self) -> bool { + let before = self.strokes.len(); self.strokes.retain(|s| s.alpha() > 0.0); - let stroke = tiny_skia::Stroke { width: self.width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; - for s in &self.strokes { - let Some(path) = s.path() else { continue }; - let mut paint = Paint::default(); - let mut c = self.color; - c.set_alpha(s.alpha()); - paint.set_color(c); - paint.anti_alias = true; - pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + let fading = self.strokes.iter().any(Stroke::fading); + if fading || self.strokes.len() != before { + self.layer.fill(Color::TRANSPARENT); + let paths: Vec<_> = self.strokes.iter().filter_map(|s| s.path().map(|p| (p, s.alpha()))).collect(); + for (path, alpha) in paths { + self.paint(&path, alpha); + } + self.dirty = Some(self.full()); } + fading + } + + pub fn pending(&self) -> Pending { + if self.strokes.iter().any(Stroke::fading) { + return Pending::Fading; + } + self.strokes + .iter() + .filter_map(Stroke::age) + .map(|a| Duration::from_secs_f32((FADE_DELAY - a).max(0.0))) + .min() + .map_or(Pending::Nothing, Pending::After) } } -pub fn draw_laser(pix: &mut Pixmap, at: (f32, f32), r: f32) { - let red = Color::from_rgba8(255, 41, 31, 255); +pub fn draw_laser(pix: &mut PixmapMut, at: (f32, f32), r: f32) { let glow = RadialGradient::new( Point::from_xy(at.0, at.1), 0.0, Point::from_xy(at.0, at.1), r * 3.2, - vec![ - GradientStop::new(0.0, Color::from_rgba8(255, 41, 31, 150)), - GradientStop::new(1.0, Color::from_rgba8(255, 41, 31, 0)), - ], + vec![GradientStop::new(0.0, px(255, 41, 31, 150)), GradientStop::new(1.0, px(255, 41, 31, 0))], SpreadMode::Pad, Transform::identity(), ); - let mut paint = Paint::default(); - paint.anti_alias = true; - if let Some(shader) = glow { + if let (Some(shader), Some(rect)) = (glow, Rect::from_xywh(at.0 - r * 3.2, at.1 - r * 3.2, r * 6.4, r * 6.4)) { + let mut paint = Paint::default(); paint.shader = shader; - if let Some(rect) = Rect::from_xywh(at.0 - r * 3.2, at.1 - r * 3.2, r * 6.4, r * 6.4) { - pix.fill_rect(rect, &paint, Transform::identity(), None); - } + pix.fill_rect(rect, &paint, Transform::identity(), None); } - let mut dot = |radius: f32, color: Color| { + for (radius, color) in [(r, px(255, 41, 31, 255)), (r * 0.45, px(255, 220, 209, 242))] { let mut p = Paint::default(); p.set_color(color); p.anti_alias = true; if let Some(path) = PathBuilder::from_circle(at.0, at.1, radius) { pix.fill_path(&path, &p, FillRule::Winding, Transform::identity(), None); } - }; - dot(r, red); - dot(r * 0.45, Color::from_rgba8(255, 220, 209, 242)); + } } diff --git a/windows/src/surface.rs b/windows/src/surface.rs index ae7cbe2..f228407 100644 --- a/windows/src/surface.rs +++ b/windows/src/surface.rs @@ -1,18 +1,16 @@ use std::ffi::c_void; use std::ptr::null_mut; -use tiny_skia::Pixmap; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; use windows::Win32::UI::WindowsAndMessaging::*; -/// Software canvas blitted to a layered window with per-pixel alpha. +/// Layered-window backing store. Pixels are premultiplied BGRA. pub struct Surface { - pub pix: Pixmap, - w: i32, - h: i32, + pub w: i32, + pub h: i32, dc: HDC, - bits: *mut u32, + bits: *mut u8, } impl Surface { @@ -36,28 +34,49 @@ impl Surface { let bmp = CreateDIBSection(Some(screen), &bmi, DIB_RGB_COLORS, &mut bits, None, 0).expect("DIB section"); SelectObject(dc, bmp.into()); ReleaseDC(None, screen); - Self { pix: Pixmap::new(w as u32, h as u32).expect("pixmap"), w, h, dc, bits: bits.cast() } + Self { w, h, dc, bits: bits.cast() } } } - pub fn size(&self) -> (i32, i32) { - (self.w, self.h) + pub fn bytes_mut(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.bits, self.w as usize * self.h as usize * 4) } } - pub fn present(&mut self, hwnd: HWND) { - let src = self.pix.data(); - let dst = unsafe { std::slice::from_raw_parts_mut(self.bits.cast::(), src.len()) }; - for (d, s) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { - d[0] = s[2]; - d[1] = s[1]; - d[2] = s[0]; - d[3] = s[3]; - } - let blend = BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: 255, AlphaFormat: AC_SRC_ALPHA as u8 }; + pub fn clear(&mut self) { + self.bytes_mut().fill(0); + } + + fn blend() -> BLENDFUNCTION { + BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: 255, AlphaFormat: AC_SRC_ALPHA as u8 } + } + + /// Sets position and size as well. Window thread only. + pub fn present_full(&self, hwnd: HWND) { let origin = POINT::default(); let size = SIZE { cx: self.w, cy: self.h }; unsafe { - let _ = UpdateLayeredWindow(hwnd, None, Some(&origin), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&blend), ULW_ALPHA); + let _ = UpdateLayeredWindow(hwnd, None, Some(&origin), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend()), ULW_ALPHA); + } + } + + /// Content only, so it is safe from any thread. + pub fn present_rect(&self, hwnd: HWND, dirty: RECT) { + let origin = POINT::default(); + let blend = Self::blend(); + let info = UPDATELAYEREDWINDOWINFO { + cbSize: size_of::() as u32, + hdcDst: HDC::default(), + pptDst: std::ptr::null(), + psize: std::ptr::null(), + hdcSrc: self.dc, + pptSrc: &origin, + crKey: COLORREF(0), + pblend: &blend, + dwFlags: ULW_ALPHA, + prcDirty: &dirty, + }; + unsafe { + let _ = UpdateLayeredWindowIndirect(hwnd, &info); } } } From 782be552ad5feb72e50d69464563fbd4f0a604c2 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:09:34 +0530 Subject: [PATCH 03/28] Windows: fix render thread deadlock, present without dirty rect so the dot shows --- windows/src/renderer.rs | 5 +++-- windows/src/surface.rs | 20 ++++---------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index e8cc9f9..ccb8209 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -161,7 +161,7 @@ impl Renderer { if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { draw_laser(&mut pix, at, r); } - self.surface.present_rect(self.hwnd, RECT { left: d.l, top: d.t, right: d.r, bottom: d.b }); + self.surface.present(self.hwnd); } } @@ -180,7 +180,8 @@ pub fn run(shared: Arc>) { hook::wait(INFINITE); continue; } - match lock(&shared).frame() { + let next = lock(&shared).frame(); + match next { Next::Frame => unsafe { let _ = DwmFlush(); }, diff --git a/windows/src/surface.rs b/windows/src/surface.rs index f228407..130eed8 100644 --- a/windows/src/surface.rs +++ b/windows/src/surface.rs @@ -59,24 +59,12 @@ impl Surface { } } - /// Content only, so it is safe from any thread. - pub fn present_rect(&self, hwnd: HWND, dirty: RECT) { + /// Content only (no move or resize), so it is safe from any thread. + /// prcDirty via UpdateLayeredWindowIndirect did not update the screen here. + pub fn present(&self, hwnd: HWND) { let origin = POINT::default(); - let blend = Self::blend(); - let info = UPDATELAYEREDWINDOWINFO { - cbSize: size_of::() as u32, - hdcDst: HDC::default(), - pptDst: std::ptr::null(), - psize: std::ptr::null(), - hdcSrc: self.dc, - pptSrc: &origin, - crKey: COLORREF(0), - pblend: &blend, - dwFlags: ULW_ALPHA, - prcDirty: &dirty, - }; unsafe { - let _ = UpdateLayeredWindowIndirect(hwnd, &info); + let _ = UpdateLayeredWindow(hwnd, None, None, None, Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend()), ULW_ALPHA); } } } From 148a3d2987f966818b4ad242cdb4154ced9807a9 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:12:11 +0530 Subject: [PATCH 04/28] Windows: present from the window thread so the overlay updates --- windows/src/hook.rs | 3 +++ windows/src/main.rs | 6 ++++++ windows/src/renderer.rs | 24 ++++++++++++++++++++++-- windows/src/surface.rs | 13 +++++-------- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/windows/src/hook.rs b/windows/src/hook.rs index b2acf5e..e83c34d 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -7,6 +7,9 @@ use windows::Win32::System::Threading::*; use windows::Win32::UI::WindowsAndMessaging::*; use windows::core::PCWSTR; +/// The window thread does the actual present. +pub const WM_PRESENT: u32 = WM_APP + 3; +pub static PRESENT_PENDING: AtomicBool = AtomicBool::new(false); pub static ACTIVE: AtomicBool = AtomicBool::new(false); pub static HELD: AtomicBool = AtomicBool::new(false); /// Set by the render thread while it sleeps. The hook wakes it on mouse moves. diff --git a/windows/src/main.rs b/windows/src/main.rs index 478ee6e..8f45364 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -94,6 +94,12 @@ impl App { fn on_message(&mut self, hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> Option { match msg { WM_TIMER => self.refresh_taskbar(), + hook::WM_PRESENT => { + hook::PRESENT_PENDING.store(false, Relaxed); + if self.active { + lock(&self.renderer).present(); + } + } WM_HOTKEY => match wp.0 as i32 { HK_TOGGLE => self.toggle(), HK_CLEAR => { diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index ccb8209..7915708 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -1,3 +1,4 @@ +use std::ffi::c_void; use std::sync::atomic::Ordering::Relaxed; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Instant; @@ -6,7 +7,7 @@ use tiny_skia::PixmapMut; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Dwm::DwmFlush; use windows::Win32::System::Threading::*; -use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; +use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, PostMessageW}; use crate::hook; use crate::scene::{Ink, Pending, Rc, draw_laser, union_opt}; @@ -31,6 +32,7 @@ pub struct Renderer { down_seq: u32, up_seq: u32, last: Instant, + drew: bool, } // The raw HWND and DIB pointer are only used behind the mutex. @@ -58,6 +60,7 @@ impl Renderer { down_seq: 0, up_seq: 0, last: Instant::now(), + drew: false, } } @@ -161,8 +164,16 @@ impl Renderer { if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { draw_laser(&mut pix, at, r); } + self.drew = true; + } + + pub fn present(&self) { self.surface.present(self.hwnd); } + + pub fn hwnd_raw(&self) -> isize { + self.hwnd.0 as isize + } } pub fn lock(r: &Mutex) -> MutexGuard<'_, Renderer> { @@ -180,7 +191,16 @@ pub fn run(shared: Arc>) { hook::wait(INFINITE); continue; } - let next = lock(&shared).frame(); + let (next, drew, hwnd) = { + let mut r = lock(&shared); + let next = r.frame(); + (next, std::mem::take(&mut r.drew), r.hwnd_raw()) + }; + if drew && !hook::PRESENT_PENDING.swap(true, Relaxed) { + unsafe { + let _ = PostMessageW(Some(HWND(hwnd as *mut c_void)), hook::WM_PRESENT, WPARAM(0), LPARAM(0)); + } + } match next { Next::Frame => unsafe { let _ = DwmFlush(); diff --git a/windows/src/surface.rs b/windows/src/surface.rs index 130eed8..77d409b 100644 --- a/windows/src/surface.rs +++ b/windows/src/surface.rs @@ -50,21 +50,18 @@ impl Surface { BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: 255, AlphaFormat: AC_SRC_ALPHA as u8 } } - /// Sets position and size as well. Window thread only. + /// Window thread only. pub fn present_full(&self, hwnd: HWND) { let origin = POINT::default(); let size = SIZE { cx: self.w, cy: self.h }; unsafe { - let _ = UpdateLayeredWindow(hwnd, None, Some(&origin), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend()), ULW_ALPHA); + if let Err(e) = UpdateLayeredWindow(hwnd, None, Some(&origin), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend()), ULW_ALPHA) { + eprintln!("ulw failed: {e}"); + } } } - /// Content only (no move or resize), so it is safe from any thread. - /// prcDirty via UpdateLayeredWindowIndirect did not update the screen here. pub fn present(&self, hwnd: HWND) { - let origin = POINT::default(); - unsafe { - let _ = UpdateLayeredWindow(hwnd, None, None, None, Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend()), ULW_ALPHA); - } + self.present_full(hwnd); } } From 1ea0fffc54ff5fe6c3f24a38803d5e49585c1c01 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:16:20 +0530 Subject: [PATCH 05/28] Windows: step aside while Snipping Tool or other capture tools have focus --- windows/Cargo.toml | 2 +- windows/src/main.rs | 76 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/windows/Cargo.toml b/windows/Cargo.toml index ae11ba6..af42393 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -5,4 +5,4 @@ edition = "2024" [dependencies] tiny-skia = "0.12.0" -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility"] } diff --git a/windows/src/main.rs b/windows/src/main.rs index 8f45364..65f6b94 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -16,11 +16,13 @@ use windows::Win32::Graphics::Dwm::*; use windows::Win32::Media::timeBeginPeriod; use windows::Win32::System::Console::SetConsoleCtrlHandler; use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Threading::{OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW}; +use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook}; use windows::Win32::UI::HiDpi::*; use windows::Win32::UI::Input::KeyboardAndMouse::*; use windows::Win32::UI::Shell::*; use windows::Win32::UI::WindowsAndMessaging::*; -use windows::core::{BOOL, PCWSTR, w}; +use windows::core::{BOOL, PCWSTR, PWSTR, w}; use renderer::{Renderer, lock}; @@ -38,6 +40,43 @@ struct App { hwnd: HWND, renderer: Arc>, active: bool, + /// A capture tool has focus. TinyHand steps aside until it leaves. + suspended: bool, +} + +const YIELD_TO: [&str; 3] = ["screenclippinghost.exe", "snippingtool.exe", "screensketch.exe"]; + +fn should_yield(hwnd: HWND) -> bool { + if hwnd.0.is_null() { + return false; + } + let mut pid = 0u32; + unsafe { + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + let Ok(process) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { return false }; + let mut buf = [0u16; 512]; + let mut len = buf.len() as u32; + let ok = QueryFullProcessImageNameW(process, PROCESS_NAME_WIN32, PWSTR(buf.as_mut_ptr()), &mut len).is_ok(); + let _ = CloseHandle(process); + if !ok { + return false; + } + let path = String::from_utf16_lossy(&buf[..len as usize]).to_lowercase(); + let name = path.rsplit('\\').next().unwrap_or(""); + YIELD_TO.contains(&name) + } +} + +unsafe extern "system" fn on_foreground(_: HWINEVENTHOOK, _: u32, hwnd: HWND, _: i32, _: i32, _: u32, _: u32) { + if hwnd.0.is_null() { + return; + } + let yielding = should_yield(hwnd); + APP.with(|cell| { + if let Some(app) = cell.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) { + app.set_suspended(yielding); + } + }); } thread_local! { @@ -55,6 +94,35 @@ impl App { fn activate(&mut self) { self.active = true; + self.suspended = should_yield(unsafe { GetForegroundWindow() }); + if !self.suspended { + self.start(); + } + } + + fn deactivate(&mut self) { + let running = self.active && !self.suspended; + self.active = false; + if running { + self.stop(); + } + } + + fn set_suspended(&mut self, suspended: bool) { + if suspended == self.suspended { + return; + } + self.suspended = suspended; + if self.active { + if suspended { + self.stop(); + } else { + self.start(); + } + } + } + + fn start(&mut self) { lock(&self.renderer).reset(); self.refresh_taskbar(); hook::HELD.store(false, Relaxed); @@ -68,8 +136,7 @@ impl App { hook::wake(); } - fn deactivate(&mut self) { - self.active = false; + fn stop(&mut self) { hook::ACTIVE.store(false, Relaxed); hook::HELD.store(false, Relaxed); cursor::restore(); @@ -223,7 +290,8 @@ fn main() { let _ = DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h))); - APP.with(|a| *a.borrow_mut() = Some(App { hwnd, renderer: renderer.clone(), active: false })); + APP.with(|a| *a.borrow_mut() = Some(App { hwnd, renderer: renderer.clone(), active: false, suspended: false })); + SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); hook::init(); hook::start(); std::thread::spawn(move || renderer::run(renderer)); From 2a1f0d70f6dab8b710565b92a8344fa390416921 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:20:26 +0530 Subject: [PATCH 06/28] Windows: render the hand and rod from the shared pointer art --- windows/src/arm.rs | 122 ++++++++++++++++++++++++++++++++++++++ windows/src/hand.rs | 127 ++++++++++++++++++++++++++++++++++++++++ windows/src/main.rs | 7 ++- windows/src/renderer.rs | 73 ++++++++++++++++------- windows/src/scene.rs | 4 ++ windows/src/styles.rs | 60 +++++++++++++++++++ 6 files changed, 370 insertions(+), 23 deletions(-) create mode 100644 windows/src/arm.rs create mode 100644 windows/src/hand.rs create mode 100644 windows/src/styles.rs diff --git a/windows/src/arm.rs b/windows/src/arm.rs new file mode 100644 index 0000000..cd55cb5 --- /dev/null +++ b/windows/src/arm.rs @@ -0,0 +1,122 @@ +use tiny_skia::*; + +use crate::hand::HandAsset; +use crate::scene::Rc; + +const MIN_ROD: f32 = 0.05; +const GRIP_LEAN: f32 = 0.30; +const GRIP_DEPTH: f32 = 0.12; +const GRIP_FOLLOW: f32 = 0.08; +/// The follow and press rates were tuned at 120 ticks per second. +const TICK_HZ: f32 = 120.0; + +/// Geometry is in y-up screen space, like the Mac layers. Draw flips it. +#[derive(Clone, Copy)] +pub struct Pose { + rotation: f32, + rod_len: f32, + hand_pts: f32, + width: f32, + grip: (f32, f32), +} + +impl Pose { + pub fn key(&self) -> Vec { + [self.rotation, self.rod_len, self.hand_pts, self.width, self.grip.0].iter().map(|v| (v * 100.0).round() as i32).collect() + } +} + +pub struct Arm { + pub asset: HandAsset, + grip_x: Option, + grip_target: f32, +} + +impl Arm { + pub fn new(asset: HandAsset) -> Self { + Self { asset, grip_x: None, grip_target: 0.0 } + } + + pub fn settling(&self) -> bool { + self.grip_x.is_some_and(|g| (self.grip_target - g).abs() > 0.5) + } + + /// cursor is in screen pixels, y down. + pub fn aim(&mut self, cursor: (f32, f32), screen: (f32, f32), press: f32, size: f32, dt: f32) -> Pose { + let (sw, sh) = screen; + let a = &mut self.asset; + let (cx, cy) = (cursor.0, sh - cursor.1); + + let target = (cx + GRIP_LEAN * sw).clamp(sw * 0.15, sw * 1.05); + let follow = 1.0 - (1.0 - GRIP_FOLLOW).powf(dt * TICK_HZ); + let gx = self.grip_x.map_or(target, |g| g + (target - g) * follow); + self.grip_x = Some(gx); + self.grip_target = target; + let grip = (gx, -GRIP_DEPTH * sh); + + a.fit(size * sh / (a.wrist - a.hand_top)); + + let (dx, dy) = (cx - grip.0, cy - grip.1); + let reach = dx.hypot(dy); + let aim_angle = dy.atan2(dx); + + let hand_pts = size * sh * (1.0 - 0.06 * press); + let s = hand_pts / (a.wrist - a.hand_top); + let width = s * a.aspect; + let tip_pts = (a.wrist - a.tip) * s; + let tip_off_x = (a.tip_x - a.rod_x) * width; + + let along = (reach * reach - tip_off_x * tip_off_x).max(0.0).sqrt(); + let rod_len = (along - tip_pts).max(MIN_ROD * sh); + let tip_angle = (rod_len + tip_pts).atan2(tip_off_x); + Pose { rotation: aim_angle - tip_angle, rod_len, hand_pts, width, grip } + } + + fn corners(&self, p: &Pose, sh: f32) -> [(f32, f32); 4] { + let anchor = self.asset.rod_x * p.width; + let (sin, cos) = p.rotation.sin_cos(); + let top = p.rod_len + p.hand_pts; + [(0.0, 0.0), (p.width, 0.0), (p.width, top), (0.0, top)].map(|(x, y)| { + let (lx, ly) = (x - anchor, y); + (p.grip.0 + cos * lx - sin * ly, sh - (p.grip.1 + sin * lx + cos * ly)) + }) + } + + pub fn bbox(&self, p: &Pose, sh: f32) -> Rc { + let c = self.corners(p, sh); + let pad = 6.0; + let min = |f: fn(&(f32, f32)) -> f32| c.iter().map(f).fold(f32::MAX, f32::min); + let max = |f: fn(&(f32, f32)) -> f32| c.iter().map(f).fold(f32::MIN, f32::max); + Rc { l: (min(|c| c.0) - pad).floor() as i32, t: (min(|c| c.1) - pad).floor() as i32, r: (max(|c| c.0) + pad).ceil() as i32, b: (max(|c| c.1) + pad).ceil() as i32 } + } + + pub fn draw(&self, pix: &mut PixmapMut, p: &Pose, sh: f32) { + let a = &self.asset; + let img = &a.display; + let (iw, ih) = (img.width() as f32, img.height() as f32); + let flip = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, sh); + let (sin, cos) = p.rotation.sin_cos(); + let base = flip + .pre_concat(Transform::from_translate(p.grip.0, p.grip.1)) + .pre_concat(Transform::from_row(cos, sin, -sin, cos, 0.0, 0.0)) + .pre_concat(Transform::from_translate(-a.rod_x * p.width, 0.0)); + + let mut paint = Paint::default(); + paint.shader = Pattern::new(img.as_ref(), SpreadMode::Pad, FilterQuality::Bilinear, 1.0, Transform::identity()); + paint.anti_alias = true; + let sx = p.width / iw; + + let (v_top, v_wrist, v_end) = (a.hand_top * ih, a.wrist * ih, a.rod_end * ih); + let k_rod = p.rod_len / (v_end - v_wrist).max(1.0); + let rod = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k_rod, 0.0, p.rod_len + k_rod * v_wrist)); + if let Some(r) = Rect::from_ltrb(0.0, v_wrist - 1.0, iw, v_end.max(v_wrist + 1.0)) { + pix.fill_rect(r, &paint, rod, None); + } + + let k_hand = p.hand_pts / (v_wrist - v_top).max(1.0); + let hand = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k_hand, 0.0, p.rod_len + p.hand_pts + k_hand * v_top)); + if let Some(r) = Rect::from_ltrb(0.0, v_top, iw, v_wrist + 1.0) { + pix.fill_rect(r, &paint, hand, None); + } + } +} diff --git a/windows/src/hand.rs b/windows/src/hand.rs new file mode 100644 index 0000000..cd5b012 --- /dev/null +++ b/windows/src/hand.rs @@ -0,0 +1,127 @@ +use std::path::PathBuf; + +use tiny_skia::*; + +/// Where the pointer art lives. TH_ASSETS wins, then next to the exe, then the repo. +fn find(name: &str) -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let mut roots = vec![]; + if let Ok(p) = std::env::var("TH_ASSETS") { + roots.push(PathBuf::from(p)); + } + roots.push(dir.join("assets")); + roots.push(PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets"))); + roots.into_iter().map(|r| r.join(format!("{name}.png"))).find(|p| p.exists()) +} + +/// Buffers are BGRA, so swap red and blue once at load. +fn swap_rb(p: &mut Pixmap) { + for px in p.data_mut().chunks_exact_mut(4) { + px.swap(0, 2); + } +} + +pub struct HandAsset { + full: Pixmap, + pub display: Pixmap, + pub aspect: f32, + pub hand_top: f32, + pub tip: f32, + pub wrist: f32, + pub rod_end: f32, + pub tip_x: f32, + pub rod_x: f32, +} + +impl HandAsset { + pub fn load(id: &str) -> Option { + let mut full = Pixmap::load_png(find(&format!("hand_{id}"))?).ok()?; + swap_rb(&mut full); + let m = measure(&full)?; + Some(Self { display: full.clone(), full, ..m }) + } + + /// Keeps the texture near its on-screen size so minification stays crisp. + pub fn fit(&mut self, rest_px: f32) { + let step = 64u32; + let needed = (rest_px.ceil() as u32).div_ceil(step) * step; + let full_h = self.full.height(); + let target = if needed * 10 >= full_h * 9 { full_h } else { needed.max(step) }; + if target != self.display.height() { + self.display = resized(&self.full, target); + } + } + + fn blank(full: Pixmap) -> Self { + Self { display: full.clone(), full, aspect: 0.0, hand_top: 0.0, tip: 0.0, wrist: 0.0, rod_end: 0.0, tip_x: 0.5, rod_x: 0.5 } + } +} + +fn measure(p: &Pixmap) -> Option { + let (w, h) = (p.width() as usize, p.height() as usize); + let data = p.data(); + let solid = |x: usize, y: usize| data[(y * w + x) * 4 + 3] > 128; + let counts: Vec = (0..h).map(|y| (0..w).filter(|&x| solid(x, y)).count()).collect(); + let center = |y: usize| { + let xs: Vec = (0..w).filter(|&x| solid(x, y)).collect(); + match (xs.first(), xs.last()) { + (Some(&lo), Some(&hi)) => (lo + hi) as f32 / 2.0 / w as f32, + _ => 0.5, + } + }; + + let top = counts.iter().position(|&c| c > 0)?; + let search_end = h.min(top + (0.45 * h as f64) as usize); + let max_w = *counts[top..search_end].iter().max()?; + if max_w == 0 { + return None; + } + let fist = (top..h).find(|&y| counts[y] as f64 > 0.6 * max_w as f64)?; + let wrist = (fist..h).find(|&y| (counts[y] as f64) < 0.35 * max_w as f64)?; + let rod_w = counts[(h - 1).min(wrist + h / 50)]; + let last_row = counts.iter().rposition(|&c| c > 0).unwrap_or(h - 1); + let from = (h - 1).min(wrist + h / 33); + let handle = (from..h).find(|&y| counts[y] as f64 > 1.8 * rod_w as f64); + let rod_end = handle.unwrap_or(last_row); + + let fh = h as f32; + let mut asset = HandAsset::blank(p.clone()); + asset.aspect = w as f32 / fh; + asset.hand_top = ((top as f32 - 0.004 * fh).max(0.0)) / fh; + asset.tip = top as f32 / fh; + asset.wrist = wrist as f32 / fh; + asset.rod_end = rod_end as f32 / fh; + asset.tip_x = center((h - 1).min(top + h / 200)); + asset.rod_x = center((wrist + rod_end) / 2); + Some(asset) +} + +fn halve(src: &Pixmap) -> Pixmap { + let (w, h) = (src.width() as usize / 2, src.height() as usize / 2); + let mut out = Pixmap::new(w as u32, h as u32).expect("halved pixmap"); + let sw = src.width() as usize; + let (s, d) = (src.data(), out.data_mut()); + for y in 0..h { + for x in 0..w { + for c in 0..4 { + let i = |dx: usize, dy: usize| s[((2 * y + dy) * sw + 2 * x + dx) * 4 + c] as u32; + d[(y * w + x) * 4 + c] = ((i(0, 0) + i(1, 0) + i(0, 1) + i(1, 1) + 2) / 4) as u8; + } + } + } + out +} + +fn resized(full: &Pixmap, height: u32) -> Pixmap { + let mut cur = full.clone(); + while cur.height() / 2 >= height && cur.width() >= 2 { + cur = halve(&cur); + } + let width = ((cur.width() as f32 * height as f32 / cur.height() as f32).round() as u32).max(1); + let mut out = Pixmap::new(width, height).expect("resized pixmap"); + let ts = Transform::from_scale(width as f32 / cur.width() as f32, height as f32 / cur.height() as f32); + let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }; + out.draw_pixmap(0, 0, cur.as_ref(), &paint, ts, None); + out +} diff --git a/windows/src/main.rs b/windows/src/main.rs index 65f6b94..6cf1bf8 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -1,9 +1,12 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod arm; mod cursor; +mod hand; mod hook; mod renderer; mod scene; +mod styles; mod surface; use std::cell::RefCell; @@ -289,7 +292,9 @@ fn main() { let off = BOOL(1); let _ = DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); - let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h))); + let id = std::env::args().nth(1).unwrap_or_else(|| "lavender".into()); + let (style, ink) = styles::load(&id); + let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h, style, ink))); APP.with(|a| *a.borrow_mut() = Some(App { hwnd, renderer: renderer.clone(), active: false, suspended: false })); SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); hook::init(); diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index 7915708..2a5e982 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -11,9 +11,12 @@ use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, PostMessageW}; use crate::hook; use crate::scene::{Ink, Pending, Rc, draw_laser, union_opt}; +use crate::styles::Style; use crate::surface::Surface; const DRAG_THRESHOLD: f32 = 4.0; +const POINTER_SIZE: f32 = 0.13; +const TICK_HZ: f32 = 120.0; pub enum Next { Frame, @@ -24,8 +27,10 @@ pub struct Renderer { hwnd: HWND, surface: Surface, ink: Ink, + style: Style, cursor: (f32, f32), - dot: Option<(f32, f32, f32)>, + key: Vec, + shape: Option, down_at: (f32, f32), press: f32, held: bool, @@ -47,13 +52,17 @@ fn cursor_pos() -> (f32, f32) { } impl Renderer { - pub fn new(hwnd: HWND, w: i32, h: i32) -> Self { + pub fn new(hwnd: HWND, w: i32, h: i32, style: Style, ink_rgb: u32) -> Self { + let mut ink = Ink::new(w, h); + ink.set_color(ink_rgb); Self { hwnd, surface: Surface::new(w, h), - ink: Ink::new(w, h), + ink, + style, cursor: (0.0, 0.0), - dot: None, + key: vec![], + shape: None, down_at: (0.0, 0.0), press: 0.0, held: false, @@ -69,7 +78,8 @@ impl Renderer { self.surface.clear(); self.ink.clear(); self.ink.take_dirty(); - self.dot = None; + self.key.clear(); + self.shape = None; self.press = 0.0; self.held = false; self.cursor = cursor_pos(); @@ -116,14 +126,14 @@ impl Renderer { self.ink.extend(at); } } else if self.press > 0.002 { - self.press *= 0.82f32.powf(dt / 0.016); + self.press *= 0.82f32.powf(dt * TICK_HZ); busy = true; } else { self.press = 0.0; } busy |= self.ink.tick(); - self.draw(at); + busy |= self.draw(at, dt); if busy { return Next::Frame; @@ -135,24 +145,35 @@ impl Renderer { } } - fn draw(&mut self, at: (f32, f32)) { + /// Returns true while the pointer is still easing toward its target. + fn draw(&mut self, at: (f32, f32), dt: f32) -> bool { let (w, h) = (self.surface.w, self.surface.h); - let r = (h as f32 * 0.0038).max(4.0) * (1.0 + 0.2 * self.press); - let dot = (at.0, at.1, r); - let dot_moved = self.dot != Some(dot); + let screen = (w as f32, h as f32); + + let mut pose = None; + let (key, shape, settling) = match &mut self.style { + Style::Hand(arm) => { + let p = arm.aim(at, screen, self.press, POINTER_SIZE, dt); + pose = Some(p); + (p.key(), arm.bbox(&p, screen.1), arm.settling()) + } + Style::Laser => { + let r = laser_radius(screen.1, self.press); + (vec![at.0 as i32 * 100, at.1 as i32 * 100, (r * 100.0) as i32], Rc::around(at.0, at.1, r * 3.2 + 3.0), false) + } + }; + let ink_dirty = self.ink.take_dirty(); - if !dot_moved && ink_dirty.is_none() { - return; + if key == self.key && ink_dirty.is_none() { + return settling; } - - let reach = r * 3.2 + 3.0; - let now = Rc::around(at.0, at.1, reach); - let mut dirty = union_opt(ink_dirty, now); - if let Some(p) = self.dot { - dirty = dirty.union(Rc::around(p.0, p.1, p.2 * 3.2 + 3.0)); + let mut dirty = union_opt(ink_dirty, shape); + if let Some(prev) = self.shape { + dirty = dirty.union(prev); } - self.dot = Some(dot); - let Some(d) = dirty.clip(w, h) else { return }; + self.key = key; + self.shape = Some(shape); + let Some(d) = dirty.clip(w, h) else { return settling }; let stride = w as usize * 4; let src = self.ink.layer.data(); @@ -162,9 +183,13 @@ impl Renderer { dst[range.clone()].copy_from_slice(&src[range]); } if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { - draw_laser(&mut pix, at, r); + match (&self.style, pose) { + (Style::Hand(arm), Some(p)) => arm.draw(&mut pix, &p, screen.1), + _ => draw_laser(&mut pix, at, laser_radius(screen.1, self.press)), + } } self.drew = true; + settling } pub fn present(&self) { @@ -176,6 +201,10 @@ impl Renderer { } } +fn laser_radius(screen_h: f32, press: f32) -> f32 { + (screen_h * 0.0038).max(4.0) * (1.0 + 0.2 * press) +} + pub fn lock(r: &Mutex) -> MutexGuard<'_, Renderer> { r.lock().unwrap_or_else(|e| e.into_inner()) } diff --git a/windows/src/scene.rs b/windows/src/scene.rs index 201db2f..15e64de 100644 --- a/windows/src/scene.rs +++ b/windows/src/scene.rs @@ -101,6 +101,10 @@ impl Ink { } } + pub fn set_color(&mut self, rgb: u32) { + self.color = px((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, 255); + } + pub fn is_drawing(&self) -> bool { self.drawing } diff --git a/windows/src/styles.rs b/windows/src/styles.rs new file mode 100644 index 0000000..9f12fa0 --- /dev/null +++ b/windows/src/styles.rs @@ -0,0 +1,60 @@ +use crate::arm::Arm; +use crate::hand::HandAsset; + +pub enum Style { + Laser, + Hand(Arm), +} + +pub const LASER_INK: u32 = 0xFF291F; + +const PURPLE: u32 = 0x8754F2; +const RED: u32 = LASER_INK; + +const HANDS: [(&str, u32); 36] = [ + ("lavender", PURPLE), + ("glove", PURPLE), + ("coral", RED), + ("plush", 0x2F86F0), + ("robot", 0x16A87A), + ("pink", 0xEE4F98), + ("blue", 0x2F86F0), + ("mint", 0x16A87A), + ("lemon", 0xF2B705), + ("black", 0x1D1D21), + ("chrome", 0x6B7380), + ("holo", PURPLE), + ("fuzzy", PURPLE), + ("wood", 0x9A5E2C), + ("marble", 0x6B7380), + ("terrazzo", 0xF0561D), + ("glass", 0x1597E5), + ("jelly", 0xA33FE6), + ("pastel", 0xEE4F98), + ("pixel", 0x1D1D21), + ("lowpoly", PURPLE), + ("knit", PURPLE), + ("skeleton", 0x9A5E2C), + ("astro", 0x2F86F0), + ("space", RED), + ("monster", 0x3E9E2E), + ("water", 0x1597E5), + ("lava", 0xF0561D), + ("grass", 0x3E9E2E), + ("cactus", 0x3E9E2E), + ("choco", 0x9A5E2C), + ("cloud", 0x2F86F0), + ("mech", 0xF0561D), + ("neon", 0xE0309A), + ("leaf", 0x3E9E2E), + ("cookie", 0x9A5E2C), +]; + +/// Unknown or unloadable hands fall back to the laser, like the Mac app. +pub fn load(id: &str) -> (Style, u32) { + let Some(&(_, ink)) = HANDS.iter().find(|(name, _)| *name == id) else { return (Style::Laser, LASER_INK) }; + match HandAsset::load(id) { + Some(asset) => (Style::Hand(Arm::new(asset)), ink), + None => (Style::Laser, LASER_INK), + } +} From 78f159c950023898f7d2cbec3fb7bc80428e31d7 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:24:41 +0530 Subject: [PATCH 07/28] Windows: attach to the parent console so Ctrl+C works in release builds --- windows/src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/windows/src/main.rs b/windows/src/main.rs index 6cf1bf8..103a111 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Dwm::*; use windows::Win32::Media::timeBeginPeriod; -use windows::Win32::System::Console::SetConsoleCtrlHandler; +use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole, SetConsoleCtrlHandler}; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::Threading::{OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW}; use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook}; @@ -301,6 +301,8 @@ fn main() { hook::start(); std::thread::spawn(move || renderer::run(renderer)); add_tray(hwnd); + // Release builds have no console of their own. Borrow the terminal's so Ctrl+C reaches us. + let _ = AttachConsole(ATTACH_PARENT_PROCESS); let _ = SetConsoleCtrlHandler(Some(on_ctrl), true); let mods = MOD_CONTROL | MOD_ALT | MOD_NOREPEAT; From 1760b1c7e9fe6a9cc9002843ca7eb3064f7451ce Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:28:40 +0530 Subject: [PATCH 08/28] Windows: drop shadow under the hand and rod --- windows/src/arm.rs | 93 ++++++++++++++++++++++++++++++++++++----- windows/src/renderer.rs | 3 +- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/windows/src/arm.rs b/windows/src/arm.rs index cd55cb5..90300f4 100644 --- a/windows/src/arm.rs +++ b/windows/src/arm.rs @@ -7,6 +7,7 @@ const MIN_ROD: f32 = 0.05; const GRIP_LEAN: f32 = 0.30; const GRIP_DEPTH: f32 = 0.12; const GRIP_FOLLOW: f32 = 0.08; +const LIFT: f32 = 0.06; /// The follow and press rates were tuned at 120 ticks per second. const TICK_HZ: f32 = 120.0; @@ -18,11 +19,17 @@ pub struct Pose { hand_pts: f32, width: f32, grip: (f32, f32), + lift: f32, + blur: f32, + opacity: f32, } impl Pose { pub fn key(&self) -> Vec { - [self.rotation, self.rod_len, self.hand_pts, self.width, self.grip.0].iter().map(|v| (v * 100.0).round() as i32).collect() + [self.rotation, self.rod_len, self.hand_pts, self.width, self.grip.0, self.lift, self.blur] + .iter() + .map(|v| (v * 100.0).round() as i32) + .collect() } } @@ -69,7 +76,18 @@ impl Arm { let along = (reach * reach - tip_off_x * tip_off_x).max(0.0).sqrt(); let rod_len = (along - tip_pts).max(MIN_ROD * sh); let tip_angle = (rod_len + tip_pts).atan2(tip_off_x); - Pose { rotation: aim_angle - tip_angle, rod_len, hand_pts, width, grip } + + let lift = LIFT * sh * (1.0 - 0.8 * press); + Pose { + rotation: aim_angle - tip_angle, + rod_len, + hand_pts, + width, + grip, + lift, + blur: (lift * 0.35).max(1.0) + 5.0 * press, + opacity: 0.26 + 0.02 * press, + } } fn corners(&self, p: &Pose, sh: f32) -> [(f32, f32); 4] { @@ -84,23 +102,29 @@ impl Arm { pub fn bbox(&self, p: &Pose, sh: f32) -> Rc { let c = self.corners(p, sh); - let pad = 6.0; + let pad = 6.0 + p.lift * 1.1 + p.blur * 2.0; let min = |f: fn(&(f32, f32)) -> f32| c.iter().map(f).fold(f32::MAX, f32::min); let max = |f: fn(&(f32, f32)) -> f32| c.iter().map(f).fold(f32::MIN, f32::max); Rc { l: (min(|c| c.0) - pad).floor() as i32, t: (min(|c| c.1) - pad).floor() as i32, r: (max(|c| c.0) + pad).ceil() as i32, b: (max(|c| c.1) + pad).ceil() as i32 } } - pub fn draw(&self, pix: &mut PixmapMut, p: &Pose, sh: f32) { - let a = &self.asset; - let img = &a.display; - let (iw, ih) = (img.width() as f32, img.height() as f32); - let flip = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, sh); + pub fn draw(&self, pix: &mut PixmapMut, p: &Pose, sh: f32, shadow: bool) { let (sin, cos) = p.rotation.sin_cos(); - let base = flip + let base = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, sh) .pre_concat(Transform::from_translate(p.grip.0, p.grip.1)) .pre_concat(Transform::from_row(cos, sin, -sin, cos, 0.0, 0.0)) - .pre_concat(Transform::from_translate(-a.rod_x * p.width, 0.0)); + .pre_concat(Transform::from_translate(-self.asset.rod_x * p.width, 0.0)); + if shadow { + self.draw_shadow(pix, p, base); + } + self.parts(pix, base, p); + } + /// base maps group-local space (origin at the rod's bottom-left, y up) to the target. + fn parts(&self, pix: &mut PixmapMut, base: Transform, p: &Pose) { + let a = &self.asset; + let img = &a.display; + let (iw, ih) = (img.width() as f32, img.height() as f32); let mut paint = Paint::default(); paint.shader = Pattern::new(img.as_ref(), SpreadMode::Pad, FilterQuality::Bilinear, 1.0, Transform::identity()); paint.anti_alias = true; @@ -119,4 +143,53 @@ impl Arm { pix.fill_rect(r, &paint, hand, None); } } + + /// Blurred in unrotated local space, where the buffer is small, then rotated into place. + fn draw_shadow(&self, pix: &mut PixmapMut, p: &Pose, base: Transform) { + let pad = (p.blur * 2.0).ceil() as i32 + 2; + let top = p.rod_len + p.hand_pts; + let w = (p.width.ceil() as i32 + 2 * pad) as u32; + let h = (top.ceil() as i32 + 2 * pad) as u32; + let Some(mut sil) = Pixmap::new(w, h) else { return }; + let to_buf = Transform::from_row(1.0, 0.0, 0.0, -1.0, pad as f32, top + pad as f32); + self.parts(&mut sil.as_mut(), to_buf, p); + + let mut alpha: Vec = sil.data().chunks_exact(4).map(|px| px[3]).collect(); + let radius = (p.blur / 2.0).round().max(1.0) as usize; + box_blur(&mut alpha, w as usize, h as usize, radius); + for (px, a) in sil.data_mut().chunks_exact_mut(4).zip(&alpha) { + px.copy_from_slice(&[0, 0, 0, (*a as f32 * p.opacity) as u8]); + } + + let offset = Transform::from_translate(p.lift * 0.6, p.lift); + let from_buf = Transform::from_row(1.0, 0.0, 0.0, -1.0, -pad as f32, top + pad as f32); + let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }; + pix.draw_pixmap(0, 0, sil.as_ref(), &paint, offset.pre_concat(base).pre_concat(from_buf), None); + } +} + +fn pass(src: &[u8], dst: &mut [u8], lines: usize, len: usize, line_step: usize, step: usize, r: usize) { + let win = (2 * r + 1) as u32; + for l in 0..lines { + let b = l * line_step; + let mut sum: u32 = (0..=r.min(len - 1)).map(|i| src[b + i * step] as u32).sum(); + for i in 0..len { + dst[b + i * step] = (sum / win) as u8; + if i + r + 1 < len { + sum += src[b + (i + r + 1) * step] as u32; + } + if i >= r { + sum -= src[b + (i - r) * step] as u32; + } + } + } +} + +/// Three box passes approximate a Gaussian. +fn box_blur(a: &mut [u8], w: usize, h: usize, r: usize) { + let mut tmp = vec![0u8; a.len()]; + for _ in 0..3 { + pass(a, &mut tmp, h, w, w, 1, r); + pass(&tmp, a, w, h, 1, w, r); + } } diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index 2a5e982..1aa5766 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -16,6 +16,7 @@ use crate::surface::Surface; const DRAG_THRESHOLD: f32 = 4.0; const POINTER_SIZE: f32 = 0.13; +const SHADOW: bool = true; const TICK_HZ: f32 = 120.0; pub enum Next { @@ -184,7 +185,7 @@ impl Renderer { } if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { match (&self.style, pose) { - (Style::Hand(arm), Some(p)) => arm.draw(&mut pix, &p, screen.1), + (Style::Hand(arm), Some(p)) => arm.draw(&mut pix, &p, screen.1, SHADOW), _ => draw_laser(&mut pix, at, laser_radius(screen.1, self.press)), } } From 912b787ac2672a3248afe2ff343a1bf99b021671 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:32:49 +0530 Subject: [PATCH 09/28] Windows: toolbar with hand thumbnails, laser and exit, saved pointer choice --- windows/src/hand.rs | 22 ++- windows/src/hook.rs | 21 ++- windows/src/main.rs | 85 ++++++++++- windows/src/prefs.rs | 18 +++ windows/src/renderer.rs | 18 +++ windows/src/surface.rs | 16 +- windows/src/toolbar.rs | 317 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 488 insertions(+), 9 deletions(-) create mode 100644 windows/src/prefs.rs create mode 100644 windows/src/toolbar.rs diff --git a/windows/src/hand.rs b/windows/src/hand.rs index cd5b012..b772abc 100644 --- a/windows/src/hand.rs +++ b/windows/src/hand.rs @@ -32,9 +32,26 @@ pub struct HandAsset { pub rod_end: f32, pub tip_x: f32, pub rod_x: f32, + min_x: f32, + max_x: f32, } impl HandAsset { + /// Square crop of just the hand, for toolbar buttons. + pub fn thumbnail(&self, px: u32) -> Pixmap { + let (w, h) = (self.full.width() as f32, self.full.height() as f32); + let side = ((self.max_x - self.min_x) * w).max((self.wrist - self.hand_top) * h) * 1.06; + let (cx, cy) = ((self.min_x + self.max_x) / 2.0 * w, (self.hand_top + self.wrist) / 2.0 * h); + let scale = px as f32 / side; + let small = resized(&self.full, ((h * scale).round() as u32).max(1)); + let k = small.height() as f32 / h; + let mut out = Pixmap::new(px, px).expect("thumbnail"); + let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }; + let ts = Transform::from_translate(px as f32 / 2.0 - cx * k, px as f32 / 2.0 - cy * k); + out.draw_pixmap(0, 0, small.as_ref(), &paint, ts, None); + out + } + pub fn load(id: &str) -> Option { let mut full = Pixmap::load_png(find(&format!("hand_{id}"))?).ok()?; swap_rb(&mut full); @@ -54,7 +71,7 @@ impl HandAsset { } fn blank(full: Pixmap) -> Self { - Self { display: full.clone(), full, aspect: 0.0, hand_top: 0.0, tip: 0.0, wrist: 0.0, rod_end: 0.0, tip_x: 0.5, rod_x: 0.5 } + Self { display: full.clone(), full, aspect: 0.0, hand_top: 0.0, tip: 0.0, wrist: 0.0, rod_end: 0.0, tip_x: 0.5, rod_x: 0.5, min_x: 0.0, max_x: 1.0 } } } @@ -94,6 +111,9 @@ fn measure(p: &Pixmap) -> Option { asset.rod_end = rod_end as f32 / fh; asset.tip_x = center((h - 1).min(top + h / 200)); asset.rod_x = center((wrist + rod_end) / 2); + let xs = || (top..wrist).flat_map(|y| (0..w).filter(move |&x| solid(x, y))); + asset.min_x = xs().min().unwrap_or(0) as f32 / w as f32; + asset.max_x = xs().max().unwrap_or(w - 1) as f32 / w as f32; Some(asset) } diff --git a/windows/src/hook.rs b/windows/src/hook.rs index e83c34d..a03cfbc 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -21,7 +21,20 @@ pub static DOWN_Y: AtomicI32 = AtomicI32::new(0); static WAKE: AtomicIsize = AtomicIsize::new(0); static SWALLOWED: AtomicBool = AtomicBool::new(false); -static PASS: [AtomicI32; 4] = [const { AtomicI32::new(0) }; 4]; +/// Clicks inside these rects reach the windows under the overlay: taskbar, toolbar. +static PASS: [[AtomicI32; 4]; 2] = [const { [const { AtomicI32::new(0) }; 4] }; 2]; +/// Cursor zone that reveals the toolbar. +pub static ZONE: [AtomicI32; 4] = [const { AtomicI32::new(0) }; 4]; + +pub fn set_zone(r: RECT) { + for (slot, v) in ZONE.iter().zip([r.left, r.top, r.right, r.bottom]) { + slot.store(v, Relaxed); + } +} + +pub fn in_zone(x: i32, y: i32) -> bool { + x >= ZONE[0].load(Relaxed) && y >= ZONE[1].load(Relaxed) && x < ZONE[2].load(Relaxed) && y < ZONE[3].load(Relaxed) +} pub fn init() { let event = unsafe { CreateEventW(None, false, false, PCWSTR::null()) }.expect("wake event"); @@ -44,14 +57,14 @@ pub fn wait(ms: u32) { } } -pub fn set_passthrough(r: RECT) { - for (slot, v) in PASS.iter().zip([r.left, r.top, r.right, r.bottom]) { +pub fn set_passthrough(index: usize, r: RECT) { + for (slot, v) in PASS[index].iter().zip([r.left, r.top, r.right, r.bottom]) { slot.store(v, Relaxed); } } fn in_passthrough(p: POINT) -> bool { - p.x >= PASS[0].load(Relaxed) && p.y >= PASS[1].load(Relaxed) && p.x < PASS[2].load(Relaxed) && p.y < PASS[3].load(Relaxed) + PASS.iter().any(|r| p.x >= r[0].load(Relaxed) && p.y >= r[1].load(Relaxed) && p.x < r[2].load(Relaxed) && p.y < r[3].load(Relaxed)) } unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { diff --git a/windows/src/main.rs b/windows/src/main.rs index 103a111..bdf7f0d 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -4,10 +4,12 @@ mod arm; mod cursor; mod hand; mod hook; +mod prefs; mod renderer; mod scene; mod styles; mod surface; +mod toolbar; use std::cell::RefCell; use std::ffi::c_void; @@ -28,6 +30,7 @@ use windows::Win32::UI::WindowsAndMessaging::*; use windows::core::{BOOL, PCWSTR, PWSTR, w}; use renderer::{Renderer, lock}; +use toolbar::{Action, Toolbar}; const TIMER_TASKBAR: usize = 1; const HK_TOGGLE: i32 = 1; @@ -39,12 +42,20 @@ const CMD_QUIT: usize = 2; static HWND_RAW: AtomicIsize = AtomicIsize::new(0); +const TIMER_INITIAL: usize = 3; +const PINNED: [&str; 6] = ["lavender", "glove", "coral", "plush", "robot", "laser"]; + struct App { hwnd: HWND, renderer: Arc>, + toolbar: Toolbar, active: bool, /// A capture tool has focus. TinyHand steps aside until it leaves. suspended: bool, + /// Cursor is in the toolbar's reveal zone. + near: bool, + /// The toolbar shows for a few seconds after turning on. + initial: bool, } const YIELD_TO: [&str; 3] = ["screenclippinghost.exe", "snippingtool.exe", "screensketch.exe"]; @@ -134,8 +145,21 @@ impl App { unsafe { let _ = RegisterHotKey(Some(self.hwnd), HK_ESC, MOD_NOREPEAT, VK_ESCAPE.0 as u32); SetTimer(Some(self.hwnd), TIMER_TASKBAR, 500, None); + SetTimer(Some(self.hwnd), TIMER_INITIAL, 3000, None); let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); } + self.near = false; + self.initial = true; + self.toolbar.show(); + self.toolbar.set_wanted(true); + hook::wake(); + } + + fn pick(&mut self, id: &str) { + let (style, ink) = styles::load(id); + lock(&self.renderer).set_style(style, ink); + self.toolbar.select(id); + prefs::save_style(id); hook::wake(); } @@ -144,8 +168,10 @@ impl App { hook::HELD.store(false, Relaxed); cursor::restore(); lock(&self.renderer).reset(); + self.toolbar.hide(); unsafe { let _ = KillTimer(Some(self.hwnd), TIMER_TASKBAR); + let _ = KillTimer(Some(self.hwnd), TIMER_INITIAL); let _ = UnregisterHotKey(Some(self.hwnd), HK_ESC); let _ = ShowWindow(self.hwnd, SW_HIDE); } @@ -158,12 +184,34 @@ impl App { let _ = GetWindowRect(bar, &mut r); } } - hook::set_passthrough(r); + hook::set_passthrough(0, r); } fn on_message(&mut self, hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> Option { + if hwnd == self.toolbar.hwnd { + let (result, action) = self.toolbar.handle(msg, wp, lp)?; + match action { + Some(Action::Pick(id)) => self.pick(&id), + Some(Action::Exit) => self.deactivate(), + None => {} + } + return Some(result); + } match msg { + WM_TIMER if wp.0 == TIMER_INITIAL => { + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER_INITIAL); + } + self.initial = false; + self.toolbar.set_wanted(self.near); + } WM_TIMER => self.refresh_taskbar(), + toolbar::WM_TB_WANT => { + self.near = wp.0 != 0; + if self.active && !self.suspended { + self.toolbar.set_wanted(self.near || self.initial); + } + } hook::WM_PRESENT => { hook::PRESENT_PENDING.store(false, Relaxed); if self.active { @@ -292,10 +340,41 @@ fn main() { let off = BOOL(1); let _ = DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); - let id = std::env::args().nth(1).unwrap_or_else(|| "lavender".into()); + let bar_class = w!("TinyHandToolbar"); + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: bar_class, + ..Default::default() + }); + let bar = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + bar_class, + w!("TinyHand toolbar"), + WS_POPUP, + 0, + 0, + 1, + 1, + None, + None, + Some(hinst), + None, + ) + .expect("toolbar window"); + let _ = DwmSetWindowAttribute(bar, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); + + let id = std::env::args().nth(1).or_else(prefs::load_style).unwrap_or_else(|| "lavender".into()); let (style, ink) = styles::load(&id); + let mut pinned = PINNED.to_vec(); + if !pinned.contains(&id.as_str()) { + pinned.insert(pinned.len() - 1, &id); + } + let toolbar = Toolbar::new(bar, &pinned, &id); let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h, style, ink))); - APP.with(|a| *a.borrow_mut() = Some(App { hwnd, renderer: renderer.clone(), active: false, suspended: false })); + let app = App { hwnd, renderer: renderer.clone(), toolbar, active: false, suspended: false, near: false, initial: false }; + APP.with(|a| *a.borrow_mut() = Some(app)); SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); hook::init(); hook::start(); diff --git a/windows/src/prefs.rs b/windows/src/prefs.rs new file mode 100644 index 0000000..f403bf6 --- /dev/null +++ b/windows/src/prefs.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +fn file() -> Option { + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("TinyHand").join("style.txt")) +} + +pub fn load_style() -> Option { + let text = std::fs::read_to_string(file()?).ok()?; + Some(text.trim().to_string()).filter(|s| !s.is_empty()) +} + +pub fn save_style(id: &str) { + let Some(path) = file() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(path, id); +} diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index 1aa5766..8d82083 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -12,6 +12,7 @@ use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, PostMessageW}; use crate::hook; use crate::scene::{Ink, Pending, Rc, draw_laser, union_opt}; use crate::styles::Style; +use crate::toolbar::WM_TB_WANT; use crate::surface::Surface; const DRAG_THRESHOLD: f32 = 4.0; @@ -39,6 +40,7 @@ pub struct Renderer { up_seq: u32, last: Instant, drew: bool, + near_toolbar: bool, } // The raw HWND and DIB pointer are only used behind the mutex. @@ -71,11 +73,19 @@ impl Renderer { up_seq: 0, last: Instant::now(), drew: false, + near_toolbar: false, } } + pub fn set_style(&mut self, style: Style, ink_rgb: u32) { + self.style = style; + self.ink.set_color(ink_rgb); + self.key.clear(); + } + /// Blank slate and an empty frame. Window thread only. pub fn reset(&mut self) { + self.near_toolbar = false; self.surface.clear(); self.ink.clear(); self.ink.take_dirty(); @@ -134,6 +144,14 @@ impl Renderer { } busy |= self.ink.tick(); + let near = !self.ink.is_drawing() && hook::in_zone(at.0 as i32, at.1 as i32); + if near != self.near_toolbar { + self.near_toolbar = near; + unsafe { + let _ = PostMessageW(Some(self.hwnd), WM_TB_WANT, WPARAM(near as usize), LPARAM(0)); + } + } + busy |= self.draw(at, dt); if busy { diff --git a/windows/src/surface.rs b/windows/src/surface.rs index 77d409b..6854817 100644 --- a/windows/src/surface.rs +++ b/windows/src/surface.rs @@ -47,7 +47,21 @@ impl Surface { } fn blend() -> BLENDFUNCTION { - BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: 255, AlphaFormat: AC_SRC_ALPHA as u8 } + Self::blend_with(255) + } + + fn blend_with(alpha: u8) -> BLENDFUNCTION { + BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: alpha, AlphaFormat: AC_SRC_ALPHA as u8 } + } + + /// Places the window at (x, y) with the whole surface at the given opacity. + pub fn present_at(&self, hwnd: HWND, x: i32, y: i32, alpha: u8) { + let origin = POINT::default(); + let at = POINT { x, y }; + let size = SIZE { cx: self.w, cy: self.h }; + unsafe { + let _ = UpdateLayeredWindow(hwnd, None, Some(&at), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend_with(alpha)), ULW_ALPHA); + } } /// Window thread only. diff --git a/windows/src/toolbar.rs b/windows/src/toolbar.rs new file mode 100644 index 0000000..d902ac8 --- /dev/null +++ b/windows/src/toolbar.rs @@ -0,0 +1,317 @@ +use std::time::Instant; + +use tiny_skia::*; +use windows::Win32::Foundation::*; +use windows::Win32::UI::HiDpi::GetDpiForWindow; +use windows::Win32::UI::Input::KeyboardAndMouse::*; +use windows::Win32::UI::WindowsAndMessaging::*; + +use crate::hand::HandAsset; +use crate::hook; +use crate::scene::{draw_laser, px}; +use crate::surface::Surface; + +pub const WM_TB_WANT: u32 = WM_APP + 22; +const TIMER_ANIM: usize = 2; +const WM_MOUSELEAVE: u32 = 0x02A3; + +const BUTTON: f32 = 40.0; +const GAP: f32 = 6.0; +const PAD_X: f32 = 10.0; +const PAD_Y: f32 = 8.0; +const RADIUS: f32 = 16.0; +const FADE_IN: f32 = 0.15; +const FADE_OUT: f32 = 0.35; +const MARGIN_BOTTOM: f32 = 14.0; + +pub enum Action { + Pick(String), + Exit, +} + +enum Kind { + Style(String), + Exit, +} + +struct Item { + kind: Kind, + icon: Option, + x: f32, +} + +pub struct Toolbar { + pub hwnd: HWND, + items: Vec, + divider_x: f32, + surface: Surface, + scale: f32, + selected: String, + hover: Option, + alpha: f32, + target: f32, + last: Instant, + tracking: bool, + frame: RECT, +} + +fn round_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option { + let k = 0.5523 * r; + let mut pb = PathBuilder::new(); + pb.move_to(x + r, y); + pb.line_to(x + w - r, y); + pb.cubic_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r); + pb.line_to(x + w, y + h - r); + pb.cubic_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h); + pb.line_to(x + r, y + h); + pb.cubic_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r); + pb.line_to(x, y + r); + pb.cubic_to(x, y + r - k, x + r - k, y, x + r, y); + pb.close(); + pb.finish() +} + +fn fill(pix: &mut PixmapMut, path: &Path, color: Color) { + let mut paint = Paint::default(); + paint.set_color(color); + paint.anti_alias = true; + pix.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None); +} + +impl Toolbar { + pub fn new(hwnd: HWND, styles: &[&str], selected: &str) -> Self { + let scale = unsafe { GetDpiForWindow(hwnd) }.max(96) as f32 / 96.0; + let icon_px = (36.0 * scale).round() as u32; + let mut items: Vec = styles + .iter() + .map(|id| Item { + kind: Kind::Style(id.to_string()), + icon: HandAsset::load(id).map(|a| a.thumbnail(icon_px)), + x: 0.0, + }) + .collect(); + let mut x = PAD_X * scale; + for item in &mut items { + item.x = x; + x += (BUTTON + GAP) * scale; + } + let divider_x = x; + x += (1.0 + GAP) * scale; + items.push(Item { kind: Kind::Exit, icon: None, x }); + x += (BUTTON + PAD_X) * scale; + + let (w, h) = (x.round() as i32, ((BUTTON + 2.0 * PAD_Y) * scale).round() as i32); + Self { + hwnd, + items, + divider_x, + surface: Surface::new(w, h), + scale, + selected: selected.to_string(), + hover: None, + alpha: 0.0, + target: 0.0, + last: Instant::now(), + tracking: false, + frame: RECT::default(), + } + } + + fn size(&self) -> (i32, i32) { + (self.surface.w, self.surface.h) + } + + fn place(&mut self) { + let mut work = RECT::default(); + unsafe { + let _ = SystemParametersInfoW(SPI_GETWORKAREA, 0, Some((&raw mut work).cast()), SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0)); + } + let (w, h) = self.size(); + let left = work.left + (work.right - work.left - w) / 2; + let top = work.bottom - h - (MARGIN_BOTTOM * self.scale) as i32; + self.frame = RECT { left, top, right: left + w, bottom: top + h }; + let s = self.scale; + hook::set_zone(RECT { + left: left - (80.0 * s) as i32, + top: top - (100.0 * s) as i32, + right: left + w + (80.0 * s) as i32, + bottom: top + h + (10.0 * s) as i32, + }); + } + + pub fn show(&mut self) { + self.place(); + self.alpha = 0.0; + self.target = 0.0; + self.set_interactive(false); + self.paint(); + unsafe { + let _ = SetWindowPos(self.hwnd, Some(HWND_TOPMOST), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); + } + } + + pub fn hide(&mut self) { + self.alpha = 0.0; + self.target = 0.0; + self.hover = None; + self.set_interactive(false); + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER_ANIM); + let _ = ShowWindow(self.hwnd, SW_HIDE); + } + } + + pub fn select(&mut self, id: &str) { + self.selected = id.to_string(); + self.paint(); + } + + pub fn set_wanted(&mut self, wanted: bool) { + let target = if wanted { 1.0 } else { 0.0 }; + if target == self.target { + return; + } + self.target = target; + self.last = Instant::now(); + self.set_interactive(wanted); + unsafe { + SetTimer(Some(self.hwnd), TIMER_ANIM, 8, None); + } + } + + /// Hidden means clicks fall through the window and the hook stops letting them pass. + fn set_interactive(&self, on: bool) { + unsafe { + let ex = GetWindowLongPtrW(self.hwnd, GWL_EXSTYLE); + let flag = WS_EX_TRANSPARENT.0 as isize; + SetWindowLongPtrW(self.hwnd, GWL_EXSTYLE, if on { ex & !flag } else { ex | flag }); + } + hook::set_passthrough(1, if on { self.frame } else { RECT::default() }); + } + + fn animate(&mut self) { + let dt = self.last.elapsed().as_secs_f32(); + self.last = Instant::now(); + let rate = if self.target > self.alpha { 1.0 / FADE_IN } else { -1.0 / FADE_OUT }; + self.alpha = (self.alpha + rate * dt).clamp(0.0, 1.0); + if (self.alpha - self.target).abs() < 0.001 || (self.target == 1.0 && self.alpha >= 1.0) { + self.alpha = self.target; + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER_ANIM); + } + } + self.present(); + } + + fn present(&self) { + self.surface.present_at(self.hwnd, self.frame.left, self.frame.top, (self.alpha * 255.0).round() as u8); + } + + fn paint(&mut self) { + let (w, h) = self.size(); + let s = self.scale; + self.surface.clear(); + let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) else { return }; + + if let Some(path) = round_rect(0.5, 0.5, w as f32 - 1.0, h as f32 - 1.0, RADIUS * s) { + fill(&mut pix, &path, px(18, 18, 18, 158)); + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, 31)); + paint.anti_alias = true; + let stroke = Stroke { width: 1.0, ..Default::default() }; + pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + + let (btn, top) = (BUTTON * s, PAD_Y * s); + for (i, item) in self.items.iter().enumerate() { + let chosen = matches!(&item.kind, Kind::Style(id) if *id == self.selected); + let tint = if chosen { 46 } else if self.hover == Some(i) { 24 } else { 0 }; + if tint > 0 { + if let Some(path) = round_rect(item.x, top, btn, btn, 10.0 * s) { + fill(&mut pix, &path, px(255, 255, 255, tint)); + } + } + let (cx, cy) = (item.x + btn / 2.0, top + btn / 2.0); + match (&item.kind, &item.icon) { + (_, Some(icon)) => { + let (ix, iy) = (cx - icon.width() as f32 / 2.0, cy - icon.height() as f32 / 2.0); + pix.draw_pixmap(ix.round() as i32, iy.round() as i32, icon.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + } + (Kind::Style(_), None) => draw_laser(&mut pix, (cx, cy), 5.0 * s), + (Kind::Exit, None) => { + let d = 6.0 * s; + let mut pb = PathBuilder::new(); + pb.move_to(cx - d, cy - d); + pb.line_to(cx + d, cy + d); + pb.move_to(cx + d, cy - d); + pb.line_to(cx - d, cy + d); + if let Some(path) = pb.finish() { + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, 217)); + paint.anti_alias = true; + let stroke = Stroke { width: 1.8 * s, line_cap: LineCap::Round, ..Default::default() }; + pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + } + } + + if let Some(rect) = Rect::from_xywh(self.divider_x, (h as f32 - 24.0 * s) / 2.0, s.max(1.0), 24.0 * s) { + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, 46)); + pix.fill_rect(rect, &paint, Transform::identity(), None); + } + self.present(); + } + + fn hit(&self, x: i32, y: i32) -> Option { + let s = self.scale; + let (btn, top) = (BUTTON * s, PAD_Y * s); + let (x, y) = (x as f32, y as f32); + self.items.iter().position(|i| x >= i.x && x < i.x + btn && y >= top && y < top + btn) + } + + pub fn handle(&mut self, msg: u32, wp: WPARAM, lp: LPARAM) -> Option<(LRESULT, Option)> { + let point = || ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32); + match msg { + WM_MOUSEACTIVATE => Some((LRESULT(MA_NOACTIVATE as isize), None)), + WM_TIMER if wp.0 == TIMER_ANIM => { + self.animate(); + Some((LRESULT(0), None)) + } + WM_MOUSEMOVE => { + if !self.tracking { + let mut t = TRACKMOUSEEVENT { cbSize: size_of::() as u32, dwFlags: TME_LEAVE, hwndTrack: self.hwnd, dwHoverTime: 0 }; + unsafe { + let _ = TrackMouseEvent(&mut t); + } + self.tracking = true; + } + let (x, y) = point(); + let hover = self.hit(x, y); + if hover != self.hover { + self.hover = hover; + self.paint(); + } + Some((LRESULT(0), None)) + } + WM_MOUSELEAVE => { + self.tracking = false; + if self.hover.take().is_some() { + self.paint(); + } + Some((LRESULT(0), None)) + } + WM_LBUTTONUP => { + let (x, y) = point(); + let action = self.hit(x, y).map(|i| match &self.items[i].kind { + Kind::Style(id) => Action::Pick(id.clone()), + Kind::Exit => Action::Exit, + }); + Some((LRESULT(0), action)) + } + _ => None, + } + } +} From f5cf2e54c8ec88c3721ba8b19e0462a0e300b342 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:36:28 +0530 Subject: [PATCH 10/28] Windows: hold Alt to click through to apps with the real cursor --- windows/src/hook.rs | 76 +++++++++++++++++++++++++++++++++++++++-- windows/src/main.rs | 13 ++++++- windows/src/renderer.rs | 21 ++++++++---- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/windows/src/hook.rs b/windows/src/hook.rs index a03cfbc..54ff1d4 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -4,6 +4,7 @@ use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, AtomicU32, Ordering: use windows::Win32::Foundation::*; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::Threading::*; +use windows::Win32::UI::Input::KeyboardAndMouse::*; use windows::Win32::UI::WindowsAndMessaging::*; use windows::core::PCWSTR; @@ -67,6 +68,75 @@ fn in_passthrough(p: POINT) -> bool { PASS.iter().any(|r| p.x >= r[0].load(Relaxed) && p.y >= r[1].load(Relaxed) && p.x < r[2].load(Relaxed) && p.y < r[3].load(Relaxed)) } +/// Alt is held to click for real. The key is hidden from apps while it is. +pub const WM_INTERACT: u32 = WM_APP + 4; +pub static INTERACT: AtomicBool = AtomicBool::new(false); +/// Alt joined another key (Alt+Tab), so it was handed back to the app. +static PASSED: AtomicBool = AtomicBool::new(false); +static TARGET: AtomicIsize = AtomicIsize::new(0); + +pub fn reset_keys() { + INTERACT.store(false, Relaxed); + PASSED.store(false, Relaxed); +} + +fn post_interact(on: bool) { + let hwnd = HWND(TARGET.load(Relaxed) as *mut c_void); + unsafe { + let _ = PostMessageW(Some(hwnd), WM_INTERACT, WPARAM(on as usize), LPARAM(0)); + } + wake(); +} + +fn key_input(vk: u32, scan: u32, extended: bool) -> INPUT { + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(vk as u16), + wScan: scan as u16, + dwFlags: if extended { KEYEVENTF_EXTENDEDKEY } else { KEYBD_EVENT_FLAGS(0) }, + time: 0, + dwExtraInfo: 0, + }, + }, + } +} + +unsafe extern "system" fn key_proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { + if code == HC_ACTION as i32 && ACTIVE.load(Relaxed) { + let k = unsafe { &*(lp.0 as *const KBDLLHOOKSTRUCT) }; + if k.flags.0 & LLKHF_INJECTED.0 == 0 { + let msg = wp.0 as u32; + let down = msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN; + let is_alt = [VK_LMENU, VK_RMENU, VK_MENU].iter().any(|v| v.0 as u32 == k.vkCode); + if is_alt && down && !PASSED.load(Relaxed) { + if !INTERACT.swap(true, Relaxed) { + post_interact(true); + } + return LRESULT(1); + } + if is_alt && !down && !PASSED.swap(false, Relaxed) && INTERACT.swap(false, Relaxed) { + post_interact(false); + return LRESULT(1); + } + if !is_alt && down && INTERACT.load(Relaxed) && !PASSED.load(Relaxed) { + // Give the app its Alt back, then the key, in order. + PASSED.store(true, Relaxed); + INTERACT.store(false, Relaxed); + post_interact(false); + let extended = k.flags.0 & LLKHF_EXTENDED.0 != 0; + let inputs = [key_input(VK_MENU.0 as u32, 0, false), key_input(k.vkCode, k.scanCode, extended)]; + unsafe { + SendInput(&inputs, size_of::() as i32); + } + return LRESULT(1); + } + } + } + unsafe { CallNextHookEx(None, code, wp, lp) } +} + unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { if code == HC_ACTION as i32 && ACTIVE.load(Relaxed) { let info = unsafe { &*(lp.0 as *const MSLLHOOKSTRUCT) }; @@ -78,7 +148,7 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { } else if info.flags & LLMHF_INJECTED == 0 { match msg { WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN => { - if !in_passthrough(info.pt) { + if !INTERACT.load(Relaxed) && !in_passthrough(info.pt) { SWALLOWED.store(true, Relaxed); DOWN_X.store(info.pt.x, Relaxed); DOWN_Y.store(info.pt.y, Relaxed); @@ -105,12 +175,14 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { } /// Own thread, so the callback never waits on rendering. -pub fn start() { +pub fn start(target: HWND) { + TARGET.store(target.0 as isize, Relaxed); std::thread::spawn(|| unsafe { let hinst = GetModuleHandleW(None).map(|m| HINSTANCE(m.0)).ok(); if SetWindowsHookExW(WH_MOUSE_LL, Some(proc), hinst, 0).is_err() { return; } + let _ = SetWindowsHookExW(WH_KEYBOARD_LL, Some(key_proc), hinst, 0); let mut msg = MSG::default(); while GetMessageW(&mut msg, None, 0, 0).as_bool() { let _ = TranslateMessage(&msg); diff --git a/windows/src/main.rs b/windows/src/main.rs index bdf7f0d..a1dc6f1 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -139,6 +139,7 @@ impl App { fn start(&mut self) { lock(&self.renderer).reset(); self.refresh_taskbar(); + hook::reset_keys(); hook::HELD.store(false, Relaxed); hook::ACTIVE.store(true, Relaxed); cursor::hide(); @@ -166,6 +167,7 @@ impl App { fn stop(&mut self) { hook::ACTIVE.store(false, Relaxed); hook::HELD.store(false, Relaxed); + hook::reset_keys(); cursor::restore(); lock(&self.renderer).reset(); self.toolbar.hide(); @@ -206,6 +208,15 @@ impl App { self.toolbar.set_wanted(self.near); } WM_TIMER => self.refresh_taskbar(), + hook::WM_INTERACT => { + if self.active && !self.suspended { + if wp.0 != 0 { + cursor::restore(); + } else { + cursor::hide(); + } + } + } toolbar::WM_TB_WANT => { self.near = wp.0 != 0; if self.active && !self.suspended { @@ -377,7 +388,7 @@ fn main() { APP.with(|a| *a.borrow_mut() = Some(app)); SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); hook::init(); - hook::start(); + hook::start(hwnd); std::thread::spawn(move || renderer::run(renderer)); add_tray(hwnd); // Release builds have no console of their own. Borrow the terminal's so Ctrl+C reaches us. diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index 8d82083..fb2a1ec 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -10,7 +10,7 @@ use windows::Win32::System::Threading::*; use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, PostMessageW}; use crate::hook; -use crate::scene::{Ink, Pending, Rc, draw_laser, union_opt}; +use crate::scene::{Ink, Pending, Rc, draw_laser}; use crate::styles::Style; use crate::toolbar::WM_TB_WANT; use crate::surface::Surface; @@ -182,17 +182,20 @@ impl Renderer { } }; + // Holding Alt shows the real cursor, so the pointer steps out of the way. + let hidden = hook::INTERACT.load(Relaxed); + let mut key = key; + key.push(hidden as i32); + let shape = (!hidden).then_some(shape); + let ink_dirty = self.ink.take_dirty(); if key == self.key && ink_dirty.is_none() { return settling; } - let mut dirty = union_opt(ink_dirty, shape); - if let Some(prev) = self.shape { - dirty = dirty.union(prev); - } + let dirty = [ink_dirty, shape, self.shape].into_iter().flatten().reduce(Rc::union); self.key = key; - self.shape = Some(shape); - let Some(d) = dirty.clip(w, h) else { return settling }; + self.shape = shape; + let Some(d) = dirty.and_then(|d| d.clip(w, h)) else { return settling }; let stride = w as usize * 4; let src = self.ink.layer.data(); @@ -201,6 +204,10 @@ impl Renderer { let range = y * stride + d.l as usize * 4..y * stride + d.r as usize * 4; dst[range.clone()].copy_from_slice(&src[range]); } + if hidden { + self.drew = true; + return settling; + } if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { match (&self.style, pose) { (Style::Hand(arm), Some(p)) => arm.draw(&mut pix, &p, screen.1, SHADOW), From 02844901d5d7a8e0470065cc9a507956a79c6321 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:39:18 +0530 Subject: [PATCH 11/28] Windows: click-through mode, toggled from toolbar, tray or Ctrl+Alt+C --- windows/src/hook.rs | 15 +++++++++++---- windows/src/main.rs | 24 +++++++++++++++++++++++- windows/src/prefs.rs | 36 ++++++++++++++++++++++++++++++------ windows/src/toolbar.rs | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/windows/src/hook.rs b/windows/src/hook.rs index 54ff1d4..3e22194 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -13,6 +13,8 @@ pub const WM_PRESENT: u32 = WM_APP + 3; pub static PRESENT_PENDING: AtomicBool = AtomicBool::new(false); pub static ACTIVE: AtomicBool = AtomicBool::new(false); pub static HELD: AtomicBool = AtomicBool::new(false); +/// Clicks reach the apps all the time. The hand still taps and drags still draw. +pub static CLICK_THROUGH: AtomicBool = AtomicBool::new(false); /// Set by the render thread while it sleeps. The hook wakes it on mouse moves. pub static IDLE: AtomicBool = AtomicBool::new(false); pub static DOWN_SEQ: AtomicU32 = AtomicU32::new(0); @@ -148,22 +150,27 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { } else if info.flags & LLMHF_INJECTED == 0 { match msg { WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN => { + SWALLOWED.store(false, Relaxed); if !INTERACT.load(Relaxed) && !in_passthrough(info.pt) { - SWALLOWED.store(true, Relaxed); DOWN_X.store(info.pt.x, Relaxed); DOWN_Y.store(info.pt.y, Relaxed); HELD.store(true, Relaxed); DOWN_SEQ.fetch_add(1, Relaxed); wake(); - return LRESULT(1); + if !CLICK_THROUGH.load(Relaxed) { + SWALLOWED.store(true, Relaxed); + return LRESULT(1); + } } - SWALLOWED.store(false, Relaxed); } WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP => { - if SWALLOWED.swap(false, Relaxed) { + let swallowed = SWALLOWED.swap(false, Relaxed); + if swallowed || (CLICK_THROUGH.load(Relaxed) && HELD.load(Relaxed)) { HELD.store(false, Relaxed); UP_SEQ.fetch_add(1, Relaxed); wake(); + } + if swallowed { return LRESULT(1); } } diff --git a/windows/src/main.rs b/windows/src/main.rs index a1dc6f1..a6c7bd5 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -36,9 +36,11 @@ const TIMER_TASKBAR: usize = 1; const HK_TOGGLE: i32 = 1; const HK_CLEAR: i32 = 2; const HK_ESC: i32 = 3; +const HK_THROUGH: i32 = 4; const WM_TRAY: u32 = WM_APP + 10; const CMD_TOGGLE: usize = 1; const CMD_QUIT: usize = 2; +const CMD_THROUGH: usize = 3; static HWND_RAW: AtomicIsize = AtomicIsize::new(0); @@ -156,6 +158,17 @@ impl App { hook::wake(); } + fn toggle_click_through(&mut self) { + let on = !hook::CLICK_THROUGH.load(Relaxed); + hook::CLICK_THROUGH.store(on, Relaxed); + if hook::HELD.swap(false, Relaxed) { + hook::UP_SEQ.fetch_add(1, Relaxed); + } + self.toolbar.set_click_through(on); + prefs::save_click_through(on); + hook::wake(); + } + fn pick(&mut self, id: &str) { let (style, ink) = styles::load(id); lock(&self.renderer).set_style(style, ink); @@ -194,6 +207,7 @@ impl App { let (result, action) = self.toolbar.handle(msg, wp, lp)?; match action { Some(Action::Pick(id)) => self.pick(&id), + Some(Action::ToggleClickThrough) => self.toggle_click_through(), Some(Action::Exit) => self.deactivate(), None => {} } @@ -236,6 +250,7 @@ impl App { hook::wake(); } HK_ESC => self.deactivate(), + HK_THROUGH => self.toggle_click_through(), _ => {} }, WM_TRAY => match (lp.0 as u32) & 0xFFFF { @@ -245,6 +260,7 @@ impl App { }, WM_COMMAND => match wp.0 & 0xFFFF { CMD_TOGGLE => self.toggle(), + CMD_THROUGH => self.toggle_click_through(), CMD_QUIT => unsafe { PostQuitMessage(0) }, _ => {} }, @@ -258,6 +274,8 @@ impl App { let Ok(menu) = CreatePopupMenu() else { return }; let label = if self.active { w!("Turn pointer off") } else { w!("Turn pointer on") }; let _ = AppendMenuW(menu, MF_STRING, CMD_TOGGLE, label); + let checked = if hook::CLICK_THROUGH.load(Relaxed) { MF_CHECKED } else { MF_UNCHECKED }; + let _ = AppendMenuW(menu, MF_STRING | checked, CMD_THROUGH, w!("Click through\tCtrl+Alt+C")); let _ = AppendMenuW(menu, MF_STRING, CMD_QUIT, w!("Quit TinyHand")); let mut p = POINT::default(); let _ = GetCursorPos(&mut p); @@ -382,7 +400,10 @@ fn main() { if !pinned.contains(&id.as_str()) { pinned.insert(pinned.len() - 1, &id); } - let toolbar = Toolbar::new(bar, &pinned, &id); + let mut toolbar = Toolbar::new(bar, &pinned, &id); + let through = prefs::load_click_through(); + hook::CLICK_THROUGH.store(through, Relaxed); + toolbar.set_click_through(through); let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h, style, ink))); let app = App { hwnd, renderer: renderer.clone(), toolbar, active: false, suspended: false, near: false, initial: false }; APP.with(|a| *a.borrow_mut() = Some(app)); @@ -398,6 +419,7 @@ fn main() { let mods = MOD_CONTROL | MOD_ALT | MOD_NOREPEAT; let _ = RegisterHotKey(Some(hwnd), HK_TOGGLE, mods, 'H' as u32); let _ = RegisterHotKey(Some(hwnd), HK_CLEAR, mods, 'X' as u32); + let _ = RegisterHotKey(Some(hwnd), HK_THROUGH, mods, 'C' as u32); let mut msg = MSG::default(); while GetMessageW(&mut msg, None, 0, 0).as_bool() { diff --git a/windows/src/prefs.rs b/windows/src/prefs.rs index f403bf6..9b6f304 100644 --- a/windows/src/prefs.rs +++ b/windows/src/prefs.rs @@ -1,18 +1,42 @@ use std::path::PathBuf; fn file() -> Option { - std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("TinyHand").join("style.txt")) + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("TinyHand").join("settings.txt")) } -pub fn load_style() -> Option { - let text = std::fs::read_to_string(file()?).ok()?; - Some(text.trim().to_string()).filter(|s| !s.is_empty()) +fn read() -> Vec<(String, String)> { + let text = file().and_then(|f| std::fs::read_to_string(f).ok()).unwrap_or_default(); + text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().into(), v.trim().into())).collect() } -pub fn save_style(id: &str) { +fn get(key: &str) -> Option { + read().into_iter().find(|(k, _)| k == key).map(|(_, v)| v).filter(|v| !v.is_empty()) +} + +fn set(key: &str, value: &str) { let Some(path) = file() else { return }; + let mut all = read(); + all.retain(|(k, _)| k != key); + all.push((key.into(), value.into())); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } - let _ = std::fs::write(path, id); + let text: String = all.iter().map(|(k, v)| format!("{k}={v}\n")).collect(); + let _ = std::fs::write(path, text); +} + +pub fn load_style() -> Option { + get("style") +} + +pub fn save_style(id: &str) { + set("style", id); +} + +pub fn load_click_through() -> bool { + get("click_through").is_some_and(|v| v == "1") +} + +pub fn save_click_through(on: bool) { + set("click_through", if on { "1" } else { "0" }); } diff --git a/windows/src/toolbar.rs b/windows/src/toolbar.rs index d902ac8..096c390 100644 --- a/windows/src/toolbar.rs +++ b/windows/src/toolbar.rs @@ -26,11 +26,13 @@ const MARGIN_BOTTOM: f32 = 14.0; pub enum Action { Pick(String), + ToggleClickThrough, Exit, } enum Kind { Style(String), + ClickThrough, Exit, } @@ -47,6 +49,7 @@ pub struct Toolbar { surface: Surface, scale: f32, selected: String, + through: bool, hover: Option, alpha: f32, target: f32, @@ -97,6 +100,8 @@ impl Toolbar { } let divider_x = x; x += (1.0 + GAP) * scale; + items.push(Item { kind: Kind::ClickThrough, icon: None, x }); + x += (BUTTON + GAP) * scale; items.push(Item { kind: Kind::Exit, icon: None, x }); x += (BUTTON + PAD_X) * scale; @@ -108,6 +113,7 @@ impl Toolbar { surface: Surface::new(w, h), scale, selected: selected.to_string(), + through: false, hover: None, alpha: 0.0, target: 0.0, @@ -162,6 +168,11 @@ impl Toolbar { } } + pub fn set_click_through(&mut self, on: bool) { + self.through = on; + self.paint(); + } + pub fn select(&mut self, id: &str) { self.selected = id.to_string(); self.paint(); @@ -225,7 +236,11 @@ impl Toolbar { let (btn, top) = (BUTTON * s, PAD_Y * s); for (i, item) in self.items.iter().enumerate() { - let chosen = matches!(&item.kind, Kind::Style(id) if *id == self.selected); + let chosen = match &item.kind { + Kind::Style(id) => *id == self.selected, + Kind::ClickThrough => self.through, + Kind::Exit => false, + }; let tint = if chosen { 46 } else if self.hover == Some(i) { 24 } else { 0 }; if tint > 0 { if let Some(path) = round_rect(item.x, top, btn, btn, 10.0 * s) { @@ -239,6 +254,22 @@ impl Toolbar { pix.draw_pixmap(ix.round() as i32, iy.round() as i32, icon.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } (Kind::Style(_), None) => draw_laser(&mut pix, (cx, cy), 5.0 * s), + (Kind::ClickThrough, None) => { + let arrow = [(0.0, 0.0), (0.0, 16.0), (4.2, 12.2), (7.0, 18.5), (9.6, 17.4), (6.9, 11.3), (12.4, 11.0)]; + let mut pb = PathBuilder::new(); + for (i, (ax, ay)) in arrow.iter().enumerate() { + let (px_, py_) = (cx + (ax - 6.0) * s, cy + (ay - 9.0) * s); + if i == 0 { + pb.move_to(px_, py_); + } else { + pb.line_to(px_, py_); + } + } + pb.close(); + if let Some(path) = pb.finish() { + fill(&mut pix, &path, px(255, 255, 255, 230)); + } + } (Kind::Exit, None) => { let d = 6.0 * s; let mut pb = PathBuilder::new(); @@ -307,6 +338,7 @@ impl Toolbar { let (x, y) = point(); let action = self.hit(x, y).map(|i| match &self.items[i].kind { Kind::Style(id) => Action::Pick(id.clone()), + Kind::ClickThrough => Action::ToggleClickThrough, Kind::Exit => Action::Exit, }); Some((LRESULT(0), action)) From 8fcb132e628b06205a92606974c90120396d0f65 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:41:29 +0530 Subject: [PATCH 12/28] Windows: click-through holds the press so strokes never select text in apps --- windows/src/hook.rs | 55 +++++++++++++++++++++++++++++++++++++-------- windows/src/main.rs | 1 + 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/windows/src/hook.rs b/windows/src/hook.rs index 3e22194..4e12c8c 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -13,7 +13,7 @@ pub const WM_PRESENT: u32 = WM_APP + 3; pub static PRESENT_PENDING: AtomicBool = AtomicBool::new(false); pub static ACTIVE: AtomicBool = AtomicBool::new(false); pub static HELD: AtomicBool = AtomicBool::new(false); -/// Clicks reach the apps all the time. The hand still taps and drags still draw. +/// Plain clicks reach the apps. Drags draw and stay out of the apps. pub static CLICK_THROUGH: AtomicBool = AtomicBool::new(false); /// Set by the render thread while it sleeps. The hook wakes it on mouse moves. pub static IDLE: AtomicBool = AtomicBool::new(false); @@ -70,6 +70,35 @@ fn in_passthrough(p: POINT) -> bool { PASS.iter().any(|r| p.x >= r[0].load(Relaxed) && p.y >= r[1].load(Relaxed) && p.x < r[2].load(Relaxed) && p.y < r[3].load(Relaxed)) } +/// Past this distance a press becomes a stroke. Matches the renderer. +pub const DRAG_PIXELS: f32 = 4.0; +/// A held press that may still turn out to be a plain click. +static PENDING: AtomicBool = AtomicBool::new(false); +static BUTTON: AtomicI32 = AtomicI32::new(0); + +pub fn cancel_pending() { + PENDING.store(false, Relaxed); +} + +fn mouse_input(flags: MOUSE_EVENT_FLAGS) -> INPUT { + INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { mi: MOUSEINPUT { dx: 0, dy: 0, mouseData: 0, dwFlags: flags, time: 0, dwExtraInfo: 0 } }, + } +} + +fn replay_click(x: i32, y: i32, button: i32) { + let (down, up) = match button { + 0 => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), + 1 => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), + _ => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), + }; + unsafe { + let _ = SetCursorPos(x, y); + SendInput(&[mouse_input(down), mouse_input(up)], size_of::() as i32); + } +} + /// Alt is held to click for real. The key is hidden from apps while it is. pub const WM_INTERACT: u32 = WM_APP + 4; pub static INTERACT: AtomicBool = AtomicBool::new(false); @@ -80,6 +109,7 @@ static TARGET: AtomicIsize = AtomicIsize::new(0); pub fn reset_keys() { INTERACT.store(false, Relaxed); PASSED.store(false, Relaxed); + cancel_pending(); } fn post_interact(on: bool) { @@ -144,6 +174,12 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { let info = unsafe { &*(lp.0 as *const MSLLHOOKSTRUCT) }; let msg = wp.0 as u32; if msg == WM_MOUSEMOVE { + if PENDING.load(Relaxed) { + let (dx, dy) = (info.pt.x - DOWN_X.load(Relaxed), info.pt.y - DOWN_Y.load(Relaxed)); + if ((dx * dx + dy * dy) as f32).sqrt() >= DRAG_PIXELS { + PENDING.store(false, Relaxed); + } + } if IDLE.swap(false, Relaxed) { wake(); } @@ -156,21 +192,22 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { DOWN_Y.store(info.pt.y, Relaxed); HELD.store(true, Relaxed); DOWN_SEQ.fetch_add(1, Relaxed); + SWALLOWED.store(true, Relaxed); + // Click-through holds the press until it is clear whether it is a click or a stroke. + PENDING.store(CLICK_THROUGH.load(Relaxed), Relaxed); + BUTTON.store(if msg == WM_LBUTTONDOWN { 0 } else if msg == WM_RBUTTONDOWN { 1 } else { 2 }, Relaxed); wake(); - if !CLICK_THROUGH.load(Relaxed) { - SWALLOWED.store(true, Relaxed); - return LRESULT(1); - } + return LRESULT(1); } } WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP => { - let swallowed = SWALLOWED.swap(false, Relaxed); - if swallowed || (CLICK_THROUGH.load(Relaxed) && HELD.load(Relaxed)) { + if SWALLOWED.swap(false, Relaxed) { HELD.store(false, Relaxed); UP_SEQ.fetch_add(1, Relaxed); + if PENDING.swap(false, Relaxed) { + replay_click(DOWN_X.load(Relaxed), DOWN_Y.load(Relaxed), BUTTON.load(Relaxed)); + } wake(); - } - if swallowed { return LRESULT(1); } } diff --git a/windows/src/main.rs b/windows/src/main.rs index a6c7bd5..52af262 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -161,6 +161,7 @@ impl App { fn toggle_click_through(&mut self) { let on = !hook::CLICK_THROUGH.load(Relaxed); hook::CLICK_THROUGH.store(on, Relaxed); + hook::cancel_pending(); if hook::HELD.swap(false, Relaxed) { hook::UP_SEQ.fetch_add(1, Relaxed); } From c5c18f02e7b78b26a0d97079ec54898499008a20 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:45:26 +0530 Subject: [PATCH 13/28] Windows: tap sounds with a toolbar mute button --- windows/Cargo.toml | 2 +- windows/src/hand.rs | 10 +++- windows/src/hook.rs | 1 + windows/src/main.rs | 33 ++++++++++- windows/src/prefs.rs | 12 ++-- windows/src/sound.rs | 132 +++++++++++++++++++++++++++++++++++++++++ windows/src/toolbar.rs | 52 +++++++++++++++- 7 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 windows/src/sound.rs diff --git a/windows/Cargo.toml b/windows/Cargo.toml index af42393..f713e4d 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -5,4 +5,4 @@ edition = "2024" [dependencies] tiny-skia = "0.12.0" -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility", "Win32_Media_Audio"] } diff --git a/windows/src/hand.rs b/windows/src/hand.rs index b772abc..b011980 100644 --- a/windows/src/hand.rs +++ b/windows/src/hand.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use tiny_skia::*; -/// Where the pointer art lives. TH_ASSETS wins, then next to the exe, then the repo. -fn find(name: &str) -> Option { +/// Where the art and sounds live. TH_ASSETS wins, then next to the exe, then the repo. +pub fn find_asset(rel: &str) -> Option { let exe = std::env::current_exe().ok()?; let dir = exe.parent()?; let mut roots = vec![]; @@ -12,7 +12,11 @@ fn find(name: &str) -> Option { } roots.push(dir.join("assets")); roots.push(PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets"))); - roots.into_iter().map(|r| r.join(format!("{name}.png"))).find(|p| p.exists()) + roots.into_iter().map(|r| r.join(rel)).find(|p| p.exists()) +} + +fn find(name: &str) -> Option { + find_asset(&format!("{name}.png")) } /// Buffers are BGRA, so swap red and blue once at load. diff --git a/windows/src/hook.rs b/windows/src/hook.rs index 4e12c8c..0cfc57d 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -193,6 +193,7 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { HELD.store(true, Relaxed); DOWN_SEQ.fetch_add(1, Relaxed); SWALLOWED.store(true, Relaxed); + crate::sound::play(); // Click-through holds the press until it is clear whether it is a click or a stroke. PENDING.store(CLICK_THROUGH.load(Relaxed), Relaxed); BUTTON.store(if msg == WM_LBUTTONDOWN { 0 } else if msg == WM_RBUTTONDOWN { 1 } else { 2 }, Relaxed); diff --git a/windows/src/main.rs b/windows/src/main.rs index 52af262..4341ab9 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -7,6 +7,7 @@ mod hook; mod prefs; mod renderer; mod scene; +mod sound; mod styles; mod surface; mod toolbar; @@ -58,6 +59,7 @@ struct App { near: bool, /// The toolbar shows for a few seconds after turning on. initial: bool, + style_id: String, } const YIELD_TO: [&str; 3] = ["screenclippinghost.exe", "snippingtool.exe", "screensketch.exe"]; @@ -151,6 +153,7 @@ impl App { SetTimer(Some(self.hwnd), TIMER_INITIAL, 3000, None); let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); } + sound::open(); self.near = false; self.initial = true; self.toolbar.show(); @@ -158,6 +161,23 @@ impl App { hook::wake(); } + fn sound_key(&self) -> &'static str { + if self.style_id == "laser" { "sound_laser" } else { "sound_hands" } + } + + /// Hands tap by default and the laser stays quiet, each with its own switch. + fn refresh_sound(&mut self) { + let on = prefs::flag(self.sound_key(), self.style_id != "laser"); + sound::set_enabled(on); + self.toolbar.set_sound(on); + } + + fn toggle_sound(&mut self) { + let key = self.sound_key(); + prefs::set_flag(key, !prefs::flag(key, self.style_id != "laser")); + self.refresh_sound(); + } + fn toggle_click_through(&mut self) { let on = !hook::CLICK_THROUGH.load(Relaxed); hook::CLICK_THROUGH.store(on, Relaxed); @@ -166,7 +186,7 @@ impl App { hook::UP_SEQ.fetch_add(1, Relaxed); } self.toolbar.set_click_through(on); - prefs::save_click_through(on); + prefs::set_flag("click_through", on); hook::wake(); } @@ -175,6 +195,8 @@ impl App { lock(&self.renderer).set_style(style, ink); self.toolbar.select(id); prefs::save_style(id); + self.style_id = id.to_string(); + self.refresh_sound(); hook::wake(); } @@ -184,6 +206,7 @@ impl App { hook::reset_keys(); cursor::restore(); lock(&self.renderer).reset(); + sound::close(); self.toolbar.hide(); unsafe { let _ = KillTimer(Some(self.hwnd), TIMER_TASKBAR); @@ -209,6 +232,7 @@ impl App { match action { Some(Action::Pick(id)) => self.pick(&id), Some(Action::ToggleClickThrough) => self.toggle_click_through(), + Some(Action::ToggleSound) => self.toggle_sound(), Some(Action::Exit) => self.deactivate(), None => {} } @@ -402,11 +426,14 @@ fn main() { pinned.insert(pinned.len() - 1, &id); } let mut toolbar = Toolbar::new(bar, &pinned, &id); - let through = prefs::load_click_through(); + let through = prefs::flag("click_through", false); hook::CLICK_THROUGH.store(through, Relaxed); toolbar.set_click_through(through); + sound::load(); + sound::set_choice(&prefs::tap_sound()); let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h, style, ink))); - let app = App { hwnd, renderer: renderer.clone(), toolbar, active: false, suspended: false, near: false, initial: false }; + let mut app = App { hwnd, renderer: renderer.clone(), toolbar, active: false, suspended: false, near: false, initial: false, style_id: id.clone() }; + app.refresh_sound(); APP.with(|a| *a.borrow_mut() = Some(app)); SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); hook::init(); diff --git a/windows/src/prefs.rs b/windows/src/prefs.rs index 9b6f304..240a715 100644 --- a/windows/src/prefs.rs +++ b/windows/src/prefs.rs @@ -33,10 +33,14 @@ pub fn save_style(id: &str) { set("style", id); } -pub fn load_click_through() -> bool { - get("click_through").is_some_and(|v| v == "1") +pub fn tap_sound() -> String { + get("tap_sound").unwrap_or_else(|| "thock".into()) } -pub fn save_click_through(on: bool) { - set("click_through", if on { "1" } else { "0" }); +pub fn flag(key: &str, default: bool) -> bool { + get(key).map_or(default, |v| v == "1") +} + +pub fn set_flag(key: &str, on: bool) { + set(key, if on { "1" } else { "0" }); } diff --git a/windows/src/sound.rs b/windows/src/sound.rs new file mode 100644 index 0000000..fc2533c --- /dev/null +++ b/windows/src/sound.rs @@ -0,0 +1,132 @@ +use std::cell::UnsafeCell; +use std::ffi::c_void; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicUsize, Ordering::Relaxed}; + +use windows::Win32::Media::Audio::*; +use windows::Win32::Media::MMSYSERR_NOERROR; +use windows::core::PSTR; + +use crate::hand::find_asset; + +pub const CHOICES: [&str; 6] = ["thock", "click", "pop", "wood", "boop", "squeak"]; + +struct Clip { + data: Vec, + hdr: UnsafeCell, +} + +struct Bank(Vec); + +// Headers are only touched by whichever thread is playing, one call at a time. +unsafe impl Sync for Bank {} +unsafe impl Send for Bank {} + +static BANK: OnceLock = OnceLock::new(); +static DEVICE: AtomicIsize = AtomicIsize::new(0); +static CHOICE: AtomicUsize = AtomicUsize::new(0); +static ENABLED: AtomicBool = AtomicBool::new(true); + +/// 44.1 kHz, 16-bit, mono. Returns the raw samples or None for anything else. +fn pcm(bytes: &[u8]) -> Option> { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let (mut at, mut ok) = (12, false); + while at + 8 <= bytes.len() { + let size = u32::from_le_bytes(bytes[at + 4..at + 8].try_into().ok()?) as usize; + let body = bytes.get(at + 8..(at + 8 + size).min(bytes.len()))?; + match &bytes[at..at + 4] { + b"fmt " if body.len() >= 16 => { + let tag = u16::from_le_bytes([body[0], body[1]]); + let channels = u16::from_le_bytes([body[2], body[3]]); + let rate = u32::from_le_bytes(body[4..8].try_into().ok()?); + let bits = u16::from_le_bytes([body[14], body[15]]); + ok = tag == 1 && channels == 1 && rate == 44100 && bits == 16; + } + b"data" => return ok.then(|| body.to_vec()), + _ => {} + } + at += 8 + size + (size & 1); + } + None +} + +pub fn load() { + BANK.get_or_init(|| { + let clips = CHOICES + .iter() + .map(|name| { + let data = find_asset(&format!("sounds/tap_{name}.wav")).and_then(|p| std::fs::read(p).ok()).and_then(|b| pcm(&b)).unwrap_or_default(); + Clip { data, hdr: UnsafeCell::new(WAVEHDR::default()) } + }) + .collect(); + Bank(clips) + }); +} + +pub fn set_enabled(on: bool) { + ENABLED.store(on, Relaxed); +} + +pub fn set_choice(name: &str) { + if let Some(i) = CHOICES.iter().position(|c| *c == name) { + CHOICE.store(i, Relaxed); + } +} + +fn handle() -> HWAVEOUT { + HWAVEOUT(DEVICE.load(Relaxed) as *mut c_void) +} + +/// Opened while the pointer is on, like the Mac engine that stops when idle. +pub fn open() { + let Some(bank) = BANK.get() else { return }; + if DEVICE.load(Relaxed) != 0 { + return; + } + let format = WAVEFORMATEX { wFormatTag: WAVE_FORMAT_PCM as u16, nChannels: 1, nSamplesPerSec: 44100, nAvgBytesPerSec: 88200, nBlockAlign: 2, wBitsPerSample: 16, cbSize: 0 }; + let mut device = HWAVEOUT::default(); + unsafe { + if waveOutOpen(Some(&mut device), WAVE_MAPPER, &format, None, None, CALLBACK_NULL) != MMSYSERR_NOERROR { + return; + } + for clip in &bank.0 { + let hdr = &mut *clip.hdr.get(); + *hdr = WAVEHDR { lpData: PSTR(clip.data.as_ptr() as *mut u8), dwBufferLength: clip.data.len() as u32, ..Default::default() }; + waveOutPrepareHeader(device, hdr, size_of::() as u32); + } + } + DEVICE.store(device.0 as isize, Relaxed); +} + +pub fn close() { + let device = handle(); + if DEVICE.swap(0, Relaxed) == 0 { + return; + } + let Some(bank) = BANK.get() else { return }; + unsafe { + waveOutReset(device); + for clip in &bank.0 { + waveOutUnprepareHeader(device, clip.hdr.get(), size_of::() as u32); + } + waveOutClose(device); + } +} + +/// Called from the mouse hook. Cuts off the previous tap, like Mac's .interrupts. +pub fn play() { + if !ENABLED.load(Relaxed) { + return; + } + let (device, Some(bank)) = (handle(), BANK.get()) else { return }; + if device.0.is_null() { + return; + } + let Some(clip) = bank.0.get(CHOICE.load(Relaxed)).filter(|c| !c.data.is_empty()) else { return }; + unsafe { + waveOutReset(device); + waveOutWrite(device, clip.hdr.get(), size_of::() as u32); + } +} diff --git a/windows/src/toolbar.rs b/windows/src/toolbar.rs index 096c390..d389bca 100644 --- a/windows/src/toolbar.rs +++ b/windows/src/toolbar.rs @@ -27,12 +27,14 @@ const MARGIN_BOTTOM: f32 = 14.0; pub enum Action { Pick(String), ToggleClickThrough, + ToggleSound, Exit, } enum Kind { Style(String), ClickThrough, + Sound, Exit, } @@ -50,6 +52,7 @@ pub struct Toolbar { scale: f32, selected: String, through: bool, + sound: bool, hover: Option, alpha: f32, target: f32, @@ -102,6 +105,8 @@ impl Toolbar { x += (1.0 + GAP) * scale; items.push(Item { kind: Kind::ClickThrough, icon: None, x }); x += (BUTTON + GAP) * scale; + items.push(Item { kind: Kind::Sound, icon: None, x }); + x += (BUTTON + GAP) * scale; items.push(Item { kind: Kind::Exit, icon: None, x }); x += (BUTTON + PAD_X) * scale; @@ -114,6 +119,7 @@ impl Toolbar { scale, selected: selected.to_string(), through: false, + sound: true, hover: None, alpha: 0.0, target: 0.0, @@ -173,6 +179,11 @@ impl Toolbar { self.paint(); } + pub fn set_sound(&mut self, on: bool) { + self.sound = on; + self.paint(); + } + pub fn select(&mut self, id: &str) { self.selected = id.to_string(); self.paint(); @@ -239,7 +250,7 @@ impl Toolbar { let chosen = match &item.kind { Kind::Style(id) => *id == self.selected, Kind::ClickThrough => self.through, - Kind::Exit => false, + Kind::Sound | Kind::Exit => false, }; let tint = if chosen { 46 } else if self.hover == Some(i) { 24 } else { 0 }; if tint > 0 { @@ -254,6 +265,44 @@ impl Toolbar { pix.draw_pixmap(ix.round() as i32, iy.round() as i32, icon.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } (Kind::Style(_), None) => draw_laser(&mut pix, (cx, cy), 5.0 * s), + (Kind::Sound, None) => { + let alpha = if self.sound { 217 } else { 115 }; + let at = |x: f32, y: f32| (cx + (x - 9.0) * s, cy + (y - 8.0) * s); + let mut pb = PathBuilder::new(); + for (i, (x, y)) in [(0.0, 5.0), (4.0, 5.0), (9.0, 1.0), (9.0, 15.0), (4.0, 11.0), (0.0, 11.0)].iter().enumerate() { + let (px_, py_) = at(*x, *y); + if i == 0 { + pb.move_to(px_, py_); + } else { + pb.line_to(px_, py_); + } + } + pb.close(); + if let Some(path) = pb.finish() { + fill(&mut pix, &path, px(255, 255, 255, alpha)); + } + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, alpha)); + paint.anti_alias = true; + let stroke = Stroke { width: 1.5 * s, line_cap: LineCap::Round, ..Default::default() }; + let mut pb = PathBuilder::new(); + if self.sound { + for (x0, y0, cx_, cy_, x1, y1) in [(11.5, 5.5, 14.0, 8.0, 11.5, 10.5), (13.5, 3.5, 18.0, 8.0, 13.5, 12.5)] { + let (a, b, c) = (at(x0, y0), at(cx_, cy_), at(x1, y1)); + pb.move_to(a.0, a.1); + pb.quad_to(b.0, b.1, c.0, c.1); + } + } else { + for (x0, y0, x1, y1) in [(12.0, 5.0, 17.0, 11.0), (17.0, 5.0, 12.0, 11.0)] { + let (a, b) = (at(x0, y0), at(x1, y1)); + pb.move_to(a.0, a.1); + pb.line_to(b.0, b.1); + } + } + if let Some(path) = pb.finish() { + pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } (Kind::ClickThrough, None) => { let arrow = [(0.0, 0.0), (0.0, 16.0), (4.2, 12.2), (7.0, 18.5), (9.6, 17.4), (6.9, 11.3), (12.4, 11.0)]; let mut pb = PathBuilder::new(); @@ -339,6 +388,7 @@ impl Toolbar { let action = self.hit(x, y).map(|i| match &self.items[i].kind { Kind::Style(id) => Action::Pick(id.clone()), Kind::ClickThrough => Action::ToggleClickThrough, + Kind::Sound => Action::ToggleSound, Kind::Exit => Action::Exit, }); Some((LRESULT(0), action)) From 82bea2a61eadb988f2dbb23b0147fb0565fe434d Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:48:58 +0530 Subject: [PATCH 14/28] Windows: run audio on its own thread so the mouse hook never waits on the device --- windows/src/sound.rs | 163 +++++++++++++++++++++++++------------------ 1 file changed, 95 insertions(+), 68 deletions(-) diff --git a/windows/src/sound.rs b/windows/src/sound.rs index fc2533c..89eb15c 100644 --- a/windows/src/sound.rs +++ b/windows/src/sound.rs @@ -1,7 +1,6 @@ -use std::cell::UnsafeCell; -use std::ffi::c_void; use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicUsize, Ordering::Relaxed}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; +use std::sync::mpsc::{Receiver, Sender, channel}; use windows::Win32::Media::Audio::*; use windows::Win32::Media::MMSYSERR_NOERROR; @@ -11,19 +10,13 @@ use crate::hand::find_asset; pub const CHOICES: [&str; 6] = ["thock", "click", "pop", "wood", "boop", "squeak"]; -struct Clip { - data: Vec, - hdr: UnsafeCell, +enum Cmd { + Open, + Close, + Play, } -struct Bank(Vec); - -// Headers are only touched by whichever thread is playing, one call at a time. -unsafe impl Sync for Bank {} -unsafe impl Send for Bank {} - -static BANK: OnceLock = OnceLock::new(); -static DEVICE: AtomicIsize = AtomicIsize::new(0); +static TX: OnceLock> = OnceLock::new(); static CHOICE: AtomicUsize = AtomicUsize::new(0); static ENABLED: AtomicBool = AtomicBool::new(true); @@ -52,19 +45,93 @@ fn pcm(bytes: &[u8]) -> Option> { None } +/// Everything that touches the audio device lives on this one thread. The mouse hook +/// only sends a message, because a slow or blocked hook stalls the whole system. +struct Player { + clips: Vec>, + headers: Vec>, + device: Option, +} + +impl Player { + fn open(&mut self) { + if self.device.is_some() { + return; + } + let format = WAVEFORMATEX { wFormatTag: WAVE_FORMAT_PCM as u16, nChannels: 1, nSamplesPerSec: 44100, nAvgBytesPerSec: 88200, nBlockAlign: 2, wBitsPerSample: 16, cbSize: 0 }; + let mut device = HWAVEOUT::default(); + unsafe { + if waveOutOpen(Some(&mut device), WAVE_MAPPER, &format, None, None, CALLBACK_NULL) != MMSYSERR_NOERROR { + return; + } + for (clip, hdr) in self.clips.iter().zip(&mut self.headers) { + **hdr = WAVEHDR { lpData: PSTR(clip.as_ptr() as *mut u8), dwBufferLength: clip.len() as u32, ..Default::default() }; + waveOutPrepareHeader(device, &mut **hdr, size_of::() as u32); + } + } + self.device = Some(device); + } + + fn close(&mut self) { + let Some(device) = self.device.take() else { return }; + unsafe { + waveOutReset(device); + for hdr in &mut self.headers { + waveOutUnprepareHeader(device, &mut **hdr, size_of::() as u32); + } + waveOutClose(device); + } + } + + /// Cuts off the previous tap, like Mac's .interrupts. + fn play(&mut self) { + let Some(device) = self.device else { return }; + let i = CHOICE.load(Relaxed); + let Some(hdr) = self.headers.get_mut(i).filter(|_| self.clips.get(i).is_some_and(|c| !c.is_empty())) else { return }; + unsafe { + waveOutReset(device); + waveOutWrite(device, &mut **hdr, size_of::() as u32); + } + } +} + +fn run(rx: Receiver) { + let clips: Vec> = CHOICES + .iter() + .map(|name| find_asset(&format!("sounds/tap_{name}.wav")).and_then(|p| std::fs::read(p).ok()).and_then(|b| pcm(&b)).unwrap_or_default()) + .collect(); + let headers = clips.iter().map(|_| Box::new(WAVEHDR::default())).collect(); + let mut player = Player { clips, headers, device: None }; + while let Ok(first) = rx.recv() { + let mut batch = vec![first]; + batch.extend(rx.try_iter()); + // Taps that piled up while the device was busy collapse into one. + let last = batch.len() - 1; + for (i, cmd) in batch.iter().enumerate() { + match cmd { + Cmd::Open => player.open(), + Cmd::Close => player.close(), + Cmd::Play if matches!(batch.get(i + 1), Some(Cmd::Play)) && i != last => {} + Cmd::Play => player.play(), + } + } + } +} + pub fn load() { - BANK.get_or_init(|| { - let clips = CHOICES - .iter() - .map(|name| { - let data = find_asset(&format!("sounds/tap_{name}.wav")).and_then(|p| std::fs::read(p).ok()).and_then(|b| pcm(&b)).unwrap_or_default(); - Clip { data, hdr: UnsafeCell::new(WAVEHDR::default()) } - }) - .collect(); - Bank(clips) + TX.get_or_init(|| { + let (tx, rx) = channel(); + std::thread::spawn(move || run(rx)); + tx }); } +fn send(cmd: Cmd) { + if let Some(tx) = TX.get() { + let _ = tx.send(cmd); + } +} + pub fn set_enabled(on: bool) { ENABLED.store(on, Relaxed); } @@ -75,58 +142,18 @@ pub fn set_choice(name: &str) { } } -fn handle() -> HWAVEOUT { - HWAVEOUT(DEVICE.load(Relaxed) as *mut c_void) -} - /// Opened while the pointer is on, like the Mac engine that stops when idle. pub fn open() { - let Some(bank) = BANK.get() else { return }; - if DEVICE.load(Relaxed) != 0 { - return; - } - let format = WAVEFORMATEX { wFormatTag: WAVE_FORMAT_PCM as u16, nChannels: 1, nSamplesPerSec: 44100, nAvgBytesPerSec: 88200, nBlockAlign: 2, wBitsPerSample: 16, cbSize: 0 }; - let mut device = HWAVEOUT::default(); - unsafe { - if waveOutOpen(Some(&mut device), WAVE_MAPPER, &format, None, None, CALLBACK_NULL) != MMSYSERR_NOERROR { - return; - } - for clip in &bank.0 { - let hdr = &mut *clip.hdr.get(); - *hdr = WAVEHDR { lpData: PSTR(clip.data.as_ptr() as *mut u8), dwBufferLength: clip.data.len() as u32, ..Default::default() }; - waveOutPrepareHeader(device, hdr, size_of::() as u32); - } - } - DEVICE.store(device.0 as isize, Relaxed); + send(Cmd::Open); } pub fn close() { - let device = handle(); - if DEVICE.swap(0, Relaxed) == 0 { - return; - } - let Some(bank) = BANK.get() else { return }; - unsafe { - waveOutReset(device); - for clip in &bank.0 { - waveOutUnprepareHeader(device, clip.hdr.get(), size_of::() as u32); - } - waveOutClose(device); - } + send(Cmd::Close); } -/// Called from the mouse hook. Cuts off the previous tap, like Mac's .interrupts. +/// Safe to call from the mouse hook: a channel send, no device access. pub fn play() { - if !ENABLED.load(Relaxed) { - return; - } - let (device, Some(bank)) = (handle(), BANK.get()) else { return }; - if device.0.is_null() { - return; - } - let Some(clip) = bank.0.get(CHOICE.load(Relaxed)).filter(|c| !c.data.is_empty()) else { return }; - unsafe { - waveOutReset(device); - waveOutWrite(device, clip.hdr.get(), size_of::() as u32); + if ENABLED.load(Relaxed) { + send(Cmd::Play); } } From fedf86351dcad652538dc7277a98fde527f11770 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:53:52 +0530 Subject: [PATCH 15/28] Windows: inject replayed clicks and keys from the window thread, never inside a hook --- windows/src/hook.rs | 36 ++++++++++++++++++++++++++++++------ windows/src/main.rs | 2 ++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/windows/src/hook.rs b/windows/src/hook.rs index 0cfc57d..5566eb3 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -87,7 +87,26 @@ fn mouse_input(flags: MOUSE_EVENT_FLAGS) -> INPUT { } } +/// Injecting input from inside a hook callback stalls the whole input system until Windows +/// times the hook out, so the hook only posts these and the window thread does the work. +pub const WM_REPLAY_CLICK: u32 = WM_APP + 5; +pub const WM_REPLAY_KEY: u32 = WM_APP + 6; + +fn post_to_window(msg: u32, wp: usize, lp: isize) { + let hwnd = HWND(TARGET.load(Relaxed) as *mut c_void); + unsafe { + let _ = PostMessageW(Some(hwnd), msg, WPARAM(wp), LPARAM(lp)); + } +} + fn replay_click(x: i32, y: i32, button: i32) { + let packed = ((y as i16 as u16 as isize) << 16) | (x as i16 as u16 as isize); + post_to_window(WM_REPLAY_CLICK, button as usize, packed); +} + +/// Window thread only. +pub fn send_click(lp: LPARAM, button: usize) { + let (x, y) = ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32); let (down, up) = match button { 0 => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), 1 => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), @@ -99,6 +118,15 @@ fn replay_click(x: i32, y: i32, button: i32) { } } +/// Window thread only. Gives the app its Alt back, then the key, in order. +pub fn send_alt_and_key(vk: usize, lp: LPARAM) { + let (scan, extended) = ((lp.0 & 0xFFFF) as u32, (lp.0 >> 16) & 1 != 0); + let inputs = [key_input(VK_MENU.0 as u32, 0, false), key_input(vk as u32, scan, extended)]; + unsafe { + SendInput(&inputs, size_of::() as i32); + } +} + /// Alt is held to click for real. The key is hidden from apps while it is. pub const WM_INTERACT: u32 = WM_APP + 4; pub static INTERACT: AtomicBool = AtomicBool::new(false); @@ -153,15 +181,11 @@ unsafe extern "system" fn key_proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT return LRESULT(1); } if !is_alt && down && INTERACT.load(Relaxed) && !PASSED.load(Relaxed) { - // Give the app its Alt back, then the key, in order. PASSED.store(true, Relaxed); INTERACT.store(false, Relaxed); post_interact(false); - let extended = k.flags.0 & LLKHF_EXTENDED.0 != 0; - let inputs = [key_input(VK_MENU.0 as u32, 0, false), key_input(k.vkCode, k.scanCode, extended)]; - unsafe { - SendInput(&inputs, size_of::() as i32); - } + let extended = (k.flags.0 & LLKHF_EXTENDED.0 != 0) as isize; + post_to_window(WM_REPLAY_KEY, k.vkCode as usize, (k.scanCode as isize & 0xFFFF) | (extended << 16)); return LRESULT(1); } } diff --git a/windows/src/main.rs b/windows/src/main.rs index 4341ab9..5412b85 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -247,6 +247,8 @@ impl App { self.toolbar.set_wanted(self.near); } WM_TIMER => self.refresh_taskbar(), + hook::WM_REPLAY_CLICK => hook::send_click(lp, wp.0), + hook::WM_REPLAY_KEY => hook::send_alt_and_key(wp.0, lp), hook::WM_INTERACT => { if self.active && !self.suspended { if wp.0 != 0 { From 0c0dcc6db03d999880c4b25f8ff4b9bd2eb82b73 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 21:58:11 +0530 Subject: [PATCH 16/28] Windows: switch audio off until the waveOut backend is replaced --- windows/src/sound.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/windows/src/sound.rs b/windows/src/sound.rs index 89eb15c..5c0213f 100644 --- a/windows/src/sound.rs +++ b/windows/src/sound.rs @@ -118,7 +118,13 @@ fn run(rx: Receiver) { } } +/// Off until the waveOut backend is replaced: processes with it open got stuck exiting. +const AUDIO_ENABLED: bool = false; + pub fn load() { + if !AUDIO_ENABLED { + return; + } TX.get_or_init(|| { let (tx, rx) = channel(); std::thread::spawn(move || run(rx)); From f92a163623bd004abe9258dd7b2b1ab8052850a4 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:08:34 +0530 Subject: [PATCH 17/28] Windows: crash log, survive render panics, cheaper shadow, tag own injected input --- windows/src/arm.rs | 13 ++++++++----- windows/src/hook.rs | 16 +++++++++++++--- windows/src/log.rs | 24 ++++++++++++++++++++++++ windows/src/main.rs | 5 ++++- windows/src/renderer.rs | 8 +++++++- 5 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 windows/src/log.rs diff --git a/windows/src/arm.rs b/windows/src/arm.rs index 90300f4..b13a0ba 100644 --- a/windows/src/arm.rs +++ b/windows/src/arm.rs @@ -8,6 +8,8 @@ const GRIP_LEAN: f32 = 0.30; const GRIP_DEPTH: f32 = 0.12; const GRIP_FOLLOW: f32 = 0.08; const LIFT: f32 = 0.06; +/// The shadow is a soft blur, so it is drawn and blurred at a quarter of the size. +const SHADOW_SCALE: f32 = 0.25; /// The follow and press rates were tuned at 120 ticks per second. const TICK_HZ: f32 = 120.0; @@ -148,21 +150,22 @@ impl Arm { fn draw_shadow(&self, pix: &mut PixmapMut, p: &Pose, base: Transform) { let pad = (p.blur * 2.0).ceil() as i32 + 2; let top = p.rod_len + p.hand_pts; - let w = (p.width.ceil() as i32 + 2 * pad) as u32; - let h = (top.ceil() as i32 + 2 * pad) as u32; + let k = SHADOW_SCALE; + let w = (((p.width.ceil() as i32 + 2 * pad) as f32 * k).ceil() as u32).max(1); + let h = (((top.ceil() as i32 + 2 * pad) as f32 * k).ceil() as u32).max(1); let Some(mut sil) = Pixmap::new(w, h) else { return }; - let to_buf = Transform::from_row(1.0, 0.0, 0.0, -1.0, pad as f32, top + pad as f32); + let to_buf = Transform::from_scale(k, k).pre_concat(Transform::from_row(1.0, 0.0, 0.0, -1.0, pad as f32, top + pad as f32)); self.parts(&mut sil.as_mut(), to_buf, p); let mut alpha: Vec = sil.data().chunks_exact(4).map(|px| px[3]).collect(); - let radius = (p.blur / 2.0).round().max(1.0) as usize; + let radius = (p.blur / 2.0 * k).round().max(1.0) as usize; box_blur(&mut alpha, w as usize, h as usize, radius); for (px, a) in sil.data_mut().chunks_exact_mut(4).zip(&alpha) { px.copy_from_slice(&[0, 0, 0, (*a as f32 * p.opacity) as u8]); } let offset = Transform::from_translate(p.lift * 0.6, p.lift); - let from_buf = Transform::from_row(1.0, 0.0, 0.0, -1.0, -pad as f32, top + pad as f32); + let from_buf = Transform::from_row(1.0 / k, 0.0, 0.0, -1.0 / k, -pad as f32, top + pad as f32); let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }; pix.draw_pixmap(0, 0, sil.as_ref(), &paint, offset.pre_concat(base).pre_concat(from_buf), None); } diff --git a/windows/src/hook.rs b/windows/src/hook.rs index 5566eb3..a20e65d 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -80,10 +80,19 @@ pub fn cancel_pending() { PENDING.store(false, Relaxed); } +/// Marks input TinyHand injects itself, so it is never mistaken for the user's. +const MAGIC: usize = 0x5448_414E; +static TEST_INPUT: AtomicBool = AtomicBool::new(false); + +/// Real input is anything not injected. The test switch also counts others' injected input. +fn is_real(flags: u32, extra: usize) -> bool { + flags & 1 == 0 || (TEST_INPUT.load(Relaxed) && extra != MAGIC) +} + fn mouse_input(flags: MOUSE_EVENT_FLAGS) -> INPUT { INPUT { r#type: INPUT_MOUSE, - Anonymous: INPUT_0 { mi: MOUSEINPUT { dx: 0, dy: 0, mouseData: 0, dwFlags: flags, time: 0, dwExtraInfo: 0 } }, + Anonymous: INPUT_0 { mi: MOUSEINPUT { dx: 0, dy: 0, mouseData: 0, dwFlags: flags, time: 0, dwExtraInfo: MAGIC } }, } } @@ -157,7 +166,7 @@ fn key_input(vk: u32, scan: u32, extended: bool) -> INPUT { wScan: scan as u16, dwFlags: if extended { KEYEVENTF_EXTENDEDKEY } else { KEYBD_EVENT_FLAGS(0) }, time: 0, - dwExtraInfo: 0, + dwExtraInfo: MAGIC, }, }, } @@ -207,7 +216,7 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { if IDLE.swap(false, Relaxed) { wake(); } - } else if info.flags & LLMHF_INJECTED == 0 { + } else if is_real(info.flags, info.dwExtraInfo) { match msg { WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN => { SWALLOWED.store(false, Relaxed); @@ -246,6 +255,7 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { /// Own thread, so the callback never waits on rendering. pub fn start(target: HWND) { TARGET.store(target.0 as isize, Relaxed); + TEST_INPUT.store(std::env::var_os("TH_TEST_INPUT").is_some(), Relaxed); std::thread::spawn(|| unsafe { let hinst = GetModuleHandleW(None).map(|m| HINSTANCE(m.0)).ok(); if SetWindowsHookExW(WH_MOUSE_LL, Some(proc), hinst, 0).is_err() { diff --git a/windows/src/log.rs b/windows/src/log.rs new file mode 100644 index 0000000..137f6e2 --- /dev/null +++ b/windows/src/log.rs @@ -0,0 +1,24 @@ +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_BYTES: u64 = 256 * 1024; + +fn file() -> Option { + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("TinyHand").join("log.txt")) +} + +/// Appends a line to %APPDATA%\TinyHand\log.txt. Release builds have no console. +pub fn write(message: &str) { + let Some(path) = file() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_BYTES) { + let _ = std::fs::remove_file(&path); + } + let secs = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "[{secs}] {message}"); + } +} diff --git a/windows/src/main.rs b/windows/src/main.rs index 5412b85..9a54f31 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -4,6 +4,7 @@ mod arm; mod cursor; mod hand; mod hook; +mod log; mod prefs; mod renderer; mod scene; @@ -357,6 +358,8 @@ fn main() { let default_panic = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { cursor::restore(); + let thread = std::thread::current(); + log::write(&format!("panic on '{}': {info}\n{}", thread.name().unwrap_or("?"), std::backtrace::Backtrace::force_capture())); default_panic(info); })); @@ -440,7 +443,7 @@ fn main() { SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); hook::init(); hook::start(hwnd); - std::thread::spawn(move || renderer::run(renderer)); + let _ = std::thread::Builder::new().name("render".into()).spawn(move || renderer::run(renderer)); add_tray(hwnd); // Release builds have no console of their own. Borrow the terminal's so Ctrl+C reaches us. let _ = AttachConsole(ATTACH_PARENT_PROCESS); diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index fb2a1ec..82d6640 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -246,10 +246,16 @@ pub fn run(shared: Arc>) { hook::wait(INFINITE); continue; } - let (next, drew, hwnd) = { + let frame = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut r = lock(&shared); let next = r.frame(); (next, std::mem::take(&mut r.drew), r.hwnd_raw()) + })); + // A panic here would freeze the overlay while the hook keeps swallowing clicks. Log it and carry on. + let Ok((next, drew, hwnd)) = frame else { + crate::log::write("render frame panicked, retrying"); + std::thread::sleep(std::time::Duration::from_millis(100)); + continue; }; if drew && !hook::PRESENT_PENDING.swap(true, Relaxed) { unsafe { From 7f5a57b9f2e58524acccd386f2d0d5a3f0d5d434 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:16:04 +0530 Subject: [PATCH 18/28] Windows: keep the overlay above apps like Paint, halve per-frame cost --- windows/src/arm.rs | 66 ++++++++++++++++++++++++++++++++------------- windows/src/hand.rs | 12 ++++++++- windows/src/main.rs | 18 +++++++++++++ 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/windows/src/arm.rs b/windows/src/arm.rs index b13a0ba..618880a 100644 --- a/windows/src/arm.rs +++ b/windows/src/arm.rs @@ -124,6 +124,11 @@ impl Arm { /// base maps group-local space (origin at the rod's bottom-left, y up) to the target. fn parts(&self, pix: &mut PixmapMut, base: Transform, p: &Pose) { + self.part(pix, base, p, Part::Rod); + self.part(pix, base, p, Part::Hand); + } + + fn part(&self, pix: &mut PixmapMut, base: Transform, p: &Pose, part: Part) { let a = &self.asset; let img = &a.display; let (iw, ih) = (img.width() as f32, img.height() as f32); @@ -131,31 +136,50 @@ impl Arm { paint.shader = Pattern::new(img.as_ref(), SpreadMode::Pad, FilterQuality::Bilinear, 1.0, Transform::identity()); paint.anti_alias = true; let sx = p.width / iw; - let (v_top, v_wrist, v_end) = (a.hand_top * ih, a.wrist * ih, a.rod_end * ih); - let k_rod = p.rod_len / (v_end - v_wrist).max(1.0); - let rod = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k_rod, 0.0, p.rod_len + k_rod * v_wrist)); - if let Some(r) = Rect::from_ltrb(0.0, v_wrist - 1.0, iw, v_end.max(v_wrist + 1.0)) { - pix.fill_rect(r, &paint, rod, None); + let columns = |(lo, hi): (f32, f32)| ((lo * iw - 2.0).max(0.0), (hi * iw + 2.0).min(iw)); + + match part { + Part::Rod => { + let k = p.rod_len / (v_end - v_wrist).max(1.0); + let ts = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k, 0.0, p.rod_len + k * v_wrist)); + let (l, r) = columns((a.rod_min_x, a.rod_max_x)); + if let Some(rect) = Rect::from_ltrb(l, v_wrist - 1.0, r, v_end.max(v_wrist + 1.0)) { + pix.fill_rect(rect, &paint, ts, None); + } + } + Part::Hand => { + let k = p.hand_pts / (v_wrist - v_top).max(1.0); + let ts = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k, 0.0, p.rod_len + p.hand_pts + k * v_top)); + let (l, r) = columns(a.hand_columns()); + if let Some(rect) = Rect::from_ltrb(l, v_top, r, v_wrist + 1.0) { + pix.fill_rect(rect, &paint, ts, None); + } + } } + } - let k_hand = p.hand_pts / (v_wrist - v_top).max(1.0); - let hand = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k_hand, 0.0, p.rod_len + p.hand_pts + k_hand * v_top)); - if let Some(r) = Rect::from_ltrb(0.0, v_top, iw, v_wrist + 1.0) { - pix.fill_rect(r, &paint, hand, None); + /// One shadow per piece, like the Mac layers, each in a buffer cropped to just that piece. + fn draw_shadow(&self, pix: &mut PixmapMut, p: &Pose, base: Transform) { + let a = &self.asset; + let (hand_l, hand_r) = a.hand_columns(); + for (part, cols, rows) in [ + (Part::Rod, (a.rod_min_x, a.rod_max_x), (0.0, p.rod_len)), + (Part::Hand, (hand_l, hand_r), (p.rod_len, p.rod_len + p.hand_pts)), + ] { + self.shadow_piece(pix, p, base, part, (cols.0 * p.width, cols.1 * p.width), rows); } } - /// Blurred in unrotated local space, where the buffer is small, then rotated into place. - fn draw_shadow(&self, pix: &mut PixmapMut, p: &Pose, base: Transform) { - let pad = (p.blur * 2.0).ceil() as i32 + 2; - let top = p.rod_len + p.hand_pts; + /// Drawn and blurred at reduced size in unrotated local space, then rotated into place. + fn shadow_piece(&self, pix: &mut PixmapMut, p: &Pose, base: Transform, part: Part, x: (f32, f32), y: (f32, f32)) { + let pad = (p.blur * 2.0).ceil() + 2.0; + let (x0, x1, y0, y1) = (x.0 - pad, x.1 + pad, y.0 - pad, y.1 + pad); let k = SHADOW_SCALE; - let w = (((p.width.ceil() as i32 + 2 * pad) as f32 * k).ceil() as u32).max(1); - let h = (((top.ceil() as i32 + 2 * pad) as f32 * k).ceil() as u32).max(1); + let (w, h) = ((((x1 - x0) * k).ceil() as u32).max(1), (((y1 - y0) * k).ceil() as u32).max(1)); let Some(mut sil) = Pixmap::new(w, h) else { return }; - let to_buf = Transform::from_scale(k, k).pre_concat(Transform::from_row(1.0, 0.0, 0.0, -1.0, pad as f32, top + pad as f32)); - self.parts(&mut sil.as_mut(), to_buf, p); + let to_buf = Transform::from_row(k, 0.0, 0.0, -k, -x0 * k, y1 * k); + self.part(&mut sil.as_mut(), to_buf, p, part); let mut alpha: Vec = sil.data().chunks_exact(4).map(|px| px[3]).collect(); let radius = (p.blur / 2.0 * k).round().max(1.0) as usize; @@ -165,12 +189,18 @@ impl Arm { } let offset = Transform::from_translate(p.lift * 0.6, p.lift); - let from_buf = Transform::from_row(1.0 / k, 0.0, 0.0, -1.0 / k, -pad as f32, top + pad as f32); + let from_buf = Transform::from_row(1.0 / k, 0.0, 0.0, -1.0 / k, x0, y1); let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }; pix.draw_pixmap(0, 0, sil.as_ref(), &paint, offset.pre_concat(base).pre_concat(from_buf), None); } } +#[derive(Clone, Copy)] +enum Part { + Rod, + Hand, +} + fn pass(src: &[u8], dst: &mut [u8], lines: usize, len: usize, line_step: usize, step: usize, r: usize) { let win = (2 * r + 1) as u32; for l in 0..lines { diff --git a/windows/src/hand.rs b/windows/src/hand.rs index b011980..7d6c91e 100644 --- a/windows/src/hand.rs +++ b/windows/src/hand.rs @@ -38,9 +38,16 @@ pub struct HandAsset { pub rod_x: f32, min_x: f32, max_x: f32, + /// Columns that actually hold the stick, so thin parts are not painted full width. + pub rod_min_x: f32, + pub rod_max_x: f32, } impl HandAsset { + pub fn hand_columns(&self) -> (f32, f32) { + (self.min_x, self.max_x) + } + /// Square crop of just the hand, for toolbar buttons. pub fn thumbnail(&self, px: u32) -> Pixmap { let (w, h) = (self.full.width() as f32, self.full.height() as f32); @@ -75,7 +82,7 @@ impl HandAsset { } fn blank(full: Pixmap) -> Self { - Self { display: full.clone(), full, aspect: 0.0, hand_top: 0.0, tip: 0.0, wrist: 0.0, rod_end: 0.0, tip_x: 0.5, rod_x: 0.5, min_x: 0.0, max_x: 1.0 } + Self { display: full.clone(), full, aspect: 0.0, hand_top: 0.0, tip: 0.0, wrist: 0.0, rod_end: 0.0, tip_x: 0.5, rod_x: 0.5, min_x: 0.0, max_x: 1.0, rod_min_x: 0.0, rod_max_x: 1.0 } } } @@ -118,6 +125,9 @@ fn measure(p: &Pixmap) -> Option { let xs = || (top..wrist).flat_map(|y| (0..w).filter(move |&x| solid(x, y))); asset.min_x = xs().min().unwrap_or(0) as f32 / w as f32; asset.max_x = xs().max().unwrap_or(w - 1) as f32 / w as f32; + let rod_rows = || (wrist..=rod_end.min(h - 1)).flat_map(|y| (0..w).filter(move |&x| solid(x, y))); + asset.rod_min_x = rod_rows().min().unwrap_or(0) as f32 / w as f32; + asset.rod_max_x = (rod_rows().max().unwrap_or(w - 1) + 1) as f32 / w as f32; Some(asset) } diff --git a/windows/src/main.rs b/windows/src/main.rs index 9a54f31..ed9150e 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -47,6 +47,7 @@ const CMD_THROUGH: usize = 3; static HWND_RAW: AtomicIsize = AtomicIsize::new(0); const TIMER_INITIAL: usize = 3; +const TIMER_RAISE: usize = 4; const PINNED: [&str; 6] = ["lavender", "glove", "coral", "plush", "robot", "laser"]; struct App { @@ -94,6 +95,7 @@ unsafe extern "system" fn on_foreground(_: HWINEVENTHOOK, _: u32, hwnd: HWND, _: APP.with(|cell| { if let Some(app) = cell.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) { app.set_suspended(yielding); + app.raise(); } }); } @@ -152,6 +154,7 @@ impl App { let _ = RegisterHotKey(Some(self.hwnd), HK_ESC, MOD_NOREPEAT, VK_ESCAPE.0 as u32); SetTimer(Some(self.hwnd), TIMER_TASKBAR, 500, None); SetTimer(Some(self.hwnd), TIMER_INITIAL, 3000, None); + SetTimer(Some(self.hwnd), TIMER_RAISE, 250, None); let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); } sound::open(); @@ -212,11 +215,25 @@ impl App { unsafe { let _ = KillTimer(Some(self.hwnd), TIMER_TASKBAR); let _ = KillTimer(Some(self.hwnd), TIMER_INITIAL); + let _ = KillTimer(Some(self.hwnd), TIMER_RAISE); let _ = UnregisterHotKey(Some(self.hwnd), HK_ESC); let _ = ShowWindow(self.hwnd, SW_HIDE); } } + /// Some windows (Paint) open above even topmost windows, which hides the hand + /// while the real cursor is already blanked. Put both back on top, toolbar last. + fn raise(&self) { + if !self.active || self.suspended { + return; + } + let place = |hwnd: HWND| unsafe { + let _ = SetWindowPos(hwnd, Some(HWND_TOPMOST), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + }; + place(self.hwnd); + place(self.toolbar.hwnd); + } + fn refresh_taskbar(&self) { let mut r = RECT::default(); unsafe { @@ -247,6 +264,7 @@ impl App { self.initial = false; self.toolbar.set_wanted(self.near); } + WM_TIMER if wp.0 == TIMER_RAISE => self.raise(), WM_TIMER => self.refresh_taskbar(), hook::WM_REPLAY_CLICK => hook::send_click(lp, wp.0), hook::WM_REPLAY_KEY => hook::send_alt_and_key(wp.0, lp), From d4301d394e633615e05b6e17bf991ae4b5362608 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:19:46 +0530 Subject: [PATCH 19/28] Windows: tap sounds on WASAPI, run from their own thread --- windows/Cargo.toml | 2 +- windows/src/sound.rs | 190 +++++++++++++++++++++++++++++-------------- 2 files changed, 128 insertions(+), 64 deletions(-) diff --git a/windows/Cargo.toml b/windows/Cargo.toml index f713e4d..e755b84 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -5,4 +5,4 @@ edition = "2024" [dependencies] tiny-skia = "0.12.0" -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility", "Win32_Media_Audio"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility", "Win32_Media_Audio", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell_PropertiesSystem", "Win32_Devices_FunctionDiscovery", "Win32_System_Variant"] } diff --git a/windows/src/sound.rs b/windows/src/sound.rs index 5c0213f..6acaf06 100644 --- a/windows/src/sound.rs +++ b/windows/src/sound.rs @@ -1,15 +1,21 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; -use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::time::{Duration, Instant}; use windows::Win32::Media::Audio::*; -use windows::Win32::Media::MMSYSERR_NOERROR; -use windows::core::PSTR; +use windows::Win32::System::Com::*; use crate::hand::find_asset; pub const CHOICES: [&str; 6] = ["thock", "click", "pop", "wood", "boop", "squeak"]; +const RATE: u32 = 44100; +/// Audio queued ahead of the speakers. Small, so a tap is heard within a few milliseconds. +const QUEUE_FRAMES: u32 = 280; +const TICK: Duration = Duration::from_millis(3); +const STOP_AFTER_IDLE: Duration = Duration::from_secs(30); + enum Cmd { Open, Close, @@ -20,8 +26,8 @@ static TX: OnceLock> = OnceLock::new(); static CHOICE: AtomicUsize = AtomicUsize::new(0); static ENABLED: AtomicBool = AtomicBool::new(true); -/// 44.1 kHz, 16-bit, mono. Returns the raw samples or None for anything else. -fn pcm(bytes: &[u8]) -> Option> { +/// 44.1 kHz, 16-bit, mono. Returns the samples or None for anything else. +fn pcm(bytes: &[u8]) -> Option> { if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { return None; } @@ -35,9 +41,9 @@ fn pcm(bytes: &[u8]) -> Option> { let channels = u16::from_le_bytes([body[2], body[3]]); let rate = u32::from_le_bytes(body[4..8].try_into().ok()?); let bits = u16::from_le_bytes([body[14], body[15]]); - ok = tag == 1 && channels == 1 && rate == 44100 && bits == 16; + ok = tag == 1 && channels == 1 && rate == RATE && bits == 16; } - b"data" => return ok.then(|| body.to_vec()), + b"data" => return ok.then(|| body.chunks_exact(2).map(|b| i16::from_le_bytes([b[0], b[1]])).collect()), _ => {} } at += 8 + size + (size & 1); @@ -45,89 +51,147 @@ fn pcm(bytes: &[u8]) -> Option> { None } -/// Everything that touches the audio device lives on this one thread. The mouse hook -/// only sends a message, because a slow or blocked hook stalls the whole system. -struct Player { - clips: Vec>, - headers: Vec>, - device: Option, +/// An open shared-mode stream. WASAPI converts our 44.1 kHz mono to the device format. +struct Stream { + client: IAudioClient, + render: IAudioRenderClient, + frames: u32, + running: bool, } -impl Player { - fn open(&mut self) { - if self.device.is_some() { - return; - } - let format = WAVEFORMATEX { wFormatTag: WAVE_FORMAT_PCM as u16, nChannels: 1, nSamplesPerSec: 44100, nAvgBytesPerSec: 88200, nBlockAlign: 2, wBitsPerSample: 16, cbSize: 0 }; - let mut device = HWAVEOUT::default(); +impl Stream { + fn open() -> windows::core::Result { unsafe { - if waveOutOpen(Some(&mut device), WAVE_MAPPER, &format, None, None, CALLBACK_NULL) != MMSYSERR_NOERROR { - return; - } - for (clip, hdr) in self.clips.iter().zip(&mut self.headers) { - **hdr = WAVEHDR { lpData: PSTR(clip.as_ptr() as *mut u8), dwBufferLength: clip.len() as u32, ..Default::default() }; - waveOutPrepareHeader(device, &mut **hdr, size_of::() as u32); - } + let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole)?; + let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?; + let format = WAVEFORMATEX { wFormatTag: WAVE_FORMAT_PCM as u16, nChannels: 1, nSamplesPerSec: RATE, nAvgBytesPerSec: RATE * 2, nBlockAlign: 2, wBitsPerSample: 16, cbSize: 0 }; + let flags = AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY; + client.Initialize(AUDCLNT_SHAREMODE_SHARED, flags, 200_000, 0, &format, None)?; + let frames = client.GetBufferSize()?; + let render: IAudioRenderClient = client.GetService()?; + Ok(Self { client, render, frames, running: false }) } - self.device = Some(device); } - fn close(&mut self) { - let Some(device) = self.device.take() else { return }; - unsafe { - waveOutReset(device); - for hdr in &mut self.headers { - waveOutUnprepareHeader(device, &mut **hdr, size_of::() as u32); + fn start(&mut self) { + if !self.running && unsafe { self.client.Start() }.is_ok() { + self.running = true; + } + } + + fn stop(&mut self) { + if self.running { + unsafe { + let _ = self.client.Stop(); + let _ = self.client.Reset(); } - waveOutClose(device); + self.running = false; } } - /// Cuts off the previous tap, like Mac's .interrupts. - fn play(&mut self) { - let Some(device) = self.device else { return }; - let i = CHOICE.load(Relaxed); - let Some(hdr) = self.headers.get_mut(i).filter(|_| self.clips.get(i).is_some_and(|c| !c.is_empty())) else { return }; + /// Tops the queue up to a few milliseconds, with the current tap or silence. + fn pump(&mut self, voice: &mut Option<(usize, usize)>, clips: &[Vec]) { + let Ok(padding) = (unsafe { self.client.GetCurrentPadding() }) else { return }; + let room = self.frames.saturating_sub(padding); + let n = QUEUE_FRAMES.saturating_sub(padding).min(room); + if n == 0 { + return; + } + let Ok(buffer) = (unsafe { self.render.GetBuffer(n) }) else { return }; + let out = unsafe { std::slice::from_raw_parts_mut(buffer.cast::(), n as usize) }; + out.fill(0); + if let Some((clip, pos)) = voice { + let data = &clips[*clip]; + let take = (data.len() - *pos).min(out.len()); + out[..take].copy_from_slice(&data[*pos..*pos + take]); + *pos += take; + if *pos >= data.len() { + *voice = None; + } + } unsafe { - waveOutReset(device); - waveOutWrite(device, &mut **hdr, size_of::() as u32); + let _ = self.render.ReleaseBuffer(n, 0); } } } +/// Everything that touches the audio device lives on this one thread. The mouse hook +/// only sends a message, because a slow or blocked hook stalls the whole system. fn run(rx: Receiver) { - let clips: Vec> = CHOICES + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + let clips: Vec> = CHOICES .iter() .map(|name| find_asset(&format!("sounds/tap_{name}.wav")).and_then(|p| std::fs::read(p).ok()).and_then(|b| pcm(&b)).unwrap_or_default()) .collect(); - let headers = clips.iter().map(|_| Box::new(WAVEHDR::default())).collect(); - let mut player = Player { clips, headers, device: None }; - while let Ok(first) = rx.recv() { - let mut batch = vec![first]; - batch.extend(rx.try_iter()); - // Taps that piled up while the device was busy collapse into one. - let last = batch.len() - 1; - for (i, cmd) in batch.iter().enumerate() { - match cmd { - Cmd::Open => player.open(), - Cmd::Close => player.close(), - Cmd::Play if matches!(batch.get(i + 1), Some(Cmd::Play)) && i != last => {} - Cmd::Play => player.play(), + + let mut stream: Option = None; + let mut voice: Option<(usize, usize)> = None; + let mut wanted = false; + let mut last_tap = Instant::now(); + + loop { + let cmd = match stream.as_ref().is_some_and(|s| s.running) { + true => match rx.recv_timeout(TICK) { + Ok(cmd) => Some(cmd), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => break, + }, + false => match rx.recv() { + Ok(cmd) => Some(cmd), + Err(_) => break, + }, + }; + match cmd { + Some(Cmd::Open) => { + wanted = true; + if stream.is_none() { + match Stream::open() { + Ok(s) => stream = Some(s), + Err(e) => crate::log::write(&format!("audio open failed: {e}")), + } + } + last_tap = Instant::now(); + if let Some(s) = &mut stream { + s.start(); + } + } + Some(Cmd::Close) => { + wanted = false; + voice = None; + if let Some(mut s) = stream.take() { + s.stop(); + } + } + Some(Cmd::Play) if wanted => { + let i = CHOICE.load(Relaxed); + if clips.get(i).is_some_and(|c| !c.is_empty()) { + voice = Some((i, 0)); + } + last_tap = Instant::now(); + if let Some(s) = &mut stream { + s.start(); + } + } + _ => {} + } + if let Some(s) = &mut stream { + if s.running { + s.pump(&mut voice, &clips); + if voice.is_none() && last_tap.elapsed() > STOP_AFTER_IDLE { + s.stop(); + } } } } } -/// Off until the waveOut backend is replaced: processes with it open got stuck exiting. -const AUDIO_ENABLED: bool = false; - pub fn load() { - if !AUDIO_ENABLED { - return; - } TX.get_or_init(|| { let (tx, rx) = channel(); - std::thread::spawn(move || run(rx)); + let _ = std::thread::Builder::new().name("audio".into()).spawn(move || run(rx)); tx }); } From d9cb05387d594375f9d6016c84ec942412deaa2b Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:33:26 +0530 Subject: [PATCH 20/28] Windows: usable tray menu and system popups while the pointer is on, never hold focus --- windows/src/hook.rs | 10 ++- windows/src/main.rs | 173 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 147 insertions(+), 36 deletions(-) diff --git a/windows/src/hook.rs b/windows/src/hook.rs index a20e65d..26ae3bc 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -24,8 +24,12 @@ pub static DOWN_Y: AtomicI32 = AtomicI32::new(0); static WAKE: AtomicIsize = AtomicIsize::new(0); static SWALLOWED: AtomicBool = AtomicBool::new(false); -/// Clicks inside these rects reach the windows under the overlay: taskbar, toolbar. -static PASS: [[AtomicI32; 4]; 2] = [const { [const { AtomicI32::new(0) }; 4] }; 2]; +/// Clicks inside these rects reach the windows under the overlay. Slot 0 is the taskbar, +/// 1 the toolbar, the rest open menus, flyouts and Start. +pub const PASS_SLOTS: usize = 8; +static PASS: [[AtomicI32; 4]; PASS_SLOTS] = [const { [const { AtomicI32::new(0) }; 4] }; PASS_SLOTS]; +/// Every click goes through while TinyHand's own menu is open. +pub static PAUSE: AtomicBool = AtomicBool::new(false); /// Cursor zone that reveals the toolbar. pub static ZONE: [AtomicI32; 4] = [const { AtomicI32::new(0) }; 4]; @@ -220,7 +224,7 @@ unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { match msg { WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN => { SWALLOWED.store(false, Relaxed); - if !INTERACT.load(Relaxed) && !in_passthrough(info.pt) { + if !INTERACT.load(Relaxed) && !PAUSE.load(Relaxed) && !in_passthrough(info.pt) { DOWN_X.store(info.pt.x, Relaxed); DOWN_Y.store(info.pt.y, Relaxed); HELD.store(true, Relaxed); diff --git a/windows/src/main.rs b/windows/src/main.rs index ed9150e..57dec21 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -48,6 +48,7 @@ static HWND_RAW: AtomicIsize = AtomicIsize::new(0); const TIMER_INITIAL: usize = 3; const TIMER_RAISE: usize = 4; +const TIMER_POPUPS: usize = 5; const PINNED: [&str; 6] = ["lavender", "glove", "coral", "plush", "robot", "laser"]; struct App { @@ -66,24 +67,61 @@ struct App { const YIELD_TO: [&str; 3] = ["screenclippinghost.exe", "snippingtool.exe", "screensketch.exe"]; -fn should_yield(hwnd: HWND) -> bool { +/// Lower-cased exe name of the process that owns a window. +fn process_name(hwnd: HWND) -> Option { if hwnd.0.is_null() { - return false; + return None; } let mut pid = 0u32; unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)); - let Ok(process) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { return false }; + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; let mut buf = [0u16; 512]; let mut len = buf.len() as u32; let ok = QueryFullProcessImageNameW(process, PROCESS_NAME_WIN32, PWSTR(buf.as_mut_ptr()), &mut len).is_ok(); let _ = CloseHandle(process); - if !ok { - return false; - } let path = String::from_utf16_lossy(&buf[..len as usize]).to_lowercase(); - let name = path.rsplit('\\').next().unwrap_or(""); - YIELD_TO.contains(&name) + ok.then(|| path.rsplit('\\').next().unwrap_or("").to_string()) + } +} + +fn should_yield(hwnd: HWND) -> bool { + process_name(hwnd).is_some_and(|n| YIELD_TO.contains(&n.as_str())) +} + +const SHELL_HOSTS: [&str; 3] = ["startmenuexperiencehost.exe", "searchhost.exe", "shellexperiencehost.exe"]; + +/// Windows' own popups. Clicks on them must reach Windows, or menus and flyouts cannot be used. +fn is_system_popup(hwnd: HWND) -> bool { + let mut class = [0u16; 64]; + let n = unsafe { GetClassNameW(hwnd, &mut class) } as usize; + match String::from_utf16_lossy(&class[..n]).as_str() { + "#32768" | "Shell_TrayWnd" | "Shell_SecondaryTrayWnd" | "TopLevelWindowForOverflowXamlIsland" | "NotifyIconOverflowWindow" | "XamlExplorerHostIslandWindow" => true, + // Apps host their UI in these too, so only the shell's own count. + "Windows.UI.Core.CoreWindow" => process_name(hwnd).is_some_and(|n| SHELL_HOSTS.contains(&n.as_str())), + _ => false, + } +} + +unsafe extern "system" fn collect_popup(hwnd: HWND, lp: LPARAM) -> BOOL { + let found = unsafe { &mut *(lp.0 as *mut Vec) }; + if found.len() < hook::PASS_SLOTS - 2 && unsafe { IsWindowVisible(hwnd) }.as_bool() && is_system_popup(hwnd) { + let mut r = RECT::default(); + if unsafe { GetWindowRect(hwnd, &mut r) }.is_ok() && r.right > r.left && r.bottom > r.top { + found.push(r); + } + } + BOOL(1) +} + +/// Keeps the hook's pass-through list up to date with whatever system popups are open. +fn scan_popups() { + let mut found: Vec = Vec::new(); + unsafe { + let _ = EnumWindows(Some(collect_popup), LPARAM((&raw mut found) as isize)); + } + for slot in 0..hook::PASS_SLOTS - 2 { + hook::set_passthrough(2 + slot, found.get(slot).copied().unwrap_or_default()); } } @@ -146,6 +184,7 @@ impl App { fn start(&mut self) { lock(&self.renderer).reset(); self.refresh_taskbar(); + scan_popups(); hook::reset_keys(); hook::HELD.store(false, Relaxed); hook::ACTIVE.store(true, Relaxed); @@ -155,6 +194,7 @@ impl App { SetTimer(Some(self.hwnd), TIMER_TASKBAR, 500, None); SetTimer(Some(self.hwnd), TIMER_INITIAL, 3000, None); SetTimer(Some(self.hwnd), TIMER_RAISE, 250, None); + SetTimer(Some(self.hwnd), TIMER_POPUPS, 100, None); let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); } sound::open(); @@ -211,11 +251,15 @@ impl App { cursor::restore(); lock(&self.renderer).reset(); sound::close(); + for slot in 2..hook::PASS_SLOTS { + hook::set_passthrough(slot, RECT::default()); + } self.toolbar.hide(); unsafe { let _ = KillTimer(Some(self.hwnd), TIMER_TASKBAR); let _ = KillTimer(Some(self.hwnd), TIMER_INITIAL); let _ = KillTimer(Some(self.hwnd), TIMER_RAISE); + let _ = KillTimer(Some(self.hwnd), TIMER_POPUPS); let _ = UnregisterHotKey(Some(self.hwnd), HK_ESC); let _ = ShowWindow(self.hwnd, SW_HIDE); } @@ -245,6 +289,10 @@ impl App { } fn on_message(&mut self, hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> Option { + if msg == WM_ACTIVATE && (hwnd == self.hwnd || hwnd == self.toolbar.hwnd) && wp.0 & 0xFFFF != 0 { + self.give_back_focus(HWND(lp.0 as *mut c_void)); + return Some(LRESULT(0)); + } if hwnd == self.toolbar.hwnd { let (result, action) = self.toolbar.handle(msg, wp, lp)?; match action { @@ -265,6 +313,7 @@ impl App { self.toolbar.set_wanted(self.near); } WM_TIMER if wp.0 == TIMER_RAISE => self.raise(), + WM_TIMER if wp.0 == TIMER_POPUPS => scan_popups(), WM_TIMER => self.refresh_taskbar(), hook::WM_REPLAY_CLICK => hook::send_click(lp, wp.0), hook::WM_REPLAY_KEY => hook::send_alt_and_key(wp.0, lp), @@ -299,40 +348,86 @@ impl App { HK_THROUGH => self.toggle_click_through(), _ => {} }, - WM_TRAY => match (lp.0 as u32) & 0xFFFF { - WM_LBUTTONUP => self.toggle(), - WM_RBUTTONUP => self.tray_menu(hwnd), - _ => {} - }, - WM_COMMAND => match wp.0 & 0xFFFF { - CMD_TOGGLE => self.toggle(), - CMD_THROUGH => self.toggle_click_through(), - CMD_QUIT => unsafe { PostQuitMessage(0) }, - _ => {} - }, + WM_TRAY => { + if (lp.0 as u32) & 0xFFFF == WM_LBUTTONUP { + self.toggle(); + } + } + WM_COMMAND => self.command(wp.0 & 0xFFFF), _ => return None, } Some(LRESULT(0)) } - fn tray_menu(&self, hwnd: HWND) { + fn command(&mut self, cmd: usize) { + match cmd { + CMD_TOGGLE => self.toggle(), + CMD_THROUGH => self.toggle_click_through(), + CMD_QUIT => unsafe { PostQuitMessage(0) }, + _ => {} + } + } + + /// The full-screen overlay must never hold keyboard focus. When Windows hands it over + /// (for example after a flyout closes), pass it back to the window that had it. + fn give_back_focus(&self, previous: HWND) { + let ours = |h: HWND| h == self.hwnd || h == self.toolbar.hwnd; unsafe { - let Ok(menu) = CreatePopupMenu() else { return }; - let label = if self.active { w!("Turn pointer off") } else { w!("Turn pointer on") }; - let _ = AppendMenuW(menu, MF_STRING, CMD_TOGGLE, label); - let checked = if hook::CLICK_THROUGH.load(Relaxed) { MF_CHECKED } else { MF_UNCHECKED }; - let _ = AppendMenuW(menu, MF_STRING | checked, CMD_THROUGH, w!("Click through\tCtrl+Alt+C")); - let _ = AppendMenuW(menu, MF_STRING, CMD_QUIT, w!("Quit TinyHand")); - let mut p = POINT::default(); - let _ = GetCursorPos(&mut p); - let _ = SetForegroundWindow(hwnd); - let _ = TrackPopupMenu(menu, TPM_RIGHTBUTTON, p.x, p.y, Some(0), hwnd, None); - let _ = DestroyMenu(menu); + if !previous.0.is_null() && !ours(previous) && IsWindowVisible(previous).as_bool() { + let _ = SetForegroundWindow(previous); + return; + } + let mut next = GetWindow(self.hwnd, GW_HWNDNEXT).ok(); + while let Some(w) = next { + let tool = GetWindowLongPtrW(w, GWL_EXSTYLE) & WS_EX_TOOLWINDOW.0 as isize != 0; + if !ours(w) && IsWindowVisible(w).as_bool() && !tool && GetWindowTextLengthW(w) > 0 { + let _ = SetForegroundWindow(w); + return; + } + next = GetWindow(w, GW_HWNDNEXT).ok(); + } + } + } +} + +/// Runs outside the app borrow and its own tiny window. While it is open the overlay keeps +/// redrawing and every click goes through, so the menu can actually be used. +fn show_tray_menu(tray: HWND) { + let active = APP.with(|c| c.try_borrow().ok().and_then(|s| s.as_ref().map(|a| a.active))).unwrap_or(false); + unsafe { + let Ok(menu) = CreatePopupMenu() else { return }; + let label = if active { w!("Turn pointer off") } else { w!("Turn pointer on") }; + let _ = AppendMenuW(menu, MF_STRING, CMD_TOGGLE, label); + let checked = if hook::CLICK_THROUGH.load(Relaxed) { MF_CHECKED } else { MF_UNCHECKED }; + let _ = AppendMenuW(menu, MF_STRING | checked, CMD_THROUGH, w!("Click through\tCtrl+Alt+C")); + let _ = AppendMenuW(menu, MF_STRING, CMD_QUIT, w!("Quit TinyHand")); + let mut p = POINT::default(); + let _ = GetCursorPos(&mut p); + hook::PAUSE.store(true, Relaxed); + if active { + cursor::restore(); } + let _ = SetForegroundWindow(tray); + let cmd = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, p.x, p.y, Some(0), tray, None); + let _ = PostMessageW(Some(tray), WM_NULL, WPARAM(0), LPARAM(0)); + let _ = DestroyMenu(menu); + hook::PAUSE.store(false, Relaxed); + APP.with(|c| { + if let Some(app) = c.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) { + if app.active && !app.suspended { + cursor::hide(); + } + app.command(cmd.0 as usize); + } + }); } } unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + if msg == WM_TRAY && (lp.0 as u32) & 0xFFFF == WM_RBUTTONUP { + show_tray_menu(hwnd); + return LRESULT(0); + } // try_borrow_mut: ShowWindow and friends re-enter this proc. let handled = APP.with(|cell| { let mut slot = cell.try_borrow_mut().ok()?; @@ -442,6 +537,18 @@ fn main() { .expect("toolbar window"); let _ = DwmSetWindowAttribute(bar, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); + // The tray icon and its menu get their own tiny window, so the overlay never takes focus. + let tray_class = w!("TinyHandTray"); + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: tray_class, + ..Default::default() + }); + let tray = CreateWindowExW(WS_EX_TOOLWINDOW, tray_class, w!("TinyHand tray"), WS_POPUP, 0, 0, 0, 0, None, None, Some(hinst), None).expect("tray window"); + HWND_RAW.store(tray.0 as isize, Relaxed); + let id = std::env::args().nth(1).or_else(prefs::load_style).unwrap_or_else(|| "lavender".into()); let (style, ink) = styles::load(&id); let mut pinned = PINNED.to_vec(); @@ -462,7 +569,7 @@ fn main() { hook::init(); hook::start(hwnd); let _ = std::thread::Builder::new().name("render".into()).spawn(move || renderer::run(renderer)); - add_tray(hwnd); + add_tray(tray); // Release builds have no console of their own. Borrow the terminal's so Ctrl+C reaches us. let _ = AttachConsole(ATTACH_PARENT_PROCESS); let _ = SetConsoleCtrlHandler(Some(on_ctrl), true); @@ -480,6 +587,6 @@ fn main() { hook::ACTIVE.store(false, Relaxed); cursor::restore(); - remove_tray(hwnd); + remove_tray(tray); } } From e671e62a1a7edc818505744b3f450b6f19995ba0 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:36:27 +0530 Subject: [PATCH 21/28] Windows: overlay and toolbar follow the cursor across monitors --- windows/src/main.rs | 5 ++++ windows/src/renderer.rs | 59 ++++++++++++++++++++++++++++++++++++----- windows/src/scene.rs | 9 +++++++ windows/src/surface.rs | 26 +++++++++--------- windows/src/toolbar.rs | 20 +++++++++++++- 5 files changed, 99 insertions(+), 20 deletions(-) diff --git a/windows/src/main.rs b/windows/src/main.rs index 57dec21..b3a5643 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -326,6 +326,11 @@ impl App { } } } + toolbar::WM_SCREEN_CHANGED => { + if self.active && !self.suspended { + self.toolbar.follow_cursor(); + } + } toolbar::WM_TB_WANT => { self.near = wp.0 != 0; if self.active && !self.suspended { diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index 82d6640..80290dd 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -6,13 +6,14 @@ use std::time::Instant; use tiny_skia::PixmapMut; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Dwm::DwmFlush; +use windows::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint}; use windows::Win32::System::Threading::*; use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, PostMessageW}; use crate::hook; use crate::scene::{Ink, Pending, Rc, draw_laser}; use crate::styles::Style; -use crate::toolbar::WM_TB_WANT; +use crate::toolbar::{WM_SCREEN_CHANGED, WM_TB_WANT}; use crate::surface::Surface; const DRAG_THRESHOLD: f32 = 4.0; @@ -36,6 +37,7 @@ pub struct Renderer { down_at: (f32, f32), press: f32, held: bool, + stale_press: bool, down_seq: u32, up_seq: u32, last: Instant, @@ -54,6 +56,16 @@ fn cursor_pos() -> (f32, f32) { (p.x as f32, p.y as f32) } +/// The monitor under a point, in virtual-desktop pixels. +fn monitor_rect(at: (f32, f32)) -> RECT { + unsafe { + let monitor = MonitorFromPoint(POINT { x: at.0 as i32, y: at.1 as i32 }, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; + let _ = GetMonitorInfoW(monitor, &mut info); + info.rcMonitor + } +} + impl Renderer { pub fn new(hwnd: HWND, w: i32, h: i32, style: Style, ink_rgb: u32) -> Self { let mut ink = Ink::new(w, h); @@ -69,6 +81,7 @@ impl Renderer { down_at: (0.0, 0.0), press: 0.0, held: false, + stale_press: false, down_seq: 0, up_seq: 0, last: Instant::now(), @@ -77,6 +90,22 @@ impl Renderer { } } + /// Follows the cursor onto another monitor. Returns true when the screen changed. + fn sync_screen(&mut self, at: (f32, f32)) -> bool { + let r = monitor_rect(at); + let (w, h) = (r.right - r.left, r.bottom - r.top); + if w <= 0 || h <= 0 || ((r.left, r.top) == self.surface.origin && (w, h) == (self.surface.w, self.surface.h)) { + return false; + } + self.surface = Surface::new(w, h); + self.surface.origin = (r.left, r.top); + self.ink.resize(w, h); + self.key.clear(); + self.shape = None; + self.drew = true; + true + } + pub fn set_style(&mut self, style: Style, ink_rgb: u32) { self.style = style; self.ink.set_color(ink_rgb); @@ -86,6 +115,8 @@ impl Renderer { /// Blank slate and an empty frame. Window thread only. pub fn reset(&mut self) { self.near_toolbar = false; + self.sync_screen(cursor_pos()); + self.stale_press = false; self.surface.clear(); self.ink.clear(); self.ink.take_dirty(); @@ -111,9 +142,18 @@ impl Renderer { pub fn frame(&mut self) -> Next { let dt = self.last.elapsed().as_secs_f32(); self.last = Instant::now(); - let at = cursor_pos(); - let mut busy = at != self.cursor; - self.cursor = at; + let global = cursor_pos(); + let mut busy = global != self.cursor; + self.cursor = global; + if self.sync_screen(global) { + // A press that began on the old screen cannot carry a stroke onto this one. + self.stale_press = hook::HELD.load(Relaxed); + unsafe { + let _ = PostMessageW(Some(self.hwnd), WM_SCREEN_CHANGED, WPARAM(0), LPARAM(0)); + } + } + let (ox, oy) = (self.surface.origin.0 as f32, self.surface.origin.1 as f32); + let at = (global.0 - ox, global.1 - oy); let down = hook::DOWN_SEQ.load(Relaxed); let up = hook::UP_SEQ.load(Relaxed); @@ -124,13 +164,18 @@ impl Renderer { if down != self.down_seq { self.down_seq = down; self.press = 1.0; - self.down_at = (hook::DOWN_X.load(Relaxed) as f32, hook::DOWN_Y.load(Relaxed) as f32); + self.stale_press = false; + self.down_at = (hook::DOWN_X.load(Relaxed) as f32 - ox, hook::DOWN_Y.load(Relaxed) as f32 - oy); } self.held = hook::HELD.load(Relaxed); + if !self.held { + self.stale_press = false; + } if self.held { busy = true; - if self.ink.is_drawing() { + if self.stale_press { + } else if self.ink.is_drawing() { self.ink.extend(at); } else if (at.0 - self.down_at.0).hypot(at.1 - self.down_at.1) >= DRAG_THRESHOLD { self.ink.begin(self.down_at); @@ -144,7 +189,7 @@ impl Renderer { } busy |= self.ink.tick(); - let near = !self.ink.is_drawing() && hook::in_zone(at.0 as i32, at.1 as i32); + let near = !self.ink.is_drawing() && hook::in_zone(global.0 as i32, global.1 as i32); if near != self.near_toolbar { self.near_toolbar = near; unsafe { diff --git a/windows/src/scene.rs b/windows/src/scene.rs index 15e64de..1aeabd6 100644 --- a/windows/src/scene.rs +++ b/windows/src/scene.rs @@ -101,6 +101,15 @@ impl Ink { } } + /// Starts over at a new screen size. Strokes do not carry between screens. + pub fn resize(&mut self, w: i32, h: i32) { + self.layer = Pixmap::new(w as u32, h as u32).expect("ink layer"); + self.strokes.clear(); + self.drawing = false; + self.width = (h as f32 * 0.0055).max(3.0); + self.dirty = None; + } + pub fn set_color(&mut self, rgb: u32) { self.color = px((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, 255); } diff --git a/windows/src/surface.rs b/windows/src/surface.rs index 6854817..8a3c402 100644 --- a/windows/src/surface.rs +++ b/windows/src/surface.rs @@ -9,10 +9,22 @@ use windows::Win32::UI::WindowsAndMessaging::*; pub struct Surface { pub w: i32, pub h: i32, + /// Where the window sits on the virtual desktop. + pub origin: (i32, i32), dc: HDC, + bmp: HBITMAP, bits: *mut u8, } +impl Drop for Surface { + fn drop(&mut self) { + unsafe { + let _ = DeleteDC(self.dc); + let _ = DeleteObject(self.bmp.into()); + } + } +} + impl Surface { pub fn new(w: i32, h: i32) -> Self { unsafe { @@ -34,7 +46,7 @@ impl Surface { let bmp = CreateDIBSection(Some(screen), &bmi, DIB_RGB_COLORS, &mut bits, None, 0).expect("DIB section"); SelectObject(dc, bmp.into()); ReleaseDC(None, screen); - Self { w, h, dc, bits: bits.cast() } + Self { w, h, origin: (0, 0), dc, bmp, bits: bits.cast() } } } @@ -46,10 +58,6 @@ impl Surface { self.bytes_mut().fill(0); } - fn blend() -> BLENDFUNCTION { - Self::blend_with(255) - } - fn blend_with(alpha: u8) -> BLENDFUNCTION { BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: alpha, AlphaFormat: AC_SRC_ALPHA as u8 } } @@ -66,13 +74,7 @@ impl Surface { /// Window thread only. pub fn present_full(&self, hwnd: HWND) { - let origin = POINT::default(); - let size = SIZE { cx: self.w, cy: self.h }; - unsafe { - if let Err(e) = UpdateLayeredWindow(hwnd, None, Some(&origin), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend()), ULW_ALPHA) { - eprintln!("ulw failed: {e}"); - } - } + self.present_at(hwnd, self.origin.0, self.origin.1, 255); } pub fn present(&self, hwnd: HWND) { diff --git a/windows/src/toolbar.rs b/windows/src/toolbar.rs index d389bca..e2872c8 100644 --- a/windows/src/toolbar.rs +++ b/windows/src/toolbar.rs @@ -2,6 +2,7 @@ use std::time::Instant; use tiny_skia::*; use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint}; use windows::Win32::UI::HiDpi::GetDpiForWindow; use windows::Win32::UI::Input::KeyboardAndMouse::*; use windows::Win32::UI::WindowsAndMessaging::*; @@ -12,6 +13,8 @@ use crate::scene::{draw_laser, px}; use crate::surface::Surface; pub const WM_TB_WANT: u32 = WM_APP + 22; +/// The cursor crossed onto another monitor. +pub const WM_SCREEN_CHANGED: u32 = WM_APP + 23; const TIMER_ANIM: usize = 2; const WM_MOUSELEAVE: u32 = 0x02A3; @@ -133,10 +136,25 @@ impl Toolbar { (self.surface.w, self.surface.h) } + /// Moves to the monitor the cursor is on, without changing whether it is showing. + pub fn follow_cursor(&mut self) { + self.place(); + self.present(); + if self.target > 0.0 { + self.set_interactive(true); + } + } + fn place(&mut self) { let mut work = RECT::default(); unsafe { - let _ = SystemParametersInfoW(SPI_GETWORKAREA, 0, Some((&raw mut work).cast()), SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0)); + let mut at = POINT::default(); + let _ = GetCursorPos(&mut at); + let monitor = MonitorFromPoint(at, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; + if GetMonitorInfoW(monitor, &mut info).as_bool() { + work = info.rcWork; + } } let (w, h) = self.size(); let left = work.left + (work.right - work.left - w) / 2; From 5771cf05151a882a841a11a63085d9cf642a2403 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:39:34 +0530 Subject: [PATCH 22/28] Windows: rebuild the toolbar for each monitor's display scaling --- windows/src/hand.rs | 2 +- windows/src/main.rs | 1 + windows/src/monitor.rs | 24 ++++++++++++ windows/src/renderer.rs | 9 +---- windows/src/toolbar.rs | 86 ++++++++++++++++++++++++----------------- 5 files changed, 78 insertions(+), 44 deletions(-) create mode 100644 windows/src/monitor.rs diff --git a/windows/src/hand.rs b/windows/src/hand.rs index 7d6c91e..5c65697 100644 --- a/windows/src/hand.rs +++ b/windows/src/hand.rs @@ -147,7 +147,7 @@ fn halve(src: &Pixmap) -> Pixmap { out } -fn resized(full: &Pixmap, height: u32) -> Pixmap { +pub fn resized(full: &Pixmap, height: u32) -> Pixmap { let mut cur = full.clone(); while cur.height() / 2 >= height && cur.width() >= 2 { cur = halve(&cur); diff --git a/windows/src/main.rs b/windows/src/main.rs index b3a5643..c7ac657 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -5,6 +5,7 @@ mod cursor; mod hand; mod hook; mod log; +mod monitor; mod prefs; mod renderer; mod scene; diff --git a/windows/src/monitor.rs b/windows/src/monitor.rs new file mode 100644 index 0000000..960dfad --- /dev/null +++ b/windows/src/monitor.rs @@ -0,0 +1,24 @@ +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; + +pub struct Info { + /// The whole monitor, in virtual-desktop pixels. + pub full: RECT, + /// The monitor minus the taskbar. + pub work: RECT, + /// Display scaling: 1.0 is 100%. + pub scale: f32, +} + +/// The monitor under a point. +pub fn at(p: (f32, f32)) -> Info { + unsafe { + let monitor = MonitorFromPoint(POINT { x: p.0 as i32, y: p.1 as i32 }, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; + let _ = GetMonitorInfoW(monitor, &mut info); + let (mut dx, mut dy) = (96u32, 96u32); + let _ = GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dx, &mut dy); + Info { full: info.rcMonitor, work: info.rcWork, scale: dx.max(96) as f32 / 96.0 } + } +} diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index 80290dd..30c2e43 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -6,7 +6,6 @@ use std::time::Instant; use tiny_skia::PixmapMut; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Dwm::DwmFlush; -use windows::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint}; use windows::Win32::System::Threading::*; use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, PostMessageW}; @@ -56,14 +55,8 @@ fn cursor_pos() -> (f32, f32) { (p.x as f32, p.y as f32) } -/// The monitor under a point, in virtual-desktop pixels. fn monitor_rect(at: (f32, f32)) -> RECT { - unsafe { - let monitor = MonitorFromPoint(POINT { x: at.0 as i32, y: at.1 as i32 }, MONITOR_DEFAULTTONEAREST); - let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; - let _ = GetMonitorInfoW(monitor, &mut info); - info.rcMonitor - } + crate::monitor::at(at).full } impl Renderer { diff --git a/windows/src/toolbar.rs b/windows/src/toolbar.rs index e2872c8..b95ef73 100644 --- a/windows/src/toolbar.rs +++ b/windows/src/toolbar.rs @@ -2,7 +2,6 @@ use std::time::Instant; use tiny_skia::*; use windows::Win32::Foundation::*; -use windows::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint}; use windows::Win32::UI::HiDpi::GetDpiForWindow; use windows::Win32::UI::Input::KeyboardAndMouse::*; use windows::Win32::UI::WindowsAndMessaging::*; @@ -43,10 +42,14 @@ enum Kind { struct Item { kind: Kind, + /// Large copy of the thumbnail, kept so icons can be resized for any display scaling. + source: Option, icon: Option, x: f32, } +const SOURCE_PX: u32 = 144; + pub struct Toolbar { pub hwnd: HWND, items: Vec, @@ -90,36 +93,19 @@ fn fill(pix: &mut PixmapMut, path: &Path, color: Color) { impl Toolbar { pub fn new(hwnd: HWND, styles: &[&str], selected: &str) -> Self { let scale = unsafe { GetDpiForWindow(hwnd) }.max(96) as f32 / 96.0; - let icon_px = (36.0 * scale).round() as u32; let mut items: Vec = styles .iter() - .map(|id| Item { - kind: Kind::Style(id.to_string()), - icon: HandAsset::load(id).map(|a| a.thumbnail(icon_px)), - x: 0.0, - }) + .map(|id| Item { kind: Kind::Style(id.to_string()), source: HandAsset::load(id).map(|a| a.thumbnail(SOURCE_PX)), icon: None, x: 0.0 }) .collect(); - let mut x = PAD_X * scale; - for item in &mut items { - item.x = x; - x += (BUTTON + GAP) * scale; + for kind in [Kind::ClickThrough, Kind::Sound, Kind::Exit] { + items.push(Item { kind, source: None, icon: None, x: 0.0 }); } - let divider_x = x; - x += (1.0 + GAP) * scale; - items.push(Item { kind: Kind::ClickThrough, icon: None, x }); - x += (BUTTON + GAP) * scale; - items.push(Item { kind: Kind::Sound, icon: None, x }); - x += (BUTTON + GAP) * scale; - items.push(Item { kind: Kind::Exit, icon: None, x }); - x += (BUTTON + PAD_X) * scale; - - let (w, h) = (x.round() as i32, ((BUTTON + 2.0 * PAD_Y) * scale).round() as i32); - Self { + let mut bar = Self { hwnd, items, - divider_x, - surface: Surface::new(w, h), - scale, + divider_x: 0.0, + surface: Surface::new(1, 1), + scale: 0.0, selected: selected.to_string(), through: false, sound: true, @@ -129,7 +115,31 @@ impl Toolbar { last: Instant::now(), tracking: false, frame: RECT::default(), + }; + bar.layout(scale); + bar + } + + /// Sizes and positions everything for a display scaling. Called again when the toolbar + /// moves to a monitor with a different one. + fn layout(&mut self, scale: f32) { + self.scale = scale; + let icon_px = (36.0 * scale).round() as u32; + let styles = self.items.iter().filter(|i| matches!(i.kind, Kind::Style(_))).count(); + let mut x = PAD_X * scale; + for (i, item) in self.items.iter_mut().enumerate() { + if i == styles { + self.divider_x = x; + x += (1.0 + GAP) * scale; + } + item.icon = item.source.as_ref().map(|s| crate::hand::resized(s, icon_px)); + item.x = x; + x += (BUTTON + GAP) * scale; } + x += (PAD_X - GAP) * scale; + let (w, h) = (x.round() as i32, ((BUTTON + 2.0 * PAD_Y) * scale).round() as i32); + self.surface = Surface::new(w, h); + self.hover = None; } fn size(&self) -> (i32, i32) { @@ -138,23 +148,28 @@ impl Toolbar { /// Moves to the monitor the cursor is on, without changing whether it is showing. pub fn follow_cursor(&mut self) { - self.place(); - self.present(); + if self.place() { + self.paint(); + } else { + self.present(); + } if self.target > 0.0 { self.set_interactive(true); } } - fn place(&mut self) { - let mut work = RECT::default(); + /// Puts the toolbar at the bottom of the cursor's monitor. Returns true if that monitor's + /// display scaling differed, in which case the toolbar was rebuilt and needs repainting. + fn place(&mut self) -> bool { + let mut at = POINT::default(); unsafe { - let mut at = POINT::default(); let _ = GetCursorPos(&mut at); - let monitor = MonitorFromPoint(at, MONITOR_DEFAULTTONEAREST); - let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; - if GetMonitorInfoW(monitor, &mut info).as_bool() { - work = info.rcWork; - } + } + let monitor = crate::monitor::at((at.x as f32, at.y as f32)); + let work = monitor.work; + let rescaled = (monitor.scale - self.scale).abs() > 0.01; + if rescaled { + self.layout(monitor.scale); } let (w, h) = self.size(); let left = work.left + (work.right - work.left - w) / 2; @@ -167,6 +182,7 @@ impl Toolbar { right: left + w + (80.0 * s) as i32, bottom: top + h + (10.0 * s) as i32, }); + rescaled } pub fn show(&mut self) { From cc81d354ede125a38f96d8129519b015562402e4 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:44:18 +0530 Subject: [PATCH 23/28] Windows: settings window hosting WebView2, opened from the tray menu --- windows/Cargo.lock | 2348 +++++++++++++++++++++++++++++++++++-- windows/Cargo.toml | 2 + windows/src/main.rs | 49 +- windows/src/settings.html | 15 + windows/src/settings.rs | 105 ++ 5 files changed, 2433 insertions(+), 86 deletions(-) create mode 100644 windows/src/settings.html create mode 100644 windows/src/settings.rs diff --git a/windows/Cargo.lock b/windows/Cargo.lock index 33b8e51..f811cdc 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -20,172 +20,2101 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.19+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyhand" +version = "0.1.0" +dependencies = [ + "raw-window-handle", + "tiny-skia", + "windows", + "wry", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] -name = "bytemuck" -version = "1.25.2" +name = "toml" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] [[package]] -name = "cfg-if" -version = "1.0.5" +name = "toml_datetime" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] [[package]] -name = "crc32fast" -version = "1.5.4" +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" dependencies = [ - "cfg-if", + "serde_core", ] [[package]] -name = "fdeflate" -version = "0.3.7" +name = "toml_edit" +version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "simd-adler32", + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", ] [[package]] -name = "flate2" -version = "1.1.11" +name = "toml_edit" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", ] [[package]] -name = "log" -version = "0.4.35" +name = "toml_edit" +version = "0.25.19+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" +dependencies = [ + "indexmap", + "toml_datetime 1.1.3+spec-1.1.0", + "toml_parser", + "winnow 1.0.5", +] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "toml_parser" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "adler2", - "simd-adler32", + "winnow 1.0.5", ] [[package]] -name = "miniz_oxide" -version = "0.9.1" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "adler2", - "simd-adler32", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", ] [[package]] -name = "png" -version = "0.18.1" +name = "utf8_iter" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" dependencies = [ - "bitflags", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", + "utf_types", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "utf_types" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "unicode-ident", + "same-file", + "winapi-util", ] [[package]] -name = "quote" -version = "1.0.47" +name = "web_atoms" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ - "proc-macro2", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] -name = "simd-adler32" -version = "0.3.10" +name = "webkit2gtk" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] [[package]] -name = "strict-num" -version = "0.1.1" +name = "webkit2gtk-sys" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] [[package]] -name = "syn" -version = "2.0.119" +name = "webview2-com" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.119", ] [[package]] -name = "tiny-skia" -version = "0.12.0" +name = "webview2-com-sys" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png", - "tiny-skia-path", + "thiserror 2.0.21", + "windows", + "windows-core", ] [[package]] -name = "tiny-skia-path" -version = "0.12.0" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "arrayref", - "bytemuck", - "strict-num", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[package]] -name = "tinyhand" -version = "0.1.0" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "tiny-skia", - "windows", + "windows-sys 0.61.2", ] [[package]] -name = "unicode-ident" -version = "1.0.27" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" @@ -240,7 +2169,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -251,7 +2180,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -288,6 +2217,39 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -297,6 +2259,222 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" +dependencies = [ + "base64", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/windows/Cargo.toml b/windows/Cargo.toml index e755b84..237eca4 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -4,5 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +raw-window-handle = "0.6.2" tiny-skia = "0.12.0" windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility", "Win32_Media_Audio", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell_PropertiesSystem", "Win32_Devices_FunctionDiscovery", "Win32_System_Variant"] } +wry = "0.57.1" diff --git a/windows/src/main.rs b/windows/src/main.rs index c7ac657..2c1ab80 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -9,6 +9,7 @@ mod monitor; mod prefs; mod renderer; mod scene; +mod settings; mod sound; mod styles; mod surface; @@ -21,6 +22,7 @@ use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Dwm::*; +use windows::Win32::Graphics::Gdi::{COLOR_WINDOW, GetSysColorBrush}; use windows::Win32::Media::timeBeginPeriod; use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole, SetConsoleCtrlHandler}; use windows::Win32::System::LibraryLoader::GetModuleHandleW; @@ -44,6 +46,7 @@ const WM_TRAY: u32 = WM_APP + 10; const CMD_TOGGLE: usize = 1; const CMD_QUIT: usize = 2; const CMD_THROUGH: usize = 3; +const CMD_SETTINGS: usize = 4; static HWND_RAW: AtomicIsize = AtomicIsize::new(0); @@ -64,6 +67,7 @@ struct App { /// The toolbar shows for a few seconds after turning on. initial: bool, style_id: String, + settings: Option, } const YIELD_TO: [&str; 3] = ["screenclippinghost.exe", "snippingtool.exe", "screensketch.exe"]; @@ -294,6 +298,14 @@ impl App { self.give_back_focus(HWND(lp.0 as *mut c_void)); return Some(LRESULT(0)); } + if self.settings.as_ref().is_some_and(|s| s.hwnd == hwnd) { + match msg { + WM_SIZE => self.settings.as_ref()?.resize(), + WM_CLOSE => self.close_settings(), + _ => return None, + } + return Some(LRESULT(0)); + } if hwnd == self.toolbar.hwnd { let (result, action) = self.toolbar.handle(msg, wp, lp)?; match action { @@ -369,11 +381,35 @@ impl App { match cmd { CMD_TOGGLE => self.toggle(), CMD_THROUGH => self.toggle_click_through(), + CMD_SETTINGS => self.open_settings(), CMD_QUIT => unsafe { PostQuitMessage(0) }, _ => {} } } + fn open_settings(&mut self) { + if let Some(s) = &self.settings { + s.focus(); + return; + } + // Settings is a normal window, so the pointer steps aside while it is open. + self.settings = settings::SettingsWindow::open(|message| { + APP.with(|c| { + if let Some(app) = c.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) { + app.on_settings_message(&message); + } + }); + }); + } + + fn close_settings(&mut self) { + self.settings = None; + } + + fn on_settings_message(&mut self, message: &str) { + log::write(&format!("settings message: {message}")); + } + /// The full-screen overlay must never hold keyboard focus. When Windows hands it over /// (for example after a flyout closes), pass it back to the window that had it. fn give_back_focus(&self, previous: HWND) { @@ -406,6 +442,7 @@ fn show_tray_menu(tray: HWND) { let _ = AppendMenuW(menu, MF_STRING, CMD_TOGGLE, label); let checked = if hook::CLICK_THROUGH.load(Relaxed) { MF_CHECKED } else { MF_UNCHECKED }; let _ = AppendMenuW(menu, MF_STRING | checked, CMD_THROUGH, w!("Click through\tCtrl+Alt+C")); + let _ = AppendMenuW(menu, MF_STRING, CMD_SETTINGS, w!("Settings…")); let _ = AppendMenuW(menu, MF_STRING, CMD_QUIT, w!("Quit TinyHand")); let mut p = POINT::default(); let _ = GetCursorPos(&mut p); @@ -543,6 +580,16 @@ fn main() { .expect("toolbar window"); let _ = DwmSetWindowAttribute(bar, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: settings::CLASS, + hCursor: LoadCursorW(None, IDC_ARROW).unwrap_or_default(), + hbrBackground: GetSysColorBrush(COLOR_WINDOW), + ..Default::default() + }); + // The tray icon and its menu get their own tiny window, so the overlay never takes focus. let tray_class = w!("TinyHandTray"); RegisterClassExW(&WNDCLASSEXW { @@ -568,7 +615,7 @@ fn main() { sound::load(); sound::set_choice(&prefs::tap_sound()); let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h, style, ink))); - let mut app = App { hwnd, renderer: renderer.clone(), toolbar, active: false, suspended: false, near: false, initial: false, style_id: id.clone() }; + let mut app = App { hwnd, renderer: renderer.clone(), toolbar, active: false, suspended: false, near: false, initial: false, style_id: id.clone(), settings: None }; app.refresh_sound(); APP.with(|a| *a.borrow_mut() = Some(app)); SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); diff --git a/windows/src/settings.html b/windows/src/settings.html new file mode 100644 index 0000000..94c8527 --- /dev/null +++ b/windows/src/settings.html @@ -0,0 +1,15 @@ + + + + +TinyHand Settings + + + +

TinyHand

+

Settings are loading.

+ + + diff --git a/windows/src/settings.rs b/windows/src/settings.rs new file mode 100644 index 0000000..2be28ec --- /dev/null +++ b/windows/src/settings.rs @@ -0,0 +1,105 @@ +use std::num::NonZeroIsize; + +use raw_window_handle::{HandleError, HasWindowHandle, RawWindowHandle, Win32WindowHandle, WindowHandle}; +use windows::Win32::Foundation::*; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; +use windows::core::w; +use wry::dpi::{PhysicalPosition, PhysicalSize}; +use wry::{Rect, WebView, WebViewBuilder}; + +pub const CLASS: windows::core::PCWSTR = w!("TinyHandSettings"); + +const PAGE: &str = include_str!("settings.html"); + +struct Parent(HWND); + +impl HasWindowHandle for Parent { + fn window_handle(&self) -> Result, HandleError> { + let raw = NonZeroIsize::new(self.0.0 as isize).ok_or(HandleError::Unavailable)?; + // SAFETY: the handle stays valid while Parent is borrowed, and the window outlives the webview. + Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::Win32(Win32WindowHandle::new(raw))) }) + } +} + +/// The settings window. Dropping it tears down WebView2 completely, so a closed +/// settings window costs no memory. +pub struct SettingsWindow { + pub hwnd: HWND, + webview: Option, +} + +impl SettingsWindow { + pub fn open(on_message: impl Fn(String) + 'static) -> Option { + let (client_w, client_h) = (780, 560); + unsafe { + let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; + let mut frame = RECT { left: 0, top: 0, right: client_w, bottom: client_h }; + let _ = AdjustWindowRect(&mut frame, style, false); + let (w, h) = (frame.right - frame.left, frame.bottom - frame.top); + let x = (GetSystemMetrics(SM_CXSCREEN) - w) / 2; + let y = (GetSystemMetrics(SM_CYSCREEN) - h) / 2; + let hinst = HINSTANCE(GetModuleHandleW(None).ok()?.0); + let hwnd = match CreateWindowExW(WINDOW_EX_STYLE(0), CLASS, w!("TinyHand Settings"), style, x, y, w, h, None, None, Some(hinst), None) { + Ok(h) => h, + Err(e) => { + crate::log::write(&format!("settings: window failed: {e}")); + return None; + } + }; + let webview = WebViewBuilder::new() + .with_html(PAGE) + .with_ipc_handler(move |request| on_message(request.into_body())) + .with_bounds(Rect { position: PhysicalPosition::new(0, 0).into(), size: PhysicalSize::new(client_w as u32, client_h as u32).into() }) + .build_as_child(&Parent(hwnd)); + match webview { + Ok(webview) => { + let _ = ShowWindow(hwnd, SW_SHOW); + let _ = SetForegroundWindow(hwnd); + Some(Self { hwnd, webview: Some(webview) }) + } + Err(e) => { + crate::log::write(&format!("settings: WebView2 failed: {e}")); + let _ = DestroyWindow(hwnd); + None + } + } + } + } + + pub fn resize(&self) { + let mut r = RECT::default(); + unsafe { + let _ = GetClientRect(self.hwnd, &mut r); + } + if let Some(webview) = &self.webview { + let _ = webview.set_bounds(Rect { + position: PhysicalPosition::new(0, 0).into(), + size: PhysicalSize::new((r.right - r.left).max(1) as u32, (r.bottom - r.top).max(1) as u32).into(), + }); + } + } + + pub fn send(&self, script: &str) { + if let Some(webview) = &self.webview { + let _ = webview.evaluate_script(script); + } + } + + pub fn focus(&self) { + unsafe { + let _ = ShowWindow(self.hwnd, SW_RESTORE); + let _ = SetForegroundWindow(self.hwnd); + } + } +} + +impl Drop for SettingsWindow { + fn drop(&mut self) { + // The webview must go before its parent window. + self.webview.take(); + unsafe { + let _ = DestroyWindow(self.hwnd); + } + } +} From ec6d4e47d25ea6602fdcbcf3d90d67e3b32a6214 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:49:15 +0530 Subject: [PATCH 24/28] Windows: settings pages for general, appearance, drawing, sound and about, applied live --- windows/Cargo.lock | 22 +++- windows/Cargo.toml | 1 + windows/src/config.rs | 136 ++++++++++++++++++++++ windows/src/hook.rs | 4 +- windows/src/main.rs | 67 ++++++++++- windows/src/prefs.rs | 58 +++++++--- windows/src/renderer.rs | 18 ++- windows/src/scene.rs | 11 +- windows/src/settings.html | 237 +++++++++++++++++++++++++++++++++++++- windows/src/sound.rs | 31 +++++ 10 files changed, 551 insertions(+), 34 deletions(-) create mode 100644 windows/src/config.rs diff --git a/windows/Cargo.lock b/windows/Cargo.lock index f811cdc..ac1cbca 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -252,7 +252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1540,6 +1540,19 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1844,6 +1857,7 @@ name = "tinyhand" version = "0.1.0" dependencies = [ "raw-window-handle", + "serde_json", "tiny-skia", "windows", "wry", @@ -2480,3 +2494,9 @@ name = "zlib-rs" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/windows/Cargo.toml b/windows/Cargo.toml index 237eca4..f533398 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] raw-window-handle = "0.6.2" +serde_json = "1.0.154" tiny-skia = "0.12.0" windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility", "Win32_Media_Audio", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell_PropertiesSystem", "Win32_Devices_FunctionDiscovery", "Win32_System_Variant"] } wry = "0.57.1" diff --git a/windows/src/config.rs b/windows/src/config.rs new file mode 100644 index 0000000..223581d --- /dev/null +++ b/windows/src/config.rs @@ -0,0 +1,136 @@ +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering::Relaxed}; + +use serde_json::{Value, json}; + +use crate::prefs; + +pub const SIZE_RANGE: (f32, f32) = (0.08, 0.20); +const DEFAULT_SIZE: f32 = 0.13; +const DEFAULT_FADE: f32 = 3.0; + +pub const INKS: [&str; 9] = ["match", "red", "orange", "yellow", "green", "blue", "purple", "white", "black"]; +const INK_RGB: [u32; 9] = [0, 0xFF291F, 0xFF8A00, 0xFFCC00, 0x30C25A, 0x0A84FF, 0x8754F2, 0xFFFFFF, 0x1D1D21]; + +pub const INTERACT_KEYS: [&str; 4] = ["alt", "ctrl", "shift", "off"]; + +static SIZE: AtomicU32 = AtomicU32::new(DEFAULT_SIZE.to_bits()); +static SHADOW: AtomicBool = AtomicBool::new(true); +static FADE: AtomicU32 = AtomicU32::new(DEFAULT_FADE.to_bits()); +static INK: AtomicU8 = AtomicU8::new(0); +static INK_BUTTON: AtomicBool = AtomicBool::new(true); +static INTERACT: AtomicU8 = AtomicU8::new(0); + +pub fn pointer_size() -> f32 { + f32::from_bits(SIZE.load(Relaxed)) +} + +pub fn shadow() -> bool { + SHADOW.load(Relaxed) +} + +/// Seconds before a stroke starts fading, or None to keep drawings until cleared. +pub fn fade_delay() -> Option { + let seconds = f32::from_bits(FADE.load(Relaxed)); + (seconds > 0.0).then_some(seconds.max(0.5)) +} + +pub fn show_ink_button() -> bool { + INK_BUTTON.load(Relaxed) +} + +/// A fixed ink color the user picked, or None to match the pointer. +pub fn ink_override() -> Option { + match INK.load(Relaxed) as usize { + 0 => None, + i => INK_RGB.get(i).copied(), + } +} + +/// Virtual keys that count as the interact key, and the one to hand back to apps. +pub fn interact_keys() -> (&'static [u32], u32) { + match INTERACT.load(Relaxed) { + 0 => (&[0xA4, 0xA5, 0x12], 0x12), + 1 => (&[0xA2, 0xA3, 0x11], 0x11), + 2 => (&[0xA0, 0xA1, 0x10], 0x10), + _ => (&[], 0), + } +} + +pub fn load() { + let size = prefs::get_f32("pointer_size").unwrap_or(DEFAULT_SIZE).clamp(SIZE_RANGE.0, SIZE_RANGE.1); + SIZE.store(size.to_bits(), Relaxed); + SHADOW.store(prefs::flag("shadow", true), Relaxed); + FADE.store(prefs::get_f32("fade_seconds").unwrap_or(DEFAULT_FADE).max(0.0).to_bits(), Relaxed); + INK.store(index_of(&INKS, prefs::get_str("ink_choice").as_deref()), Relaxed); + INK_BUTTON.store(prefs::flag("show_ink_button", true), Relaxed); + INTERACT.store(index_of(&INTERACT_KEYS, prefs::get_str("interact_key").as_deref()), Relaxed); +} + +fn index_of(list: &[&str], name: Option<&str>) -> u8 { + name.and_then(|n| list.iter().position(|x| *x == n)).unwrap_or(0) as u8 +} + +/// Applies one change from the settings window and saves it. Returns false for unknown keys. +pub fn set(key: &str, value: &Value) -> bool { + match key { + "pointer_size" => { + let v = value.as_f64().unwrap_or(DEFAULT_SIZE as f64) as f32; + let v = v.clamp(SIZE_RANGE.0, SIZE_RANGE.1); + SIZE.store(v.to_bits(), Relaxed); + prefs::set_f32("pointer_size", v); + } + "shadow" => { + let on = value.as_bool().unwrap_or(true); + SHADOW.store(on, Relaxed); + prefs::set_flag("shadow", on); + } + "fade_seconds" => { + let v = (value.as_f64().unwrap_or(DEFAULT_FADE as f64) as f32).max(0.0); + FADE.store(v.to_bits(), Relaxed); + prefs::set_f32("fade_seconds", v); + } + "ink_choice" => { + let name = value.as_str().unwrap_or("match"); + INK.store(index_of(&INKS, Some(name)), Relaxed); + prefs::set_str("ink_choice", INKS[INK.load(Relaxed) as usize]); + } + "show_ink_button" => { + let on = value.as_bool().unwrap_or(true); + INK_BUTTON.store(on, Relaxed); + prefs::set_flag("show_ink_button", on); + } + "interact_key" => { + let name = value.as_str().unwrap_or("alt"); + INTERACT.store(index_of(&INTERACT_KEYS, Some(name)), Relaxed); + prefs::set_str("interact_key", INTERACT_KEYS[INTERACT.load(Relaxed) as usize]); + } + _ => return false, + } + true +} + +pub fn reset() { + for key in ["pointer_size", "shadow", "fade_seconds", "ink_choice", "show_ink_button", "interact_key", "tap_sound", "sound_hands", "sound_laser"] { + prefs::remove(key); + } + load(); +} + +/// Everything the settings page shows. +pub fn snapshot() -> Value { + json!({ + "pointer_size": pointer_size(), + "size_min": SIZE_RANGE.0, + "size_max": SIZE_RANGE.1, + "shadow": shadow(), + "fade_seconds": f32::from_bits(FADE.load(Relaxed)), + "ink_choice": INKS[INK.load(Relaxed) as usize], + "show_ink_button": show_ink_button(), + "interact_key": INTERACT_KEYS[INTERACT.load(Relaxed) as usize], + "tap_sound": prefs::tap_sound(), + "sound_hands": prefs::flag("sound_hands", true), + "sound_laser": prefs::flag("sound_laser", false), + "click_through": prefs::flag("click_through", false), + "version": env!("CARGO_PKG_VERSION"), + }) +} diff --git a/windows/src/hook.rs b/windows/src/hook.rs index 26ae3bc..b184005 100644 --- a/windows/src/hook.rs +++ b/windows/src/hook.rs @@ -134,7 +134,7 @@ pub fn send_click(lp: LPARAM, button: usize) { /// Window thread only. Gives the app its Alt back, then the key, in order. pub fn send_alt_and_key(vk: usize, lp: LPARAM) { let (scan, extended) = ((lp.0 & 0xFFFF) as u32, (lp.0 >> 16) & 1 != 0); - let inputs = [key_input(VK_MENU.0 as u32, 0, false), key_input(vk as u32, scan, extended)]; + let inputs = [key_input(crate::config::interact_keys().1, 0, false), key_input(vk as u32, scan, extended)]; unsafe { SendInput(&inputs, size_of::() as i32); } @@ -182,7 +182,7 @@ unsafe extern "system" fn key_proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT if k.flags.0 & LLKHF_INJECTED.0 == 0 { let msg = wp.0 as u32; let down = msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN; - let is_alt = [VK_LMENU, VK_RMENU, VK_MENU].iter().any(|v| v.0 as u32 == k.vkCode); + let is_alt = crate::config::interact_keys().0.contains(&k.vkCode); if is_alt && down && !PASSED.load(Relaxed) { if !INTERACT.swap(true, Relaxed) { post_interact(true); diff --git a/windows/src/main.rs b/windows/src/main.rs index 2c1ab80..05c21c9 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod arm; +mod config; mod cursor; mod hand; mod hook; @@ -392,7 +393,8 @@ impl App { s.focus(); return; } - // Settings is a normal window, so the pointer steps aside while it is open. + // Settings is an ordinary window, so the pointer turns off first, as on the Mac. + self.deactivate(); self.settings = settings::SettingsWindow::open(|message| { APP.with(|c| { if let Some(app) = c.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) { @@ -406,8 +408,56 @@ impl App { self.settings = None; } + fn push_settings(&self) { + if let Some(s) = &self.settings { + s.send(&format!("window.__state({})", config::snapshot())); + } + } + fn on_settings_message(&mut self, message: &str) { - log::write(&format!("settings message: {message}")); + let Ok(msg) = serde_json::from_str::(message) else { return }; + match msg["type"].as_str() { + Some("get") => self.push_settings(), + Some("set") => self.set_setting(msg["key"].as_str().unwrap_or(""), &msg["value"]), + Some("preview_sound") => sound::preview(msg["name"].as_str().unwrap_or("")), + Some("open_url") => open_url(msg["url"].as_str().unwrap_or("")), + Some("reset") => { + config::reset(); + sound::set_choice(&prefs::tap_sound()); + self.refresh_sound(); + lock(&self.renderer).refresh(); + self.push_settings(); + } + _ => {} + } + } + + fn set_setting(&mut self, key: &str, value: &serde_json::Value) { + match key { + "tap_sound" => { + let name = value.as_str().unwrap_or("thock"); + if sound::CHOICES.contains(&name) { + prefs::set_str("tap_sound", name); + sound::set_choice(name); + sound::preview(name); + } + } + "sound_hands" | "sound_laser" => { + prefs::set_flag(key, value.as_bool().unwrap_or(false)); + self.refresh_sound(); + } + "click_through" => { + if value.as_bool().unwrap_or(false) != hook::CLICK_THROUGH.load(Relaxed) { + self.toggle_click_through(); + } + } + _ => { + if config::set(key, value) { + lock(&self.renderer).refresh(); + hook::wake(); + } + } + } } /// The full-screen overlay must never hold keyboard focus. When Windows hands it over @@ -432,6 +482,18 @@ impl App { } } +/// Opens a link in the default browser. Only TinyHand's own sites, never arbitrary input. +fn open_url(url: &str) { + let allowed = ["https://github.com/nilbuild/", "https://github.com/MUL7ii/", "https://tinyhand.app", "https://kamran.fyi"]; + if !allowed.iter().any(|p| url.starts_with(p)) { + return; + } + let wide: Vec = url.encode_utf16().chain(std::iter::once(0)).collect(); + unsafe { + ShellExecuteW(None, w!("open"), PCWSTR(wide.as_ptr()), PCWSTR::null(), PCWSTR::null(), SW_SHOWNORMAL); + } +} + /// Runs outside the app borrow and its own tiny window. While it is open the overlay keeps /// redrawing and every click goes through, so the menu can actually be used. fn show_tray_menu(tray: HWND) { @@ -602,6 +664,7 @@ fn main() { let tray = CreateWindowExW(WS_EX_TOOLWINDOW, tray_class, w!("TinyHand tray"), WS_POPUP, 0, 0, 0, 0, None, None, Some(hinst), None).expect("tray window"); HWND_RAW.store(tray.0 as isize, Relaxed); + config::load(); let id = std::env::args().nth(1).or_else(prefs::load_style).unwrap_or_else(|| "lavender".into()); let (style, ink) = styles::load(&id); let mut pinned = PINNED.to_vec(); diff --git a/windows/src/prefs.rs b/windows/src/prefs.rs index 240a715..ab8e416 100644 --- a/windows/src/prefs.rs +++ b/windows/src/prefs.rs @@ -1,23 +1,24 @@ use std::path::PathBuf; +use std::sync::Mutex; fn file() -> Option { std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("TinyHand").join("settings.txt")) } -fn read() -> Vec<(String, String)> { - let text = file().and_then(|f| std::fs::read_to_string(f).ok()).unwrap_or_default(); - text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().into(), v.trim().into())).collect() -} +/// Settings live in memory and are written back whole on every change, so hot paths never touch disk. +static CACHE: Mutex>> = Mutex::new(None); -fn get(key: &str) -> Option { - read().into_iter().find(|(k, _)| k == key).map(|(_, v)| v).filter(|v| !v.is_empty()) +fn with(f: impl FnOnce(&mut Vec<(String, String)>) -> T) -> T { + let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let all = guard.get_or_insert_with(|| { + let text = file().and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default(); + text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect() + }); + f(all) } -fn set(key: &str, value: &str) { +fn save(all: &[(String, String)]) { let Some(path) = file() else { return }; - let mut all = read(); - all.retain(|(k, _)| k != key); - all.push((key.into(), value.into())); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } @@ -25,22 +26,49 @@ fn set(key: &str, value: &str) { let _ = std::fs::write(path, text); } +pub fn get_str(key: &str) -> Option { + with(|all| all.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())).filter(|v| !v.is_empty()) +} + +pub fn set_str(key: &str, value: &str) { + with(|all| { + all.retain(|(k, _)| k != key); + all.push((key.into(), value.into())); + save(all); + }); +} + +pub fn remove(key: &str) { + with(|all| { + all.retain(|(k, _)| k != key); + save(all); + }); +} + +pub fn get_f32(key: &str) -> Option { + get_str(key)?.parse().ok() +} + +pub fn set_f32(key: &str, value: f32) { + set_str(key, &value.to_string()); +} + pub fn load_style() -> Option { - get("style") + get_str("style") } pub fn save_style(id: &str) { - set("style", id); + set_str("style", id); } pub fn tap_sound() -> String { - get("tap_sound").unwrap_or_else(|| "thock".into()) + get_str("tap_sound").unwrap_or_else(|| "thock".into()) } pub fn flag(key: &str, default: bool) -> bool { - get(key).map_or(default, |v| v == "1") + get_str(key).map_or(default, |v| v == "1") } pub fn set_flag(key: &str, on: bool) { - set(key, if on { "1" } else { "0" }); + set_str(key, if on { "1" } else { "0" }); } diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs index 30c2e43..0ecbe24 100644 --- a/windows/src/renderer.rs +++ b/windows/src/renderer.rs @@ -16,8 +16,6 @@ use crate::toolbar::{WM_SCREEN_CHANGED, WM_TB_WANT}; use crate::surface::Surface; const DRAG_THRESHOLD: f32 = 4.0; -const POINTER_SIZE: f32 = 0.13; -const SHADOW: bool = true; const TICK_HZ: f32 = 120.0; pub enum Next { @@ -30,6 +28,7 @@ pub struct Renderer { surface: Surface, ink: Ink, style: Style, + style_ink: u32, cursor: (f32, f32), key: Vec, shape: Option, @@ -62,12 +61,13 @@ fn monitor_rect(at: (f32, f32)) -> RECT { impl Renderer { pub fn new(hwnd: HWND, w: i32, h: i32, style: Style, ink_rgb: u32) -> Self { let mut ink = Ink::new(w, h); - ink.set_color(ink_rgb); + ink.set_color(crate::config::ink_override().unwrap_or(ink_rgb)); Self { hwnd, surface: Surface::new(w, h), ink, style, + style_ink: ink_rgb, cursor: (0.0, 0.0), key: vec![], shape: None, @@ -101,7 +101,13 @@ impl Renderer { pub fn set_style(&mut self, style: Style, ink_rgb: u32) { self.style = style; - self.ink.set_color(ink_rgb); + self.style_ink = ink_rgb; + self.refresh(); + } + + /// Picks up changed settings: ink color, pointer size, shadow. + pub fn refresh(&mut self) { + self.ink.set_color(crate::config::ink_override().unwrap_or(self.style_ink)); self.key.clear(); } @@ -210,7 +216,7 @@ impl Renderer { let mut pose = None; let (key, shape, settling) = match &mut self.style { Style::Hand(arm) => { - let p = arm.aim(at, screen, self.press, POINTER_SIZE, dt); + let p = arm.aim(at, screen, self.press, crate::config::pointer_size(), dt); pose = Some(p); (p.key(), arm.bbox(&p, screen.1), arm.settling()) } @@ -248,7 +254,7 @@ impl Renderer { } if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { match (&self.style, pose) { - (Style::Hand(arm), Some(p)) => arm.draw(&mut pix, &p, screen.1, SHADOW), + (Style::Hand(arm), Some(p)) => arm.draw(&mut pix, &p, screen.1, crate::config::shadow()), _ => draw_laser(&mut pix, at, laser_radius(screen.1, self.press)), } } diff --git a/windows/src/scene.rs b/windows/src/scene.rs index 1aeabd6..0951439 100644 --- a/windows/src/scene.rs +++ b/windows/src/scene.rs @@ -2,7 +2,6 @@ use std::time::{Duration, Instant}; use tiny_skia::*; -const FADE_DELAY: f32 = 3.0; const FADE_FOR: f32 = 0.6; /// Every buffer here is BGRA, so colours swap red and blue. @@ -48,11 +47,14 @@ impl Stroke { } fn alpha(&self) -> f32 { - self.age().map_or(1.0, |a| 1.0 - ((a - FADE_DELAY) / FADE_FOR).clamp(0.0, 1.0)) + match (self.age(), crate::config::fade_delay()) { + (Some(a), Some(delay)) => 1.0 - ((a - delay) / FADE_FOR).clamp(0.0, 1.0), + _ => 1.0, + } } fn fading(&self) -> bool { - self.age().is_some_and(|a| a > FADE_DELAY) + matches!((self.age(), crate::config::fade_delay()), (Some(a), Some(delay)) if a > delay) } fn path(&self) -> Option { @@ -206,13 +208,14 @@ impl Ink { } pub fn pending(&self) -> Pending { + let Some(delay) = crate::config::fade_delay() else { return Pending::Nothing }; if self.strokes.iter().any(Stroke::fading) { return Pending::Fading; } self.strokes .iter() .filter_map(Stroke::age) - .map(|a| Duration::from_secs_f32((FADE_DELAY - a).max(0.0))) + .map(|a| Duration::from_secs_f32((delay - a).max(0.0))) .min() .map_or(Pending::Nothing, Pending::After) } diff --git a/windows/src/settings.html b/windows/src/settings.html index 94c8527..7993c33 100644 --- a/windows/src/settings.html +++ b/windows/src/settings.html @@ -2,14 +2,243 @@ + TinyHand Settings -

TinyHand

-

Settings are loading.

- + +
+ + diff --git a/windows/src/sound.rs b/windows/src/sound.rs index 6acaf06..aaf0d39 100644 --- a/windows/src/sound.rs +++ b/windows/src/sound.rs @@ -20,8 +20,11 @@ enum Cmd { Open, Close, Play, + Preview(usize), } +const PREVIEW_LINGER: Duration = Duration::from_millis(1500); + static TX: OnceLock> = OnceLock::new(); static CHOICE: AtomicUsize = AtomicUsize::new(0); static ENABLED: AtomicBool = AtomicBool::new(true); @@ -175,6 +178,22 @@ fn run(rx: Receiver) { s.start(); } } + Some(Cmd::Preview(i)) => { + // Settings can preview a sound while the pointer is off. + if stream.is_none() { + match Stream::open() { + Ok(s) => stream = Some(s), + Err(e) => crate::log::write(&format!("audio open failed: {e}")), + } + } + if clips.get(i).is_some_and(|c| !c.is_empty()) { + voice = Some((i, 0)); + } + last_tap = Instant::now(); + if let Some(s) = &mut stream { + s.start(); + } + } _ => {} } if let Some(s) = &mut stream { @@ -185,6 +204,11 @@ fn run(rx: Receiver) { } } } + if !wanted && voice.is_none() && last_tap.elapsed() > PREVIEW_LINGER { + if let Some(mut s) = stream.take() { + s.stop(); + } + } } } @@ -212,6 +236,13 @@ pub fn set_choice(name: &str) { } } +/// Plays one sound once, for the settings page. +pub fn preview(name: &str) { + if let Some(i) = CHOICES.iter().position(|c| *c == name) { + send(Cmd::Preview(i)); + } +} + /// Opened while the pointer is on, like the Mac engine that stops when idle. pub fn open() { send(Cmd::Open); From 9c470565015766bfb5af23f1b7627545269d930d Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:51:25 +0530 Subject: [PATCH 25/28] Windows: use the official logo for the app icon, tray icon and settings --- windows/Cargo.lock | 47 ++++++++++++++++++++++++-- windows/Cargo.toml | 4 +++ windows/build.rs | 61 ++++++++++++++++++++++++++++++++++ windows/src/icon.rs | 69 +++++++++++++++++++++++++++++++++++++++ windows/src/main.rs | 3 +- windows/src/settings.html | 10 +++--- windows/src/settings.rs | 5 ++- 7 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 windows/build.rs create mode 100644 windows/src/icon.rs diff --git a/windows/Cargo.lock b/windows/Cargo.lock index ac1cbca..f30b319 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -1562,6 +1562,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -1726,7 +1735,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml", + "toml 0.8.2", "version-compare", ] @@ -1860,6 +1869,7 @@ dependencies = [ "serde_json", "tiny-skia", "windows", + "winresource", "wry", ] @@ -1880,11 +1890,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.3", "toml_edit 0.20.2", ] +[[package]] +name = "toml" +version = "1.2.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.3", + "toml_datetime 1.1.3+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.5", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -1922,7 +1947,7 @@ checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -1948,6 +1973,12 @@ dependencies = [ "winnow 1.0.5", ] +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + [[package]] name = "typenum" version = "1.20.1" @@ -2342,6 +2373,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" +dependencies = [ + "toml 1.2.1+spec-1.1.0", + "version_check", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/windows/Cargo.toml b/windows/Cargo.toml index f533398..1346823 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -9,3 +9,7 @@ serde_json = "1.0.154" tiny-skia = "0.12.0" windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility", "Win32_Media_Audio", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell_PropertiesSystem", "Win32_Devices_FunctionDiscovery", "Win32_System_Variant"] } wry = "0.57.1" + +[build-dependencies] +tiny-skia = "0.12.0" +winresource = "0.1.34" diff --git a/windows/build.rs b/windows/build.rs new file mode 100644 index 0000000..26c53cb --- /dev/null +++ b/windows/build.rs @@ -0,0 +1,61 @@ +//! Builds the app icon from the shared logo, so the exe carries the official mark. + +use std::path::PathBuf; + +use tiny_skia::{FilterQuality, Pixmap, PixmapPaint, Transform}; + +const LOGO: &str = "../.github/logo.png"; +const SIZES: [u32; 7] = [16, 24, 32, 48, 64, 128, 256]; + +fn scaled(src: &Pixmap, size: u32) -> Pixmap { + // Halve until close, then one bilinear step, so small sizes stay crisp. + let mut cur = src.clone(); + while cur.width() / 2 >= size { + let (w, h) = (cur.width() / 2, cur.height() / 2); + let mut next = Pixmap::new(w, h).unwrap(); + next.draw_pixmap(0, 0, cur.as_ref(), &PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }, Transform::from_scale(0.5, 0.5), None); + cur = next; + } + let mut out = Pixmap::new(size, size).unwrap(); + let k = size as f32 / cur.width() as f32; + out.draw_pixmap(0, 0, cur.as_ref(), &PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }, Transform::from_scale(k, k), None); + out +} + +fn main() { + println!("cargo:rerun-if-changed={LOGO}"); + println!("cargo:rerun-if-changed=build.rs"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let logo = Pixmap::load_png(LOGO).expect("logo.png"); + let images: Vec<(u32, Vec)> = SIZES.iter().map(|&s| (s, scaled(&logo, s).encode_png().expect("png"))).collect(); + + // An .ico is a small directory followed by the images. PNG payloads are allowed. + let mut ico = vec![0, 0, 1, 0]; + ico.extend((images.len() as u16).to_le_bytes()); + let mut offset = 6 + 16 * images.len() as u32; + for (size, png) in &images { + let dim = if *size >= 256 { 0 } else { *size as u8 }; + ico.extend([dim, dim, 0, 0]); + ico.extend(1u16.to_le_bytes()); + ico.extend(32u16.to_le_bytes()); + ico.extend((png.len() as u32).to_le_bytes()); + ico.extend(offset.to_le_bytes()); + offset += png.len() as u32; + } + for (_, png) in &images { + ico.extend(png); + } + let path = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("tinyhand.ico"); + std::fs::write(&path, ico).unwrap(); + + let mut res = winresource::WindowsResource::new(); + res.set_icon(path.to_str().unwrap()); + res.set("ProductName", "TinyHand for Windows"); + res.set("FileDescription", "TinyHand, a tiny hand pointer"); + res.set("LegalCopyright", "MIT License. Based on TinyHand by Kamran Ahmed."); + if let Err(e) = res.compile() { + println!("cargo:warning=could not embed the app icon: {e}"); + } +} diff --git a/windows/src/icon.rs b/windows/src/icon.rs new file mode 100644 index 0000000..6bff480 --- /dev/null +++ b/windows/src/icon.rs @@ -0,0 +1,69 @@ +use std::ffi::c_void; +use std::ptr::null_mut; + +use tiny_skia::Pixmap; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::UI::HiDpi::{GetDpiForSystem, GetSystemMetricsForDpi}; +use windows::Win32::UI::WindowsAndMessaging::*; + +/// The official TinyHand mark, shared with the Mac app and the README. +const LOGO: &[u8] = include_bytes!("../../.github/logo.png"); + +/// A window icon at the size Windows wants for this display scaling. +pub fn logo_for(metric: SYSTEM_METRICS_INDEX) -> HICON { + let size = unsafe { GetSystemMetricsForDpi(metric, GetDpiForSystem()) }.max(16); + logo(size) +} + +pub fn logo(size: i32) -> HICON { + let Ok(src) = Pixmap::decode_png(LOGO) else { return HICON::default() }; + let px = crate::hand::resized(&src, size as u32); + let (w, h) = (px.width() as i32, px.height() as i32); + unsafe { + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: w, + biHeight: -h, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits: *mut c_void = null_mut(); + let Ok(color) = CreateDIBSection(None, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) else { return HICON::default() }; + // Icons carry plain (not premultiplied) BGRA. + let out = std::slice::from_raw_parts_mut(bits.cast::(), (w * h * 4) as usize); + for (d, s) in out.chunks_exact_mut(4).zip(px.data().chunks_exact(4)) { + let a = s[3] as u32; + let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a / 2) / a).min(255) as u8 }; + d.copy_from_slice(&[un(s[2]), un(s[1]), un(s[0]), s[3]]); + } + let mask = CreateBitmap(w, h, 1, 1, None); + let info = ICONINFO { fIcon: true.into(), xHotspot: 0, yHotspot: 0, hbmMask: mask, hbmColor: color }; + let icon = CreateIconIndirect(&info).unwrap_or_default(); + let _ = DeleteObject(color.into()); + let _ = DeleteObject(mask.into()); + icon + } +} + +/// The logo as a data URL, for the settings page. +pub fn data_url() -> String { + format!("data:image/png;base64,{}", base64(LOGO)) +} + +fn base64(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let n = (chunk[0] as u32) << 16 | (*chunk.get(1).unwrap_or(&0) as u32) << 8 | *chunk.get(2).unwrap_or(&0) as u32; + out.push(TABLE[(n >> 18) as usize & 63] as char); + out.push(TABLE[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' }); + } + out +} diff --git a/windows/src/main.rs b/windows/src/main.rs index 05c21c9..5b2f7d9 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -5,6 +5,7 @@ mod config; mod cursor; mod hand; mod hook; +mod icon; mod log; mod monitor; mod prefs; @@ -549,7 +550,7 @@ fn add_tray(hwnd: HWND) { let mut nid = tray_data(hwnd); nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; nid.uCallbackMessage = WM_TRAY; - nid.hIcon = unsafe { LoadIconW(None, IDI_APPLICATION).unwrap_or_default() }; + nid.hIcon = icon::logo_for(SM_CXSMICON); for (d, s) in nid.szTip.iter_mut().zip("TinyHand".encode_utf16()) { *d = s; } diff --git a/windows/src/settings.html b/windows/src/settings.html index 7993c33..2a809dc 100644 --- a/windows/src/settings.html +++ b/windows/src/settings.html @@ -19,6 +19,7 @@ --accent-ink: #ffffff; --track: #c9c9d1; --shadow: 0 1px 2px rgba(20, 20, 40, 0.06); + --logo: url(__LOGO__); } @media (prefers-color-scheme: dark) { :root { @@ -57,7 +58,8 @@ gap: 2px; } nav .brand { display: flex; align-items: center; gap: 8px; padding: 6px 10px 18px; font-weight: 600; font-size: 15px; } - nav .brand i { width: 22px; height: 22px; border-radius: 6px; background: var(--accent); display: grid; place-items: center; color: var(--accent-ink); font-style: normal; font-size: 13px; } + .logo { display: block; background: var(--logo) center / contain no-repeat; flex: none; } + nav .brand .logo { width: 26px; height: 26px; } nav button { all: unset; display: flex; @@ -105,7 +107,7 @@ .kbd { display: inline-flex; gap: 4px; flex: none; } .kbd kbd { font: 600 12px "Segoe UI", sans-serif; padding: 3px 8px; border-radius: 6px; background: var(--hover); border: 1px solid var(--line); border-bottom-width: 2px; } .about { text-align: center; padding: 28px 16px; } - .about .mark { width: 72px; height: 72px; border-radius: 18px; background: var(--accent); color: var(--accent-ink); display: grid; place-items: center; font-size: 36px; margin: 0 auto 12px; } + .about .logo { width: 84px; height: 84px; margin: 0 auto 12px; filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.28)); } .about h2 { margin: 0; font-size: 20px; } .about p { color: var(--muted); margin: 6px 0 0; } a.link { color: var(--accent); text-decoration: none; cursor: pointer; font-weight: 600; } @@ -193,7 +195,7 @@ about: () => `
-
☝
+

TinyHand for Windows

Version ${esc(S.version)}

A tiny hand on a stick that points at things on your screen.

@@ -208,7 +210,7 @@

TinyHand for Windows

function render() { if (!S) return; - $('#nav').innerHTML = `
☝TinyHand
` + SECTIONS.map(([id, name]) => + $('#nav').innerHTML = `
TinyHand
` + SECTIONS.map(([id, name]) => ``).join(''); const title = SECTIONS.find(([id]) => id === page)[1]; $('#page').innerHTML = `

${title}

${PAGES[page]()}`; diff --git a/windows/src/settings.rs b/windows/src/settings.rs index 2be28ec..791ee2e 100644 --- a/windows/src/settings.rs +++ b/windows/src/settings.rs @@ -47,8 +47,11 @@ impl SettingsWindow { return None; } }; + for (kind, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] { + let _ = SendMessageW(hwnd, WM_SETICON, Some(WPARAM(kind as usize)), Some(LPARAM(crate::icon::logo_for(metric).0 as isize))); + } let webview = WebViewBuilder::new() - .with_html(PAGE) + .with_html(PAGE.replace("__LOGO__", &crate::icon::data_url())) .with_ipc_handler(move |request| on_message(request.into_body())) .with_bounds(Rect { position: PhysicalPosition::new(0, 0).into(), size: PhysicalSize::new(client_w as u32, client_h as u32).into() }) .build_as_child(&Parent(hwnd)); From 216220bb3a6576f9c8f92a936d1e29741c065a79 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:58:39 +0530 Subject: [PATCH 26/28] Windows: Pointers settings page to pick, add, remove and reorder toolbar hands --- windows/src/config.rs | 24 +++++++- windows/src/icon.rs | 2 +- windows/src/main.rs | 94 ++++++++++++++++++++++++++--- windows/src/settings.html | 120 +++++++++++++++++++++++++++++++++++++- windows/src/styles.rs | 87 +++++++++++++++------------ windows/src/thumbs.rs | 37 ++++++++++++ windows/src/toolbar.rs | 20 +++++++ 7 files changed, 334 insertions(+), 50 deletions(-) create mode 100644 windows/src/thumbs.rs diff --git a/windows/src/config.rs b/windows/src/config.rs index 223581d..812bcde 100644 --- a/windows/src/config.rs +++ b/windows/src/config.rs @@ -56,6 +56,28 @@ pub fn interact_keys() -> (&'static [u32], u32) { } } +pub const MAX_PINNED: usize = 8; +const DEFAULT_PINNED: [&str; 6] = ["lavender", "glove", "coral", "plush", "robot", "laser"]; + +/// The pointers shown on the toolbar, in order. Never empty, never unknown, never repeated. +pub fn pinned() -> Vec { + let saved = prefs::get_str("pinned").unwrap_or_default(); + let mut list: Vec = Vec::new(); + for id in saved.split(',').map(str::trim) { + if crate::styles::is_known(id) && !list.iter().any(|x| x == id) && list.len() < MAX_PINNED { + list.push(id.to_string()); + } + } + if list.is_empty() { + list = DEFAULT_PINNED.iter().map(|s| s.to_string()).collect(); + } + list +} + +pub fn set_pinned(list: &[String]) { + prefs::set_str("pinned", &list.join(",")); +} + pub fn load() { let size = prefs::get_f32("pointer_size").unwrap_or(DEFAULT_SIZE).clamp(SIZE_RANGE.0, SIZE_RANGE.1); SIZE.store(size.to_bits(), Relaxed); @@ -110,7 +132,7 @@ pub fn set(key: &str, value: &Value) -> bool { } pub fn reset() { - for key in ["pointer_size", "shadow", "fade_seconds", "ink_choice", "show_ink_button", "interact_key", "tap_sound", "sound_hands", "sound_laser"] { + for key in ["pointer_size", "shadow", "fade_seconds", "ink_choice", "show_ink_button", "interact_key", "tap_sound", "sound_hands", "sound_laser", "pinned"] { prefs::remove(key); } load(); diff --git a/windows/src/icon.rs b/windows/src/icon.rs index 6bff480..c4a66fd 100644 --- a/windows/src/icon.rs +++ b/windows/src/icon.rs @@ -55,7 +55,7 @@ pub fn data_url() -> String { format!("data:image/png;base64,{}", base64(LOGO)) } -fn base64(bytes: &[u8]) -> String { +pub fn base64(bytes: &[u8]) -> String { const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { diff --git a/windows/src/main.rs b/windows/src/main.rs index 5b2f7d9..152d53c 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -15,6 +15,7 @@ mod settings; mod sound; mod styles; mod surface; +mod thumbs; mod toolbar; use std::cell::RefCell; @@ -55,7 +56,6 @@ static HWND_RAW: AtomicIsize = AtomicIsize::new(0); const TIMER_INITIAL: usize = 3; const TIMER_RAISE: usize = 4; const TIMER_POPUPS: usize = 5; -const PINNED: [&str; 6] = ["lavender", "glove", "coral", "plush", "robot", "laser"]; struct App { hwnd: HWND, @@ -244,9 +244,15 @@ impl App { fn pick(&mut self, id: &str) { let (style, ink) = styles::load(id); lock(&self.renderer).set_style(style, ink); - self.toolbar.select(id); prefs::save_style(id); + let pinned = config::pinned(); + let rebuild = !pinned.iter().any(|p| *p == self.style_id) || !pinned.iter().any(|p| p == id); self.style_id = id.to_string(); + if rebuild { + let list = self.toolbar_styles(); + self.toolbar.set_styles(&list); + } + self.toolbar.select(id); self.refresh_sound(); hook::wake(); } @@ -409,16 +415,86 @@ impl App { self.settings = None; } + /// The pinned pointers, plus the current one if it is not pinned, as on the Mac. + fn toolbar_styles(&self) -> Vec { + let mut list = config::pinned(); + if !list.contains(&self.style_id) { + list.push(self.style_id.clone()); + } + list + } + fn push_settings(&self) { + let Some(s) = &self.settings else { return }; + let mut state = config::snapshot(); + state["style"] = serde_json::json!(self.style_id); + state["pinned"] = serde_json::json!(config::pinned()); + state["max_pinned"] = serde_json::json!(config::MAX_PINNED); + state["hands"] = serde_json::Value::Array(styles::all().map(|(id, title)| serde_json::json!({ "id": id, "title": title })).collect()); + s.send(&format!("window.__state({state})")); + s.send(&format!("window.__thumbs({})", serde_json::json!(thumbs::all()))); + } + + fn notice(&self, text: &str) { if let Some(s) = &self.settings { - s.send(&format!("window.__state({})", config::snapshot())); + s.send(&format!("window.__notice({})", serde_json::json!(text))); + } + } + + fn apply_pinned(&mut self) { + let list = self.toolbar_styles(); + self.toolbar.set_styles(&list); + self.toolbar.select(&self.style_id.clone()); + self.push_settings(); + } + + fn pin(&mut self, id: &str) { + let mut list = config::pinned(); + if let Some(at) = list.iter().position(|x| x == id) { + if list.len() == 1 { + return self.notice("Keep at least one pointer in the toolbar."); + } + list.remove(at); + } else if !styles::is_known(id) { + return; + } else if list.len() >= config::MAX_PINNED { + return self.notice("The toolbar is full. Remove one first."); + } else { + list.push(id.to_string()); + } + config::set_pinned(&list); + self.apply_pinned(); + } + + fn reorder(&mut self, ids: &[String]) { + let mut list: Vec = Vec::new(); + for id in ids { + if styles::is_known(id) && !list.contains(id) && list.len() < config::MAX_PINNED { + list.push(id.clone()); + } + } + if !list.is_empty() { + config::set_pinned(&list); + self.apply_pinned(); } } fn on_settings_message(&mut self, message: &str) { let Ok(msg) = serde_json::from_str::(message) else { return }; match msg["type"].as_str() { - Some("get") => self.push_settings(), + Some("get") | Some("thumbs") => self.push_settings(), + Some("pin") => self.pin(msg["id"].as_str().unwrap_or("")), + Some("reorder") => { + let ids: Vec = msg["ids"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(); + self.reorder(&ids); + } + Some("pick") => { + let id = msg["id"].as_str().unwrap_or("").to_string(); + if styles::is_known(&id) { + self.pick(&id); + self.push_settings(); + } + } Some("set") => self.set_setting(msg["key"].as_str().unwrap_or(""), &msg["value"]), Some("preview_sound") => sound::preview(msg["name"].as_str().unwrap_or("")), Some("open_url") => open_url(msg["url"].as_str().unwrap_or("")), @@ -668,11 +744,13 @@ fn main() { config::load(); let id = std::env::args().nth(1).or_else(prefs::load_style).unwrap_or_else(|| "lavender".into()); let (style, ink) = styles::load(&id); - let mut pinned = PINNED.to_vec(); - if !pinned.contains(&id.as_str()) { - pinned.insert(pinned.len() - 1, &id); + let mut shown = config::pinned(); + if !shown.contains(&id) { + shown.push(id.clone()); } - let mut toolbar = Toolbar::new(bar, &pinned, &id); + let shown: Vec<&str> = shown.iter().map(String::as_str).collect(); + let mut toolbar = Toolbar::new(bar, &shown, &id); + thumbs::build_in_background(); let through = prefs::flag("click_through", false); hook::CLICK_THROUGH.store(through, Relaxed); toolbar.set_click_through(through); diff --git a/windows/src/settings.html b/windows/src/settings.html index 2a809dc..3b079f9 100644 --- a/windows/src/settings.html +++ b/windows/src/settings.html @@ -106,6 +106,32 @@ .btn:hover { background: var(--line); } .kbd { display: inline-flex; gap: 4px; flex: none; } .kbd kbd { font: 600 12px "Segoe UI", sans-serif; padding: 3px 8px; border-radius: 6px; background: var(--hover); border: 1px solid var(--line); border-bottom-width: 2px; } + .strip-head { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 16px 0; } + .strip-head span { color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; } + .strip { display: flex; gap: 6px; padding: 14px 16px 6px; min-height: 100px; } + .slot { position: relative; width: 52px; flex: none; } + .slot.dragging { opacity: 0.4; } + .slot.over .slotbtn { outline: 2px dashed var(--accent); outline-offset: 2px; } + .slotbtn { all: unset; box-sizing: border-box; width: 52px; height: 52px; border-radius: 11px; background: var(--hover); display: grid; place-items: center; cursor: pointer; border: 2px solid transparent; } + .slotbtn[aria-pressed="true"] { border-color: var(--accent); background: var(--card); } + .slotbtn img { width: 40px; height: 40px; object-fit: contain; pointer-events: none; } + .x { all: unset; position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: var(--text); color: var(--card); font-size: 13px; line-height: 18px; text-align: center; cursor: pointer; opacity: 0; transition: opacity .12s; } + .slot:hover .x, .x:focus-visible { opacity: 1; } + .move { display: flex; justify-content: space-between; margin-top: 4px; } + .move button { all: unset; width: 24px; height: 20px; border-radius: 6px; text-align: center; line-height: 20px; cursor: pointer; color: var(--muted); } + .move button:hover:not([disabled]) { background: var(--hover); color: var(--text); } + .move button[disabled] { opacity: 0.25; cursor: default; } + .move button:focus-visible { outline: 2px solid var(--accent); } + .grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 12px; } + .cell { all: unset; box-sizing: border-box; position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 6px 8px; border-radius: 10px; cursor: pointer; border: 2px solid transparent; font-size: 12.5px; text-align: center; } + .cell:hover { background: var(--hover); } + .cell[aria-pressed="true"] { border-color: var(--accent); background: var(--hover); } + .cell img, .cell .ph { width: 56px; height: 56px; object-fit: contain; } + .cell .ph { border-radius: 10px; background: var(--hover); } + .badge { position: absolute; top: 6px; right: 8px; width: 18px; height: 18px; border-radius: 50%; background: var(--accent); color: var(--accent-ink); font-style: normal; font-size: 11px; line-height: 18px; text-align: center; display: none; } + .cell[aria-pressed="true"] .badge { display: block; } + .laser { width: 20px; height: 20px; border-radius: 50%; background: radial-gradient(circle, #ffdcd1 0 28%, #ff291f 30% 60%, rgba(255, 41, 31, 0.0) 100%); box-shadow: 0 0 14px 6px rgba(255, 41, 31, 0.45); display: inline-block; } + #notice { min-height: 20px; padding: 0 16px 12px; color: #d9453a; font-size: 12.5px; } .about { text-align: center; padding: 28px 16px; } .about .logo { width: 84px; height: 84px; margin: 0 auto 12px; filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.28)); } .about h2 { margin: 0; font-size: 20px; } @@ -127,9 +153,11 @@ sound: '', about: '', }; +ICONS.pointers = ''; const SECTIONS = [ - ['general', 'General'], ['appearance', 'Appearance'], ['drawing', 'Drawing'], ['sound', 'Sound'], ['about', 'About'], + ['general', 'General'], ['pointers', 'Pointers'], ['appearance', 'Appearance'], ['drawing', 'Drawing'], ['sound', 'Sound'], ['about', 'About'], ]; +const DEFAULT_PINNED = ['lavender', 'glove', 'coral', 'plush', 'robot', 'laser']; const FADES = [[0, 'Never'], [1, '1 s'], [3, '3 s'], [5, '5 s'], [10, '10 s']]; const INKS = [['match', 'Match the pointer'], ['red', 'Red'], ['orange', 'Orange'], ['yellow', 'Yellow'], ['green', 'Green'], ['blue', 'Blue'], ['purple', 'Purple'], ['white', 'White'], ['black', 'Black']]; const INK_CSS = { red: '#ff291f', orange: '#ff8a00', yellow: '#ffcc00', green: '#30c25a', blue: '#0a84ff', purple: '#8754f2', white: '#ffffff', black: '#1d1d21' }; @@ -138,6 +166,13 @@ let S = null; let page = 'general'; +let THUMBS = {}; +let notice = ''; +let noticeTimer = 0; +let thumbRetries = 0; + +const nameOf = (id) => (S.hands.find((h) => h.id === id) || { title: id }).title; +const picture = (id) => id === 'laser' ? '' : (THUMBS[id] ? `` : ''); const send = (m) => window.ipc.postMessage(JSON.stringify(m)); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); @@ -169,6 +204,29 @@ ${row('Reset all settings', 'Puts every setting back to how it was at first.', '')}
`, + pointers: () => ` +
+
Toolbar${S.pinned.length} of ${S.max_pinned}
+
${S.pinned.map((id, i) => ` +
+ + +
+ + +
+
`).join('')}
+
${esc(notice)}
+
+

Click a pointer to use it. Use the arrows or drag to reorder. Your current pointer always shows on the toolbar.

+
${S.hands.map((h) => ` + `).join('')}
+
+ ${row('Reset the toolbar', 'Go back to the original set of pointers.', '')} +
`, + appearance: () => `
${row('Pointer size', 'How large the hand is on screen.', `
${Math.round(S.pointer_size * 100)}%
`)} @@ -218,9 +276,22 @@

TinyHand for Windows

const $ = (s) => document.querySelector(s); document.addEventListener('click', (e) => { - const t = e.target.closest('[data-page],[data-toggle],[data-set],[data-sound],[data-action],[data-url]'); + const t = e.target.closest('[data-page],[data-toggle],[data-set],[data-sound],[data-action],[data-url],[data-pin],[data-pick],[data-move]'); if (!t || !S) return; if (t.dataset.page) { page = t.dataset.page; render(); $('#page').scrollTop = 0; } + else if (t.dataset.move !== undefined) { + const from = Number(t.dataset.move), to = from + Number(t.dataset.by); + if (to >= 0 && to < S.pinned.length) { + const list = S.pinned.slice(); + list.splice(to, 0, list.splice(from, 1)[0]); + S.pinned = list; + send({ type: 'reorder', ids: list }); + render(); + } + } + else if (t.dataset.pin) send({ type: 'pin', id: t.dataset.pin }); + else if (t.dataset.pick) send({ type: 'pick', id: t.dataset.pick }); + else if (t.dataset.action === 'reset-toolbar') send({ type: 'reorder', ids: DEFAULT_PINNED }); else if (t.dataset.toggle) set(t.dataset.toggle, !S[t.dataset.toggle]); else if (t.dataset.set) set(t.dataset.set, JSON.parse(t.dataset.value)); else if (t.dataset.sound) { S.tap_sound = t.dataset.sound; send({ type: 'set', key: 'tap_sound', value: t.dataset.sound }); render(); } @@ -239,7 +310,52 @@

TinyHand for Windows

pending = setTimeout(() => send({ type: 'set', key, value }), 30); }); +// Reordering the toolbar by dragging. +let dragFrom = -1; +document.addEventListener('dragstart', (e) => { + const slot = e.target.closest && e.target.closest('.slot'); + if (!slot) return; + dragFrom = Number(slot.dataset.slot); + slot.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(dragFrom)); +}); +document.addEventListener('dragover', (e) => { + const slot = e.target.closest && e.target.closest('.slot'); + if (!slot || dragFrom < 0) return; + e.preventDefault(); + document.querySelectorAll('.slot.over').forEach((s) => s.classList.remove('over')); + slot.classList.add('over'); +}); +document.addEventListener('drop', (e) => { + const slot = e.target.closest && e.target.closest('.slot'); + if (!slot || dragFrom < 0) return; + e.preventDefault(); + const to = Number(slot.dataset.slot); + if (to !== dragFrom) { + const list = S.pinned.slice(); + list.splice(to, 0, list.splice(dragFrom, 1)[0]); + S.pinned = list; + send({ type: 'reorder', ids: list }); + } + dragFrom = -1; + render(); +}); +document.addEventListener('dragend', () => { dragFrom = -1; document.querySelectorAll('.slot.dragging,.slot.over').forEach((s) => s.classList.remove('dragging', 'over')); }); + window.__state = (state) => { S = state; render(); }; +window.__thumbs = (t) => { + THUMBS = t; + if (page === 'pointers') render(); + // They are built in the background after launch. Ask again until they are all here. + if (S && Object.keys(t).length < S.hands.length - 1 && thumbRetries++ < 12) setTimeout(() => send({ type: 'thumbs' }), 700); +}; +window.__notice = (text) => { + notice = text; + if (page === 'pointers') render(); + clearTimeout(noticeTimer); + noticeTimer = setTimeout(() => { notice = ''; if (page === 'pointers') render(); }, 3500); +}; send({ type: 'get' }); diff --git a/windows/src/styles.rs b/windows/src/styles.rs index 9f12fa0..624646f 100644 --- a/windows/src/styles.rs +++ b/windows/src/styles.rs @@ -7,52 +7,63 @@ pub enum Style { } pub const LASER_INK: u32 = 0xFF291F; +pub const LASER: &str = "laser"; const PURPLE: u32 = 0x8754F2; const RED: u32 = LASER_INK; -const HANDS: [(&str, u32); 36] = [ - ("lavender", PURPLE), - ("glove", PURPLE), - ("coral", RED), - ("plush", 0x2F86F0), - ("robot", 0x16A87A), - ("pink", 0xEE4F98), - ("blue", 0x2F86F0), - ("mint", 0x16A87A), - ("lemon", 0xF2B705), - ("black", 0x1D1D21), - ("chrome", 0x6B7380), - ("holo", PURPLE), - ("fuzzy", PURPLE), - ("wood", 0x9A5E2C), - ("marble", 0x6B7380), - ("terrazzo", 0xF0561D), - ("glass", 0x1597E5), - ("jelly", 0xA33FE6), - ("pastel", 0xEE4F98), - ("pixel", 0x1D1D21), - ("lowpoly", PURPLE), - ("knit", PURPLE), - ("skeleton", 0x9A5E2C), - ("astro", 0x2F86F0), - ("space", RED), - ("monster", 0x3E9E2E), - ("water", 0x1597E5), - ("lava", 0xF0561D), - ("grass", 0x3E9E2E), - ("cactus", 0x3E9E2E), - ("choco", 0x9A5E2C), - ("cloud", 0x2F86F0), - ("mech", 0xF0561D), - ("neon", 0xE0309A), - ("leaf", 0x3E9E2E), - ("cookie", 0x9A5E2C), +/// id, name shown in settings, ink color +const HANDS: [(&str, &str, u32); 36] = [ + ("lavender", "Lavender toy", PURPLE), + ("glove", "Cursor glove", PURPLE), + ("coral", "Coral vinyl", RED), + ("plush", "Plush", 0x2F86F0), + ("robot", "Robot", 0x16A87A), + ("pink", "Bubblegum", 0xEE4F98), + ("blue", "Sky", 0x2F86F0), + ("mint", "Mint", 0x16A87A), + ("lemon", "Lemon", 0xF2B705), + ("black", "Midnight", 0x1D1D21), + ("chrome", "Chrome", 0x6B7380), + ("holo", "Holo", PURPLE), + ("fuzzy", "Fuzzy", PURPLE), + ("wood", "Wood", 0x9A5E2C), + ("marble", "Marble", 0x6B7380), + ("terrazzo", "Terrazzo", 0xF0561D), + ("glass", "Glass", 0x1597E5), + ("jelly", "Jelly", 0xA33FE6), + ("pastel", "Pastel", 0xEE4F98), + ("pixel", "Pixel", 0x1D1D21), + ("lowpoly", "Low poly", PURPLE), + ("knit", "Knit", PURPLE), + ("skeleton", "Skeleton", 0x9A5E2C), + ("astro", "Astronaut", 0x2F86F0), + ("space", "Space", RED), + ("monster", "Monster", 0x3E9E2E), + ("water", "Water", 0x1597E5), + ("lava", "Lava", 0xF0561D), + ("grass", "Grass", 0x3E9E2E), + ("cactus", "Cactus", 0x3E9E2E), + ("choco", "Chocolate", 0x9A5E2C), + ("cloud", "Cloud", 0x2F86F0), + ("mech", "Mech", 0xF0561D), + ("neon", "Neon", 0xE0309A), + ("leaf", "Leaf", 0x3E9E2E), + ("cookie", "Gingerbread", 0x9A5E2C), ]; +/// Every pointer a user can pick, the laser last. +pub fn all() -> impl Iterator { + HANDS.iter().map(|(id, name, _)| (*id, *name)).chain(std::iter::once((LASER, "Laser dot"))) +} + +pub fn is_known(id: &str) -> bool { + id == LASER || HANDS.iter().any(|(h, _, _)| *h == id) +} + /// Unknown or unloadable hands fall back to the laser, like the Mac app. pub fn load(id: &str) -> (Style, u32) { - let Some(&(_, ink)) = HANDS.iter().find(|(name, _)| *name == id) else { return (Style::Laser, LASER_INK) }; + let Some(&(_, _, ink)) = HANDS.iter().find(|(name, _, _)| *name == id) else { return (Style::Laser, LASER_INK) }; match HandAsset::load(id) { Some(asset) => (Style::Hand(Arm::new(asset)), ink), None => (Style::Laser, LASER_INK), diff --git a/windows/src/thumbs.rs b/windows/src/thumbs.rs new file mode 100644 index 0000000..5dfd77b --- /dev/null +++ b/windows/src/thumbs.rs @@ -0,0 +1,37 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use crate::hand::HandAsset; +use crate::icon::base64; + +const SIZE: u32 = 96; + +/// Settings thumbnails as PNG data URLs. Built once in the background after launch, +/// so opening the Pointers page is instant and nothing loads while the pointer is in use. +static CACHE: Mutex>> = Mutex::new(None); + +fn png(id: &str) -> Option { + let mut thumb = HandAsset::load(id)?.thumbnail(SIZE); + // Buffers are BGRA here, PNG wants RGBA. + for px in thumb.data_mut().chunks_exact_mut(4) { + px.swap(0, 2); + } + Some(format!("data:image/png;base64,{}", base64(&thumb.encode_png().ok()?))) +} + +pub fn build_in_background() { + let _ = std::thread::Builder::new().name("thumbs".into()).spawn(|| { + let mut all = HashMap::new(); + for (id, _) in crate::styles::all().filter(|(id, _)| *id != crate::styles::LASER) { + if let Some(url) = png(id) { + all.insert(id.to_string(), url); + } + } + *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some(all); + }); +} + +/// Every thumbnail built so far, as id to data URL. +pub fn all() -> HashMap { + CACHE.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default() +} diff --git a/windows/src/toolbar.rs b/windows/src/toolbar.rs index b95ef73..33eeff2 100644 --- a/windows/src/toolbar.rs +++ b/windows/src/toolbar.rs @@ -120,6 +120,26 @@ impl Toolbar { bar } + /// Replaces the pointer buttons, keeping icons already loaded. + pub fn set_styles(&mut self, styles: &[String]) { + let mut old = std::mem::take(&mut self.items); + let mut items: Vec = styles + .iter() + .map(|id| { + let kept = old.iter_mut().find(|i| matches!(&i.kind, Kind::Style(x) if x == id)).and_then(|i| i.source.take()); + let source = kept.or_else(|| HandAsset::load(id).map(|a| a.thumbnail(SOURCE_PX))); + Item { kind: Kind::Style(id.clone()), source, icon: None, x: 0.0 } + }) + .collect(); + for kind in [Kind::ClickThrough, Kind::Sound, Kind::Exit] { + items.push(Item { kind, source: None, icon: None, x: 0.0 }); + } + self.items = items; + self.layout(self.scale); + self.place(); + self.paint(); + } + /// Sizes and positions everything for a display scaling. Called again when the toolbar /// moves to a monitor with a different one. fn layout(&mut self, scale: f32) { From 0f1caf2e76e4572958dd5e860a4fafb956111852 Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 22:58:39 +0530 Subject: [PATCH 27/28] Document the Windows version and build it in CI --- .github/workflows/build.yml | 20 ++++++++ README.md | 30 +++++++----- windows/README.md | 96 +++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 windows/README.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1cd008..8c39efa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,3 +13,23 @@ jobs: - uses: actions/checkout@v4 - name: Build (universal) run: swift build -c release --arch arm64 --arch x86_64 + + build-windows: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Build + working-directory: windows + run: cargo build --release + - name: Stage the app with its images and sounds + shell: pwsh + run: | + New-Item -ItemType Directory -Force dist/TinyHand/assets/sounds | Out-Null + Copy-Item windows/target/release/tinyhand.exe dist/TinyHand/TinyHand.exe + Copy-Item assets/hand_*.png dist/TinyHand/assets/ + Copy-Item assets/sounds/tap_*.wav dist/TinyHand/assets/sounds/ + - uses: actions/upload-artifact@v4 + with: + name: TinyHand-windows + path: dist/TinyHand diff --git a/README.md b/README.md index e1f8647..d8e70b9 100644 --- a/README.md +++ b/README.md @@ -3,32 +3,40 @@ # TinyHand [![Latest release](https://img.shields.io/github/v/release/nilbuild/tinyhand?display_name=tag&sort=semver&cacheSeconds=3600)](https://github.com/nilbuild/tinyhand/releases/latest) -[![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](https://tinyhand.app) +[![Platform: macOS and Windows](https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey)](https://tinyhand.app) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -A tiny hand on a stick that points at things on your Mac screen. +A tiny hand on a stick that points at things on your screen. TinyHand: a tiny hand for your Mac [![Download for macOS](https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/nilbuild/tinyhand/releases/latest/download/TinyHand.dmg) [![Visit website](https://img.shields.io/badge/Visit_website-ffffff?style=for-the-badge&logo=safari&logoColor=black)](https://tinyhand.app) -macOS 13 or later, Apple Silicon and Intel. +**macOS** 13 or later, Apple Silicon and Intel. **Windows** 10 or 11, 64-bit (build from source for now, see [windows/](windows/README.md)). ## Install +### macOS + Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** when asked. It needs that to turn your clicks into taps. TinyHand updates itself. +### Windows + +No permission prompt and no installer yet. Build it with Rust and run it, as described in [windows/README.md](windows/README.md). + ## Use -| | | -|---|---| -| Click the menu bar icon, or **⌃⌥H** | Turn the pointer on or off | -| Right-click the menu bar icon | Menu and Settings | -| Click | Tap (nothing underneath gets clicked) | -| Hold and drag | Draw on the screen | -| **⌃⌥X** | Clear the drawing | -| **Esc** | Clear everything and turn the pointer off | +| macOS | Windows | | +|---|---|---| +| Click the menu bar icon, or **⌃⌥H** | Click the tray icon, or **Ctrl+Alt+H** | Turn the pointer on or off | +| Right-click the menu bar icon | Right-click the tray icon | Menu and Settings | +| Click | Click | Tap (nothing underneath gets clicked) | +| Hold and drag | Hold and drag | Draw on the screen | +| **⌃⌥X** | **Ctrl+Alt+X** | Clear the drawing | +| **Esc** | **Esc** | Clear everything and turn the pointer off | +| Hold **⌥** and click | Hold **Alt** and click | Click for real, with your normal cursor | +| | **Ctrl+Alt+C** | Let plain clicks through to apps while drags still draw (Windows) | While the pointer is on, a small toolbar at the bottom of the screen lets you pick one of 36 hands or a laser dot, mute the tap sound, or exit. diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..70332b1 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,96 @@ +# TinyHand for Windows + +The Windows version of [TinyHand](../README.md): a tiny hand on a stick that points at things on your screen. It uses the same hands, sounds and logo as the Mac app, and behaves the same way: click to tap, drag to draw, a toolbar to pick a hand. + +Written in Rust. It draws with a layered window and a low-level mouse hook, so there is no runtime to install apart from WebView2 for the settings window (already part of Windows 11). + +## Requirements + +- Windows 10 or 11, 64-bit. Developed and tested on Windows 11. +- To build: [Rust](https://rustup.rs) (stable) and the MSVC build tools with the Windows SDK. The "Desktop development with C++" workload of Visual Studio Build Tools has both. +- The WebView2 runtime for the settings window. It ships with Windows 11 and current Windows 10. + +## Build and run + +```powershell +cd windows +cargo run --release +``` + +The app looks for `assets/` (hand images and tap sounds) next to the exe, then in this repository. To use the exe on its own, copy it together with `assets/hand_*.png` and `assets/sounds/`. + +Turn the pointer on with **Ctrl+Alt+H**, or click the tray icon. + +## Use + +| | | +|---|---| +| Click the tray icon, or **Ctrl+Alt+H** | Turn the pointer on or off | +| Right-click the tray icon | Menu and Settings | +| Click | Tap (nothing underneath gets clicked) | +| Hold and drag | Draw on the screen | +| **Ctrl+Alt+X** | Clear the drawing | +| **Ctrl+Alt+C** | Click through on or off | +| **Esc** | Clear everything and turn the pointer off | +| Hold **Alt** and click | Click for real, with your normal cursor | + +**Click through** is Windows only. With it on, plain clicks go to the app under the pointer and the hand still taps. A drag draws and never reaches the app, so it cannot select text underneath. Hold **Alt** when you need a real drag. + +The toolbar at the bottom of the screen appears when you turn the pointer on, and again when you move the mouse near it. It picks the hand or laser, toggles click through and the tap sound, and exits. The hold-to-click key can be changed to Ctrl or Shift, or turned off, in Settings. + +## Settings + +Right-click the tray icon and choose **Settings**. Everything applies as you change it. + +- **General**: click through, the hold-to-click key, the shortcuts, reset. +- **Pointers**: which hands show on the toolbar (up to 8), their order, and the one in use. +- **Appearance**: pointer size and shadow. +- **Drawing**: how long strokes last, and the ink color. +- **Sound**: tap sound for hands and for the laser, and which of the six sounds. +- **About**. + +Settings are stored in `%APPDATA%\TinyHand\settings.txt`. A log with any crash details is in `%APPDATA%\TinyHand\log.txt`. + +## How it works + +| File | Role | +|---|---| +| `src/main.rs` | The message loop, tray icon, hotkeys, and the pieces below wired together | +| `src/hook.rs` | Low-level mouse and keyboard hooks. They only set flags and post messages, so the system never waits on TinyHand | +| `src/renderer.rs` | A thread that draws at the display's refresh rate and sleeps when nothing moves | +| `src/arm.rs`, `src/hand.rs` | The hand and stick: measuring each image, aiming, the shadow | +| `src/scene.rs` | Ink strokes and the laser dot | +| `src/toolbar.rs` | The toolbar, drawn by hand and scaled per monitor | +| `src/sound.rs` | Tap sounds through WASAPI, on their own thread | +| `src/settings.rs`, `settings.html` | The settings window, a WebView2 page created on demand and destroyed on close | +| `src/monitor.rs` | Which monitor the cursor is on, and its display scaling | + +Things worth knowing if you change the code: + +- Input is never injected from inside a hook callback. Replayed clicks and keys are posted to the main window and sent from there, because injecting inside the hook can stall all input. +- Every window the app owns refuses focus, and hands it straight back if Windows gives it away. +- The real cursors are blanked while the pointer is on and always restored on exit, on a crash and at startup. + +## Differences from the Mac app + +- Click through mode, and Ctrl+Alt+C for it. +- No Accessibility permission to grant. +- Windows menus, flyouts and the taskbar are detected and let clicks through, in place of the Mac menu bar handling. +- The pointer pauses while Snipping Tool or the Win+Shift+S overlay has focus. +- Not yet ported: a recorder to change the shortcuts, "make your own hand", the toolbar ink color button, opening at login, the first-run hint, auto-update and an installer. + +## Known limits + +- Windows does not let a program see or block input going to an app running as administrator, so TinyHand cannot draw over or tap such windows. +- The overlay covers the monitor the cursor is on, and drawings clear when you move to another monitor, as on the Mac. +- The toolbar and hands scale per monitor, but mixed display scaling has only been checked with a simulated second monitor. + +## Troubleshooting + +- **The cursor looks missing after a crash**: start TinyHand again. It restores the normal cursors on launch. +- **Nothing happens when you click**: press **Esc** or **Ctrl+Alt+H** to turn the pointer off. Both work even if drawing is stuck. +- **A hotkey does nothing**: another program may already use that combination. + +## License + +MIT, the same as the rest of the repository. From ea40fa0b2ecf7c71d5336093623381ce2e6cc4db Mon Sep 17 00:00:00 2001 From: MULTidll Date: Sun, 8 Nov 2026 23:00:06 +0530 Subject: [PATCH 28/28] Add a Windows release workflow and notes --- .github/workflows/release-windows.yml | 63 +++++++++++++++++++++++++++ windows/RELEASE_NOTES.md | 30 +++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 .github/workflows/release-windows.yml create mode 100644 windows/RELEASE_NOTES.md diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 0000000..0ccc8d5 --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -0,0 +1,63 @@ +name: Release (Windows) + +on: + push: + tags: ['windows-v*'] + workflow_dispatch: + +concurrency: + group: release-windows-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + release: + runs-on: windows-latest + timeout-minutes: 40 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Read the version + shell: pwsh + run: | + $version = (Select-String -Path windows/Cargo.toml -Pattern '^version = "(.*)"').Matches[0].Groups[1].Value + "VERSION=$version" >> $env:GITHUB_ENV + + - name: Build + working-directory: windows + run: cargo build --release + + - name: Package + shell: pwsh + run: | + $dir = "dist/TinyHand" + New-Item -ItemType Directory -Force "$dir/assets/sounds" | Out-Null + Copy-Item windows/target/release/tinyhand.exe "$dir/TinyHand.exe" + Copy-Item assets/hand_*.png "$dir/assets/" + Copy-Item assets/sounds/tap_*.wav "$dir/assets/sounds/" + Copy-Item LICENSE "$dir/LICENSE" + Copy-Item windows/README.md "$dir/README.md" + $zip = "dist/TinyHand-Windows-$env:VERSION.zip" + Compress-Archive -Path $dir -DestinationPath $zip + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() + "$hash TinyHand-Windows-$env:VERSION.zip" | Out-File "$zip.sha256" -Encoding ascii + + - uses: actions/upload-artifact@v4 + with: + name: TinyHand-Windows + path: dist/*.zip* + + - name: Publish the release + if: startsWith(github.ref, 'refs/tags/windows-v') + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $files = "dist/TinyHand-Windows-$env:VERSION.zip", "dist/TinyHand-Windows-$env:VERSION.zip.sha256" + gh release view $env:GITHUB_REF_NAME *> $null + if ($LASTEXITCODE -eq 0) { + gh release upload $env:GITHUB_REF_NAME @files --clobber + } else { + gh release create $env:GITHUB_REF_NAME @files --title "TinyHand for Windows $env:VERSION" --notes-file windows/RELEASE_NOTES.md --prerelease + } diff --git a/windows/RELEASE_NOTES.md b/windows/RELEASE_NOTES.md new file mode 100644 index 0000000..90f461d --- /dev/null +++ b/windows/RELEASE_NOTES.md @@ -0,0 +1,30 @@ +A first preview of TinyHand for Windows: a tiny hand on a stick that points at things on your screen. It uses the same hands, sounds and logo as the Mac app. + +## Try it + +1. Download `TinyHand-Windows-0.1.0.zip` and unzip it anywhere. +2. Run `TinyHand.exe`. A tray icon appears. +3. Press **Ctrl+Alt+H**, or click the tray icon, to turn the pointer on. + +Click to tap, hold and drag to draw, **Esc** to exit. The toolbar at the bottom of the screen picks the hand. Right-click the tray icon for Settings. + +Windows 10 or 11, 64-bit. Settings needs the WebView2 runtime, which comes with Windows 11 and current Windows 10. + +## What works + +- The 36 hands and the laser, with shadow, tap sounds and drawing that fades. +- A toolbar you can customize, and per-monitor placement and scaling. +- **Click through**: plain clicks reach the app under the pointer while drags still draw. Toggle with **Ctrl+Alt+C**. +- Hold **Alt** to click for real with your normal cursor. +- A settings window for all of the above. + +## Known limits + +- **This build is not code-signed.** Windows SmartScreen may warn the first time you run it. Choose "More info", then "Run anyway". +- Apps running as administrator cannot be drawn on or tapped, because Windows blocks input hooks across that boundary. +- Not done yet: an installer, auto-update, a shortcut recorder, "make your own hand", the toolbar ink color button and opening at login. +- Mixed display scaling has only been checked with a simulated second monitor. + +If something looks wrong, a log is in `%APPDATA%\TinyHand\log.txt`. + +Based on TinyHand for Mac by Kamran Ahmed. MIT license.