winZoom it works similar to hyprland zoom but with typecursor follow
This commit is contained in:
Generated
+1024
-672
File diff suppressed because it is too large
Load Diff
+14
-5
@@ -8,11 +8,20 @@ name = "iwaku"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
image = { version = "0.25", features = ["png"] }
|
windows = { version = "0.58", features = [
|
||||||
tiny-skia = "0.11"
|
"Win32_Foundation",
|
||||||
serde = { version = "1", features = ["derive"] }
|
"Win32_Graphics_Gdi",
|
||||||
anyhow = "1"
|
"Win32_System_Com",
|
||||||
serde_json = "1.0.149"
|
"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]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
+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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(rename = "screenshot")]
|
|
||||||
pub screenshot: EffectsConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct EffectsConfig {
|
|
||||||
pub mode: String,
|
|
||||||
pub rounded_corners: bool,
|
|
||||||
pub corner_radius: f32,
|
|
||||||
pub drop_shadow: bool,
|
|
||||||
pub shadow_blur_radius: f32,
|
|
||||||
pub shadow_offset_x: f32,
|
|
||||||
pub shadow_offset_y: f32,
|
|
||||||
pub shadow_color: [u8; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn config_path() -> Option<PathBuf> {
|
|
||||||
let home = std::env::var("HOME").ok()?;
|
|
||||||
Some(
|
|
||||||
PathBuf::from(home)
|
|
||||||
.join(".config")
|
|
||||||
.join("zshell")
|
|
||||||
.join("config.json"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load() -> Result<Self> {
|
|
||||||
let path = Self::config_path().context("Could not determine HOME directory")?;
|
|
||||||
Self::load_from(&path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_from(path: &PathBuf) -> Result<Self> {
|
|
||||||
let raw = std::fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("Failed to read config at {}", path.display()))?;
|
|
||||||
serde_json::from_str(&raw)
|
|
||||||
.with_context(|| format!("Failed to parse JSON config at {}", path.display()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-288
@@ -1,288 +0,0 @@
|
|||||||
use crate::config::EffectsConfig;
|
|
||||||
use image::RgbaImage;
|
|
||||||
use tiny_skia::{
|
|
||||||
BlendMode, Color, FillRule, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Transform,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn apply_effects(img: RgbaImage, cfg: &EffectsConfig) -> RgbaImage {
|
|
||||||
let img = if cfg.rounded_corners {
|
|
||||||
apply_rounded_corners(img, cfg.corner_radius)
|
|
||||||
} else {
|
|
||||||
img
|
|
||||||
};
|
|
||||||
if cfg.drop_shadow {
|
|
||||||
apply_drop_shadow(
|
|
||||||
img,
|
|
||||||
cfg.shadow_blur_radius,
|
|
||||||
cfg.shadow_offset_x,
|
|
||||||
cfg.shadow_offset_y,
|
|
||||||
cfg.shadow_color,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
img
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
|
|
||||||
let (w, h) = img.dimensions();
|
|
||||||
let mut mask = Pixmap::new(w, h).expect("mask pixmap");
|
|
||||||
let path = rounded_rect_path(0.0, 0.0, w as f32, h as f32, radius);
|
|
||||||
let mut paint = Paint::default();
|
|
||||||
paint.set_color(Color::WHITE);
|
|
||||||
paint.anti_alias = true;
|
|
||||||
mask.fill_path(
|
|
||||||
&path,
|
|
||||||
&paint,
|
|
||||||
FillRule::Winding,
|
|
||||||
Transform::identity(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut pixmap = rgba_image_to_pixmap(&img);
|
|
||||||
let mut dst_paint = PixmapPaint::default();
|
|
||||||
dst_paint.blend_mode = BlendMode::DestinationIn;
|
|
||||||
pixmap.draw_pixmap(0, 0, mask.as_ref(), &dst_paint, Transform::identity(), None);
|
|
||||||
pixmap_to_rgba_image(pixmap)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_drop_shadow(
|
|
||||||
img: RgbaImage,
|
|
||||||
blur_radius: f32,
|
|
||||||
offset_x: f32,
|
|
||||||
offset_y: f32,
|
|
||||||
shadow_color: [u8; 4],
|
|
||||||
) -> RgbaImage {
|
|
||||||
let (iw, ih) = img.dimensions();
|
|
||||||
let br = blur_radius.ceil() as u32;
|
|
||||||
|
|
||||||
let extra_left = br.saturating_sub((-offset_x).max(0.0) as u32);
|
|
||||||
let extra_top = br.saturating_sub((-offset_y).max(0.0) as u32);
|
|
||||||
let extra_right = br + offset_x.max(0.0) as u32;
|
|
||||||
let extra_bottom = br + offset_y.max(0.0) as u32;
|
|
||||||
|
|
||||||
let canvas_w = iw + extra_left + extra_right;
|
|
||||||
let canvas_h = ih + extra_top + extra_bottom;
|
|
||||||
|
|
||||||
let mut shadow_pixmap = Pixmap::new(canvas_w, canvas_h).expect("shadow pixmap");
|
|
||||||
let img_pixmap = rgba_image_to_pixmap(&img);
|
|
||||||
let shadow_x = (extra_left as f32 + offset_x) as i32;
|
|
||||||
let shadow_y = (extra_top as f32 + offset_y) as i32;
|
|
||||||
|
|
||||||
let mut sp = PixmapPaint::default();
|
|
||||||
sp.blend_mode = BlendMode::Source;
|
|
||||||
shadow_pixmap.draw_pixmap(
|
|
||||||
shadow_x,
|
|
||||||
shadow_y,
|
|
||||||
img_pixmap.as_ref(),
|
|
||||||
&sp,
|
|
||||||
Transform::identity(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
|
|
||||||
|
|
||||||
let shadow_img = pixmap_to_rgba_image(shadow_pixmap);
|
|
||||||
let blurred = box_blur_rgba(&shadow_img, br);
|
|
||||||
let blurred_pixmap = rgba_image_to_pixmap(&blurred);
|
|
||||||
|
|
||||||
let mut canvas = Pixmap::new(canvas_w, canvas_h).expect("canvas pixmap");
|
|
||||||
let mut p = PixmapPaint::default();
|
|
||||||
p.blend_mode = BlendMode::Source;
|
|
||||||
canvas.draw_pixmap(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
blurred_pixmap.as_ref(),
|
|
||||||
&p,
|
|
||||||
Transform::identity(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut p2 = PixmapPaint::default();
|
|
||||||
p2.blend_mode = BlendMode::SourceOver;
|
|
||||||
canvas.draw_pixmap(
|
|
||||||
extra_left as i32,
|
|
||||||
extra_top as i32,
|
|
||||||
img_pixmap.as_ref(),
|
|
||||||
&p2,
|
|
||||||
Transform::identity(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
pixmap_to_rgba_image(canvas)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Path {
|
|
||||||
let r = r.min(w / 2.0).min(h / 2.0);
|
|
||||||
let mut pb = PathBuilder::new();
|
|
||||||
pb.move_to(x + r, y);
|
|
||||||
pb.line_to(x + w - r, y);
|
|
||||||
pb.quad_to(x + w, y, x + w, y + r);
|
|
||||||
pb.line_to(x + w, y + h - r);
|
|
||||||
pb.quad_to(x + w, y + h, x + w - r, y + h);
|
|
||||||
pb.line_to(x + r, y + h);
|
|
||||||
pb.quad_to(x, y + h, x, y + h - r);
|
|
||||||
pb.line_to(x, y + r);
|
|
||||||
pb.quad_to(x, y, x + r, y);
|
|
||||||
pb.close();
|
|
||||||
pb.finish().expect("rounded rect path")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rgba_image_to_pixmap(img: &RgbaImage) -> Pixmap {
|
|
||||||
let (w, h) = img.dimensions();
|
|
||||||
let mut pixmap = Pixmap::new(w, h).expect("pixmap alloc");
|
|
||||||
let pixels = pixmap.pixels_mut();
|
|
||||||
for (i, px) in img.pixels().enumerate() {
|
|
||||||
let [r, g, b, a] = px.0;
|
|
||||||
let af = a as f32 / 255.0;
|
|
||||||
pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(
|
|
||||||
(r as f32 * af) as u8,
|
|
||||||
(g as f32 * af) as u8,
|
|
||||||
(b as f32 * af) as u8,
|
|
||||||
a,
|
|
||||||
)
|
|
||||||
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
|
|
||||||
}
|
|
||||||
pixmap
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pixmap_to_rgba_image(pixmap: Pixmap) -> RgbaImage {
|
|
||||||
let (w, h) = (pixmap.width(), pixmap.height());
|
|
||||||
let mut out = RgbaImage::new(w, h);
|
|
||||||
for (i, px) in pixmap.pixels().iter().enumerate() {
|
|
||||||
let x = (i as u32) % w;
|
|
||||||
let y = (i as u32) / w;
|
|
||||||
let a = px.alpha();
|
|
||||||
let (r, g, b) = if a == 0 {
|
|
||||||
(0, 0, 0)
|
|
||||||
} else {
|
|
||||||
let af = a as f32 / 255.0;
|
|
||||||
(
|
|
||||||
(px.red() as f32 / af).round().min(255.0) as u8,
|
|
||||||
(px.green() as f32 / af).round().min(255.0) as u8,
|
|
||||||
(px.blue() as f32 / af).round().min(255.0) as u8,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
out.put_pixel(x, y, image::Rgba([r, g, b, a]));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tint_pixmap_as_shadow(pixmap: &mut Pixmap, color: [u8; 4]) {
|
|
||||||
let [sr, sg, sb, _] = color;
|
|
||||||
for px in pixmap.pixels_mut() {
|
|
||||||
let a = px.alpha();
|
|
||||||
if a > 0 {
|
|
||||||
let af = a as f32 / 255.0;
|
|
||||||
*px = tiny_skia::PremultipliedColorU8::from_rgba(
|
|
||||||
(sr as f32 * af) as u8,
|
|
||||||
(sg as f32 * af) as u8,
|
|
||||||
(sb as f32 * af) as u8,
|
|
||||||
a,
|
|
||||||
)
|
|
||||||
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn box_blur_rgba(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|
||||||
if radius == 0 {
|
|
||||||
return img.clone();
|
|
||||||
}
|
|
||||||
let mut buf = sliding_horizontal(img, radius);
|
|
||||||
buf = sliding_vertical(&buf, radius);
|
|
||||||
buf = sliding_horizontal(&buf, radius);
|
|
||||||
buf = sliding_vertical(&buf, radius);
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|
||||||
let (w, h) = img.dimensions();
|
|
||||||
let r = radius as i32;
|
|
||||||
let diam = (2 * r + 1) as u32;
|
|
||||||
let mut out = RgbaImage::new(w, h);
|
|
||||||
|
|
||||||
for y in 0..h {
|
|
||||||
let mut sr = 0u32;
|
|
||||||
let mut sg = 0u32;
|
|
||||||
let mut sb = 0u32;
|
|
||||||
let mut sa = 0u32;
|
|
||||||
|
|
||||||
for dx in -r..=r {
|
|
||||||
let sx = dx.clamp(0, w as i32 - 1) as u32;
|
|
||||||
let p = img.get_pixel(sx, y).0;
|
|
||||||
sr += p[0] as u32;
|
|
||||||
sg += p[1] as u32;
|
|
||||||
sb += p[2] as u32;
|
|
||||||
sa += p[3] as u32;
|
|
||||||
}
|
|
||||||
|
|
||||||
for x in 0..w {
|
|
||||||
out.put_pixel(
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
image::Rgba([
|
|
||||||
(sr / diam) as u8,
|
|
||||||
(sg / diam) as u8,
|
|
||||||
(sb / diam) as u8,
|
|
||||||
(sa / diam) as u8,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
let remove_x = (x as i32 - r).clamp(0, w as i32 - 1) as u32;
|
|
||||||
let add_x = (x as i32 + r + 1).clamp(0, w as i32 - 1) as u32;
|
|
||||||
let rp = img.get_pixel(remove_x, y).0;
|
|
||||||
let ap = img.get_pixel(add_x, y).0;
|
|
||||||
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
|
||||||
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
|
|
||||||
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
|
||||||
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sliding_vertical(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|
||||||
let (w, h) = img.dimensions();
|
|
||||||
let r = radius as i32;
|
|
||||||
let diam = (2 * r + 1) as u32;
|
|
||||||
let mut out = RgbaImage::new(w, h);
|
|
||||||
|
|
||||||
for x in 0..w {
|
|
||||||
let mut sr = 0u32;
|
|
||||||
let mut sg = 0u32;
|
|
||||||
let mut sb = 0u32;
|
|
||||||
let mut sa = 0u32;
|
|
||||||
|
|
||||||
for dy in -r..=r {
|
|
||||||
let sy = dy.clamp(0, h as i32 - 1) as u32;
|
|
||||||
let p = img.get_pixel(x, sy).0;
|
|
||||||
sr += p[0] as u32;
|
|
||||||
sg += p[1] as u32;
|
|
||||||
sb += p[2] as u32;
|
|
||||||
sa += p[3] as u32;
|
|
||||||
}
|
|
||||||
|
|
||||||
for y in 0..h {
|
|
||||||
out.put_pixel(
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
image::Rgba([
|
|
||||||
(sr / diam) as u8,
|
|
||||||
(sg / diam) as u8,
|
|
||||||
(sb / diam) as u8,
|
|
||||||
(sa / diam) as u8,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
let remove_y = (y as i32 - r).clamp(0, h as i32 - 1) as u32;
|
|
||||||
let add_y = (y as i32 + r + 1).clamp(0, h as i32 - 1) as u32;
|
|
||||||
let rp = img.get_pixel(x, remove_y).0;
|
|
||||||
let ap = img.get_pixel(x, add_y).0;
|
|
||||||
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
|
||||||
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
|
|
||||||
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
|
||||||
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
-195
@@ -1,211 +1,177 @@
|
|||||||
mod config;
|
#![windows_subsystem = "windows"]
|
||||||
mod effects;
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
mod caret;
|
||||||
use std::io::Write as _;
|
mod hook;
|
||||||
use std::process::{Command, Stdio};
|
mod magnifier;
|
||||||
|
mod state;
|
||||||
|
mod tray;
|
||||||
|
|
||||||
/// CLI overrides that map 1:1 to `EffectsConfig` fields.
|
use state::AppState;
|
||||||
/// All fields are `Option<T>` so we can tell "not supplied" from any concrete value.
|
use windows::core::w;
|
||||||
#[derive(Default)]
|
use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
|
||||||
struct CliOverrides {
|
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED};
|
||||||
rounded_corners: Option<bool>,
|
use windows::Win32::UI::HiDpi::{
|
||||||
corner_radius: Option<f32>,
|
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||||
drop_shadow: Option<bool>,
|
};
|
||||||
shadow_blur_radius: Option<f32>,
|
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||||
shadow_offset_x: Option<f32>,
|
use windows::Win32::UI::WindowsAndMessaging::WINDOW_EX_STYLE;
|
||||||
shadow_offset_y: Option<f32>,
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
/// Accepted as four comma-separated u8 values, e.g. `255,0,0,200`
|
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, PostQuitMessage,
|
||||||
shadow_color: Option<[u8; 4]>,
|
RegisterClassExW, SetTimer, TranslateMessage, CS_HREDRAW, CS_VREDRAW, MSG, WM_DESTROY,
|
||||||
}
|
WM_TIMER, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
|
||||||
|
};
|
||||||
|
|
||||||
fn parse_bool(s: &str) -> Result<bool> {
|
static mut APP_STATE: Option<AppState> = None;
|
||||||
match s.to_lowercase().as_str() {
|
static mut MAGNIFIER: Option<magnifier::Magnifier> = None;
|
||||||
"true" | "1" | "yes" => Ok(true),
|
|
||||||
"false" | "0" | "no" => Ok(false),
|
|
||||||
other => bail!("Expected a boolean (true/false), got '{other}'"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_shadow_color(s: &str) -> Result<[u8; 4]> {
|
const TIMER_ID: usize = 1;
|
||||||
let parts: Vec<&str> = s.split(',').collect();
|
const TIMER_MS: u32 = 16; // ~60 fps
|
||||||
if parts.len() != 4 {
|
|
||||||
bail!("--shadow_color expects four comma-separated u8 values, e.g. 255,0,0,200");
|
|
||||||
}
|
|
||||||
let r = parts[0]
|
|
||||||
.trim()
|
|
||||||
.parse::<u8>()
|
|
||||||
.context("shadow_color red channel")?;
|
|
||||||
let g = parts[1]
|
|
||||||
.trim()
|
|
||||||
.parse::<u8>()
|
|
||||||
.context("shadow_color green channel")?;
|
|
||||||
let b = parts[2]
|
|
||||||
.trim()
|
|
||||||
.parse::<u8>()
|
|
||||||
.context("shadow_color blue channel")?;
|
|
||||||
let a = parts[3]
|
|
||||||
.trim()
|
|
||||||
.parse::<u8>()
|
|
||||||
.context("shadow_color alpha channel")?;
|
|
||||||
Ok([r, g, b, a])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
unsafe {
|
||||||
|
// DPI awareness — must be set before any window is created.
|
||||||
|
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||||
|
|
||||||
let mut image_path: Option<String> = None;
|
// COM initialisation needed for UI Automation (caret tracking).
|
||||||
let mut overrides = CliOverrides::default();
|
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||||
|
APP_STATE = Some(AppState::new());
|
||||||
|
|
||||||
let mut i = 0;
|
let instance = get_instance();
|
||||||
while i < args.len() {
|
register_msg_class(instance);
|
||||||
match args[i].as_str() {
|
|
||||||
"--image" => {
|
|
||||||
i += 1;
|
|
||||||
image_path = Some(
|
|
||||||
args.get(i)
|
|
||||||
.cloned()
|
|
||||||
.context("Expected a path after --image")?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--rounded_corners" => {
|
|
||||||
i += 1;
|
|
||||||
let val = args
|
|
||||||
.get(i)
|
|
||||||
.context("Expected true/false after --rounded_corners")?;
|
|
||||||
overrides.rounded_corners = Some(parse_bool(val)?);
|
|
||||||
}
|
|
||||||
"--corner_radius" => {
|
|
||||||
i += 1;
|
|
||||||
let val = args
|
|
||||||
.get(i)
|
|
||||||
.context("Expected a number after --corner_radius")?;
|
|
||||||
overrides.corner_radius = Some(
|
|
||||||
val.parse::<f32>()
|
|
||||||
.context("--corner_radius must be a number")?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--drop_shadow" => {
|
|
||||||
i += 1;
|
|
||||||
let val = args
|
|
||||||
.get(i)
|
|
||||||
.context("Expected true/false after --drop_shadow")?;
|
|
||||||
overrides.drop_shadow = Some(parse_bool(val)?);
|
|
||||||
}
|
|
||||||
"--shadow_blur_radius" => {
|
|
||||||
i += 1;
|
|
||||||
let val = args
|
|
||||||
.get(i)
|
|
||||||
.context("Expected a number after --shadow_blur_radius")?;
|
|
||||||
overrides.shadow_blur_radius = Some(
|
|
||||||
val.parse::<f32>()
|
|
||||||
.context("--shadow_blur_radius must be a number")?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--shadow_offset_x" => {
|
|
||||||
i += 1;
|
|
||||||
let val = args
|
|
||||||
.get(i)
|
|
||||||
.context("Expected a number after --shadow_offset_x")?;
|
|
||||||
overrides.shadow_offset_x = Some(
|
|
||||||
val.parse::<f32>()
|
|
||||||
.context("--shadow_offset_x must be a number")?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--shadow_offset_y" => {
|
|
||||||
i += 1;
|
|
||||||
let val = args
|
|
||||||
.get(i)
|
|
||||||
.context("Expected a number after --shadow_offset_y")?;
|
|
||||||
overrides.shadow_offset_y = Some(
|
|
||||||
val.parse::<f32>()
|
|
||||||
.context("--shadow_offset_y must be a number")?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--shadow_color" => {
|
|
||||||
i += 1;
|
|
||||||
let val = args
|
|
||||||
.get(i)
|
|
||||||
.context("Expected r,g,b,a after --shadow_color")?;
|
|
||||||
overrides.shadow_color = Some(parse_shadow_color(val)?);
|
|
||||||
}
|
|
||||||
unknown => bail!("Unknown argument: {unknown}"),
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let image_path = image_path.context("Missing --image <path>")?;
|
// Invisible message-only window to receive WM_APP zoom messages and WM_TIMER.
|
||||||
|
let msg_hwnd = CreateWindowExW(
|
||||||
let config = config::Config::load().context("Failed to load config")?;
|
WINDOW_EX_STYLE(0),
|
||||||
|
w!("IwakuMsg"),
|
||||||
let mut effects = config.screenshot;
|
w!(""),
|
||||||
if effects.mode == "auto" {
|
WS_OVERLAPPEDWINDOW,
|
||||||
if let Some(v) = overrides.rounded_corners {
|
0,
|
||||||
effects.rounded_corners = v;
|
0,
|
||||||
}
|
0,
|
||||||
if let Some(v) = overrides.corner_radius {
|
0,
|
||||||
effects.corner_radius = v;
|
None,
|
||||||
}
|
None,
|
||||||
if let Some(v) = overrides.drop_shadow {
|
instance,
|
||||||
effects.drop_shadow = v;
|
None,
|
||||||
}
|
|
||||||
if let Some(v) = overrides.shadow_blur_radius {
|
|
||||||
effects.shadow_blur_radius = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = overrides.shadow_offset_x {
|
|
||||||
effects.shadow_offset_x = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = overrides.shadow_offset_y {
|
|
||||||
effects.shadow_offset_y = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = overrides.shadow_color {
|
|
||||||
effects.shadow_color = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = process_image(&image_path, &effects) {
|
|
||||||
eprintln!("Error processing '{}': {e:#}", image_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_image(path: &str, effects: &config::EffectsConfig) -> Result<()> {
|
|
||||||
let img = image::open(path)
|
|
||||||
.with_context(|| format!("Failed to open image '{path}'"))?
|
|
||||||
.into_rgba8();
|
|
||||||
|
|
||||||
let processed = effects::apply_effects(img, effects);
|
|
||||||
|
|
||||||
let mut png_bytes: Vec<u8> = Vec::new();
|
|
||||||
image::DynamicImage::ImageRgba8(processed)
|
|
||||||
.write_to(
|
|
||||||
&mut std::io::Cursor::new(&mut png_bytes),
|
|
||||||
image::ImageFormat::Png,
|
|
||||||
)
|
)
|
||||||
.context("Failed to encode processed image as PNG")?;
|
.expect("Failed to create message window");
|
||||||
|
|
||||||
let mut child = Command::new("swappy")
|
// Global Win+Scroll hook.
|
||||||
.args(["-f", "-"])
|
hook::install(msg_hwnd);
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.context("Failed to spawn swappy. Is it installed and in PATH?")?;
|
|
||||||
|
|
||||||
child
|
// Fullscreen magnifier — no overlay window needed with MagSetFullscreenTransform.
|
||||||
.stdin
|
MAGNIFIER = Some(magnifier::create());
|
||||||
.take()
|
|
||||||
.context("Failed to get swappy stdin")?
|
|
||||||
.write_all(&png_bytes)
|
|
||||||
.context("Failed to write image data to swappy")?;
|
|
||||||
|
|
||||||
let status = child.wait().context("Failed to wait for swappy")?;
|
// System tray icon.
|
||||||
|
let tray = tray::Tray::new();
|
||||||
|
|
||||||
if !status.success() {
|
// 16 ms refresh timer.
|
||||||
eprintln!(
|
SetTimer(msg_hwnd, TIMER_ID, TIMER_MS, None);
|
||||||
"swappy exited with non-zero status for '{}': {}",
|
|
||||||
path, status
|
// Message loop.
|
||||||
);
|
let mut msg = MSG::default();
|
||||||
|
loop {
|
||||||
|
if tray.process_events() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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