uh... converter?
This commit is contained in:
Generated
+19
-3101
File diff suppressed because it is too large
Load Diff
+1
-17
@@ -8,35 +8,19 @@ name = "rs-pictures"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Wayland-native screen capture
|
# Image loading and encoding
|
||||||
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"] }
|
image = { version = "0.25", features = ["png", "jpeg"] }
|
||||||
|
|
||||||
# 2D rendering for effects (rounded corners, drop shadow)
|
# 2D rendering for effects (rounded corners, drop shadow)
|
||||||
tiny-skia = "0.11"
|
tiny-skia = "0.11"
|
||||||
|
|
||||||
# Clipboard (Wayland)
|
|
||||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
|
||||||
|
|
||||||
# Config serialization
|
# Config serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|
||||||
# Platform config/data directories
|
|
||||||
directories = "5"
|
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
# Timestamp-based filenames
|
|
||||||
chrono = { version = "0.4", features = ["clock"] }
|
|
||||||
|
|
||||||
# ── Build profiles ────────────────────────────────────────────────────────────
|
# ── Build profiles ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+15
-70
@@ -1,5 +1,4 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use directories::ProjectDirs;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -7,50 +6,19 @@ use std::path::PathBuf;
|
|||||||
/// Serialized to/from ~/.config/rs-pictures/config.toml
|
/// Serialized to/from ~/.config/rs-pictures/config.toml
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// Directory where screenshots are saved.
|
/// Visual effects applied to images.
|
||||||
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,
|
pub effects: EffectsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EffectsConfig {
|
pub struct EffectsConfig {
|
||||||
/// Apply rounded corners to the screenshot.
|
/// Apply rounded corners to the image.
|
||||||
pub rounded_corners: bool,
|
pub rounded_corners: bool,
|
||||||
|
|
||||||
/// Radius in pixels for rounded corners.
|
/// Radius in pixels for rounded corners.
|
||||||
pub corner_radius: f32,
|
pub corner_radius: f32,
|
||||||
|
|
||||||
/// Apply a drop shadow beneath the screenshot.
|
/// Apply a drop shadow beneath the image.
|
||||||
pub drop_shadow: bool,
|
pub drop_shadow: bool,
|
||||||
|
|
||||||
/// Blur radius for the drop shadow (higher = softer shadow).
|
/// Blur radius for the drop shadow (higher = softer shadow).
|
||||||
@@ -82,28 +50,25 @@ impl Default for EffectsConfig {
|
|||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let save_directory = dirs_default_pictures().unwrap_or_else(|| PathBuf::from("."));
|
|
||||||
Self {
|
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(),
|
effects: EffectsConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Returns the path to the config file, creating parent directories if needed.
|
/// Returns the path to the config file: ~/.config/rs-pictures/config.toml
|
||||||
pub fn config_path() -> Option<PathBuf> {
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
ProjectDirs::from("", "", "rs-pictures")
|
let home = std::env::var("HOME").ok()?;
|
||||||
.map(|pd| pd.config_dir().join("config.toml"))
|
Some(
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("rs-pictures")
|
||||||
|
.join("config.toml"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load config from disk, or return the default config if the file doesn't exist.
|
/// Load config from disk, or write and return the default config if the file doesn't exist.
|
||||||
pub fn load() -> Result<Self> {
|
pub fn load() -> Result<Self> {
|
||||||
let path = match Self::config_path() {
|
let path = match Self::config_path() {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
@@ -125,39 +90,19 @@ impl Config {
|
|||||||
|
|
||||||
/// Persist the current config to disk.
|
/// Persist the current config to disk.
|
||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
let path = Self::config_path()
|
let path =
|
||||||
.context("Could not determine config directory")?;
|
Self::config_path().context("Could not determine config directory (HOME not set)")?;
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.with_context(|| format!("Failed to create config dir {}", parent.display()))?;
|
.with_context(|| format!("Failed to create config dir {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let serialized = toml::to_string_pretty(self)
|
let serialized = toml::to_string_pretty(self).context("Failed to serialize config")?;
|
||||||
.context("Failed to serialize config")?;
|
|
||||||
|
|
||||||
std::fs::write(&path, serialized)
|
std::fs::write(&path, serialized)
|
||||||
.with_context(|| format!("Failed to write config to {}", path.display()))?;
|
.with_context(|| format!("Failed to write config to {}", path.display()))?;
|
||||||
|
|
||||||
Ok(())
|
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()))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-26
@@ -49,7 +49,13 @@ pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
|
|||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(Color::WHITE);
|
paint.set_color(Color::WHITE);
|
||||||
paint.anti_alias = true;
|
paint.anti_alias = true;
|
||||||
mask.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
mask.fill_path(
|
||||||
|
&path,
|
||||||
|
&paint,
|
||||||
|
FillRule::Winding,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
let mut pixmap = rgba_image_to_pixmap(&img);
|
let mut pixmap = rgba_image_to_pixmap(&img);
|
||||||
let mut dst_paint = PixmapPaint::default();
|
let mut dst_paint = PixmapPaint::default();
|
||||||
@@ -70,9 +76,9 @@ pub fn apply_drop_shadow(
|
|||||||
let (iw, ih) = img.dimensions();
|
let (iw, ih) = img.dimensions();
|
||||||
let br = blur_radius.ceil() as u32;
|
let br = blur_radius.ceil() as u32;
|
||||||
|
|
||||||
let extra_left = br.saturating_sub((-offset_x).max(0.0) 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_top = br.saturating_sub((-offset_y).max(0.0) as u32);
|
||||||
let extra_right = br + offset_x.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 extra_bottom = br + offset_y.max(0.0) as u32;
|
||||||
|
|
||||||
let canvas_w = iw + extra_left + extra_right;
|
let canvas_w = iw + extra_left + extra_right;
|
||||||
@@ -82,11 +88,18 @@ pub fn apply_drop_shadow(
|
|||||||
let mut shadow_pixmap = Pixmap::new(canvas_w, canvas_h).expect("shadow pixmap");
|
let mut shadow_pixmap = Pixmap::new(canvas_w, canvas_h).expect("shadow pixmap");
|
||||||
let img_pixmap = rgba_image_to_pixmap(&img);
|
let img_pixmap = rgba_image_to_pixmap(&img);
|
||||||
let shadow_x = (extra_left as f32 + offset_x) as i32;
|
let shadow_x = (extra_left as f32 + offset_x) as i32;
|
||||||
let shadow_y = (extra_top as f32 + offset_y) as i32;
|
let shadow_y = (extra_top as f32 + offset_y) as i32;
|
||||||
|
|
||||||
let mut sp = PixmapPaint::default();
|
let mut sp = PixmapPaint::default();
|
||||||
sp.blend_mode = BlendMode::Source;
|
sp.blend_mode = BlendMode::Source;
|
||||||
shadow_pixmap.draw_pixmap(shadow_x, shadow_y, img_pixmap.as_ref(), &sp, Transform::identity(), None);
|
shadow_pixmap.draw_pixmap(
|
||||||
|
shadow_x,
|
||||||
|
shadow_y,
|
||||||
|
img_pixmap.as_ref(),
|
||||||
|
&sp,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Tint the silhouette with the shadow colour.
|
// 2. Tint the silhouette with the shadow colour.
|
||||||
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
|
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
|
||||||
@@ -100,11 +113,25 @@ pub fn apply_drop_shadow(
|
|||||||
let mut canvas = Pixmap::new(canvas_w, canvas_h).expect("canvas pixmap");
|
let mut canvas = Pixmap::new(canvas_w, canvas_h).expect("canvas pixmap");
|
||||||
let mut p = PixmapPaint::default();
|
let mut p = PixmapPaint::default();
|
||||||
p.blend_mode = BlendMode::Source;
|
p.blend_mode = BlendMode::Source;
|
||||||
canvas.draw_pixmap(0, 0, blurred_pixmap.as_ref(), &p, Transform::identity(), None);
|
canvas.draw_pixmap(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
blurred_pixmap.as_ref(),
|
||||||
|
&p,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
let mut p2 = PixmapPaint::default();
|
let mut p2 = PixmapPaint::default();
|
||||||
p2.blend_mode = BlendMode::SourceOver;
|
p2.blend_mode = BlendMode::SourceOver;
|
||||||
canvas.draw_pixmap(extra_left as i32, extra_top as i32, img_pixmap.as_ref(), &p2, Transform::identity(), None);
|
canvas.draw_pixmap(
|
||||||
|
extra_left as i32,
|
||||||
|
extra_top as i32,
|
||||||
|
img_pixmap.as_ref(),
|
||||||
|
&p2,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
pixmap_to_rgba_image(canvas)
|
pixmap_to_rgba_image(canvas)
|
||||||
}
|
}
|
||||||
@@ -157,9 +184,9 @@ fn pixmap_to_rgba_image(pixmap: Pixmap) -> RgbaImage {
|
|||||||
} else {
|
} else {
|
||||||
let af = a as f32 / 255.0;
|
let af = a as f32 / 255.0;
|
||||||
(
|
(
|
||||||
(px.red() as f32 / af).round().min(255.0) as u8,
|
(px.red() as f32 / af).round().min(255.0) as u8,
|
||||||
(px.green() 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,
|
(px.blue() as f32 / af).round().min(255.0) as u8,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
out.put_pixel(x, y, image::Rgba([r, g, b, a]));
|
out.put_pixel(x, y, image::Rgba([r, g, b, a]));
|
||||||
@@ -228,18 +255,22 @@ fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for x in 0..w {
|
for x in 0..w {
|
||||||
out.put_pixel(x, y, image::Rgba([
|
out.put_pixel(
|
||||||
(sr / diam) as u8,
|
x,
|
||||||
(sg / diam) as u8,
|
y,
|
||||||
(sb / diam) as u8,
|
image::Rgba([
|
||||||
(sa / diam) as u8,
|
(sr / diam) as u8,
|
||||||
]));
|
(sg / diam) as u8,
|
||||||
|
(sb / diam) as u8,
|
||||||
|
(sa / diam) as u8,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
// Slide: remove left edge, add right edge.
|
// Slide: remove left edge, add right edge.
|
||||||
let remove_x = (x as i32 - r).clamp(0, w as i32 - 1) as u32;
|
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 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 rp = img.get_pixel(remove_x, y).0;
|
||||||
let ap = img.get_pixel(add_x, y).0;
|
let ap = img.get_pixel(add_x, y).0;
|
||||||
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
||||||
sg = sg.saturating_sub(rp[1] as u32) + ap[1] 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;
|
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
||||||
@@ -272,17 +303,21 @@ fn sliding_vertical(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for y in 0..h {
|
for y in 0..h {
|
||||||
out.put_pixel(x, y, image::Rgba([
|
out.put_pixel(
|
||||||
(sr / diam) as u8,
|
x,
|
||||||
(sg / diam) as u8,
|
y,
|
||||||
(sb / diam) as u8,
|
image::Rgba([
|
||||||
(sa / diam) as u8,
|
(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 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 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 rp = img.get_pixel(x, remove_y).0;
|
||||||
let ap = img.get_pixel(x, add_y ).0;
|
let ap = img.get_pixel(x, add_y).0;
|
||||||
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
||||||
sg = sg.saturating_sub(rp[1] as u32) + ap[1] 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;
|
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
||||||
|
|||||||
-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()
|
|
||||||
}
|
|
||||||
+52
-170
@@ -1,192 +1,74 @@
|
|||||||
//! 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 config;
|
||||||
mod effects;
|
mod effects;
|
||||||
mod hyprland;
|
|
||||||
mod overlay;
|
|
||||||
mod review;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use arboard::{Clipboard, ImageData};
|
use std::io::Write as _;
|
||||||
use eframe::egui;
|
use std::process::{Command, Stdio};
|
||||||
use overlay::{SelectionOverlay, SelectionResult};
|
|
||||||
use review::ReviewWindow;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
// ── 1. Load config & arguments ────────────────────────────────────────────
|
// ── 1. Collect image paths from CLI arguments ─────────────────────────────
|
||||||
let config = config::Config::load().context("Failed to load config")?;
|
let paths: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let is_live_mode = config.live_mode || std::env::args().any(|a| a == "--live" || a == "-l");
|
|
||||||
|
|
||||||
// ── 2. Query Hyprland metadata (no window open yet) ───────────────────────
|
if paths.is_empty() {
|
||||||
// Scale is only needed for window-rect conversion in the overlay.
|
eprintln!("Usage: rs-pictures <image> [image2 ...]");
|
||||||
let scale = hyprland::active_monitor_scale();
|
eprintln!("No image paths provided.");
|
||||||
let (lw, lh) = hyprland::active_monitor_logical_size().unwrap_or((1920, 1080));
|
std::process::exit(1);
|
||||||
|
|
||||||
// ── 3. Pre-capture delay ──────────────────────────────────────────────────
|
|
||||||
// Give the compositor time to unmap the terminal/launcher that started us
|
|
||||||
// and repaint the desktop before we freeze it.
|
|
||||||
// Configurable via capture_delay_ms in config.toml (default 800ms).
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(config.capture_delay_ms));
|
|
||||||
|
|
||||||
// ── 4. Capture full desktop BEFORE opening any window ────────────────────
|
|
||||||
// In freeze mode (default), we snapshot before the overlay.
|
|
||||||
// In live mode, we skip this and capture later.
|
|
||||||
let background_snapshot = if is_live_mode {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(capture::capture_all_outputs().context("Failed to capture desktop snapshot")?)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 5. Run the selection overlay ─────────────────────────────────────────
|
|
||||||
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_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()
|
|
||||||
};
|
|
||||||
|
|
||||||
let bg_clone = background_snapshot.clone();
|
|
||||||
eframe::run_native(
|
|
||||||
"rs-pictures — Select Region",
|
|
||||||
native_options,
|
|
||||||
Box::new(move |cc| {
|
|
||||||
let app = SelectionOverlay::new(cc, bg_clone, scale);
|
|
||||||
Ok(Box::new(OverlayWrapper {
|
|
||||||
inner: app,
|
|
||||||
result_sink: shared_clone,
|
|
||||||
}) as Box<dyn eframe::App>)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Overlay window error: {e}"))?;
|
|
||||||
|
|
||||||
Arc::try_unwrap(shared)
|
|
||||||
.ok()
|
|
||||||
.and_then(|m| m.into_inner().ok())
|
|
||||||
.flatten()
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 6. Act on the selection ───────────────────────────────────────────────
|
|
||||||
let region = match selection_result {
|
|
||||||
Some(SelectionResult::Selected(r)) => r,
|
|
||||||
Some(SelectionResult::Cancelled) | None => {
|
|
||||||
eprintln!("Selection cancelled.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 7. Get the raw region image ───────────────────────────────────────────
|
|
||||||
let raw_image = if let Some(bg) = background_snapshot {
|
|
||||||
// Freeze mode: Crop directly from the pre-captured snapshot.
|
|
||||||
let px = (region.x.max(0) as u32).min(bg.width().saturating_sub(1));
|
|
||||||
let py = (region.y.max(0) as u32).min(bg.height().saturating_sub(1));
|
|
||||||
let pw = region.width.min(bg.width() - px);
|
|
||||||
let ph = region.height.min(bg.height() - py);
|
|
||||||
image::imageops::crop_imm(&bg, px, py, pw, ph).to_image()
|
|
||||||
} else {
|
|
||||||
// Live mode: Wait for the compositor to clear the overlay, then capture just the region.
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(config.capture_delay_ms.max(200)));
|
|
||||||
capture::capture_region(region).context("Failed to capture region")?
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 8a. Auto-mode — no review window ─────────────────────────────────────
|
|
||||||
if config.auto_save || config.auto_copy {
|
|
||||||
let final_image = effects::apply_effects(raw_image, &config.effects);
|
|
||||||
if config.auto_save {
|
|
||||||
let path = config.output_path();
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("Cannot create dir {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
final_image.save(&path)
|
|
||||||
.with_context(|| format!("Failed to save to {}", path.display()))?;
|
|
||||||
eprintln!("Saved to {}", path.display());
|
|
||||||
}
|
|
||||||
if config.auto_copy {
|
|
||||||
clipboard_copy(&final_image)?;
|
|
||||||
eprintln!("Copied to clipboard.");
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 8b. Review window ─────────────────────────────────────────────────────
|
// ── 2. Load config (generates default if missing) ─────────────────────────
|
||||||
// The review window applies effects internally on a background thread, so
|
let config = config::Config::load().context("Failed to load config")?;
|
||||||
// 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(
|
// ── 3. Process each image ─────────────────────────────────────────────────
|
||||||
"rs-pictures — Review",
|
for path in &paths {
|
||||||
native_options,
|
if let Err(e) = process_image(path, &config) {
|
||||||
Box::new(move |cc| {
|
eprintln!("Error processing '{}': {e:#}", path);
|
||||||
Ok(Box::new(ReviewWindow::new(cc, raw_image, config)) as Box<dyn eframe::App>)
|
}
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Review window error: {e}"))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Clipboard helper (shared with review.rs logic) ───────────────────────────
|
fn process_image(path: &str, config: &config::Config) -> Result<()> {
|
||||||
|
// ── a. Load image ─────────────────────────────────────────────────────────
|
||||||
|
let img = image::open(path)
|
||||||
|
.with_context(|| format!("Failed to open image '{path}'"))?
|
||||||
|
.into_rgba8();
|
||||||
|
|
||||||
pub fn clipboard_copy(img: &image::RgbaImage) -> Result<()> {
|
// ── b. Apply effects (rounded corners + drop shadow) ─────────────────────
|
||||||
let (w, h) = img.dimensions();
|
let processed = effects::apply_effects(img, &config.effects);
|
||||||
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 ──────────────────────────────────────────────────────────
|
// ── c. Encode as PNG into memory ──────────────────────────────────────────
|
||||||
|
let mut png_bytes: Vec<u8> = Vec::new();
|
||||||
|
image::DynamicImage::ImageRgba8(processed)
|
||||||
|
.write_to(
|
||||||
|
&mut std::io::Cursor::new(&mut png_bytes),
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
)
|
||||||
|
.context("Failed to encode processed image as PNG")?;
|
||||||
|
// Re-unwrap into RgbaImage — not needed further, png_bytes is ready
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
// ── d. Pipe PNG bytes to `swappy -f -` ────────────────────────────────────
|
||||||
|
let mut child = Command::new("swappy")
|
||||||
|
.args(["-f", "-"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("Failed to spawn swappy. Is it installed and in PATH?")?;
|
||||||
|
|
||||||
struct OverlayWrapper {
|
child
|
||||||
inner: SelectionOverlay,
|
.stdin
|
||||||
result_sink: Arc<Mutex<Option<SelectionResult>>>,
|
.take()
|
||||||
}
|
.context("Failed to get swappy stdin")?
|
||||||
|
.write_all(&png_bytes)
|
||||||
|
.context("Failed to write image data to swappy")?;
|
||||||
|
|
||||||
impl eframe::App for OverlayWrapper {
|
let status = child.wait().context("Failed to wait for swappy")?;
|
||||||
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
|
||||||
self.inner.update(ctx, frame);
|
if !status.success() {
|
||||||
if let Some(result) = self.inner.take_result() {
|
eprintln!(
|
||||||
*self.result_sink.lock().unwrap() = Some(result);
|
"swappy exited with non-zero status for '{}': {}",
|
||||||
}
|
path, status
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
|
Ok(())
|
||||||
self.inner.clear_color(visuals)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
let _ = eframe::egui::ViewportBuilder::default().with_active(false);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user