Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 775c222448 | |||
| 0608f1f1aa | |||
| cf634a76f2 | |||
| 075cd42064 | |||
| 2903819626 | |||
| 200e76b899 | |||
| 0e6fdb9719 | |||
| 7fe112293e |
@@ -9,10 +9,3 @@ target/
|
|||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
# RustRover
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|||||||
Generated
+1452
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "iwaku"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "iwaku"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Gdi",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Ole",
|
||||||
|
"Win32_UI_Accessibility",
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
|
"Win32_UI_Magnification",
|
||||||
|
"Win32_UI_Shell",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
tray-icon = "0.17"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 0
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 3
|
||||||
@@ -2,3 +2,5 @@
|
|||||||
|
|
||||||
What That Claude Do? (WTCD)
|
What That Claude Do? (WTCD)
|
||||||
A repository of random things I ask Claude to do for me.
|
A repository of random things I ask Claude to do for me.
|
||||||
|
|
||||||
|
In this case it is creating a screenshot tool
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use windows::Win32::Foundation::{HWND, POINT};
|
||||||
|
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||||
|
use windows::Win32::System::Com::SAFEARRAY;
|
||||||
|
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
|
||||||
|
use windows::Win32::System::Ole::{SafeArrayGetElement, SafeArrayGetLBound, SafeArrayGetUBound};
|
||||||
|
use windows::Win32::UI::Accessibility::{
|
||||||
|
CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationTextPattern, UIA_TextPatternId,
|
||||||
|
};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO,
|
||||||
|
};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static AUTOMATION: RefCell<Option<IUIAutomation>> = RefCell::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_automation() -> Option<IUIAutomation> {
|
||||||
|
AUTOMATION.with(|cell| {
|
||||||
|
let mut borrow = cell.borrow_mut();
|
||||||
|
if borrow.is_none() {
|
||||||
|
unsafe {
|
||||||
|
*borrow = CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
borrow.clone()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_caret_pos() -> Option<POINT> {
|
||||||
|
// 1. Win32 GUI-thread caret — fast, no COM. Works for classic edit controls,
|
||||||
|
// Notepad, Office dialogs, conhost-based terminals, etc.
|
||||||
|
if let Some(pt) = get_caret_via_gui_thread() {
|
||||||
|
return Some(pt);
|
||||||
|
}
|
||||||
|
// 2. UI Automation — works for Word, modern browsers, WPF, UWP apps.
|
||||||
|
get_caret_via_uia()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_caret_via_gui_thread() -> Option<POINT> {
|
||||||
|
unsafe {
|
||||||
|
let hwnd: HWND = GetForegroundWindow();
|
||||||
|
if hwnd.0.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let thread_id = GetWindowThreadProcessId(hwnd, None);
|
||||||
|
let mut gti = GUITHREADINFO {
|
||||||
|
cbSize: std::mem::size_of::<GUITHREADINFO>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
GetGUIThreadInfo(thread_id, &mut gti).ok()?;
|
||||||
|
if gti.hwndCaret.0.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut pt = POINT {
|
||||||
|
x: gti.rcCaret.left,
|
||||||
|
y: gti.rcCaret.bottom,
|
||||||
|
};
|
||||||
|
let _ = ClientToScreen(gti.hwndCaret, &mut pt);
|
||||||
|
Some(pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_caret_via_uia() -> Option<POINT> {
|
||||||
|
unsafe {
|
||||||
|
let automation = get_automation()?;
|
||||||
|
let element = automation.GetFocusedElement().ok()?;
|
||||||
|
|
||||||
|
// Try the focused element and walk up to 6 ancestors.
|
||||||
|
// Some apps expose TextPattern on a parent rather than the focused leaf.
|
||||||
|
let mut current: Option<IUIAutomationElement> = Some(element);
|
||||||
|
for _ in 0..7 {
|
||||||
|
let el = current.as_ref()?;
|
||||||
|
if let Some(sa) = try_text_pattern(el) {
|
||||||
|
return point_from_safearray(sa);
|
||||||
|
}
|
||||||
|
current = automation
|
||||||
|
.ControlViewWalker()
|
||||||
|
.ok()
|
||||||
|
.and_then(|w| w.GetParentElement(el).ok());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn try_text_pattern(el: &IUIAutomationElement) -> Option<*mut SAFEARRAY> {
|
||||||
|
unsafe {
|
||||||
|
let pattern = el
|
||||||
|
.GetCurrentPatternAs::<IUIAutomationTextPattern>(UIA_TextPatternId)
|
||||||
|
.ok()?;
|
||||||
|
let ranges = pattern.GetSelection().ok()?;
|
||||||
|
if ranges.Length().ok()? == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
ranges.GetElement(0).ok()?.GetBoundingRectangles().ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn point_from_safearray(sa: *mut SAFEARRAY) -> Option<POINT> {
|
||||||
|
unsafe {
|
||||||
|
if sa.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lb = SafeArrayGetLBound(sa, 1).ok()?;
|
||||||
|
let ub = SafeArrayGetUBound(sa, 1).ok()?;
|
||||||
|
if ub - lb < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut left: f64 = 0.0;
|
||||||
|
let mut top: f64 = 0.0;
|
||||||
|
let mut idx0 = lb;
|
||||||
|
let mut idx1 = lb + 1;
|
||||||
|
SafeArrayGetElement(sa, &mut idx0, &mut left as *mut f64 as *mut _).ok()?;
|
||||||
|
SafeArrayGetElement(sa, &mut idx1, &mut top as *mut f64 as *mut _).ok()?;
|
||||||
|
Some(POINT {
|
||||||
|
x: left as i32,
|
||||||
|
y: top as i32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
|
||||||
|
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||||
|
GetAsyncKeyState, SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, VIRTUAL_KEY,
|
||||||
|
VK_F24, VK_LWIN, VK_RWIN,
|
||||||
|
};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, MSLLHOOKSTRUCT, WH_MOUSE_LL,
|
||||||
|
WM_MOUSEWHEEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||||
|
|
||||||
|
pub const WM_APP_ZOOM_IN: u32 = 0x8001;
|
||||||
|
pub const WM_APP_ZOOM_OUT: u32 = 0x8002;
|
||||||
|
|
||||||
|
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||||
|
static MAIN_HWND: AtomicIsize = AtomicIsize::new(0);
|
||||||
|
|
||||||
|
pub fn install(main_hwnd: windows::Win32::Foundation::HWND) {
|
||||||
|
MAIN_HWND.store(main_hwnd.0 as isize, Ordering::SeqCst);
|
||||||
|
unsafe {
|
||||||
|
let mh = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_hook_proc), None, 0)
|
||||||
|
.expect("Failed to install WH_MOUSE_LL hook");
|
||||||
|
MOUSE_HOOK.store(mh.0 as isize, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uninstall() {
|
||||||
|
let raw = MOUSE_HOOK.swap(0, Ordering::SeqCst);
|
||||||
|
if raw != 0 {
|
||||||
|
unsafe {
|
||||||
|
let _ = UnhookWindowsHookEx(HHOOK(raw as *mut _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
|
if code >= 0 && wparam.0 as u32 == WM_MOUSEWHEEL {
|
||||||
|
unsafe {
|
||||||
|
let lwin = GetAsyncKeyState(VK_LWIN.0 as i32);
|
||||||
|
let rwin = GetAsyncKeyState(VK_RWIN.0 as i32);
|
||||||
|
if (lwin < 0) || (rwin < 0) {
|
||||||
|
let info = &*(lparam.0 as *const MSLLHOOKSTRUCT);
|
||||||
|
let delta = (info.mouseData >> 16) as i16;
|
||||||
|
|
||||||
|
let hwnd_raw = MAIN_HWND.load(Ordering::SeqCst);
|
||||||
|
if hwnd_raw != 0 {
|
||||||
|
let hwnd = windows::Win32::Foundation::HWND(hwnd_raw as *mut _);
|
||||||
|
let msg = if delta > 0 {
|
||||||
|
WM_APP_ZOOM_IN
|
||||||
|
} else {
|
||||||
|
WM_APP_ZOOM_OUT
|
||||||
|
};
|
||||||
|
let _ = windows::Win32::UI::WindowsAndMessaging::PostMessageW(
|
||||||
|
hwnd,
|
||||||
|
msg,
|
||||||
|
WPARAM(0),
|
||||||
|
LPARAM(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject a dummy F24 down+up between the Win key-down and the
|
||||||
|
// upcoming Win key-up. Windows only opens the Start menu when
|
||||||
|
// Win is the *sole* key in a press/release cycle; seeing any
|
||||||
|
// other key in between suppresses it — no stuck key, no Start
|
||||||
|
// menu, no keyboard hook required.
|
||||||
|
inject_f24();
|
||||||
|
|
||||||
|
return LRESULT(1); // consume the scroll event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_f24() {
|
||||||
|
let inputs = [make_key(VK_F24, false), make_key(VK_F24, true)];
|
||||||
|
unsafe {
|
||||||
|
SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_key(vk: VIRTUAL_KEY, key_up: bool) -> INPUT {
|
||||||
|
INPUT {
|
||||||
|
r#type: INPUT_KEYBOARD,
|
||||||
|
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
|
||||||
|
ki: KEYBDINPUT {
|
||||||
|
wVk: vk,
|
||||||
|
wScan: 0,
|
||||||
|
dwFlags: if key_up {
|
||||||
|
KEYEVENTF_KEYUP
|
||||||
|
} else {
|
||||||
|
Default::default()
|
||||||
|
},
|
||||||
|
time: 0,
|
||||||
|
dwExtraInfo: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use windows::Win32::Foundation::HWND;
|
||||||
|
use windows::Win32::Graphics::Gdi::{
|
||||||
|
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
|
||||||
|
};
|
||||||
|
use windows::Win32::UI::Magnification::{
|
||||||
|
MagInitialize, MagSetFullscreenTransform, MagUninitialize,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub struct Magnifier {
|
||||||
|
screen_w: i32,
|
||||||
|
screen_h: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Magnifier {
|
||||||
|
pub fn new(screen_w: i32, screen_h: i32) -> Self {
|
||||||
|
Self { screen_w, screen_h }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply or clear the fullscreen magnification transform.
|
||||||
|
pub fn update(&self, state: &mut AppState, just_activated: bool) {
|
||||||
|
unsafe {
|
||||||
|
if !state.active {
|
||||||
|
let _ = MagSetFullscreenTransform(1.0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let z = state.zoom;
|
||||||
|
let src_w = self.screen_w as f32 / z;
|
||||||
|
let src_h = self.screen_h as f32 / z;
|
||||||
|
|
||||||
|
state.update_viewport(
|
||||||
|
src_w,
|
||||||
|
src_h,
|
||||||
|
self.screen_w as f32,
|
||||||
|
self.screen_h as f32,
|
||||||
|
just_activated,
|
||||||
|
);
|
||||||
|
|
||||||
|
let x_off = state.viewport_x as i32;
|
||||||
|
let y_off = state.viewport_y as i32;
|
||||||
|
|
||||||
|
let _ = MagSetFullscreenTransform(z, x_off, y_off);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create() -> Magnifier {
|
||||||
|
unsafe {
|
||||||
|
MagInitialize().expect("MagInitialize failed");
|
||||||
|
let (sw, sh) = monitor_dimensions();
|
||||||
|
Magnifier::new(sw, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown() {
|
||||||
|
unsafe {
|
||||||
|
let _ = MagSetFullscreenTransform(1.0, 0, 0);
|
||||||
|
let _ = MagUninitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monitor_dimensions() -> (i32, i32) {
|
||||||
|
unsafe {
|
||||||
|
let hmon = MonitorFromWindow(HWND(std::ptr::null_mut()), MONITOR_DEFAULTTOPRIMARY);
|
||||||
|
let mut info = MONITORINFO {
|
||||||
|
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = GetMonitorInfoW(hmon, &mut info);
|
||||||
|
let sw = info.rcMonitor.right - info.rcMonitor.left;
|
||||||
|
let sh = info.rcMonitor.bottom - info.rcMonitor.top;
|
||||||
|
(sw, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
|
mod caret;
|
||||||
|
mod hook;
|
||||||
|
mod magnifier;
|
||||||
|
mod state;
|
||||||
|
mod tray;
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
use windows::core::w;
|
||||||
|
use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
|
||||||
|
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED};
|
||||||
|
use windows::Win32::UI::HiDpi::{
|
||||||
|
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||||
|
};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::WINDOW_EX_STYLE;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, PostQuitMessage,
|
||||||
|
RegisterClassExW, SetTimer, TranslateMessage, CS_HREDRAW, CS_VREDRAW, MSG, WM_DESTROY,
|
||||||
|
WM_TIMER, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
|
||||||
|
};
|
||||||
|
|
||||||
|
static mut APP_STATE: Option<AppState> = None;
|
||||||
|
static mut MAGNIFIER: Option<magnifier::Magnifier> = None;
|
||||||
|
|
||||||
|
const TIMER_ID: usize = 1;
|
||||||
|
const TIMER_MS: u32 = 16; // ~60 fps
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
unsafe {
|
||||||
|
// DPI awareness — must be set before any window is created.
|
||||||
|
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||||
|
|
||||||
|
// COM initialisation needed for UI Automation (caret tracking).
|
||||||
|
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||||
|
APP_STATE = Some(AppState::new());
|
||||||
|
|
||||||
|
let instance = get_instance();
|
||||||
|
register_msg_class(instance);
|
||||||
|
|
||||||
|
// Invisible message-only window to receive WM_APP zoom messages and WM_TIMER.
|
||||||
|
let msg_hwnd = CreateWindowExW(
|
||||||
|
WINDOW_EX_STYLE(0),
|
||||||
|
w!("IwakuMsg"),
|
||||||
|
w!(""),
|
||||||
|
WS_OVERLAPPEDWINDOW,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
instance,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("Failed to create message window");
|
||||||
|
|
||||||
|
// Global Win+Scroll hook.
|
||||||
|
hook::install(msg_hwnd);
|
||||||
|
|
||||||
|
// Fullscreen magnifier — no overlay window needed with MagSetFullscreenTransform.
|
||||||
|
MAGNIFIER = Some(magnifier::create());
|
||||||
|
|
||||||
|
// System tray icon.
|
||||||
|
let tray = tray::Tray::new();
|
||||||
|
|
||||||
|
// 16 ms refresh timer.
|
||||||
|
SetTimer(msg_hwnd, TIMER_ID, TIMER_MS, None);
|
||||||
|
|
||||||
|
// Message loop.
|
||||||
|
let mut msg = MSG::default();
|
||||||
|
loop {
|
||||||
|
if tray.process_events() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ret = GetMessageW(&mut msg, None, 0, 0);
|
||||||
|
if ret.0 == 0 || ret.0 == -1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
hook::uninstall();
|
||||||
|
magnifier::shutdown();
|
||||||
|
CoUninitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window procedure for the invisible message window.
|
||||||
|
unsafe extern "system" fn wnd_proc(
|
||||||
|
hwnd: HWND,
|
||||||
|
msg: u32,
|
||||||
|
wparam: WPARAM,
|
||||||
|
lparam: LPARAM,
|
||||||
|
) -> LRESULT {
|
||||||
|
unsafe {
|
||||||
|
match msg {
|
||||||
|
WM_TIMER => {
|
||||||
|
if wparam.0 == TIMER_ID {
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
m if m == hook::WM_APP_ZOOM_IN => {
|
||||||
|
if let Some(state) = (*std::ptr::addr_of_mut!(APP_STATE)).as_mut() {
|
||||||
|
state.zoom_in();
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
m if m == hook::WM_APP_ZOOM_OUT => {
|
||||||
|
if let Some(state) = (*std::ptr::addr_of_mut!(APP_STATE)).as_mut() {
|
||||||
|
state.zoom_out();
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WM_DESTROY => {
|
||||||
|
PostQuitMessage(0);
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called every ~16 ms: update inputs, recalculate priority, push to magnifier.
|
||||||
|
unsafe fn tick() {
|
||||||
|
unsafe {
|
||||||
|
let state = match (*std::ptr::addr_of_mut!(APP_STATE)).as_mut() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remember whether the magnifier was active before this tick so we
|
||||||
|
// can detect the exact frame it turns on and snap the smooth position.
|
||||||
|
let was_active = state.active;
|
||||||
|
|
||||||
|
// 1. Update cursor and caret.
|
||||||
|
let mut pt = windows::Win32::Foundation::POINT::default();
|
||||||
|
if GetCursorPos(&mut pt).is_ok() {
|
||||||
|
state.cursor = pt;
|
||||||
|
}
|
||||||
|
state.caret = caret::get_caret_pos();
|
||||||
|
|
||||||
|
// 2. Advance state.
|
||||||
|
let just_activated = state.active && !was_active;
|
||||||
|
state.update(just_activated);
|
||||||
|
|
||||||
|
// 3. Push the new transform.
|
||||||
|
if let Some(mag) = (*std::ptr::addr_of_mut!(MAGNIFIER)).as_mut() {
|
||||||
|
mag.update(state, just_activated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn get_instance() -> HINSTANCE {
|
||||||
|
unsafe {
|
||||||
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
|
GetModuleHandleW(None).unwrap().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn register_msg_class(instance: HINSTANCE) {
|
||||||
|
unsafe {
|
||||||
|
let wc = WNDCLASSEXW {
|
||||||
|
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||||
|
style: CS_HREDRAW | CS_VREDRAW,
|
||||||
|
lpfnWndProc: Some(wnd_proc),
|
||||||
|
hInstance: instance,
|
||||||
|
lpszClassName: w!("IwakuMsg"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
RegisterClassExW(&wc);
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
use windows::Win32::Foundation::POINT;
|
||||||
|
|
||||||
|
pub const ZOOM_MIN: f32 = 1.5;
|
||||||
|
pub const ZOOM_MAX: f32 = 10.0;
|
||||||
|
pub const ZOOM_STEP: f32 = 0.5;
|
||||||
|
|
||||||
|
const EDGE_MARGIN: f32 = 0.10;
|
||||||
|
const PAN_ALPHA: f32 = 0.20;
|
||||||
|
const ZOOM_ALPHA: f32 = 0.18;
|
||||||
|
const ZOOM_EPSILON: f32 = 0.01;
|
||||||
|
const CURSOR_ALPHA: f32 = 0.35;
|
||||||
|
const MOUSE_MOVE_THRESHOLD_SQ: i32 = 6 * 6;
|
||||||
|
const MOUSE_TAKEOVER_TICKS: u32 = 4;
|
||||||
|
/// Ticks of mouse stillness before caret re-takes focus (if caret present). ~240 ms.
|
||||||
|
const CARET_REFOCUS_TICKS: u32 = 15;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub zoom: f32,
|
||||||
|
target_zoom: f32,
|
||||||
|
prev_zoom: f32,
|
||||||
|
pub active: bool,
|
||||||
|
|
||||||
|
pub cursor: POINT,
|
||||||
|
prev_cursor: POINT,
|
||||||
|
smooth_cursor_x: f32,
|
||||||
|
smooth_cursor_y: f32,
|
||||||
|
|
||||||
|
pub caret: Option<POINT>,
|
||||||
|
prev_caret: Option<POINT>,
|
||||||
|
pub caret_active: bool,
|
||||||
|
mouse_move_ticks: u32,
|
||||||
|
mouse_still_ticks: u32,
|
||||||
|
|
||||||
|
pub viewport_x: f32,
|
||||||
|
pub viewport_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
zoom: 1.0,
|
||||||
|
target_zoom: 1.0,
|
||||||
|
prev_zoom: 1.0,
|
||||||
|
active: false,
|
||||||
|
cursor: POINT { x: 0, y: 0 },
|
||||||
|
prev_cursor: POINT { x: 0, y: 0 },
|
||||||
|
smooth_cursor_x: 0.0,
|
||||||
|
smooth_cursor_y: 0.0,
|
||||||
|
caret: None,
|
||||||
|
prev_caret: None,
|
||||||
|
caret_active: false,
|
||||||
|
mouse_move_ticks: 0,
|
||||||
|
mouse_still_ticks: 0,
|
||||||
|
viewport_x: 0.0,
|
||||||
|
viewport_y: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, _just_activated: bool) {
|
||||||
|
// Animate zoom.
|
||||||
|
if self.active {
|
||||||
|
let diff = self.target_zoom - self.zoom;
|
||||||
|
if diff.abs() < ZOOM_EPSILON {
|
||||||
|
self.zoom = self.target_zoom;
|
||||||
|
if self.zoom <= 1.0 {
|
||||||
|
self.zoom = 1.0;
|
||||||
|
self.active = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.zoom += diff * ZOOM_ALPHA;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth cursor.
|
||||||
|
self.smooth_cursor_x += (self.cursor.x as f32 - self.smooth_cursor_x) * CURSOR_ALPHA;
|
||||||
|
self.smooth_cursor_y += (self.cursor.y as f32 - self.smooth_cursor_y) * CURSOR_ALPHA;
|
||||||
|
|
||||||
|
// Caret / mouse priority.
|
||||||
|
let dx = self.cursor.x - self.prev_cursor.x;
|
||||||
|
let dy = self.cursor.y - self.prev_cursor.y;
|
||||||
|
let intentional = dx * dx + dy * dy >= MOUSE_MOVE_THRESHOLD_SQ;
|
||||||
|
|
||||||
|
let caret_moved = match (self.caret, self.prev_caret) {
|
||||||
|
(Some(c), Some(p)) => c.x != p.x || c.y != p.y,
|
||||||
|
(Some(_), None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if intentional {
|
||||||
|
self.mouse_move_ticks += 1;
|
||||||
|
self.mouse_still_ticks = 0;
|
||||||
|
} else {
|
||||||
|
self.mouse_move_ticks = 0;
|
||||||
|
self.mouse_still_ticks += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mouse_move_ticks >= MOUSE_TAKEOVER_TICKS {
|
||||||
|
self.caret_active = false;
|
||||||
|
self.mouse_move_ticks = 0;
|
||||||
|
}
|
||||||
|
// Re-engage caret once mouse has been still long enough.
|
||||||
|
if self.mouse_still_ticks >= CARET_REFOCUS_TICKS && self.caret.is_some() {
|
||||||
|
self.caret_active = true;
|
||||||
|
}
|
||||||
|
if caret_moved {
|
||||||
|
self.caret_active = true;
|
||||||
|
self.mouse_move_ticks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.prev_cursor = self.cursor;
|
||||||
|
self.prev_caret = self.caret;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_viewport(
|
||||||
|
&mut self,
|
||||||
|
src_w: f32,
|
||||||
|
src_h: f32,
|
||||||
|
screen_w: f32,
|
||||||
|
screen_h: f32,
|
||||||
|
just_activated: bool,
|
||||||
|
) {
|
||||||
|
let (fx, fy) = self.focus_point();
|
||||||
|
let zoom_changed = (self.zoom - self.prev_zoom).abs() > f32::EPSILON;
|
||||||
|
self.prev_zoom = self.zoom;
|
||||||
|
|
||||||
|
if just_activated {
|
||||||
|
self.viewport_x = (fx - src_w / 2.0).clamp(0.0, (screen_w - src_w).max(0.0));
|
||||||
|
self.viewport_y = (fy - src_h / 2.0).clamp(0.0, (screen_h - src_h).max(0.0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (target_x, target_y) = if self.caret_active {
|
||||||
|
// Centre on caret while typing.
|
||||||
|
let tx = (fx - src_w / 2.0).clamp(0.0, (screen_w - src_w).max(0.0));
|
||||||
|
let ty = (fy - src_h / 2.0).clamp(0.0, (screen_h - src_h).max(0.0));
|
||||||
|
(tx, ty)
|
||||||
|
} else if zoom_changed {
|
||||||
|
let tx = (fx - src_w / 2.0).clamp(0.0, (screen_w - src_w).max(0.0));
|
||||||
|
let ty = (fy - src_h / 2.0).clamp(0.0, (screen_h - src_h).max(0.0));
|
||||||
|
(tx, ty)
|
||||||
|
} else {
|
||||||
|
let mx = EDGE_MARGIN * src_w;
|
||||||
|
let my = EDGE_MARGIN * src_h;
|
||||||
|
let tx = self
|
||||||
|
.viewport_x
|
||||||
|
.clamp(fx - src_w + mx, fx - mx)
|
||||||
|
.clamp(0.0, (screen_w - src_w).max(0.0));
|
||||||
|
let ty = self
|
||||||
|
.viewport_y
|
||||||
|
.clamp(fy - src_h + my, fy - my)
|
||||||
|
.clamp(0.0, (screen_h - src_h).max(0.0));
|
||||||
|
(tx, ty)
|
||||||
|
};
|
||||||
|
|
||||||
|
self.viewport_x += (target_x - self.viewport_x) * PAN_ALPHA;
|
||||||
|
self.viewport_y += (target_y - self.viewport_y) * PAN_ALPHA;
|
||||||
|
|
||||||
|
// Hard-clamp every tick so the viewport never overshoots screen bounds
|
||||||
|
// even while the lerp is still catching up (e.g. fast zoom-out).
|
||||||
|
self.viewport_x = self.viewport_x.clamp(0.0, (screen_w - src_w).max(0.0));
|
||||||
|
self.viewport_y = self.viewport_y.clamp(0.0, (screen_h - src_h).max(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn zoom_in(&mut self) {
|
||||||
|
if !self.active {
|
||||||
|
self.target_zoom = ZOOM_MIN;
|
||||||
|
self.zoom = 1.0;
|
||||||
|
self.active = true;
|
||||||
|
} else {
|
||||||
|
self.target_zoom = (self.target_zoom + ZOOM_STEP).min(ZOOM_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn zoom_out(&mut self) {
|
||||||
|
if !self.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.target_zoom -= ZOOM_STEP;
|
||||||
|
if self.target_zoom < ZOOM_MIN {
|
||||||
|
self.target_zoom = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_point(&self) -> (f32, f32) {
|
||||||
|
if self.caret_active {
|
||||||
|
if let Some(c) = self.caret {
|
||||||
|
return (c.x as f32, c.y as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(self.smooth_cursor_x, self.smooth_cursor_y)
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
use tray_icon::{
|
||||||
|
menu::{Menu, MenuEvent, MenuId, MenuItem},
|
||||||
|
TrayIcon, TrayIconBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Tray {
|
||||||
|
_icon: TrayIcon,
|
||||||
|
exit_id: MenuId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tray {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let toggle_item = MenuItem::new("Toggle", true, None);
|
||||||
|
let exit_item = MenuItem::new("Exit", true, None);
|
||||||
|
|
||||||
|
let exit_id = exit_item.id().clone();
|
||||||
|
|
||||||
|
let menu = Menu::new();
|
||||||
|
menu.append(&toggle_item)
|
||||||
|
.expect("Failed to append toggle item");
|
||||||
|
menu.append(&exit_item).expect("Failed to append exit item");
|
||||||
|
|
||||||
|
let icon = make_icon();
|
||||||
|
|
||||||
|
let tray = TrayIconBuilder::new()
|
||||||
|
.with_menu(Box::new(menu))
|
||||||
|
.with_tooltip("iwaku magnifier")
|
||||||
|
.with_icon(icon)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build tray icon");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
_icon: tray,
|
||||||
|
exit_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the app should exit.
|
||||||
|
pub fn process_events(&self) -> bool {
|
||||||
|
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||||
|
if event.id == self.exit_id {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_icon() -> tray_icon::Icon {
|
||||||
|
const SIZE: usize = 16;
|
||||||
|
let mut rgba = vec![0u8; SIZE * SIZE * 4];
|
||||||
|
|
||||||
|
for y in 0..SIZE {
|
||||||
|
for x in 0..SIZE {
|
||||||
|
let idx = (y * SIZE + x) * 4;
|
||||||
|
let cx = x as f32 - 7.5;
|
||||||
|
let cy = y as f32 - 7.5;
|
||||||
|
let dist = (cx * cx + cy * cy).sqrt();
|
||||||
|
if (dist - 5.5).abs() < 1.5 {
|
||||||
|
rgba[idx] = 255;
|
||||||
|
rgba[idx + 1] = 255;
|
||||||
|
rgba[idx + 2] = 255;
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tray_icon::Icon::from_rgba(rgba, SIZE as u32, SIZE as u32)
|
||||||
|
.expect("Failed to create tray icon from RGBA")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user