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
|
||||
*.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
|
||||
@@ -1,4 +1,6 @@
|
||||
# What_That_Claude_DO?
|
||||
|
||||
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