What That Claude DO?! Screenshot tooling. Also there is a visual bug when creating a screenshot when moving

This commit is contained in:
2026-05-03 23:58:55 +02:00
parent 01057c1c38
commit 7fe112293e
12 changed files with 5939 additions and 1 deletions
Generated
+4238
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
[package]
name = "rs-pictures"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "rs-pictures"
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 ────────────────────────────────────────────────────────────
[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
# 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
+3 -1
View File
@@ -1,4 +1,6 @@
# What_That_Claude_DO?
What That Claude Do? (WTCD)
A repository of random things I ask Claude to do for me.
A repository of random things I ask Claude to do for me.
In this case it is creating a screenshot tool
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+37
View File
@@ -0,0 +1,37 @@
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 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 outputs: Vec<_> = if let Some(ref name) = active_name {
let filtered: Vec<_> = all.iter().filter(|o| &o.name == name).cloned().collect();
if filtered.is_empty() { all.to_vec() } else { filtered }
} else {
all.iter().take(1).cloned().collect()
};
let rgba = conn
.screenshot_outputs(&outputs, false)
.context("libwayshot failed to capture output")?
.into_rgba8();
eprintln!("[capture] capture_all_outputs → {}x{}", rgba.width(), rgba.height());
Ok(rgba)
}
+157
View File
@@ -0,0 +1,157 @@
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: 800.
#[serde(default = "default_capture_delay_ms")]
pub capture_delay_ms: u64,
/// 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(),
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 { 800 }
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
@@ -0,0 +1,293 @@
//! 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
}
+270
View File
@@ -0,0 +1,270 @@
//! 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()
}
+180
View File
@@ -0,0 +1,180 @@
//! 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).
mod capture;
mod config;
mod effects;
mod hyprland;
mod overlay;
mod review;
use anyhow::{Context, Result};
use arboard::{Clipboard, ImageData};
use eframe::egui;
use overlay::{SelectionOverlay, SelectionResult};
use review::ReviewWindow;
fn main() -> Result<()> {
// ── 1. Load config ────────────────────────────────────────────────────────
let config = config::Config::load().context("Failed to load config")?;
// ── 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();
// ── 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 ────────────────────
// This must happen before eframe::run_native is called. eframe maps its
// window immediately on entry — before our first update() frame — causing
// a white rectangle to appear in the snapshot if we capture any later.
let background_snapshot =
capture::capture_all_outputs().context("Failed to capture desktop snapshot")?;
// ── 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 native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_fullscreen(true)
.with_decorations(false)
.with_transparent(true)
.with_always_on_top()
.with_active(false)
.with_resizable(false),
..Default::default()
};
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. Crop the selected region from the original snapshot ────────────────
let raw_image = {
let px = (region.x.max(0) as u32).min(background_snapshot.width().saturating_sub(1));
let py = (region.y.max(0) as u32).min(background_snapshot.height().saturating_sub(1));
let pw = region.width.min(background_snapshot.width() - px);
let ph = region.height.min(background_snapshot.height() - py);
image::imageops::crop_imm(&background_snapshot, px, py, pw, ph).to_image()
};
// ── 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)
}
}
+358
View File
@@ -0,0 +1,358 @@
//! 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 = 2048;
pub struct SelectionOverlay {
background: 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: RgbaImage, scale: f32) -> Self {
let tex_image = fit_to_max_texture(background_snapshot);
let (tw, th) = tex_image.dimensions();
let color_image = egui::ColorImage::from_rgba_unmultiplied(
[tw as usize, th as usize],
tex_image.as_raw(),
);
let texture =
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.
painter.image(
self.background.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,
);
let border_color = if self.hovered_window.is_some() && current_rect.is_none() {
Color32::from_rgb(255, 190, 50)
} else {
Color32::from_rgb(100, 180, 255)
};
painter.rect_stroke(s, Rounding::ZERO, Stroke::new(1.5, border_color));
// 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
@@ -0,0 +1,345 @@
//! 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 = 2048;
/// 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);
}
}
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
let _ = eframe::egui::ViewportBuilder::default().with_active(false);
}