use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::time::Duration;
use eframe::egui::load::SizedTexture;
use eframe::egui::{
self, Align, Align2, Color32, CornerRadius, CursorIcon, Id, Layout, Margin, Order, RichText,
Sense, Stroke, StrokeKind, TextureHandle, TextureOptions, vec2,
};
use image::RgbImage;
use crate::capture::{CameraOption, CaptureHandle, list_cameras, start_capture};
use crate::snapshot::{SnapshotSettings, save_snapshot};
const C_WINDOW: Color32 = Color32::from_rgb(14, 15, 17);
const C_PANEL: Color32 = Color32::from_rgb(22, 24, 27);
const C_CARD: Color32 = Color32::from_rgb(30, 33, 37);
const C_STROKE: Color32 = Color32::from_rgb(52, 57, 63);
const C_TEXT: Color32 = Color32::from_rgb(236, 238, 240);
const C_DIM: Color32 = Color32::from_rgb(162, 168, 176);
const C_ACCENT: Color32 = Color32::from_rgb(232, 163, 61);
const C_ERROR: Color32 = Color32::from_rgb(229, 83, 75);
const C_LETTERBOX: Color32 = Color32::from_rgb(6, 6, 7);
const FLASH_SECS: f64 = 0.25;
const TOAST_HOLD: f64 = 1.5; const TOAST_FADE: f64 = 1.0; const TOAST_TOTAL: f64 = TOAST_HOLD + TOAST_FADE;
const THUMB_W: u32 = 160;
const THUMB_H: u32 = 90;
struct Snapshot {
path: PathBuf,
thumb_rgb: Vec<u8>,
thumb_w: u32,
thumb_h: u32,
}
type SnapResult = Result<Snapshot, String>;
struct LastShot {
texture: TextureHandle,
dir: PathBuf,
name: String,
}
enum State {
Picker {
cameras: Vec<CameraOption>,
error: Option<String>,
},
Previewing {
capture: CaptureHandle,
camera_name: String,
},
}
enum Action {
None,
Start(CameraOption),
BackToPicker(Option<String>),
Quit,
}
pub struct CamshooterApp {
settings: SnapshotSettings,
state: State,
texture: Option<TextureHandle>,
last_frame: Option<RgbImage>,
preview_dims: Option<(u32, u32)>,
flash_start: Option<f64>,
toast: Option<(String, f64, bool)>,
last_shot: Option<LastShot>,
snap_tx: Sender<SnapResult>,
snap_rx: Receiver<SnapResult>,
snap_in_flight: Arc<AtomicBool>,
}
impl CamshooterApp {
pub fn new(cc: &eframe::CreationContext<'_>, settings: SnapshotSettings) -> Self {
apply_theme(&cc.egui_ctx);
let (snap_tx, snap_rx) = channel();
let state = match list_cameras() {
Ok(cameras) => State::Picker {
cameras,
error: None,
},
Err(e) => State::Picker {
cameras: Vec::new(),
error: Some(format!("Could not list cameras: {e}")),
},
};
Self {
settings,
state,
texture: None,
last_frame: None,
preview_dims: None,
flash_start: None,
toast: None,
last_shot: None,
snap_tx,
snap_rx,
snap_in_flight: Arc::new(AtomicBool::new(false)),
}
}
fn apply(&mut self, action: Action, ctx: &egui::Context) {
match action {
Action::None => {}
Action::Start(opt) => {
self.stop_capture();
self.reset_session();
let capture = start_capture(opt.index.clone(), ctx.clone());
self.state = State::Previewing {
capture,
camera_name: opt.name,
};
}
Action::BackToPicker(err) => {
self.stop_capture();
self.reset_session();
let cameras = list_cameras().unwrap_or_default();
self.state = State::Picker {
cameras,
error: err,
};
}
Action::Quit => {
self.stop_capture(); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
fn reset_session(&mut self) {
while self.snap_rx.try_recv().is_ok() {}
self.texture = None;
self.last_frame = None;
self.preview_dims = None;
self.flash_start = None;
self.toast = None;
self.last_shot = None;
self.snap_in_flight.store(false, Ordering::Release);
}
fn stop_capture(&mut self) {
if let State::Previewing { capture, .. } = &mut self.state {
capture.stop();
}
}
fn drain_snapshots(&mut self, ctx: &egui::Context, now: f64) {
loop {
match self.snap_rx.try_recv() {
Ok(Ok(snap)) => {
self.snap_in_flight.store(false, Ordering::Release);
let name = snap
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let dir = snap
.path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let color = egui::ColorImage::from_rgb(
[snap.thumb_w as usize, snap.thumb_h as usize],
&snap.thumb_rgb,
);
let texture = ctx.load_texture("last_shot", color, TextureOptions::LINEAR);
self.toast = Some((format!("Saved {name}"), now, false));
self.last_shot = Some(LastShot { texture, dir, name });
}
Ok(Err(e)) => {
self.snap_in_flight.store(false, Ordering::Release);
self.toast = Some((format!("Snapshot failed: {e}"), now, true));
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.snap_in_flight.store(false, Ordering::Release);
break;
}
}
}
}
}
impl eframe::App for CamshooterApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let now = ctx.input(|i| i.time);
self.drain_snapshots(&ctx, now);
if let Some(start) = self.flash_start
&& now - start >= FLASH_SECS
{
self.flash_start = None;
}
if let Some((_, shown, _)) = &self.toast
&& now - *shown >= TOAST_TOTAL
{
self.toast = None;
}
let action = match &mut self.state {
State::Picker { cameras, error } => render_picker(ui, cameras, error.as_deref()),
State::Previewing {
capture,
camera_name,
} => {
if let Some(err) = capture.take_error() {
Action::BackToPicker(Some(err))
} else {
render_preview(
ui,
capture,
camera_name.as_str(),
&self.settings,
now,
&mut self.texture,
&mut self.last_frame,
&mut self.preview_dims,
&mut self.flash_start,
&self.toast,
&self.last_shot,
&self.snap_tx,
&self.snap_in_flight,
)
}
}
};
self.apply(action, &ctx);
if self.flash_start.is_some() || self.toast.is_some() {
ctx.request_repaint();
}
}
fn on_exit(&mut self) {
self.stop_capture();
}
}
fn apply_theme(ctx: &egui::Context) {
use egui::{FontFamily::Monospace, FontFamily::Proportional, FontId, TextStyle};
ctx.set_visuals(egui::Visuals::dark());
ctx.all_styles_mut(|style| {
let v = &mut style.visuals;
v.panel_fill = C_PANEL;
v.window_fill = C_WINDOW;
v.extreme_bg_color = C_LETTERBOX;
v.override_text_color = Some(C_TEXT);
v.selection.bg_fill = C_ACCENT.linear_multiply(0.35);
v.selection.stroke = Stroke::new(1.0, C_ACCENT);
v.hyperlink_color = C_ACCENT;
v.window_corner_radius = CornerRadius::same(10);
for w in [
&mut v.widgets.noninteractive,
&mut v.widgets.inactive,
&mut v.widgets.hovered,
&mut v.widgets.active,
&mut v.widgets.open,
] {
w.corner_radius = CornerRadius::same(6);
}
style.spacing.item_spacing = vec2(10.0, 8.0);
style.spacing.button_padding = vec2(12.0, 7.0);
style.text_styles = [
(TextStyle::Heading, FontId::new(22.0, Proportional)),
(TextStyle::Body, FontId::new(15.0, Proportional)),
(TextStyle::Button, FontId::new(15.0, Proportional)),
(TextStyle::Monospace, FontId::new(13.0, Monospace)),
(TextStyle::Small, FontId::new(12.5, Proportional)),
]
.into();
});
}
fn render_picker(ui: &mut egui::Ui, cameras: &[CameraOption], error: Option<&str>) -> Action {
let ctx = ui.ctx().clone();
let mut action = Action::None;
const NUM_KEYS: [egui::Key; 9] = [
egui::Key::Num1,
egui::Key::Num2,
egui::Key::Num3,
egui::Key::Num4,
egui::Key::Num5,
egui::Key::Num6,
egui::Key::Num7,
egui::Key::Num8,
egui::Key::Num9,
];
for (i, key) in NUM_KEYS.iter().enumerate() {
if i < cameras.len() && ctx.input(|input| input.key_pressed(*key)) {
action = Action::Start(cameras[i].clone());
}
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Q)) {
action = Action::Quit;
}
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.add_space(28.0);
ui.vertical_centered(|ui| {
ui.set_max_width(440.0);
ui.heading(RichText::new("camshooter").color(C_ACCENT));
ui.label(RichText::new("Choose a camera").color(C_DIM));
ui.add_space(14.0);
if let Some(err) = error {
ui.colored_label(C_ERROR, err);
ui.add_space(8.0);
}
if cameras.is_empty() {
ui.label(RichText::new("No webcams detected.").color(C_DIM));
ui.add_space(8.0);
if ui.button("⟳ Refresh").clicked() {
action = Action::BackToPicker(None); }
} else {
for (i, cam) in cameras.iter().enumerate() {
if camera_card(ui, i + 1, &cam.name).clicked() {
action = Action::Start(cam.clone());
}
}
ui.add_space(10.0);
ui.label(
RichText::new("Tip: press 1–9 to pick by number")
.small()
.color(C_DIM),
);
}
ui.add_space(4.0);
ui.label(RichText::new("Esc / Q = quit").small().color(C_DIM));
});
});
action
}
fn camera_card(ui: &mut egui::Ui, n: usize, name: &str) -> egui::Response {
let resp = egui::Frame::default()
.fill(C_CARD)
.corner_radius(CornerRadius::same(10))
.inner_margin(Margin::symmetric(14, 12))
.stroke(Stroke::new(1.0, C_STROKE))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.colored_label(C_ACCENT, "●");
ui.add_space(2.0);
ui.label(RichText::new(name).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(RichText::new(format!("[{n}]")).color(C_DIM));
});
});
})
.response
.interact(Sense::click());
if resp.hovered() {
ui.painter().rect_stroke(
resp.rect,
CornerRadius::same(10),
Stroke::new(1.5, C_ACCENT),
StrokeKind::Inside,
);
}
resp.on_hover_cursor(CursorIcon::PointingHand)
}
#[allow(clippy::too_many_arguments)]
fn render_preview(
ui: &mut egui::Ui,
capture: &CaptureHandle,
camera_name: &str,
settings: &SnapshotSettings,
now: f64,
texture: &mut Option<TextureHandle>,
last_frame: &mut Option<RgbImage>,
preview_dims: &mut Option<(u32, u32)>,
flash_start: &mut Option<f64>,
toast: &Option<(String, f64, bool)>,
last_shot: &Option<LastShot>,
snap_tx: &Sender<SnapResult>,
snap_in_flight: &Arc<AtomicBool>,
) -> Action {
let ctx = ui.ctx().clone();
if let Some(frame) = capture.latest_frame() {
*preview_dims = Some((frame.width(), frame.height()));
let size = [frame.width() as usize, frame.height() as usize];
let color = egui::ColorImage::from_rgb(size, frame.as_raw());
match texture {
Some(tex) => tex.set(color, TextureOptions::LINEAR),
None => *texture = Some(ctx.load_texture("preview", color, TextureOptions::LINEAR)),
}
*last_frame = Some(frame);
}
let snap = ctx.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::S));
let quit = ctx.input(|i| i.key_pressed(egui::Key::Q));
let back = ctx.input(|i| i.key_pressed(egui::Key::Escape));
if snap
&& let Some(frame) = last_frame.clone()
&& !snap_in_flight.swap(true, Ordering::AcqRel)
{
*flash_start = Some(now);
let settings = settings.clone();
let tx = snap_tx.clone();
let ctx2 = ctx.clone();
std::thread::spawn(move || {
let result: SnapResult = save_snapshot(&frame, &settings)
.map_err(|e| e.to_string())
.map(|path| {
let thumb = image::imageops::thumbnail(&frame, THUMB_W, THUMB_H);
Snapshot {
path,
thumb_w: thumb.width(),
thumb_h: thumb.height(),
thumb_rgb: thumb.into_raw(),
}
});
let _ = tx.send(result);
ctx2.request_repaint();
});
}
egui::Panel::top("info").show_inside(ui, |ui| {
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.colored_label(C_ACCENT, "●");
ui.label(RichText::new(camera_name).strong());
if let Some((w, h)) = *preview_dims {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(RichText::new(format!("{w}×{h}")).color(C_DIM));
});
}
});
ui.add_space(2.0);
});
egui::Panel::bottom("hint").show_inside(ui, |ui| {
ui.add_space(2.0);
ui.label(
RichText::new("Space snapshot · Esc back · Q quit")
.small()
.color(C_DIM),
);
ui.add_space(2.0);
});
egui::CentralPanel::default()
.frame(egui::Frame::default().fill(C_LETTERBOX))
.show_inside(ui, |ui| {
let avail = ui.available_size();
ui.centered_and_justified(|ui| {
if let Some(tex) = texture {
ui.add(
egui::Image::new(SizedTexture::new(tex.id(), tex.size_vec2()))
.maintain_aspect_ratio(true)
.fit_to_exact_size(avail),
);
} else {
ui.label(RichText::new("Starting camera…").color(C_DIM));
}
});
if let Some(start) = *flash_start {
let progress = ((now - start) / FLASH_SECS).clamp(0.0, 1.0);
let alpha = ((1.0 - progress) * 200.0) as u8;
if alpha > 0 {
ui.painter()
.rect_filled(ui.max_rect(), 0, Color32::from_white_alpha(alpha));
}
}
});
draw_toast(&ctx, toast, now);
draw_last_shot(&ctx, last_shot);
ctx.request_repaint_after(Duration::from_millis(100));
if quit {
Action::Quit
} else if back {
Action::BackToPicker(None)
} else {
Action::None
}
}
fn draw_toast(ctx: &egui::Context, toast: &Option<(String, f64, bool)>, now: f64) {
let Some((msg, shown, is_err)) = toast else {
return;
};
let elapsed = now - *shown;
let alpha = if elapsed < TOAST_HOLD {
1.0
} else {
(1.0 - (elapsed - TOAST_HOLD) / TOAST_FADE).clamp(0.0, 1.0)
} as f32;
if alpha <= 0.0 {
return;
}
let border = if *is_err { C_ERROR } else { C_ACCENT };
egui::Area::new(Id::new("toast"))
.order(Order::Foreground)
.default_size(vec2(320.0, 44.0)) .anchor(Align2::CENTER_BOTTOM, vec2(0.0, -76.0))
.show(ctx, |ui| {
egui::Frame::default()
.fill(Color32::from_black_alpha((220.0 * alpha) as u8))
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::symmetric(14, 8))
.stroke(Stroke::new(1.0, border.gamma_multiply(alpha)))
.show(ui, |ui| {
ui.label(RichText::new(msg).color(C_TEXT.gamma_multiply(alpha)));
});
});
}
fn draw_last_shot(ctx: &egui::Context, last_shot: &Option<LastShot>) {
let Some(shot) = last_shot else {
return;
};
egui::Area::new(Id::new("last_shot"))
.order(Order::Foreground)
.default_size(vec2(172.0, 116.0)) .anchor(Align2::RIGHT_BOTTOM, vec2(-16.0, -68.0))
.show(ctx, |ui| {
egui::Frame::default()
.fill(C_PANEL)
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(6))
.stroke(Stroke::new(1.0, C_STROKE))
.show(ui, |ui| {
ui.vertical(|ui| {
let resp = ui.add(
egui::Image::new(&shot.texture)
.fit_to_exact_size(vec2(160.0, 90.0))
.sense(Sense::click()),
);
ui.label(RichText::new(&shot.name).small().color(C_DIM));
if resp.on_hover_cursor(CursorIcon::PointingHand).clicked() {
open_folder(&shot.dir);
}
});
});
});
}
fn open_folder(dir: &Path) {
let _ = std::process::Command::new("xdg-open").arg(dir).spawn();
}