Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 775c222448 | |||
| 0608f1f1aa | |||
| cf634a76f2 | |||
| 075cd42064 | |||
| 2903819626 |
@@ -9,10 +9,3 @@ target/
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
Generated
+445
-3231
File diff suppressed because it is too large
Load Diff
+20
-39
@@ -1,55 +1,36 @@
|
||||
[package]
|
||||
name = "rs-pictures"
|
||||
name = "iwaku"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "rs-pictures"
|
||||
name = "iwaku"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Wayland-native screen capture
|
||||
libwayshot = "0.7"
|
||||
|
||||
# GUI framework (Wayland only)
|
||||
eframe = { version = "0.29", default-features = false, features = ["wayland", "wgpu"] }
|
||||
egui = "0.29"
|
||||
|
||||
# Image handling
|
||||
image = { version = "0.25", features = ["png", "jpeg"] }
|
||||
|
||||
# 2D rendering for effects (rounded corners, drop shadow)
|
||||
tiny-skia = "0.11"
|
||||
|
||||
# Clipboard (Wayland)
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Ole",
|
||||
"Win32_UI_Accessibility",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_Magnification",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
tray-icon = "0.17"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin" # link-time optimisation across crates
|
||||
codegen-units = 1 # better inlining at the cost of compile time
|
||||
strip = true # strip debug symbols → smaller binary
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
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]
|
||||
opt-level = 0
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3 # all deps at full optimisation even in dev mode
|
||||
opt-level = 3
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+158
-173
@@ -1,192 +1,177 @@
|
||||
//! rs-pictures — Wayland screenshot tool
|
||||
//!
|
||||
//! 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).
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
mod capture;
|
||||
mod config;
|
||||
mod effects;
|
||||
mod hyprland;
|
||||
mod overlay;
|
||||
mod review;
|
||||
mod caret;
|
||||
mod hook;
|
||||
mod magnifier;
|
||||
mod state;
|
||||
mod tray;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use arboard::{Clipboard, ImageData};
|
||||
use eframe::egui;
|
||||
use overlay::{SelectionOverlay, SelectionResult};
|
||||
use review::ReviewWindow;
|
||||
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 main() -> Result<()> {
|
||||
// ── 1. Load config & arguments ────────────────────────────────────────────
|
||||
let config = config::Config::load().context("Failed to load config")?;
|
||||
let is_live_mode = config.live_mode || std::env::args().any(|a| a == "--live" || a == "-l");
|
||||
static mut APP_STATE: Option<AppState> = None;
|
||||
static mut MAGNIFIER: Option<magnifier::Magnifier> = None;
|
||||
|
||||
// ── 2. Query Hyprland metadata (no window open yet) ───────────────────────
|
||||
// Scale is only needed for window-rect conversion in the overlay.
|
||||
let scale = hyprland::active_monitor_scale();
|
||||
let (lw, lh) = hyprland::active_monitor_logical_size().unwrap_or((1920, 1080));
|
||||
const TIMER_ID: usize = 1;
|
||||
const TIMER_MS: u32 = 16; // ~60 fps
|
||||
|
||||
// ── 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));
|
||||
fn main() {
|
||||
unsafe {
|
||||
// DPI awareness — must be set before any window is created.
|
||||
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
|
||||
// ── 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")?)
|
||||
};
|
||||
// COM initialisation needed for UI Automation (caret tracking).
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||
APP_STATE = Some(AppState::new());
|
||||
|
||||
// ── 5. Run the selection overlay ─────────────────────────────────────────
|
||||
let selection_result = {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let shared: Arc<Mutex<Option<SelectionResult>>> = Arc::new(Mutex::new(None));
|
||||
let shared_clone = Arc::clone(&shared);
|
||||
let instance = get_instance();
|
||||
register_msg_class(instance);
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_app_id("rs-pictures-overlay")
|
||||
.with_inner_size([lw as f32, lh as f32])
|
||||
.with_position([0.0, 0.0])
|
||||
.with_fullscreen(true)
|
||||
.with_maximized(true)
|
||||
.with_decorations(false)
|
||||
.with_transparent(true)
|
||||
.with_always_on_top()
|
||||
.with_resizable(false),
|
||||
..Default::default()
|
||||
// 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,
|
||||
};
|
||||
|
||||
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}"))?;
|
||||
// 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;
|
||||
|
||||
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(());
|
||||
// 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();
|
||||
|
||||
// ── 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")?
|
||||
};
|
||||
// 2. Advance state.
|
||||
let just_activated = state.active && !was_active;
|
||||
state.update(just_activated);
|
||||
|
||||
// ── 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);
|
||||
// 3. Push the new transform.
|
||||
if let Some(mag) = (*std::ptr::addr_of_mut!(MAGNIFIER)).as_mut() {
|
||||
mag.update(state, just_activated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
|
||||
self.inner.clear_color(visuals)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
-359
@@ -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
@@ -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
@@ -0,0 +1,192 @@
|
||||
use windows::Win32::Foundation::POINT;
|
||||
|
||||
pub const ZOOM_MIN: f32 = 1.5;
|
||||
pub const ZOOM_MAX: f32 = 10.0;
|
||||
pub const ZOOM_STEP: f32 = 0.5;
|
||||
|
||||
const EDGE_MARGIN: f32 = 0.10;
|
||||
const PAN_ALPHA: f32 = 0.20;
|
||||
const ZOOM_ALPHA: f32 = 0.18;
|
||||
const ZOOM_EPSILON: f32 = 0.01;
|
||||
const CURSOR_ALPHA: f32 = 0.35;
|
||||
const MOUSE_MOVE_THRESHOLD_SQ: i32 = 6 * 6;
|
||||
const MOUSE_TAKEOVER_TICKS: u32 = 4;
|
||||
/// Ticks of mouse stillness before caret re-takes focus (if caret present). ~240 ms.
|
||||
const CARET_REFOCUS_TICKS: u32 = 15;
|
||||
|
||||
pub struct AppState {
|
||||
pub zoom: f32,
|
||||
target_zoom: f32,
|
||||
prev_zoom: f32,
|
||||
pub active: bool,
|
||||
|
||||
pub cursor: POINT,
|
||||
prev_cursor: POINT,
|
||||
smooth_cursor_x: f32,
|
||||
smooth_cursor_y: f32,
|
||||
|
||||
pub caret: Option<POINT>,
|
||||
prev_caret: Option<POINT>,
|
||||
pub caret_active: bool,
|
||||
mouse_move_ticks: u32,
|
||||
mouse_still_ticks: u32,
|
||||
|
||||
pub viewport_x: f32,
|
||||
pub viewport_y: f32,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
zoom: 1.0,
|
||||
target_zoom: 1.0,
|
||||
prev_zoom: 1.0,
|
||||
active: false,
|
||||
cursor: POINT { x: 0, y: 0 },
|
||||
prev_cursor: POINT { x: 0, y: 0 },
|
||||
smooth_cursor_x: 0.0,
|
||||
smooth_cursor_y: 0.0,
|
||||
caret: None,
|
||||
prev_caret: None,
|
||||
caret_active: false,
|
||||
mouse_move_ticks: 0,
|
||||
mouse_still_ticks: 0,
|
||||
viewport_x: 0.0,
|
||||
viewport_y: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, _just_activated: bool) {
|
||||
// Animate zoom.
|
||||
if self.active {
|
||||
let diff = self.target_zoom - self.zoom;
|
||||
if diff.abs() < ZOOM_EPSILON {
|
||||
self.zoom = self.target_zoom;
|
||||
if self.zoom <= 1.0 {
|
||||
self.zoom = 1.0;
|
||||
self.active = false;
|
||||
}
|
||||
} else {
|
||||
self.zoom += diff * ZOOM_ALPHA;
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth cursor.
|
||||
self.smooth_cursor_x += (self.cursor.x as f32 - self.smooth_cursor_x) * CURSOR_ALPHA;
|
||||
self.smooth_cursor_y += (self.cursor.y as f32 - self.smooth_cursor_y) * CURSOR_ALPHA;
|
||||
|
||||
// Caret / mouse priority.
|
||||
let dx = self.cursor.x - self.prev_cursor.x;
|
||||
let dy = self.cursor.y - self.prev_cursor.y;
|
||||
let intentional = dx * dx + dy * dy >= MOUSE_MOVE_THRESHOLD_SQ;
|
||||
|
||||
let caret_moved = match (self.caret, self.prev_caret) {
|
||||
(Some(c), Some(p)) => c.x != p.x || c.y != p.y,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if intentional {
|
||||
self.mouse_move_ticks += 1;
|
||||
self.mouse_still_ticks = 0;
|
||||
} else {
|
||||
self.mouse_move_ticks = 0;
|
||||
self.mouse_still_ticks += 1;
|
||||
}
|
||||
|
||||
if self.mouse_move_ticks >= MOUSE_TAKEOVER_TICKS {
|
||||
self.caret_active = false;
|
||||
self.mouse_move_ticks = 0;
|
||||
}
|
||||
// Re-engage caret once mouse has been still long enough.
|
||||
if self.mouse_still_ticks >= CARET_REFOCUS_TICKS && self.caret.is_some() {
|
||||
self.caret_active = true;
|
||||
}
|
||||
if caret_moved {
|
||||
self.caret_active = true;
|
||||
self.mouse_move_ticks = 0;
|
||||
}
|
||||
|
||||
self.prev_cursor = self.cursor;
|
||||
self.prev_caret = self.caret;
|
||||
}
|
||||
|
||||
pub fn update_viewport(
|
||||
&mut self,
|
||||
src_w: f32,
|
||||
src_h: f32,
|
||||
screen_w: f32,
|
||||
screen_h: f32,
|
||||
just_activated: bool,
|
||||
) {
|
||||
let (fx, fy) = self.focus_point();
|
||||
let zoom_changed = (self.zoom - self.prev_zoom).abs() > f32::EPSILON;
|
||||
self.prev_zoom = self.zoom;
|
||||
|
||||
if just_activated {
|
||||
self.viewport_x = (fx - src_w / 2.0).clamp(0.0, (screen_w - src_w).max(0.0));
|
||||
self.viewport_y = (fy - src_h / 2.0).clamp(0.0, (screen_h - src_h).max(0.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let (target_x, target_y) = if self.caret_active {
|
||||
// Centre on caret while typing.
|
||||
let tx = (fx - src_w / 2.0).clamp(0.0, (screen_w - src_w).max(0.0));
|
||||
let ty = (fy - src_h / 2.0).clamp(0.0, (screen_h - src_h).max(0.0));
|
||||
(tx, ty)
|
||||
} else if zoom_changed {
|
||||
let tx = (fx - src_w / 2.0).clamp(0.0, (screen_w - src_w).max(0.0));
|
||||
let ty = (fy - src_h / 2.0).clamp(0.0, (screen_h - src_h).max(0.0));
|
||||
(tx, ty)
|
||||
} else {
|
||||
let mx = EDGE_MARGIN * src_w;
|
||||
let my = EDGE_MARGIN * src_h;
|
||||
let tx = self
|
||||
.viewport_x
|
||||
.clamp(fx - src_w + mx, fx - mx)
|
||||
.clamp(0.0, (screen_w - src_w).max(0.0));
|
||||
let ty = self
|
||||
.viewport_y
|
||||
.clamp(fy - src_h + my, fy - my)
|
||||
.clamp(0.0, (screen_h - src_h).max(0.0));
|
||||
(tx, ty)
|
||||
};
|
||||
|
||||
self.viewport_x += (target_x - self.viewport_x) * PAN_ALPHA;
|
||||
self.viewport_y += (target_y - self.viewport_y) * PAN_ALPHA;
|
||||
|
||||
// Hard-clamp every tick so the viewport never overshoots screen bounds
|
||||
// even while the lerp is still catching up (e.g. fast zoom-out).
|
||||
self.viewport_x = self.viewport_x.clamp(0.0, (screen_w - src_w).max(0.0));
|
||||
self.viewport_y = self.viewport_y.clamp(0.0, (screen_h - src_h).max(0.0));
|
||||
}
|
||||
|
||||
pub fn zoom_in(&mut self) {
|
||||
if !self.active {
|
||||
self.target_zoom = ZOOM_MIN;
|
||||
self.zoom = 1.0;
|
||||
self.active = true;
|
||||
} else {
|
||||
self.target_zoom = (self.target_zoom + ZOOM_STEP).min(ZOOM_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zoom_out(&mut self) {
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
self.target_zoom -= ZOOM_STEP;
|
||||
if self.target_zoom < ZOOM_MIN {
|
||||
self.target_zoom = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_point(&self) -> (f32, f32) {
|
||||
if self.caret_active {
|
||||
if let Some(c) = self.caret {
|
||||
return (c.x as f32, c.y as f32);
|
||||
}
|
||||
}
|
||||
(self.smooth_cursor_x, self.smooth_cursor_y)
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
use tray_icon::{
|
||||
menu::{Menu, MenuEvent, MenuId, MenuItem},
|
||||
TrayIcon, TrayIconBuilder,
|
||||
};
|
||||
|
||||
pub struct Tray {
|
||||
_icon: TrayIcon,
|
||||
exit_id: MenuId,
|
||||
}
|
||||
|
||||
impl Tray {
|
||||
pub fn new() -> Self {
|
||||
let toggle_item = MenuItem::new("Toggle", true, None);
|
||||
let exit_item = MenuItem::new("Exit", true, None);
|
||||
|
||||
let exit_id = exit_item.id().clone();
|
||||
|
||||
let menu = Menu::new();
|
||||
menu.append(&toggle_item)
|
||||
.expect("Failed to append toggle item");
|
||||
menu.append(&exit_item).expect("Failed to append exit item");
|
||||
|
||||
let icon = make_icon();
|
||||
|
||||
let tray = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(menu))
|
||||
.with_tooltip("iwaku magnifier")
|
||||
.with_icon(icon)
|
||||
.build()
|
||||
.expect("Failed to build tray icon");
|
||||
|
||||
Self {
|
||||
_icon: tray,
|
||||
exit_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the app should exit.
|
||||
pub fn process_events(&self) -> bool {
|
||||
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||
if event.id == self.exit_id {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn make_icon() -> tray_icon::Icon {
|
||||
const SIZE: usize = 16;
|
||||
let mut rgba = vec![0u8; SIZE * SIZE * 4];
|
||||
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
let idx = (y * SIZE + x) * 4;
|
||||
let cx = x as f32 - 7.5;
|
||||
let cy = y as f32 - 7.5;
|
||||
let dist = (cx * cx + cy * cy).sqrt();
|
||||
if (dist - 5.5).abs() < 1.5 {
|
||||
rgba[idx] = 255;
|
||||
rgba[idx + 1] = 255;
|
||||
rgba[idx + 2] = 255;
|
||||
rgba[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tray_icon::Icon::from_rgba(rgba, SIZE as u32, SIZE as u32)
|
||||
.expect("Failed to create tray icon from RGBA")
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
let _ = eframe::egui::ViewportBuilder::default().with_active(false);
|
||||
}
|
||||
Reference in New Issue
Block a user