diff --git a/src/app.rs b/src/app.rs index 291efd2..0825587 100644 --- a/src/app.rs +++ b/src/app.rs @@ -48,6 +48,10 @@ pub enum WorkerMessage { progress: f32, detail: Option, }, + LogMessage { + job_id: usize, + message: String, + }, JobComplete { job_id: usize, output_path: PathBuf, @@ -58,6 +62,13 @@ pub enum WorkerMessage { }, } +/// A log entry with timestamp +#[derive(Clone)] +pub struct LogEntry { + pub timestamp: std::time::Instant, + pub message: String, +} + /// Application settings for AVIF encoding #[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct EncodingSettings { @@ -101,16 +112,36 @@ pub struct AvifMakerApp { drop_zone: DropZone, settings_ui: Settings, queue_ui: Queue, + /// Console log entries + pub console_log: Vec, + /// Whether console is visible + pub show_console: bool, + /// Start time for relative timestamps + start_time: std::time::Instant, } impl AvifMakerApp { - pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + // Configure larger fonts for accessibility + let mut style = (*cc.egui_ctx.style()).clone(); + + // Increase all font sizes + for (_text_style, font_id) in style.text_styles.iter_mut() { + font_id.size *= 1.3; // 30% larger + } + + // Increase spacing for touch/visibility + style.spacing.item_spacing = egui::vec2(10.0, 8.0); + style.spacing.button_padding = egui::vec2(8.0, 4.0); + + cc.egui_ctx.set_style(style); + let (worker_tx, worker_rx) = mpsc::channel(); // Try to load saved settings let settings = Self::load_settings().unwrap_or_default(); - Self { + let mut app = Self { settings, jobs: Vec::new(), next_job_id: 0, @@ -120,8 +151,40 @@ impl AvifMakerApp { drop_zone: DropZone::new(), settings_ui: Settings::new(), queue_ui: Queue::new(), + console_log: Vec::new(), + show_console: false, + start_time: std::time::Instant::now(), + }; + + app.log("AVIF Maker started"); + app + } + + /// Add a message to the console log + pub fn log(&mut self, message: impl Into) { + self.console_log.push(LogEntry { + timestamp: std::time::Instant::now(), + message: message.into(), + }); + // Keep log reasonable size + if self.console_log.len() > 1000 { + self.console_log.remove(0); } } + + /// Format the console log as plain text for copy/save + fn format_console_log(&self) -> String { + let mut output = String::new(); + output.push_str("AVIF Maker Console Log\n"); + output.push_str("======================\n\n"); + + for entry in &self.console_log { + let elapsed = entry.timestamp.duration_since(self.start_time); + output.push_str(&format!("[{:8.2}s] {}\n", elapsed.as_secs_f64(), entry.message)); + } + + output + } fn load_settings() -> Option { let dirs = directories::ProjectDirs::from("", "", "avif-maker")?; @@ -231,14 +294,41 @@ impl AvifMakerApp { settings: EncodingSettings, tx: Sender, ) { + let filename = input_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Log start + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("Starting conversion: {}", filename), + }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!(" Input: {}", input_path.display()), + }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!(" Output: {}", output_path.display()), + }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!(" Settings: quality={}, alpha={}, speed={}", + settings.quality, settings.alpha_quality, settings.speed), + }); + // Create decode progress callback let tx_decode = tx.clone(); + let tx_decode_log = tx.clone(); + let last_logged_frame = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let last_logged_clone = last_logged_frame.clone(); + let decode_progress: ProgressCallback = Arc::new(move |current, total| { let (progress, detail) = if let Some(total) = total { let pct = current as f32 / total as f32; - (pct * 0.5, format!("frame {}/{}", current, total)) // Decoding is 0-50% + (pct * 0.5, format!("frame {}/{}", current, total)) } else { - // Unknown total - just show frame count (0.25, format!("frame {}", current)) }; let _ = tx_decode.send(WorkerMessage::ProgressUpdate { @@ -247,6 +337,18 @@ impl AvifMakerApp { progress, detail: Some(detail), }); + + // Log every 10 frames or when total is known and we hit milestones + let last = last_logged_clone.load(std::sync::atomic::Ordering::Relaxed); + if current >= last + 10 || current == 1 { + last_logged_clone.store(current, std::sync::atomic::Ordering::Relaxed); + let msg = if let Some(t) = total { + format!(" Decoded frame {}/{}", current, t) + } else { + format!(" Decoded frame {}", current) + }; + let _ = tx_decode_log.send(WorkerMessage::LogMessage { job_id, message: msg }); + } }); // Update status: decoding started @@ -256,12 +358,21 @@ impl AvifMakerApp { progress: 0.0, detail: Some("starting...".to_string()), }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: "Decoding started...".to_string(), + }); // Decode input with progress + let decode_start = std::time::Instant::now(); let decoder = Decoder::for_path(&input_path); let frames = match decoder.decode_with_progress(&input_path, Some(decode_progress)) { Ok(frames) => frames, Err(e) => { + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("ERROR: Decode failed: {}", e), + }); let _ = tx.send(WorkerMessage::JobFailed { job_id, error: format!("Decode error: {}", e), @@ -269,13 +380,30 @@ impl AvifMakerApp { return; } }; + let decode_time = decode_start.elapsed(); let total_frames = frames.frames.len(); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("Decode complete: {} frames in {:.2}s", total_frames, decode_time.as_secs_f64()), + }); + + if frames.has_alpha { + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: " Source has alpha channel".to_string(), + }); + } // Create encode progress callback let tx_encode = tx.clone(); + let tx_encode_log = tx.clone(); + let last_logged_encode = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let last_logged_encode_clone = last_logged_encode.clone(); + let total_for_log = total_frames; + let encode_progress: EncodeProgressCallback = Arc::new(move |current, total| { - let pct = 0.5 + (current as f32 / total as f32) * 0.5; // Encoding is 50-100% + let pct = 0.5 + (current as f32 / total as f32) * 0.5; let detail = format!("frame {}/{}", current, total); let _ = tx_encode.send(WorkerMessage::ProgressUpdate { job_id, @@ -283,6 +411,16 @@ impl AvifMakerApp { progress: pct, detail: Some(detail), }); + + // Log every 10 frames + let last = last_logged_encode_clone.load(std::sync::atomic::Ordering::Relaxed); + if current >= last + 10 || current == 1 || current == total_for_log { + last_logged_encode_clone.store(current, std::sync::atomic::Ordering::Relaxed); + let _ = tx_encode_log.send(WorkerMessage::LogMessage { + job_id, + message: format!(" Encoded frame {}/{}", current, total), + }); + } }); // Update status: encoding started @@ -292,17 +430,55 @@ impl AvifMakerApp { progress: 0.5, detail: Some(format!("0/{} frames", total_frames)), }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("Encoding started ({} frames)...", total_frames), + }); // Encode to AVIF with progress + let encode_start = std::time::Instant::now(); let encoder = AvifEncoder::new(&settings); match encoder.encode_with_progress(&frames, &output_path, Some(encode_progress)) { Ok(()) => { + let encode_time = encode_start.elapsed(); + let total_time = decode_time + encode_time; + + // Get output file size + let file_size = std::fs::metadata(&output_path) + .map(|m| m.len()) + .unwrap_or(0); + let size_str = if file_size > 1024 * 1024 { + format!("{:.2} MB", file_size as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.2} KB", file_size as f64 / 1024.0) + }; + + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("Encode complete in {:.2}s", encode_time.as_secs_f64()), + }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("Output size: {}", size_str), + }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("Total time: {:.2}s", total_time.as_secs_f64()), + }); + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("SUCCESS: {}", filename), + }); let _ = tx.send(WorkerMessage::JobComplete { job_id, output_path, }); } Err(e) => { + let _ = tx.send(WorkerMessage::LogMessage { + job_id, + message: format!("ERROR: Encode failed: {}", e), + }); let _ = tx.send(WorkerMessage::JobFailed { job_id, error: format!("Encode error: {}", e), @@ -326,6 +502,9 @@ impl AvifMakerApp { job.progress_detail = detail; } } + WorkerMessage::LogMessage { job_id: _, message } => { + self.log(message); + } WorkerMessage::JobComplete { job_id, output_path } => { if let Some(job) = self.jobs.iter_mut().find(|j| j.id == job_id) { job.status = JobStatus::Complete; @@ -340,7 +519,7 @@ impl AvifMakerApp { WorkerMessage::JobFailed { job_id, error } => { if let Some(job) = self.jobs.iter_mut().find(|j| j.id == job_id) { job.status = JobStatus::Failed; - job.error = Some(error); + job.error = Some(error.clone()); job.progress_detail = None; } self.is_converting = false; @@ -376,25 +555,107 @@ impl eframe::App for AvifMakerApp { ui.horizontal(|ui| { ui.heading("AVIF Maker"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Console toggle button + let console_label = if self.show_console { "Hide Console" } else { "Show Console" }; + if ui.button(console_label).clicked() { + self.show_console = !self.show_console; + } + ui.separator(); ui.label(format!("{} files in queue", self.jobs.len())); }); }); ui.add_space(4.0); }); - // Left panel with settings + // Left panel with settings (rendered before bottom panel so it claims full height) egui::SidePanel::left("settings_panel") .resizable(true) .default_width(280.0) .show(ctx, |ui| { - self.settings_ui.show(ui, &mut self.settings); - ui.add_space(16.0); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + self.settings_ui.show(ui, &mut self.settings); + ui.add_space(16.0); - // Save settings when changed - if ui.button("Save Settings").clicked() { - self.save_settings(); - } + // Save settings when changed + if ui.button("Save Settings").clicked() { + self.save_settings(); + } + ui.add_space(8.0); + }); }); + + // Console panel (bottom) - rendered after left panel so it doesn't overlap + if self.show_console { + egui::TopBottomPanel::bottom("console_panel") + .resizable(true) + .default_height(200.0) + .min_height(100.0) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.heading("Console"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Clear").clicked() { + self.console_log.clear(); + } + + if ui.button("Save...").clicked() { + if let Some(path) = rfd::FileDialog::new() + .set_file_name("avif-maker-log.txt") + .add_filter("Text files", &["txt", "log"]) + .save_file() + { + let log_text = self.format_console_log(); + if let Err(e) = std::fs::write(&path, &log_text) { + self.log(format!("ERROR: Failed to save log: {}", e)); + } else { + self.log(format!("Log saved to: {}", path.display())); + } + } + } + + if ui.button("Copy").clicked() { + let log_text = self.format_console_log(); + ui.ctx().copy_text(log_text); + self.log("Console log copied to clipboard"); + } + + ui.separator(); + ui.label(format!("{} entries", self.console_log.len())); + }); + }); + ui.separator(); + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + ui.style_mut().override_font_id = Some(egui::FontId::monospace(14.0)); + + for entry in &self.console_log { + let elapsed = entry.timestamp.duration_since(self.start_time); + let timestamp = format!("[{:6.2}s]", elapsed.as_secs_f64()); + + // Color based on message content + let color = if entry.message.contains("ERROR") { + egui::Color32::from_rgb(255, 100, 100) + } else if entry.message.contains("SUCCESS") { + egui::Color32::from_rgb(100, 255, 100) + } else if entry.message.starts_with(" ") { + egui::Color32::from_gray(160) + } else { + egui::Color32::from_gray(220) + }; + + ui.horizontal(|ui| { + ui.label(egui::RichText::new(×tamp).color(egui::Color32::from_gray(120))); + ui.label(egui::RichText::new(&entry.message).color(color)); + }); + } + }); + }); + } // Collect files from drop zone (to avoid borrow issues) let mut dropped_paths: Vec = Vec::new(); @@ -413,27 +674,40 @@ impl eframe::App for AvifMakerApp { // Action buttons ui.horizontal(|ui| { let has_queued = self.jobs.iter().any(|j| j.status == JobStatus::Queued); + let has_completed = self.jobs.iter().any(|j| + j.status == JobStatus::Complete || j.status == JobStatus::Failed + ); + // Clear Completed on the left if ui - .add_enabled( - has_queued && !self.is_converting, - egui::Button::new("Convert All"), - ) + .add_enabled(has_completed, egui::Button::new("Clear Completed")) .clicked() { - self.start_conversion(); - } - - if ui - .add_enabled(self.is_converting, egui::Button::new("Cancel")) - .clicked() - { - // Cancel is a no-op for now - could implement with cancellation tokens + self.clear_completed(); } + // Convert All and Cancel on the right ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Clear Completed").clicked() { - self.clear_completed(); + if ui + .add_enabled(self.is_converting, egui::Button::new("Cancel")) + .clicked() + { + // Cancel is a no-op for now - could implement with cancellation tokens + } + + // Make Convert All prominent (green) when there are files to process + let convert_button = if has_queued && !self.is_converting { + egui::Button::new(egui::RichText::new("Convert All").strong()) + .fill(egui::Color32::from_rgb(34, 139, 34)) // Forest green + } else { + egui::Button::new("Convert All") + }; + + if ui + .add_enabled(has_queued && !self.is_converting, convert_button) + .clicked() + { + self.start_conversion(); } }); }); diff --git a/src/main.rs b/src/main.rs index f983d4e..c9610a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,8 @@ fn main() -> eframe::Result<()> { let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([900.0, 700.0]) - .with_min_inner_size([600.0, 400.0]) + .with_inner_size([1000.0, 900.0]) + .with_min_inner_size([700.0, 600.0]) .with_drag_and_drop(true), ..Default::default() }; diff --git a/src/ui/drop_zone.rs b/src/ui/drop_zone.rs index 7d423cd..fd50a31 100644 --- a/src/ui/drop_zone.rs +++ b/src/ui/drop_zone.rs @@ -15,7 +15,7 @@ impl DropZone { F: FnMut(Vec), { let available_width = ui.available_width(); - let height = 120.0; + let height = 140.0; // Check for drag-and-drop hover state let is_hovering = ui.ctx().input(|i| !i.raw.hovered_files.is_empty()); @@ -82,35 +82,57 @@ impl DropZone { } } - // Draw text - let text = if self.is_hovering { - "Drop files here" - } else { - "Drag and drop files here\nor click to browse" - }; - + // Text colors let text_color = if self.is_hovering { Color32::WHITE } else { Color32::from_gray(180) }; - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(16.0), - text_color, - ); + let font = egui::FontId::proportional(20.0); + let hint_font = egui::FontId::proportional(15.0); - // Supported formats hint - ui.painter().text( - egui::pos2(rect.center().x, rect.max.y - 20.0), - egui::Align2::CENTER_CENTER, - "GIF, PNG, JPEG, WebP, MP4, MOV, WebM", - egui::FontId::proportional(11.0), - Color32::from_gray(120), - ); + // Calculate vertical positions for all text elements to be visually centered together + // Main text line height ~24px, hint ~18px, spacing between ~12px + // Total height: 24 + 24 + 12 + 18 = ~78px for non-hover, center that in rect + let total_text_height = 78.0; + let top_of_text = rect.center().y - total_text_height / 2.0; + + if self.is_hovering { + // Single line when hovering - center it + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Drop files here", + font, + text_color, + ); + } else { + // Line 1: "Drag and drop files here" + ui.painter().text( + egui::pos2(rect.center().x, top_of_text + 12.0), + egui::Align2::CENTER_CENTER, + "Drag and drop files here", + font.clone(), + text_color, + ); + // Line 2: "or click to browse" + ui.painter().text( + egui::pos2(rect.center().x, top_of_text + 38.0), + egui::Align2::CENTER_CENTER, + "or click to browse", + font, + text_color, + ); + // Hint line at bottom of text block + ui.painter().text( + egui::pos2(rect.center().x, top_of_text + 66.0), + egui::Align2::CENTER_CENTER, + "GIF, PNG, JPEG, WebP, MP4, MOV, WebM", + hint_font, + Color32::from_gray(140), + ); + } // Handle click to open file dialog if response.clicked() { diff --git a/src/ui/queue.rs b/src/ui/queue.rs index 14d5cab..d7c5abb 100644 --- a/src/ui/queue.rs +++ b/src/ui/queue.rs @@ -16,7 +16,7 @@ impl Queue { ui.centered_and_justified(|ui| { ui.label( RichText::new("No files in queue") - .size(14.0) + .size(18.0) .color(Color32::from_gray(120)), ); }); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 7433b23..66e5ae1 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -12,11 +12,17 @@ impl Settings { ui.heading("Encoding Settings"); ui.add_space(12.0); - // Quality slider + // Quality setting with editable number ui.horizontal(|ui| { ui.label("Quality:"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(format!("{}", settings.quality)); + let mut quality = settings.quality as i32; + let drag = egui::DragValue::new(&mut quality) + .range(0..=100) + .speed(1); + if ui.add(drag).changed() { + settings.quality = quality.clamp(0, 100) as u8; + } }); }); let quality_slider = egui::Slider::new(&mut settings.quality, 0..=100) @@ -25,17 +31,22 @@ impl Settings { ui.add_space(4.0); ui.label( egui::RichText::new("0 = smallest file, 100 = best quality") - .small() .weak(), ); ui.add_space(12.0); - // Alpha quality slider + // Alpha quality setting with editable number ui.horizontal(|ui| { ui.label("Alpha Quality:"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(format!("{}", settings.alpha_quality)); + let mut alpha = settings.alpha_quality as i32; + let drag = egui::DragValue::new(&mut alpha) + .range(0..=100) + .speed(1); + if ui.add(drag).changed() { + settings.alpha_quality = alpha.clamp(0, 100) as u8; + } }); }); let alpha_slider = egui::Slider::new(&mut settings.alpha_quality, 0..=100) @@ -44,17 +55,22 @@ impl Settings { ui.add_space(4.0); ui.label( egui::RichText::new("Separate quality for transparency") - .small() .weak(), ); ui.add_space(12.0); - // Speed slider + // Speed setting with editable number ui.horizontal(|ui| { ui.label("Speed:"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(format!("{}", settings.speed)); + let mut speed = settings.speed as i32; + let drag = egui::DragValue::new(&mut speed) + .range(0..=10) + .speed(0.1); + if ui.add(drag).changed() { + settings.speed = speed.clamp(0, 10) as u8; + } }); }); let speed_slider = egui::Slider::new(&mut settings.speed, 0..=10) @@ -63,7 +79,6 @@ impl Settings { ui.add_space(4.0); ui.label( egui::RichText::new("0 = slowest/best, 10 = fastest") - .small() .weak(), ); @@ -83,7 +98,6 @@ impl Settings { ui.add_space(4.0); ui.label( egui::RichText::new("Perfect quality, larger files") - .small() .weak(), ); @@ -148,7 +162,7 @@ impl Settings { .map(|p| p.display().to_string()) .unwrap_or_else(|| "Not set".to_string()); - ui.label(egui::RichText::new(&dir_text).small()); + ui.label(egui::RichText::new(&dir_text)); }); if ui.button("Browse...").clicked() {