uh... converter?

This commit is contained in:
2026-05-11 16:20:51 +02:00
parent 200e76b899
commit 2903819626
12 changed files with 148 additions and 4415 deletions
+52 -170
View File
@@ -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 effects;
mod hyprland;
mod overlay;
mod review;
use anyhow::{Context, Result};
use arboard::{Clipboard, ImageData};
use eframe::egui;
use overlay::{SelectionOverlay, SelectionResult};
use review::ReviewWindow;
use std::io::Write as _;
use std::process::{Command, Stdio};
fn main() -> Result<()> {
// ── 1. Load config & arguments ────────────────────────────────────────────
let config = config::Config::load().context("Failed to load config")?;
let is_live_mode = config.live_mode || std::env::args().any(|a| a == "--live" || a == "-l");
// ── 1. Collect image paths from CLI arguments ─────────────────────────────
let paths: Vec<String> = std::env::args().skip(1).collect();
// ── 2. Query Hyprland metadata (no window open yet) ───────────────────────
// Scale is only needed for window-rect conversion in the overlay.
let scale = hyprland::active_monitor_scale();
let (lw, lh) = hyprland::active_monitor_logical_size().unwrap_or((1920, 1080));
// ── 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(());
if paths.is_empty() {
eprintln!("Usage: rs-pictures <image> [image2 ...]");
eprintln!("No image paths provided.");
std::process::exit(1);
}
// ── 8b. Review window ─────────────────────────────────────────────────────
// The review window applies effects internally on a background thread, so
// we hand it the raw (un-effected) image.
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("rs-pictures — Review")
.with_inner_size([900.0, 700.0])
.with_min_inner_size([400.0, 300.0]),
..Default::default()
};
// ── 2. Load config (generates default if missing) ─────────────────────────
let config = config::Config::load().context("Failed to load config")?;
eframe::run_native(
"rs-pictures — Review",
native_options,
Box::new(move |cc| {
Ok(Box::new(ReviewWindow::new(cc, raw_image, config)) as Box<dyn eframe::App>)
}),
)
.map_err(|e| anyhow::anyhow!("Review window error: {e}"))?;
// ── 3. Process each image ─────────────────────────────────────────────────
for path in &paths {
if let Err(e) = process_image(path, &config) {
eprintln!("Error processing '{}': {e:#}", path);
}
}
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<()> {
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")
}
// ── b. Apply effects (rounded corners + drop shadow) ─────────────────────
let processed = effects::apply_effects(img, &config.effects);
// ── 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 {
inner: SelectionOverlay,
result_sink: Arc<Mutex<Option<SelectionResult>>>,
}
child
.stdin
.take()
.context("Failed to get swappy stdin")?
.write_all(&png_bytes)
.context("Failed to write image data to swappy")?;
impl eframe::App for OverlayWrapper {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.inner.update(ctx, frame);
if let Some(result) = self.inner.take_result() {
*self.result_sink.lock().unwrap() = Some(result);
}
let status = child.wait().context("Failed to wait for swappy")?;
if !status.success() {
eprintln!(
"swappy exited with non-zero status for '{}': {}",
path, status
);
}
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
self.inner.clear_color(visuals)
}
Ok(())
}