//! After-capture review window. //! //! Performance design: //! - Effects (rounded corners, drop shadow) run on a background thread so the //! UI never blocks. A `Receiver` is polled each frame; when the result //! arrives the texture is swapped out. //! - A debounce timer (`dirty_since`) ensures we only spawn a new worker 150 ms //! after the last slider change, not on every incremental drag tick. //! - The raw image is wrapped in Arc so it is shared with worker threads //! without cloning the pixel data. //! - Texture uploads are guarded by MAX_TEX so we never panic on large images. use std::path::PathBuf; use std::sync::{Arc, mpsc}; use std::time::{Duration, Instant}; use arboard::{Clipboard, ImageData}; use eframe::egui::{self, Color32, ColorImage, Rounding, ScrollArea, Stroke, TextureHandle, TextureOptions, Vec2}; use image::RgbaImage; use crate::{config::Config, effects::apply_effects}; const MAX_TEX: u32 = 2048; /// How long to wait after the last setting change before spawning the worker. const DEBOUNCE: Duration = Duration::from_millis(150); #[derive(Debug)] pub enum ReviewAction { #[allow(dead_code)] // path stored for future use (e.g. desktop notification) Saved(PathBuf), Copied, Discarded, } /// Channel message from the background effects worker. struct EffectsResult(RgbaImage); pub struct ReviewWindow { /// Full-resolution raw capture — shared with worker threads via Arc. raw_image: Arc, /// Last fully-processed preview (what gets saved/copied). preview_image: Arc, /// GPU texture (may be downscaled for display). preview_texture: TextureHandle, pub config: Config, save_as_path: String, status_message: Option, pub action: Option, settings_open: bool, /// Set when settings change; cleared when a worker is spawned. dirty_since: Option, /// Receives the processed image from the background worker. worker_rx: Option>, /// 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); } } }