5 Commits

19 changed files with 1183 additions and 4937 deletions
-7
View File
@@ -9,10 +9,3 @@ target/
# MSVC Windows builds of rustc generate these, which store debugging information # MSVC Windows builds of rustc generate these, which store debugging information
*.pdb *.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
Generated
+445 -3231
View File
File diff suppressed because it is too large Load Diff
+20 -39
View File
@@ -1,55 +1,36 @@
[package] [package]
name = "rs-pictures" name = "iwaku"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
name = "rs-pictures" name = "iwaku"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
# Wayland-native screen capture windows = { version = "0.58", features = [
libwayshot = "0.7" "Win32_Foundation",
"Win32_Graphics_Gdi",
# GUI framework (Wayland only) "Win32_System_Com",
eframe = { version = "0.29", default-features = false, features = ["wayland", "wgpu"] } "Win32_System_LibraryLoader",
egui = "0.29" "Win32_System_Ole",
"Win32_UI_Accessibility",
# Image handling "Win32_UI_HiDpi",
image = { version = "0.25", features = ["png", "jpeg"] } "Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Magnification",
# 2D rendering for effects (rounded corners, drop shadow) "Win32_UI_Shell",
tiny-skia = "0.11" "Win32_UI_WindowsAndMessaging",
] }
# Clipboard (Wayland) tray-icon = "0.17"
arboard = { version = "3.6", features = ["wayland-data-control"] }
# Config serialization
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# Platform config/data directories
directories = "5"
# Error handling
anyhow = "1"
# Timestamp-based filenames
chrono = { version = "0.4", features = ["clock"] }
# ── Build profiles ────────────────────────────────────────────────────────────
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" # link-time optimisation across crates lto = "thin"
codegen-units = 1 # better inlining at the cost of compile time codegen-units = 1
strip = true # strip debug symbols → smaller binary strip = true
# Dev builds are slow for pixel-processing code. This gives opt-level 2
# to our own crate only while keeping dependencies at their default (opt=3
# they already compiled with), so incremental rebuilds stay fast.
[profile.dev] [profile.dev]
opt-level = 0 opt-level = 0
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 # all deps at full optimisation even in dev mode opt-level = 3
+1
View File
@@ -0,0 +1 @@
/usr/bin/bash: line 1: del: command not found
-53
View File
@@ -1,53 +0,0 @@
use anyhow::{Context, Result};
use image::RgbaImage;
use libwayshot::WayshotConnection;
/// A rectangular region on screen in physical pixels.
#[derive(Debug, Clone, Copy)]
pub struct Region {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
/// Captures the specified region in physical pixels.
pub fn capture_region(region: Region) -> Result<RgbaImage> {
let full = capture_all_outputs()?;
let px = (region.x.max(0) as u32).min(full.width().saturating_sub(1));
let py = (region.y.max(0) as u32).min(full.height().saturating_sub(1));
let pw = region.width.min(full.width() - px);
let ph = region.height.min(full.height() - py);
eprintln!(
"[capture] crop ({px},{py}) {pw}x{ph} from {}x{}",
full.width(), full.height(),
);
Ok(image::imageops::crop_imm(&full, px, py, pw, ph).to_image())
}
/// Captures all connected outputs stitched together into one image.
pub fn capture_all_outputs() -> Result<RgbaImage> {
let conn = WayshotConnection::new()
.context("Failed to connect to Wayland display")?;
let all = conn.get_all_outputs();
eprintln!("[capture] outputs: {:?}", all.iter().map(|o| &o.name).collect::<Vec<_>>());
let active_name = crate::hyprland::active_monitor_name();
let target_output = if let Some(ref name) = active_name {
all.iter().find(|o| &o.name == name).unwrap_or(&all[0])
} else {
&all[0]
};
let rgba = conn
.screenshot_single_output(target_output, false)
.context("libwayshot failed to capture output")?
.into_rgba8();
eprintln!("[capture] capture_all_outputs → {}x{}", rgba.width(), rgba.height());
Ok(rgba)
}
+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,
})
}
}
-163
View File
@@ -1,163 +0,0 @@
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Top-level application configuration.
/// Serialized to/from ~/.config/rs-pictures/config.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Directory where screenshots are saved.
pub save_directory: PathBuf,
/// File format for saved screenshots: "png" or "jpeg".
pub save_format: String,
/// Filename template. Supports strftime-style tokens via chrono.
/// Example: "screenshot_%Y-%m-%d_%H-%M-%S"
pub filename_template: String,
/// When true, automatically save to disk after capture without opening
/// the review window.
#[serde(default)]
pub auto_save: bool,
/// When true, automatically copy to the clipboard after capture without
/// opening the review window.
#[serde(default)]
pub auto_copy: bool,
/// Milliseconds to wait after launch before capturing the desktop snapshot.
/// Increase this if the overlay background still shows the terminal/launcher
/// that started rs-pictures. Default: 200.
#[serde(default = "default_capture_delay_ms")]
pub capture_delay_ms: u64,
/// If true, the selection overlay will be transparent (live preview) instead of
/// a frozen screenshot. The final capture happens after selection.
#[serde(default)]
pub live_mode: bool,
/// Visual effects applied after capture.
pub effects: EffectsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EffectsConfig {
/// Apply rounded corners to the screenshot.
pub rounded_corners: bool,
/// Radius in pixels for rounded corners.
pub corner_radius: f32,
/// Apply a drop shadow beneath the screenshot.
pub drop_shadow: bool,
/// Blur radius for the drop shadow (higher = softer shadow).
pub shadow_blur_radius: f32,
/// Horizontal offset of the shadow in pixels.
pub shadow_offset_x: f32,
/// Vertical offset of the shadow in pixels.
pub shadow_offset_y: f32,
/// Shadow color as [R, G, B, A] in 0..=255.
pub shadow_color: [u8; 4],
}
impl Default for EffectsConfig {
fn default() -> Self {
Self {
rounded_corners: false,
corner_radius: 12.0,
drop_shadow: false,
shadow_blur_radius: 20.0,
shadow_offset_x: 5.0,
shadow_offset_y: 8.0,
shadow_color: [0, 0, 0, 160],
}
}
}
impl Default for Config {
fn default() -> Self {
let save_directory = dirs_default_pictures().unwrap_or_else(|| PathBuf::from("."));
Self {
save_directory,
save_format: "png".into(),
filename_template: "screenshot_%Y-%m-%d_%H-%M-%S".into(),
auto_save: false,
auto_copy: false,
capture_delay_ms: default_capture_delay_ms(),
live_mode: false,
effects: EffectsConfig::default(),
}
}
}
impl Config {
/// Returns the path to the config file, creating parent directories if needed.
pub fn config_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "rs-pictures")
.map(|pd| pd.config_dir().join("config.toml"))
}
/// Load config from disk, or return the default config if the file doesn't exist.
pub fn load() -> Result<Self> {
let path = match Self::config_path() {
Some(p) => p,
None => return Ok(Self::default()),
};
if !path.exists() {
let config = Self::default();
config.save()?;
return Ok(config);
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config at {}", path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("Failed to parse config at {}", path.display()))
}
/// Persist the current config to disk.
pub fn save(&self) -> Result<()> {
let path = Self::config_path()
.context("Could not determine config directory")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config dir {}", parent.display()))?;
}
let serialized = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
std::fs::write(&path, serialized)
.with_context(|| format!("Failed to write config to {}", path.display()))?;
Ok(())
}
/// Build the full output path for a new screenshot using chrono formatting.
pub fn output_path(&self) -> PathBuf {
let now = chrono::Local::now();
let filename = now.format(&self.filename_template).to_string();
let ext = &self.save_format;
self.save_directory.join(format!("{filename}.{ext}"))
}
}
fn default_capture_delay_ms() -> u64 { 200 }
fn dirs_default_pictures() -> Option<PathBuf> {
// Use XDG_PICTURES_DIR if available, otherwise ~/Pictures
if let Ok(val) = std::env::var("XDG_PICTURES_DIR") {
return Some(PathBuf::from(val));
}
directories::UserDirs::new()
.and_then(|ud| ud.picture_dir().map(|p| p.to_path_buf()))
}
-293
View File
@@ -1,293 +0,0 @@
//! Post-capture image effects: rounded corners and drop shadow.
//!
//! Pipeline:
//! RgbaImage (captured)
//! → apply_rounded_corners() clips corners to transparent via tiny-skia mask
//! → apply_drop_shadow() composites a blurred shadow beneath the image
//! → final RgbaImage (may be larger when shadow is added)
//!
//! Performance notes:
//! - Box blur uses a sliding-window algorithm: O(W*H) regardless of radius.
//! Three passes of the box filter approximate a Gaussian.
//! - Pixel format conversions between RgbaImage and tiny-skia Pixmap are done
//! with a single pass each way.
//! - This module is called from a background thread in review.rs so the UI
//! never blocks.
use crate::config::EffectsConfig;
use image::RgbaImage;
use tiny_skia::{
BlendMode, Color, FillRule, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Transform,
};
/// Apply all configured effects in order. Returns a new image.
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
}
}
// ─── Rounded corners ─────────────────────────────────────────────────────────
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)
}
// ─── Drop shadow ─────────────────────────────────────────────────────────────
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;
// 1. Place the image silhouette at the shadow position.
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);
// 2. Tint the silhouette with the shadow colour.
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
// 3. Blur the shadow (sliding-window box blur, 3 passes).
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);
// 4. Composite: shadow first, image on top.
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)
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
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);
}
}
}
// ─── Sliding-window box blur (O(W*H) regardless of radius) ───────────────────
//
// Classic algorithm: maintain a running sum over a window of (2r+1) pixels.
// When the window slides by one pixel, subtract the pixel leaving the window
// and add the pixel entering it. Three passes (H→V→H or H→V→H) approximate
// a Gaussian kernel.
fn box_blur_rgba(img: &RgbaImage, radius: u32) -> RgbaImage {
if radius == 0 {
return img.clone();
}
// Three passes of H+V to approximate a Gaussian.
let mut buf = sliding_horizontal(img, radius);
buf = sliding_vertical(&buf, radius);
buf = sliding_horizontal(&buf, radius);
buf = sliding_vertical(&buf, radius);
buf
}
/// Horizontal sliding-window box blur, single pass.
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 {
// Accumulator for the current window.
let mut sr = 0u32;
let mut sg = 0u32;
let mut sb = 0u32;
let mut sa = 0u32;
// Seed the window around x=0.
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,
]));
// Slide: remove left edge, add right edge.
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
}
/// Vertical sliding-window box blur, single pass.
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,
},
},
}
}
-270
View File
@@ -1,270 +0,0 @@
//! Hyprland window geometry queries.
//!
//! Uses `hyprctl clients -j` and `hyprctl activeworkspace -j` to enumerate
//! windows on the active workspace. Returns logical pixel coordinates that
//! match the coordinate space used by libwayshot LogicalRegion.
//!
//! If `hyprctl` is not available (non-Hyprland compositor) the functions
//! return an empty list so the overlay degrades gracefully to manual
//! selection only.
/// A window's position and size in Wayland logical pixels.
#[derive(Debug, Clone)]
pub struct WindowRect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub title: String,
}
/// Returns the logical size (width, height) of the primary/active monitor.
/// Falls back to None if hyprctl is unavailable.
pub fn active_monitor_logical_size() -> Option<(u32, u32)> {
let info = active_monitor_info()?;
Some((info.0, info.1))
}
/// Returns the scale factor of the active monitor (e.g. 1.33 on HiDPI).
/// Falls back to 1.0 if hyprctl is unavailable.
pub fn active_monitor_scale() -> f32 {
active_monitor_info().map(|i| i.2).unwrap_or(1.0)
}
/// Returns the name of the active/focused monitor (e.g. "DP-1").
pub fn active_monitor_name() -> Option<String> {
let output = std::process::Command::new("hyprctl")
.args(["monitors", "-j"])
.output()
.ok()?;
if !output.status.success() { return None; }
let text = std::str::from_utf8(&output.stdout).ok()?;
for obj in split_objects(text) {
if json_bool(obj, "focused") == Some(true) {
return json_string(obj, "name");
}
}
None
}
/// Returns (logical_width, logical_height, scale) for the focused monitor.
fn active_monitor_info() -> Option<(u32, u32, f32)> {
let output = std::process::Command::new("hyprctl")
.args(["monitors", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = std::str::from_utf8(&output.stdout).ok()?;
// Find the focused monitor (focused: true) or fall back to the first.
for obj in split_objects(text) {
let focused = json_bool(obj, "focused");
if focused != Some(true) {
continue;
}
let w = json_i64(obj, "width")? as f32;
let h = json_i64(obj, "height")? as f32;
let scale = json_f32(obj, "scale").unwrap_or(1.0);
return Some(((w / scale).round() as u32, (h / scale).round() as u32, scale));
}
// No focused monitor found — take the first one.
let obj = split_objects(text).into_iter().next()?;
let w = json_i64(obj, "width")? as f32;
let h = json_i64(obj, "height")? as f32;
let scale = json_f32(obj, "scale").unwrap_or(1.0);
Some(((w / scale).round() as u32, (h / scale).round() as u32, scale))
}
/// Returns an empty Vec if hyprctl is unavailable or returns bad data.
pub fn active_workspace_windows() -> Vec<WindowRect> {
let workspace_id = match active_workspace_id() {
Some(id) => id,
None => return vec![],
};
let output = match std::process::Command::new("hyprctl")
.args(["clients", "-j"])
.output()
{
Ok(o) if o.status.success() => o.stdout,
_ => return vec![],
};
parse_clients(&output, workspace_id)
}
// ─── Private helpers ──────────────────────────────────────────────────────────
fn active_workspace_id() -> Option<i64> {
let output = std::process::Command::new("hyprctl")
.args(["activeworkspace", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
// Extract "id": <number> with a tiny hand-rolled parse — no serde dep.
let text = std::str::from_utf8(&output.stdout).ok()?;
json_i64(text, "id")
}
/// Parse the `hyprctl clients -j` JSON output without pulling in serde_json.
///
/// We only need four fields per client: `at`, `size`, `workspace.id`, `title`,
/// `mapped`, `hidden`. A minimal hand-rolled extractor is sufficient.
fn parse_clients(data: &[u8], workspace_id: i64) -> Vec<WindowRect> {
let text = match std::str::from_utf8(data) {
Ok(s) => s,
Err(_) => return vec![],
};
let mut result = Vec::new();
// Split on top-level `{` … `}` objects.
// The JSON is a flat array of objects with no nested arrays of objects,
// so a simple brace-depth scan is safe here.
for obj in split_objects(text) {
// Skip unmapped or hidden windows.
if json_bool(obj, "mapped") != Some(true) { continue; }
if json_bool(obj, "hidden") == Some(true) { continue; }
// Only windows on the active workspace.
if json_i64_nested(obj, "workspace", "id") != Some(workspace_id) { continue; }
let at = json_pair_i64(obj, "at");
let size = json_pair_i64(obj, "size");
let (Some((x, y)), Some((w, h))) = (at, size) else { continue };
if w <= 0 || h <= 0 { continue; }
let title = json_string(obj, "title").unwrap_or_default();
result.push(WindowRect { x, y, width: w, height: h, title });
}
result
}
// ─── Tiny JSON field extractors ───────────────────────────────────────────────
/// Split a JSON array text into individual object strings.
fn split_objects(text: &str) -> Vec<&str> {
let mut objects = Vec::new();
let bytes = text.as_bytes();
let mut depth = 0i32;
let mut start = None;
let mut in_string = false;
let mut escape = false;
for (i, &b) in bytes.iter().enumerate() {
if escape { escape = false; continue; }
if b == b'\\' && in_string { escape = true; continue; }
if b == b'"' { in_string = !in_string; continue; }
if in_string { continue; }
match b {
b'{' => {
if depth == 0 { start = Some(i); }
depth += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
if let Some(s) = start {
objects.push(&text[s..=i]);
}
start = None;
}
}
_ => {}
}
}
objects
}
/// Extract `"key": <integer>` from a JSON object string.
fn json_i64(text: &str, key: &str) -> Option<i64> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let end = after.find(|c: char| !c.is_ascii_digit() && c != '-').unwrap_or(after.len());
after[..end].parse().ok()
}
/// Extract `"key": <bool>` from a JSON object string.
fn json_bool(text: &str, key: &str) -> Option<bool> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
if after.starts_with("true") { return Some(true); }
if after.starts_with("false") { return Some(false); }
None
}
/// Extract `"key": "string value"` from a JSON object string.
fn json_string<'a>(text: &'a str, key: &str) -> Option<String> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let after = after.strip_prefix('"')?;
// Collect until unescaped closing quote.
let mut out = String::new();
let mut chars = after.chars();
loop {
match chars.next()? {
'\\' => { chars.next(); } // skip escaped char
'"' => break,
c => out.push(c),
}
}
Some(out)
}
/// Extract `"key": [a, b]` → (a, b) as i64 pair.
fn json_pair_i64(text: &str, key: &str) -> Option<(i32, i32)> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let after = after.strip_prefix('[')?;
let end = after.find(']')?;
let inner = &after[..end];
let mut parts = inner.split(',');
let a: i32 = parts.next()?.trim().parse().ok()?;
let b: i32 = parts.next()?.trim().parse().ok()?;
Some((a, b))
}
/// Extract `"outer": { "inner_key": <integer> }` — one level of nesting.
fn json_i64_nested(text: &str, outer: &str, inner_key: &str) -> Option<i64> {
let needle = format!("\"{}\"", outer);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let brace_start = after.find('{')?;
let brace_end = after.find('}')?;
let nested = &after[brace_start..=brace_end];
json_i64(nested, inner_key)
}
/// Extract `"key": <float>` from a JSON object string.
fn json_f32(text: &str, key: &str) -> Option<f32> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let end = after
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
.unwrap_or(after.len());
after[..end].parse().ok()
}
+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)
}
}
+168 -183
View File
@@ -1,192 +1,177 @@
//! rs-pictures — Wayland screenshot tool #![windows_subsystem = "windows"]
//!
//! Flow:
//! 1. Load config from ~/.config/rs-pictures/config.toml
//! 2. Sleep briefly so the user can switch away from the terminal that
//! launched us, and the compositor has time to repaint.
//! 3. Capture all outputs → frozen desktop snapshot for overlay background.
//! 4. Open fullscreen selection overlay (drag / two-click rubber-band).
//! 5. Sleep 120 ms so compositor repaints after overlay closes.
//! 6. Capture the selected region with libwayshot.
//! 7a. auto_save/auto_copy set → apply effects, act silently, exit.
//! 7b. Otherwise → open review window (effects applied interactively there).
mod capture; mod caret;
mod config; mod hook;
mod effects; mod magnifier;
mod hyprland; mod state;
mod overlay; mod tray;
mod review;
use anyhow::{Context, Result}; use state::AppState;
use arboard::{Clipboard, ImageData}; use windows::core::w;
use eframe::egui; use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
use overlay::{SelectionOverlay, SelectionResult}; use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED};
use review::ReviewWindow; use windows::Win32::UI::HiDpi::{
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
fn main() -> Result<()> { };
// ── 1. Load config & arguments ──────────────────────────────────────────── use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
let config = config::Config::load().context("Failed to load config")?; use windows::Win32::UI::WindowsAndMessaging::WINDOW_EX_STYLE;
let is_live_mode = config.live_mode || std::env::args().any(|a| a == "--live" || a == "-l"); use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, PostQuitMessage,
// ── 2. Query Hyprland metadata (no window open yet) ─────────────────────── RegisterClassExW, SetTimer, TranslateMessage, CS_HREDRAW, CS_VREDRAW, MSG, WM_DESTROY,
// Scale is only needed for window-rect conversion in the overlay. WM_TIMER, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
let scale = hyprland::active_monitor_scale();
let (lw, lh) = hyprland::active_monitor_logical_size().unwrap_or((1920, 1080));
// ── 3. Pre-capture delay ──────────────────────────────────────────────────
// Give the compositor time to unmap the terminal/launcher that started us
// and repaint the desktop before we freeze it.
// Configurable via capture_delay_ms in config.toml (default 800ms).
std::thread::sleep(std::time::Duration::from_millis(config.capture_delay_ms));
// ── 4. Capture full desktop BEFORE opening any window ────────────────────
// In freeze mode (default), we snapshot before the overlay.
// In live mode, we skip this and capture later.
let background_snapshot = if is_live_mode {
None
} else {
Some(capture::capture_all_outputs().context("Failed to capture desktop snapshot")?)
}; };
// ── 5. Run the selection overlay ───────────────────────────────────────── static mut APP_STATE: Option<AppState> = None;
let selection_result = { static mut MAGNIFIER: Option<magnifier::Magnifier> = None;
use std::sync::{Arc, Mutex};
let shared: Arc<Mutex<Option<SelectionResult>>> = Arc::new(Mutex::new(None));
let shared_clone = Arc::clone(&shared);
let native_options = eframe::NativeOptions { const TIMER_ID: usize = 1;
viewport: egui::ViewportBuilder::default() const TIMER_MS: u32 = 16; // ~60 fps
.with_app_id("rs-pictures-overlay")
.with_inner_size([lw as f32, lh as f32]) fn main() {
.with_position([0.0, 0.0]) unsafe {
.with_fullscreen(true) // DPI awareness — must be set before any window is created.
.with_maximized(true) let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
.with_decorations(false)
.with_transparent(true) // COM initialisation needed for UI Automation (caret tracking).
.with_always_on_top() let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
.with_resizable(false), 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() ..Default::default()
}; };
RegisterClassExW(&wc);
let bg_clone = background_snapshot.clone();
eframe::run_native(
"rs-pictures — Select Region",
native_options,
Box::new(move |cc| {
let app = SelectionOverlay::new(cc, bg_clone, scale);
Ok(Box::new(OverlayWrapper {
inner: app,
result_sink: shared_clone,
}) as Box<dyn eframe::App>)
}),
)
.map_err(|e| anyhow::anyhow!("Overlay window error: {e}"))?;
Arc::try_unwrap(shared)
.ok()
.and_then(|m| m.into_inner().ok())
.flatten()
};
// ── 6. Act on the selection ───────────────────────────────────────────────
let region = match selection_result {
Some(SelectionResult::Selected(r)) => r,
Some(SelectionResult::Cancelled) | None => {
eprintln!("Selection cancelled.");
return Ok(());
}
};
// ── 7. Get the raw region image ───────────────────────────────────────────
let raw_image = if let Some(bg) = background_snapshot {
// Freeze mode: Crop directly from the pre-captured snapshot.
let px = (region.x.max(0) as u32).min(bg.width().saturating_sub(1));
let py = (region.y.max(0) as u32).min(bg.height().saturating_sub(1));
let pw = region.width.min(bg.width() - px);
let ph = region.height.min(bg.height() - py);
image::imageops::crop_imm(&bg, px, py, pw, ph).to_image()
} else {
// Live mode: Wait for the compositor to clear the overlay, then capture just the region.
std::thread::sleep(std::time::Duration::from_millis(config.capture_delay_ms.max(200)));
capture::capture_region(region).context("Failed to capture region")?
};
// ── 8a. Auto-mode — no review window ─────────────────────────────────────
if config.auto_save || config.auto_copy {
let final_image = effects::apply_effects(raw_image, &config.effects);
if config.auto_save {
let path = config.output_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Cannot create dir {}", parent.display()))?;
}
final_image.save(&path)
.with_context(|| format!("Failed to save to {}", path.display()))?;
eprintln!("Saved to {}", path.display());
}
if config.auto_copy {
clipboard_copy(&final_image)?;
eprintln!("Copied to clipboard.");
}
return Ok(());
}
// ── 8b. Review window ─────────────────────────────────────────────────────
// The review window applies effects internally on a background thread, so
// we hand it the raw (un-effected) image.
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("rs-pictures — Review")
.with_inner_size([900.0, 700.0])
.with_min_inner_size([400.0, 300.0]),
..Default::default()
};
eframe::run_native(
"rs-pictures — Review",
native_options,
Box::new(move |cc| {
Ok(Box::new(ReviewWindow::new(cc, raw_image, config)) as Box<dyn eframe::App>)
}),
)
.map_err(|e| anyhow::anyhow!("Review window error: {e}"))?;
Ok(())
}
// ─── Clipboard helper (shared with review.rs logic) ───────────────────────────
pub fn clipboard_copy(img: &image::RgbaImage) -> Result<()> {
let (w, h) = img.dimensions();
let bytes = img.as_raw().clone();
let mut cb = Clipboard::new().context("Could not open clipboard")?;
cb.set_image(ImageData {
width: w as usize,
height: h as usize,
bytes: bytes.into(),
})
.context("Failed to write image to clipboard")
}
// ─── Overlay wrapper ──────────────────────────────────────────────────────────
use std::sync::{Arc, Mutex};
struct OverlayWrapper {
inner: SelectionOverlay,
result_sink: Arc<Mutex<Option<SelectionResult>>>,
}
impl eframe::App for OverlayWrapper {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.inner.update(ctx, frame);
if let Some(result) = self.inner.take_result() {
*self.result_sink.lock().unwrap() = Some(result);
}
}
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
self.inner.clear_color(visuals)
} }
} }
-359
View File
@@ -1,359 +0,0 @@
//! Region selection overlay.
//!
//! Input modes (all available simultaneously):
//! - Window pick: hover over a window → it highlights; single click captures it.
//! - Drag: press and drag → rubber-band selection (overrides window pick).
//! - Two-click: click once (anchor), move, click again → selection.
//!
//! Coordinate system:
//! egui screen_rect == physical pixels of the monitor.
//! capture::Region also uses physical pixels.
//! Window rects from hyprctl are logical pixels — multiplied by scale to get physical.
//! No scale division anywhere: Region handed to capture.rs is in physical pixels,
//! and capture.rs crops from the physical full-screen capture directly.
use eframe::egui::{self, Color32, CursorIcon, Pos2, Rect, Rounding, Stroke, Vec2};
use image::RgbaImage;
use crate::capture::Region;
use crate::hyprland::WindowRect;
/// Result returned when the user completes or cancels the selection.
#[derive(Debug)]
pub enum SelectionResult {
Selected(Region),
Cancelled,
}
/// Input state machine.
#[derive(Default)]
enum SelectionState {
#[default]
Idle,
Dragging { start: Pos2 },
AwaitingSecondClick { start: Pos2 },
Done { start: Pos2, end: Pos2 },
Cancelled,
}
const MAX_TEX: u32 = 8192;
pub struct SelectionOverlay {
background: Option<egui::TextureHandle>,
/// Hyprland monitor scale (logical→physical). Used only for window rect conversion.
scale: f32,
state: SelectionState,
result: Option<SelectionResult>,
windows: Vec<WindowRect>,
hovered_window: Option<usize>,
diag_printed: bool,
}
impl SelectionOverlay {
pub fn new(cc: &eframe::CreationContext<'_>, background_snapshot: Option<RgbaImage>, scale: f32) -> Self {
let texture = background_snapshot.map(|img| {
let tex_image = fit_to_max_texture(img);
let (tw, th) = tex_image.dimensions();
let color_image = egui::ColorImage::from_rgba_unmultiplied(
[tw as usize, th as usize],
tex_image.as_raw(),
);
cc.egui_ctx
.load_texture("background", color_image, egui::TextureOptions::LINEAR)
});
let windows = crate::hyprland::active_workspace_windows();
Self {
background: texture,
scale,
state: SelectionState::default(),
result: None,
windows,
hovered_window: None,
diag_printed: false,
}
}
pub fn take_result(&mut self) -> Option<SelectionResult> {
self.result.take()
}
/// Convert a hyprctl WindowRect (logical px) to an egui Rect (logical points).
fn window_to_egui_rect(win: &WindowRect, _scale: f32) -> Rect {
Rect::from_min_size(
Pos2::new(win.x as f32, win.y as f32),
Vec2::new(win.width as f32, win.height as f32),
)
}
}
impl eframe::App for SelectionOverlay {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.state = SelectionState::Cancelled;
}
let screen_rect = ctx.screen_rect();
if !self.diag_printed {
self.diag_printed = true;
eprintln!(
"[overlay diag] screen_rect={screen_rect:?} ppp={} scale={}",
ctx.pixels_per_point(),
self.scale,
);
}
let dim = Color32::from_black_alpha(140);
const DRAG_THRESHOLD: f32 = 4.0;
let (hover_pos, press_origin, primary_down, primary_released) =
ctx.input(|i| {
(
i.pointer.hover_pos(),
i.pointer.press_origin(),
i.pointer.primary_down(),
i.pointer.primary_released(),
)
});
let travel: f32 = match (press_origin, hover_pos) {
(Some(o), Some(p)) => o.distance(p),
_ => 0.0,
};
let is_click = primary_released && travel <= DRAG_THRESHOLD;
let is_drag = primary_down && travel > DRAG_THRESHOLD;
// ── Window hover detection ────────────────────────────────────────────
self.hovered_window = None;
if matches!(self.state, SelectionState::Idle) {
if let Some(pos) = hover_pos {
for (i, win) in self.windows.iter().enumerate().rev() {
if Self::window_to_egui_rect(win, self.scale).contains(pos) {
self.hovered_window = Some(i);
break;
}
}
}
}
// ── State transitions ─────────────────────────────────────────────────
match &self.state {
SelectionState::Idle => {
if is_drag {
self.hovered_window = None;
self.state = SelectionState::Dragging {
start: press_origin.unwrap_or_default(),
};
} else if is_click {
if let Some(idx) = self.hovered_window {
let win = &self.windows[idx];
let rect = Self::window_to_egui_rect(win, self.scale).intersect(screen_rect);
self.state = SelectionState::Done {
start: rect.min,
end: rect.max,
};
} else {
self.state = SelectionState::AwaitingSecondClick {
start: press_origin.unwrap_or_default(),
};
}
}
}
SelectionState::Dragging { start } => {
let start = *start;
if primary_released {
if let Some(end) = hover_pos {
self.state = SelectionState::Done { start, end };
} else {
self.state = SelectionState::Idle;
}
}
if !primary_down && !primary_released {
self.state = SelectionState::AwaitingSecondClick { start };
}
}
SelectionState::AwaitingSecondClick { start } => {
let start = *start;
if is_click {
if let Some(end) = hover_pos {
self.state = SelectionState::Done { start, end };
}
}
}
SelectionState::Done { .. } | SelectionState::Cancelled => {}
}
// ── Current live rect ─────────────────────────────────────────────────
let current_rect: Option<Rect> = match &self.state {
SelectionState::Dragging { start } => hover_pos.map(|p| Rect::from_two_pos(*start, p)),
SelectionState::AwaitingSecondClick { start } => hover_pos.map(|p| Rect::from_two_pos(*start, p)),
SelectionState::Done { start, end } => Some(Rect::from_two_pos(*start, *end)),
_ => None,
};
// ── Draw ──────────────────────────────────────────────────────────────
egui::CentralPanel::default()
.frame(egui::Frame::none())
.show(ctx, |ui| {
let painter = ui.painter();
// 1. Background screenshot (if in freeze mode).
if let Some(bg) = &self.background {
painter.image(
bg.id(),
screen_rect,
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
}
// 2. Dim overlay.
let active_rect = current_rect.or_else(|| {
self.hovered_window
.map(|i| Self::window_to_egui_rect(&self.windows[i], self.scale).intersect(screen_rect))
});
if let Some(sel) = active_rect {
let s = sel.intersect(screen_rect);
painter.rect_filled(
Rect::from_min_max(screen_rect.min, Pos2::new(screen_rect.max.x, s.min.y)),
Rounding::ZERO, dim,
);
painter.rect_filled(
Rect::from_min_max(Pos2::new(screen_rect.min.x, s.max.y), screen_rect.max),
Rounding::ZERO, dim,
);
painter.rect_filled(
Rect::from_min_max(
Pos2::new(screen_rect.min.x, s.min.y),
Pos2::new(s.min.x, s.max.y),
),
Rounding::ZERO, dim,
);
painter.rect_filled(
Rect::from_min_max(
Pos2::new(s.max.x, s.min.y),
Pos2::new(screen_rect.max.x, s.max.y),
),
Rounding::ZERO, dim,
);
// Only draw a stroke if we are actively dragging a selection.
if current_rect.is_some() {
painter.rect_stroke(s, Rounding::ZERO, Stroke::new(1.5, Color32::from_rgb(100, 180, 255)));
}
// Size label in physical pixels.
let label = format!("{} × {}", s.width().round() as u32, s.height().round() as u32);
let lp = Pos2::new(s.min.x + 4.0, s.min.y - 18.0)
.clamp(Pos2::ZERO, screen_rect.max);
painter.text(lp, egui::Align2::LEFT_TOP, label,
egui::FontId::monospace(13.0), Color32::WHITE);
if let Some(idx) = self.hovered_window {
if current_rect.is_none() {
let title = &self.windows[idx].title;
if !title.is_empty() {
painter.text(
Pos2::new(s.min.x + 4.0, s.min.y + 4.0),
egui::Align2::LEFT_TOP,
title,
egui::FontId::proportional(12.0),
Color32::from_rgba_unmultiplied(255, 190, 50, 220),
);
}
}
}
} else {
painter.rect_filled(screen_rect, Rounding::ZERO, dim);
}
// 3. Crosshair.
if let Some(pos) = hover_pos {
let s = Stroke::new(1.0, Color32::from_white_alpha(180));
painter.line_segment(
[Pos2::new(screen_rect.min.x, pos.y), Pos2::new(screen_rect.max.x, pos.y)], s);
painter.line_segment(
[Pos2::new(pos.x, screen_rect.min.y), Pos2::new(pos.x, screen_rect.max.y)], s);
}
// 4. Hint text.
let hint = match &self.state {
SelectionState::Idle if self.hovered_window.is_some() =>
"Click to capture window | Drag for custom selection | Esc to cancel",
SelectionState::Idle =>
"Click or drag to select | Esc to cancel",
SelectionState::Dragging { .. } => "Release to capture",
SelectionState::AwaitingSecondClick { .. } =>
"Click to set the second corner | Esc to cancel",
_ => "",
};
if !hint.is_empty() {
painter.text(
Pos2::new(screen_rect.center().x, screen_rect.max.y - 28.0),
egui::Align2::CENTER_BOTTOM,
hint,
egui::FontId::proportional(14.0),
Color32::from_white_alpha(200),
);
}
});
// ── Resolve ───────────────────────────────────────────────────────────
match &self.state {
SelectionState::Done { start, end } => {
let rect = Rect::from_two_pos(*start, *end).intersect(screen_rect);
if rect.width() > 2.0 && rect.height() > 2.0 {
let ppp = ctx.pixels_per_point();
// egui coords are logical points — scale to physical pixels.
let x = ((rect.min.x - screen_rect.min.x) * ppp).round() as i32;
let y = ((rect.min.y - screen_rect.min.y) * ppp).round() as i32;
let width = (rect.width() * ppp).round() as u32;
let height = (rect.height() * ppp).round() as u32;
eprintln!("[overlay] physical x={x} y={y} w={width} h={height}");
self.result = Some(SelectionResult::Selected(Region {
x: x.max(0),
y: y.max(0),
width,
height,
}));
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else {
self.state = SelectionState::Idle;
}
}
SelectionState::Cancelled => {
self.result = Some(SelectionResult::Cancelled);
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
_ => {}
}
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
[0.0, 0.0, 0.0, 0.0]
}
}
fn fit_to_max_texture(img: RgbaImage) -> RgbaImage {
let (w, h) = img.dimensions();
if w <= MAX_TEX && h <= MAX_TEX {
return img;
}
let scale = (MAX_TEX as f32 / w as f32).min(MAX_TEX as f32 / h as f32);
image::imageops::resize(
&img,
(w as f32 * scale) as u32,
(h as f32 * scale) as u32,
image::imageops::FilterType::Triangle,
)
}
-345
View File
@@ -1,345 +0,0 @@
//! After-capture review window.
//!
//! Performance design:
//! - Effects (rounded corners, drop shadow) run on a background thread so the
//! UI never blocks. A `Receiver` is polled each frame; when the result
//! arrives the texture is swapped out.
//! - A debounce timer (`dirty_since`) ensures we only spawn a new worker 150 ms
//! after the last slider change, not on every incremental drag tick.
//! - The raw image is wrapped in Arc so it is shared with worker threads
//! without cloning the pixel data.
//! - Texture uploads are guarded by MAX_TEX so we never panic on large images.
use std::path::PathBuf;
use std::sync::{Arc, mpsc};
use std::time::{Duration, Instant};
use arboard::{Clipboard, ImageData};
use eframe::egui::{self, Color32, ColorImage, Rounding, ScrollArea, Stroke, TextureHandle, TextureOptions, Vec2};
use image::RgbaImage;
use crate::{config::Config, effects::apply_effects};
const MAX_TEX: u32 = 8192;
/// How long to wait after the last setting change before spawning the worker.
const DEBOUNCE: Duration = Duration::from_millis(150);
#[derive(Debug)]
pub enum ReviewAction {
#[allow(dead_code)] // path stored for future use (e.g. desktop notification)
Saved(PathBuf),
Copied,
Discarded,
}
/// Channel message from the background effects worker.
struct EffectsResult(RgbaImage);
pub struct ReviewWindow {
/// Full-resolution raw capture — shared with worker threads via Arc.
raw_image: Arc<RgbaImage>,
/// Last fully-processed preview (what gets saved/copied).
preview_image: Arc<RgbaImage>,
/// GPU texture (may be downscaled for display).
preview_texture: TextureHandle,
pub config: Config,
save_as_path: String,
status_message: Option<String>,
pub action: Option<ReviewAction>,
settings_open: bool,
/// Set when settings change; cleared when a worker is spawned.
dirty_since: Option<Instant>,
/// Receives the processed image from the background worker.
worker_rx: Option<mpsc::Receiver<EffectsResult>>,
/// True while a worker is running.
worker_running: bool,
}
impl ReviewWindow {
pub fn new(cc: &eframe::CreationContext<'_>, raw_image: RgbaImage, config: Config) -> Self {
let raw = Arc::new(raw_image);
let preview = Arc::new(apply_effects((*raw).clone(), &config.effects));
let texture = upload_texture(&cc.egui_ctx, &preview);
let save_as_path = config.output_path().display().to_string();
Self {
raw_image: raw,
preview_image: preview,
preview_texture: texture,
config,
save_as_path,
status_message: None,
action: None,
settings_open: false,
dirty_since: None,
worker_rx: None,
worker_running: false,
}
}
/// Mark settings as changed. A worker will be spawned after the debounce.
fn mark_dirty(&mut self) {
// Only reset the timer if we're not already waiting (avoids pushing
// the debounce out indefinitely on fast slider drag).
if self.dirty_since.is_none() {
self.dirty_since = Some(Instant::now());
}
}
/// Poll for a finished worker result and/or spawn a new one if due.
fn tick_effects(&mut self, ctx: &egui::Context) {
// 1. Check if the running worker is done.
if let Some(rx) = &self.worker_rx {
if let Ok(EffectsResult(img)) = rx.try_recv() {
let img = Arc::new(img);
self.preview_texture = upload_texture(ctx, &img);
self.preview_image = img;
self.worker_rx = None;
self.worker_running = false;
}
}
// 2. Spawn a new worker if debounce has elapsed and none is running.
if let Some(since) = self.dirty_since {
if !self.worker_running && since.elapsed() >= DEBOUNCE {
self.dirty_since = None;
self.worker_running = true;
let raw = Arc::clone(&self.raw_image);
let effects_cfg = self.config.effects.clone();
let (tx, rx) = mpsc::channel();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let result = apply_effects((*raw).clone(), &effects_cfg);
let _ = tx.send(EffectsResult(result));
// Wake the egui event loop so the new texture is picked up.
ctx_clone.request_repaint();
});
self.worker_rx = Some(rx);
}
// Keep repainting while waiting for the debounce to fire.
if self.worker_running || self.dirty_since.is_some() {
ctx.request_repaint_after(Duration::from_millis(50));
}
}
}
fn copy_to_clipboard(&mut self) {
match Clipboard::new() {
Ok(mut cb) => {
let (w, h) = self.preview_image.dimensions();
let bytes = self.preview_image.as_raw().clone();
match cb.set_image(ImageData { width: w as usize, height: h as usize, bytes: bytes.into() }) {
Ok(_) => {
self.status_message = Some("Copied to clipboard.".into());
self.action = Some(ReviewAction::Copied);
}
Err(e) => self.status_message = Some(format!("Clipboard error: {e}")),
}
}
Err(e) => self.status_message = Some(format!("Could not open clipboard: {e}")),
}
}
fn save_to_path(&mut self, path: PathBuf) {
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
self.status_message = Some(format!("Could not create directory: {e}"));
return;
}
}
match self.preview_image.save(&path) {
Ok(_) => {
self.status_message = Some(format!("Saved to {}", path.display()));
self.action = Some(ReviewAction::Saved(path));
}
Err(e) => self.status_message = Some(format!("Save error: {e}")),
}
}
}
impl eframe::App for ReviewWindow {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.tick_effects(ctx);
// ── Global keybinds ───────────────────────────────────────────────────
// Ctrl+C — copy to clipboard immediately.
// Checked before any panel so it works regardless of widget focus.
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::C)) {
self.copy_to_clipboard();
}
// ── Top bar ───────────────────────────────────────────────────────────
egui::TopBottomPanel::top("actions").show(ctx, |ui| {
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("📋 Copy").clicked() {
self.copy_to_clipboard();
}
if ui.button("💾 Save").clicked() {
let path = self.config.output_path();
self.save_to_path(path);
}
ui.separator();
ui.label("Save As:");
ui.add(
egui::TextEdit::singleline(&mut self.save_as_path)
.desired_width(300.0)
.hint_text("/home/user/Pictures/shot.png"),
);
if ui.button("Save").clicked() {
let path = PathBuf::from(&self.save_as_path);
self.save_to_path(path);
}
ui.separator();
let label = if self.settings_open { "▲ Effects" } else { "▼ Effects" };
if ui.button(label).clicked() {
self.settings_open = !self.settings_open;
}
// Spinner while worker is active.
if self.worker_running {
ui.spinner();
}
ui.separator();
if ui.button("✖ Discard").clicked() {
self.action = Some(ReviewAction::Discarded);
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.add_space(4.0);
});
// ── Effects panel ─────────────────────────────────────────────────────
if self.settings_open {
egui::TopBottomPanel::top("settings").show(ctx, |ui| {
ui.add_space(6.0);
ui.heading("Effects");
ui.separator();
let e = &mut self.config.effects;
let mut changed = false;
ui.horizontal(|ui| {
changed |= ui.checkbox(&mut e.rounded_corners, "Rounded corners").changed();
if e.rounded_corners {
ui.label("Radius:");
changed |= ui
.add(egui::Slider::new(&mut e.corner_radius, 1.0..=64.0).suffix(" px"))
.changed();
}
});
ui.horizontal(|ui| {
changed |= ui.checkbox(&mut e.drop_shadow, "Drop shadow").changed();
if e.drop_shadow {
ui.label("Blur:");
changed |= ui
.add(egui::Slider::new(&mut e.shadow_blur_radius, 0.0..=60.0).suffix(" px"))
.changed();
ui.label("X:");
changed |= ui
.add(egui::Slider::new(&mut e.shadow_offset_x, -40.0..=40.0).suffix(" px"))
.changed();
ui.label("Y:");
changed |= ui
.add(egui::Slider::new(&mut e.shadow_offset_y, -40.0..=40.0).suffix(" px"))
.changed();
}
});
if changed {
self.mark_dirty();
let _ = self.config.save();
}
ui.add_space(4.0);
});
}
// ── Status bar ────────────────────────────────────────────────────────
if let Some(msg) = self.status_message.clone() {
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&msg).color(Color32::LIGHT_GREEN));
if ui.small_button("").clicked() {
self.status_message = None;
}
});
ui.add_space(4.0);
});
}
// ── Preview ───────────────────────────────────────────────────────────
egui::CentralPanel::default().show(ctx, |ui| {
ScrollArea::both().show(ui, |ui| {
let tex_size = self.preview_texture.size_vec2();
let available = ui.available_size();
let scale = (available.x / tex_size.x)
.min(available.y / tex_size.y)
.min(1.0);
let display_size = tex_size * scale;
let img_rect = ui.allocate_space(display_size).1;
draw_checkerboard(ui.painter(), img_rect);
ui.painter().image(
self.preview_texture.id(),
img_rect,
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
ui.painter().rect_stroke(
img_rect,
Rounding::ZERO,
Stroke::new(1.0, Color32::from_gray(80)),
);
});
});
// Close after save/copy — give one extra frame so the status message
// is visible for a moment.
if let Some(ReviewAction::Saved(_) | ReviewAction::Copied) = &self.action {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn upload_texture(ctx: &egui::Context, img: &RgbaImage) -> TextureHandle {
let (w, h) = img.dimensions();
let scaled;
let src: &RgbaImage = if w > MAX_TEX || h > MAX_TEX {
let s = (MAX_TEX as f32 / w as f32).min(MAX_TEX as f32 / h as f32);
scaled = image::imageops::resize(
img,
(w as f32 * s) as u32,
(h as f32 * s) as u32,
image::imageops::FilterType::Triangle,
);
&scaled
} else {
img
};
let (uw, uh) = src.dimensions();
let ci = ColorImage::from_rgba_unmultiplied([uw as usize, uh as usize], src.as_raw());
ctx.load_texture("preview", ci, TextureOptions::LINEAR)
}
fn draw_checkerboard(painter: &egui::Painter, rect: egui::Rect) {
let tile = 8.0_f32;
let c0 = Color32::from_gray(200);
let c1 = Color32::from_gray(160);
let cols = (rect.width() / tile).ceil() as u32;
let rows = (rect.height() / tile).ceil() as u32;
for row in 0..rows {
for col in 0..cols {
let color = if (row + col) % 2 == 0 { c0 } else { c1 };
let min = rect.min + Vec2::new(col as f32 * tile, row as f32 * tile);
let max = (min + Vec2::splat(tile)).min(rect.max);
painter.rect_filled(egui::Rect::from_min_max(min, max), Rounding::ZERO, color);
}
}
}
+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")
}
BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
fn main() { println!("test"); }
-3
View File
@@ -1,3 +0,0 @@
fn main() {
let _ = eframe::egui::ViewportBuilder::default().with_active(false);
}