1 Commits

11 changed files with 1760 additions and 1206 deletions
Generated
+1024 -672
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -8,11 +8,20 @@ name = "iwaku"
path = "src/main.rs"
[dependencies]
image = { version = "0.25", features = ["png"] }
tiny-skia = "0.11"
serde = { version = "1", features = ["derive"] }
anyhow = "1"
serde_json = "1.0.149"
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
+1
View File
@@ -0,0 +1 @@
/usr/bin/bash: line 1: del: command not found
+121
View File
@@ -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,
})
}
}
-45
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
},
}
}
+76
View File
@@ -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
View File
@@ -1,211 +1,177 @@
mod config;
mod effects;
#![windows_subsystem = "windows"]
use anyhow::{bail, Context, Result};
use std::io::Write as _;
use std::process::{Command, Stdio};
mod caret;
mod hook;
mod magnifier;
mod state;
mod tray;
/// CLI overrides that map 1:1 to `EffectsConfig` fields.
/// All fields are `Option<T>` so we can tell "not supplied" from any concrete value.
#[derive(Default)]
struct CliOverrides {
rounded_corners: Option<bool>,
corner_radius: Option<f32>,
drop_shadow: Option<bool>,
shadow_blur_radius: Option<f32>,
shadow_offset_x: Option<f32>,
shadow_offset_y: Option<f32>,
/// Accepted as four comma-separated u8 values, e.g. `255,0,0,200`
shadow_color: Option<[u8; 4]>,
}
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,
};
fn parse_bool(s: &str) -> Result<bool> {
match s.to_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
other => bail!("Expected a boolean (true/false), got '{other}'"),
}
}
static mut APP_STATE: Option<AppState> = None;
static mut MAGNIFIER: Option<magnifier::Magnifier> = None;
fn parse_shadow_color(s: &str) -> Result<[u8; 4]> {
let parts: Vec<&str> = s.split(',').collect();
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])
}
const TIMER_ID: usize = 1;
const TIMER_MS: u32 = 16; // ~60 fps
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
fn main() {
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;
let mut overrides = CliOverrides::default();
// COM initialisation needed for UI Automation (caret tracking).
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
APP_STATE = Some(AppState::new());
let mut i = 0;
while i < args.len() {
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 instance = get_instance();
register_msg_class(instance);
let image_path = image_path.context("Missing --image <path>")?;
let config = config::Config::load().context("Failed to load config")?;
let mut effects = config.screenshot;
if effects.mode == "auto" {
if let Some(v) = overrides.rounded_corners {
effects.rounded_corners = v;
}
if let Some(v) = overrides.corner_radius {
effects.corner_radius = v;
}
if let Some(v) = overrides.drop_shadow {
effects.drop_shadow = v;
}
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,
// 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,
)
.context("Failed to encode processed image as PNG")?;
.expect("Failed to create message window");
let mut child = Command::new("swappy")
.args(["-f", "-"])
.stdin(Stdio::piped())
.spawn()
.context("Failed to spawn swappy. Is it installed and in PATH?")?;
// Global Win+Scroll hook.
hook::install(msg_hwnd);
child
.stdin
.take()
.context("Failed to get swappy stdin")?
.write_all(&png_bytes)
.context("Failed to write image data to swappy")?;
// Fullscreen magnifier — no overlay window needed with MagSetFullscreenTransform.
MAGNIFIER = Some(magnifier::create());
let status = child.wait().context("Failed to wait for swappy")?;
// System tray icon.
let tray = tray::Tray::new();
if !status.success() {
eprintln!(
"swappy exited with non-zero status for '{}': {}",
path, status
);
// 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;
}
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
View File
@@ -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
View File
@@ -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")
}