Files
What_That_Claude_Do/src/review.rs
T

346 lines
14 KiB
Rust

//! 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<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);
}
}
}