Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0608f1f1aa | |||
| cf634a76f2 | |||
| 075cd42064 | |||
| 2903819626 |
@@ -9,10 +9,3 @@ target/
|
|||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
# RustRover
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|||||||
Generated
+34
-3172
File diff suppressed because it is too large
Load Diff
+8
-36
@@ -1,55 +1,27 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rs-pictures"
|
name = "iwaku"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "rs-pictures"
|
name = "iwaku"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Wayland-native screen capture
|
image = { version = "0.25", features = ["png"] }
|
||||||
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"
|
tiny-skia = "0.11"
|
||||||
|
|
||||||
# Clipboard (Wayland)
|
|
||||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
|
||||||
|
|
||||||
# Config serialization
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
|
||||||
|
|
||||||
# Platform config/data directories
|
|
||||||
directories = "5"
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
serde_json = "1.0.149"
|
||||||
# Timestamp-based filenames
|
|
||||||
chrono = { version = "0.4", features = ["clock"] }
|
|
||||||
|
|
||||||
# ── Build profiles ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin" # link-time optimisation across crates
|
lto = "thin"
|
||||||
codegen-units = 1 # better inlining at the cost of compile time
|
codegen-units = 1
|
||||||
strip = true # strip debug symbols → smaller binary
|
strip = true
|
||||||
|
|
||||||
# Dev builds are slow for pixel-processing code. This gives opt-level 2
|
|
||||||
# to our own crate only while keeping dependencies at their default (opt=3
|
|
||||||
# they already compiled with), so incremental rebuilds stay fast.
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 0
|
opt-level = 0
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 3 # all deps at full optimisation even in dev mode
|
opt-level = 3
|
||||||
|
|||||||
@@ -1,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)
|
|
||||||
}
|
|
||||||
+17
-135
@@ -1,163 +1,45 @@
|
|||||||
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;
|
||||||
|
|
||||||
/// Top-level application configuration.
|
|
||||||
/// 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.
|
#[serde(rename = "screenshot")]
|
||||||
pub save_directory: PathBuf,
|
pub screenshot: EffectsConfig,
|
||||||
|
|
||||||
/// File format for saved screenshots: "png" or "jpeg".
|
|
||||||
pub save_format: String,
|
|
||||||
|
|
||||||
/// Filename template. Supports strftime-style tokens via chrono.
|
|
||||||
/// Example: "screenshot_%Y-%m-%d_%H-%M-%S"
|
|
||||||
pub filename_template: String,
|
|
||||||
|
|
||||||
/// When true, automatically save to disk after capture without opening
|
|
||||||
/// the review window.
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_save: bool,
|
|
||||||
|
|
||||||
/// When true, automatically copy to the clipboard after capture without
|
|
||||||
/// opening the review window.
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_copy: bool,
|
|
||||||
|
|
||||||
/// Milliseconds to wait after launch before capturing the desktop snapshot.
|
|
||||||
/// Increase this if the overlay background still shows the terminal/launcher
|
|
||||||
/// that started rs-pictures. Default: 200.
|
|
||||||
#[serde(default = "default_capture_delay_ms")]
|
|
||||||
pub capture_delay_ms: u64,
|
|
||||||
|
|
||||||
/// If true, the selection overlay will be transparent (live preview) instead of
|
|
||||||
/// a frozen screenshot. The final capture happens after selection.
|
|
||||||
#[serde(default)]
|
|
||||||
pub live_mode: bool,
|
|
||||||
|
|
||||||
/// Visual effects applied after capture.
|
|
||||||
pub effects: EffectsConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EffectsConfig {
|
pub struct EffectsConfig {
|
||||||
/// Apply rounded corners to the screenshot.
|
pub mode: String,
|
||||||
pub rounded_corners: bool,
|
pub rounded_corners: bool,
|
||||||
|
|
||||||
/// Radius in pixels for rounded corners.
|
|
||||||
pub corner_radius: f32,
|
pub corner_radius: f32,
|
||||||
|
|
||||||
/// Apply a drop shadow beneath the screenshot.
|
|
||||||
pub drop_shadow: bool,
|
pub drop_shadow: bool,
|
||||||
|
|
||||||
/// Blur radius for the drop shadow (higher = softer shadow).
|
|
||||||
pub shadow_blur_radius: f32,
|
pub shadow_blur_radius: f32,
|
||||||
|
|
||||||
/// Horizontal offset of the shadow in pixels.
|
|
||||||
pub shadow_offset_x: f32,
|
pub shadow_offset_x: f32,
|
||||||
|
|
||||||
/// Vertical offset of the shadow in pixels.
|
|
||||||
pub shadow_offset_y: f32,
|
pub shadow_offset_y: f32,
|
||||||
|
|
||||||
/// Shadow color as [R, G, B, A] in 0..=255.
|
|
||||||
pub shadow_color: [u8; 4],
|
pub shadow_color: [u8; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EffectsConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
rounded_corners: false,
|
|
||||||
corner_radius: 12.0,
|
|
||||||
drop_shadow: false,
|
|
||||||
shadow_blur_radius: 20.0,
|
|
||||||
shadow_offset_x: 5.0,
|
|
||||||
shadow_offset_y: 8.0,
|
|
||||||
shadow_color: [0, 0, 0, 160],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
let save_directory = dirs_default_pictures().unwrap_or_else(|| PathBuf::from("."));
|
|
||||||
Self {
|
|
||||||
save_directory,
|
|
||||||
save_format: "png".into(),
|
|
||||||
filename_template: "screenshot_%Y-%m-%d_%H-%M-%S".into(),
|
|
||||||
auto_save: false,
|
|
||||||
auto_copy: false,
|
|
||||||
capture_delay_ms: default_capture_delay_ms(),
|
|
||||||
live_mode: false,
|
|
||||||
effects: EffectsConfig::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Returns the path to the config file, creating parent directories if needed.
|
|
||||||
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("zshell")
|
||||||
|
.join("config.json"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load config from disk, or 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 = Self::config_path().context("Could not determine HOME directory")?;
|
||||||
Some(p) => p,
|
Self::load_from(&path)
|
||||||
None => return Ok(Self::default()),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if !path.exists() {
|
pub fn load_from(path: &PathBuf) -> Result<Self> {
|
||||||
let config = Self::default();
|
let raw = std::fs::read_to_string(path)
|
||||||
config.save()?;
|
|
||||||
return Ok(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(&path)
|
|
||||||
.with_context(|| format!("Failed to read config at {}", path.display()))?;
|
.with_context(|| format!("Failed to read config at {}", path.display()))?;
|
||||||
|
serde_json::from_str(&raw)
|
||||||
toml::from_str(&raw)
|
.with_context(|| format!("Failed to parse JSON config at {}", path.display()))
|
||||||
.with_context(|| format!("Failed to parse config at {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the current config to disk.
|
|
||||||
pub fn save(&self) -> Result<()> {
|
|
||||||
let path = Self::config_path()
|
|
||||||
.context("Could not determine config directory")?;
|
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("Failed to create config dir {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let serialized = toml::to_string_pretty(self)
|
|
||||||
.context("Failed to serialize config")?;
|
|
||||||
|
|
||||||
std::fs::write(&path, serialized)
|
|
||||||
.with_context(|| format!("Failed to write config to {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the full output path for a new screenshot using chrono formatting.
|
|
||||||
pub fn output_path(&self) -> PathBuf {
|
|
||||||
let now = chrono::Local::now();
|
|
||||||
let filename = now.format(&self.filename_template).to_string();
|
|
||||||
let ext = &self.save_format;
|
|
||||||
self.save_directory.join(format!("{filename}.{ext}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_capture_delay_ms() -> u64 { 200 }
|
|
||||||
|
|
||||||
fn dirs_default_pictures() -> Option<PathBuf> {
|
|
||||||
// Use XDG_PICTURES_DIR if available, otherwise ~/Pictures
|
|
||||||
if let Ok(val) = std::env::var("XDG_PICTURES_DIR") {
|
|
||||||
return Some(PathBuf::from(val));
|
|
||||||
}
|
|
||||||
directories::UserDirs::new()
|
|
||||||
.and_then(|ud| ud.picture_dir().map(|p| p.to_path_buf()))
|
|
||||||
}
|
|
||||||
|
|||||||
+61
-66
@@ -1,26 +1,9 @@
|
|||||||
//! 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 crate::config::EffectsConfig;
|
||||||
use image::RgbaImage;
|
use image::RgbaImage;
|
||||||
use tiny_skia::{
|
use tiny_skia::{
|
||||||
BlendMode, Color, FillRule, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Transform,
|
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 {
|
pub fn apply_effects(img: RgbaImage, cfg: &EffectsConfig) -> RgbaImage {
|
||||||
let img = if cfg.rounded_corners {
|
let img = if cfg.rounded_corners {
|
||||||
apply_rounded_corners(img, cfg.corner_radius)
|
apply_rounded_corners(img, cfg.corner_radius)
|
||||||
@@ -40,8 +23,6 @@ pub fn apply_effects(img: RgbaImage, cfg: &EffectsConfig) -> RgbaImage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Rounded corners ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
|
pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
|
||||||
let (w, h) = img.dimensions();
|
let (w, h) = img.dimensions();
|
||||||
let mut mask = Pixmap::new(w, h).expect("mask pixmap");
|
let mut mask = Pixmap::new(w, h).expect("mask pixmap");
|
||||||
@@ -49,7 +30,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();
|
||||||
@@ -58,8 +45,6 @@ pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
|
|||||||
pixmap_to_rgba_image(pixmap)
|
pixmap_to_rgba_image(pixmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Drop shadow ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn apply_drop_shadow(
|
pub fn apply_drop_shadow(
|
||||||
img: RgbaImage,
|
img: RgbaImage,
|
||||||
blur_radius: f32,
|
blur_radius: f32,
|
||||||
@@ -70,47 +55,62 @@ 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;
|
||||||
let canvas_h = ih + extra_top + extra_bottom;
|
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 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.
|
|
||||||
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
|
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 shadow_img = pixmap_to_rgba_image(shadow_pixmap);
|
||||||
let blurred = box_blur_rgba(&shadow_img, br);
|
let blurred = box_blur_rgba(&shadow_img, br);
|
||||||
let blurred_pixmap = rgba_image_to_pixmap(&blurred);
|
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 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Path {
|
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 r = r.min(w / 2.0).min(h / 2.0);
|
||||||
let mut pb = PathBuilder::new();
|
let mut pb = PathBuilder::new();
|
||||||
@@ -157,9 +157,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]));
|
||||||
@@ -184,18 +184,10 @@ fn tint_pixmap_as_shadow(pixmap: &mut Pixmap, color: [u8; 4]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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 {
|
fn box_blur_rgba(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||||||
if radius == 0 {
|
if radius == 0 {
|
||||||
return img.clone();
|
return img.clone();
|
||||||
}
|
}
|
||||||
// Three passes of H+V to approximate a Gaussian.
|
|
||||||
let mut buf = sliding_horizontal(img, radius);
|
let mut buf = sliding_horizontal(img, radius);
|
||||||
buf = sliding_vertical(&buf, radius);
|
buf = sliding_vertical(&buf, radius);
|
||||||
buf = sliding_horizontal(&buf, radius);
|
buf = sliding_horizontal(&buf, radius);
|
||||||
@@ -203,7 +195,6 @@ fn box_blur_rgba(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Horizontal sliding-window box blur, single pass.
|
|
||||||
fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||||||
let (w, h) = img.dimensions();
|
let (w, h) = img.dimensions();
|
||||||
let r = radius as i32;
|
let r = radius as i32;
|
||||||
@@ -211,13 +202,11 @@ fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|||||||
let mut out = RgbaImage::new(w, h);
|
let mut out = RgbaImage::new(w, h);
|
||||||
|
|
||||||
for y in 0..h {
|
for y in 0..h {
|
||||||
// Accumulator for the current window.
|
|
||||||
let mut sr = 0u32;
|
let mut sr = 0u32;
|
||||||
let mut sg = 0u32;
|
let mut sg = 0u32;
|
||||||
let mut sb = 0u32;
|
let mut sb = 0u32;
|
||||||
let mut sa = 0u32;
|
let mut sa = 0u32;
|
||||||
|
|
||||||
// Seed the window around x=0.
|
|
||||||
for dx in -r..=r {
|
for dx in -r..=r {
|
||||||
let sx = dx.clamp(0, w as i32 - 1) as u32;
|
let sx = dx.clamp(0, w as i32 - 1) as u32;
|
||||||
let p = img.get_pixel(sx, y).0;
|
let p = img.get_pixel(sx, y).0;
|
||||||
@@ -228,18 +217,21 @@ 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.
|
|
||||||
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;
|
||||||
@@ -249,7 +241,6 @@ fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vertical sliding-window box blur, single pass.
|
|
||||||
fn sliding_vertical(img: &RgbaImage, radius: u32) -> RgbaImage {
|
fn sliding_vertical(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||||||
let (w, h) = img.dimensions();
|
let (w, h) = img.dimensions();
|
||||||
let r = radius as i32;
|
let r = radius as i32;
|
||||||
@@ -272,17 +263,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()
|
|
||||||
}
|
|
||||||
+187
-168
@@ -1,192 +1,211 @@
|
|||||||
//! 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::{bail, 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;
|
/// CLI overrides that map 1:1 to `EffectsConfig` fields.
|
||||||
|
/// All fields are `Option<T>` so we can tell "not supplied" from any concrete value.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CliOverrides {
|
||||||
|
rounded_corners: Option<bool>,
|
||||||
|
corner_radius: Option<f32>,
|
||||||
|
drop_shadow: Option<bool>,
|
||||||
|
shadow_blur_radius: Option<f32>,
|
||||||
|
shadow_offset_x: Option<f32>,
|
||||||
|
shadow_offset_y: Option<f32>,
|
||||||
|
/// Accepted as four comma-separated u8 values, e.g. `255,0,0,200`
|
||||||
|
shadow_color: Option<[u8; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(s: &str) -> Result<bool> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"true" | "1" | "yes" => Ok(true),
|
||||||
|
"false" | "0" | "no" => Ok(false),
|
||||||
|
other => bail!("Expected a boolean (true/false), got '{other}'"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_shadow_color(s: &str) -> Result<[u8; 4]> {
|
||||||
|
let parts: Vec<&str> = s.split(',').collect();
|
||||||
|
if parts.len() != 4 {
|
||||||
|
bail!("--shadow_color expects four comma-separated u8 values, e.g. 255,0,0,200");
|
||||||
|
}
|
||||||
|
let r = parts[0]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color red channel")?;
|
||||||
|
let g = parts[1]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color green channel")?;
|
||||||
|
let b = parts[2]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color blue channel")?;
|
||||||
|
let a = parts[3]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color alpha channel")?;
|
||||||
|
Ok([r, g, b, a])
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
// ── 1. Load config & arguments ────────────────────────────────────────────
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let config = config::Config::load().context("Failed to load config")?;
|
|
||||||
let is_live_mode = config.live_mode || std::env::args().any(|a| a == "--live" || a == "-l");
|
|
||||||
|
|
||||||
// ── 2. Query Hyprland metadata (no window open yet) ───────────────────────
|
let mut image_path: Option<String> = None;
|
||||||
// Scale is only needed for window-rect conversion in the overlay.
|
let mut overrides = CliOverrides::default();
|
||||||
let scale = hyprland::active_monitor_scale();
|
|
||||||
let (lw, lh) = hyprland::active_monitor_logical_size().unwrap_or((1920, 1080));
|
|
||||||
|
|
||||||
// ── 3. Pre-capture delay ──────────────────────────────────────────────────
|
let mut i = 0;
|
||||||
// Give the compositor time to unmap the terminal/launcher that started us
|
while i < args.len() {
|
||||||
// and repaint the desktop before we freeze it.
|
match args[i].as_str() {
|
||||||
// Configurable via capture_delay_ms in config.toml (default 800ms).
|
"--image" => {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(config.capture_delay_ms));
|
i += 1;
|
||||||
|
image_path = Some(
|
||||||
// ── 4. Capture full desktop BEFORE opening any window ────────────────────
|
args.get(i)
|
||||||
// In freeze mode (default), we snapshot before the overlay.
|
.cloned()
|
||||||
// In live mode, we skip this and capture later.
|
.context("Expected a path after --image")?,
|
||||||
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)
|
"--rounded_corners" => {
|
||||||
.with_context(|| format!("Failed to save to {}", path.display()))?;
|
i += 1;
|
||||||
eprintln!("Saved to {}", path.display());
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected true/false after --rounded_corners")?;
|
||||||
|
overrides.rounded_corners = Some(parse_bool(val)?);
|
||||||
|
}
|
||||||
|
"--corner_radius" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --corner_radius")?;
|
||||||
|
overrides.corner_radius = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--corner_radius must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--drop_shadow" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected true/false after --drop_shadow")?;
|
||||||
|
overrides.drop_shadow = Some(parse_bool(val)?);
|
||||||
|
}
|
||||||
|
"--shadow_blur_radius" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --shadow_blur_radius")?;
|
||||||
|
overrides.shadow_blur_radius = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--shadow_blur_radius must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--shadow_offset_x" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --shadow_offset_x")?;
|
||||||
|
overrides.shadow_offset_x = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--shadow_offset_x must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--shadow_offset_y" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --shadow_offset_y")?;
|
||||||
|
overrides.shadow_offset_y = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--shadow_offset_y must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--shadow_color" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected r,g,b,a after --shadow_color")?;
|
||||||
|
overrides.shadow_color = Some(parse_shadow_color(val)?);
|
||||||
|
}
|
||||||
|
unknown => bail!("Unknown argument: {unknown}"),
|
||||||
}
|
}
|
||||||
if config.auto_copy {
|
i += 1;
|
||||||
clipboard_copy(&final_image)?;
|
|
||||||
eprintln!("Copied to clipboard.");
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 8b. Review window ─────────────────────────────────────────────────────
|
let image_path = image_path.context("Missing --image <path>")?;
|
||||||
// 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(
|
let config = config::Config::load().context("Failed to load config")?;
|
||||||
"rs-pictures — Review",
|
|
||||||
native_options,
|
let mut effects = config.screenshot;
|
||||||
Box::new(move |cc| {
|
if effects.mode == "auto" {
|
||||||
Ok(Box::new(ReviewWindow::new(cc, raw_image, config)) as Box<dyn eframe::App>)
|
if let Some(v) = overrides.rounded_corners {
|
||||||
}),
|
effects.rounded_corners = v;
|
||||||
)
|
}
|
||||||
.map_err(|e| anyhow::anyhow!("Review window error: {e}"))?;
|
if let Some(v) = overrides.corner_radius {
|
||||||
|
effects.corner_radius = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.drop_shadow {
|
||||||
|
effects.drop_shadow = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_blur_radius {
|
||||||
|
effects.shadow_blur_radius = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_offset_x {
|
||||||
|
effects.shadow_offset_x = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_offset_y {
|
||||||
|
effects.shadow_offset_y = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_color {
|
||||||
|
effects.shadow_color = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = process_image(&image_path, &effects) {
|
||||||
|
eprintln!("Error processing '{}': {e:#}", image_path);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Clipboard helper (shared with review.rs logic) ───────────────────────────
|
fn process_image(path: &str, effects: &config::EffectsConfig) -> Result<()> {
|
||||||
|
let img = image::open(path)
|
||||||
|
.with_context(|| format!("Failed to open image '{path}'"))?
|
||||||
|
.into_rgba8();
|
||||||
|
|
||||||
pub fn clipboard_copy(img: &image::RgbaImage) -> Result<()> {
|
let processed = effects::apply_effects(img, effects);
|
||||||
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 ──────────────────────────────────────────────────────────
|
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")?;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
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