uh... converter?
This commit is contained in:
+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 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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user