diff --git a/.gitignore b/.gitignore index afeae4c..40199b1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ Thumbs.db tmp/ .temp/ dist/ + +# Local agent/tooling installs +/.agents/ +/.claude/ +/node_modules/ +/package.json +/bun.lock diff --git a/Cargo.lock b/Cargo.lock index 60d7dc5..82b297a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" @@ -932,6 +938,7 @@ version = "0.0.1" dependencies = [ "anyhow", "arboard", + "base64", "chrono", "clap", "gdk-pixbuf", diff --git a/Cargo.toml b/Cargo.toml index 0d853c9..da63eb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ serde_json = "1" image = { version = "0.25", default-features = false, features = ["png"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } clap = { version = "4.5", features = ["derive"] } +base64 = "0.22" # GTK stack gtk4 = { version = "0.8", package = "gtk4" } diff --git a/src/app/tray.rs b/src/app/tray.rs index 685b60d..7aab4d6 100644 --- a/src/app/tray.rs +++ b/src/app/tray.rs @@ -404,7 +404,13 @@ pub fn run_launcher(settings: Settings) -> AppResult<()> { doctor_view .buffer() .set_text(&diagnostics_service::doctor_report()); - status_label.set_text(&result.message); + let status_prefix = match (result.attempted, result.repaired) { + (true, true) => "Repair complete", + (true, false) => "Repair attempted", + (false, true) => "No repair needed", + (false, false) => "Repair unavailable", + }; + status_label.set_text(&format!("{status_prefix}: {}", result.message)); }); } connect_clear_shortcut( diff --git a/src/app/window.rs b/src/app/window.rs index 2e8f27b..fd5fc9e 100644 --- a/src/app/window.rs +++ b/src/app/window.rs @@ -234,6 +234,10 @@ pub fn run(capture: CaptureResult, settings: Settings, current_mode: CaptureMode close_after_copy_toggle.set_active(settings.borrow().close_after_copy); close_after_copy_toggle .set_tooltip_text(Some("Close the editor immediately after Ctrl+C or Copy.")); + let close_after_save_toggle = gtk::CheckButton::with_label("Close After Save"); + close_after_save_toggle.set_active(settings.borrow().close_after_save); + close_after_save_toggle + .set_tooltip_text(Some("Close the editor immediately after Save or Save As.")); let open_after_save_toggle = gtk::CheckButton::with_label("Open After Save"); open_after_save_toggle.set_active(settings.borrow().open_after_save); open_after_save_toggle @@ -278,15 +282,20 @@ pub fn run(capture: CaptureResult, settings: Settings, current_mode: CaptureMode let c = canvas.clone(); let s = Rc::clone(&settings); let status_label = status_label.clone(); + let app = app.clone(); save_btn.connect_clicked(move |_| { if let Ok(png) = c.render_png() { let cfg = s.borrow(); if let Ok(path) = export::save_capture(&png, &cfg, current_mode) { + let close_after_save = cfg.close_after_save; c.mark_saved(); let _ = export::maybe_open_saved_path(&path, &cfg); status_label .set_text(&format!("Saved annotated image to {}.", path.display())); eprintln!("Saved annotated image: {}", path.display()); + if close_after_save { + app.quit(); + } } else { status_label.set_text("Saving failed. Check the terminal for details."); } @@ -312,6 +321,14 @@ pub fn run(capture: CaptureResult, settings: Settings, current_mode: CaptureMode let _ = settings_service::save(&guard); }); } + { + let s = Rc::clone(&settings); + close_after_save_toggle.connect_toggled(move |toggle| { + let mut guard = s.borrow_mut(); + guard.close_after_save = toggle.is_active(); + let _ = settings_service::save(&guard); + }); + } { let s = Rc::clone(&settings); open_after_save_toggle.connect_toggled(move |toggle| { @@ -491,6 +508,7 @@ pub fn run(capture: CaptureResult, settings: Settings, current_mode: CaptureMode inspector.append(&export_settings_title); inspector.append(&folder_btn); inspector.append(&close_after_copy_toggle); + inspector.append(&close_after_save_toggle); inspector.append(&open_after_save_toggle); shell.append(&tools_panel); @@ -513,8 +531,9 @@ pub fn run(capture: CaptureResult, settings: Settings, current_mode: CaptureMode let s = Rc::clone(&settings); let status_label = status_label.clone(); let window = window.clone(); + let app = app.clone(); save_as_btn.connect_clicked(move |_| { - prompt_save_as(&window, &c, &s, &status_label, current_mode); + prompt_save_as(&window, &c, &s, &status_label, &app, current_mode); }); } { @@ -786,6 +805,7 @@ fn prompt_save_as( canvas: &EditorCanvas, settings: &Rc>, status_label: >k::Label, + app: &adw::Application, current_mode: CaptureMode, ) { let cfg = settings.borrow().clone(); @@ -804,6 +824,7 @@ fn prompt_save_as( let canvas = canvas.clone(); let settings = Rc::clone(settings); let status_label = status_label.clone(); + let app = app.clone(); dialog.run_async(move |dialog, response| { if response == gtk::ResponseType::Accept { if let Some(file) = dialog.file() { @@ -813,11 +834,15 @@ fn prompt_save_as( Ok(()) => { canvas.mark_saved(); let cfg = settings.borrow(); + let close_after_save = cfg.close_after_save; let _ = export::maybe_open_saved_path(&path, &cfg); status_label.set_text(&format!( "Saved annotated image to {}.", path.display() )); + if close_after_save { + app.quit(); + } } Err(err) => { status_label.set_text(&format!("Save As failed: {err}")); diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 6989c88..3d8b52f 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -6,7 +6,7 @@ use crate::platform::linux::{grim, hyprctl}; use crate::settings::config::CaptureMode; use anyhow::Result; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct CaptureResult { pub png_data: Vec, } diff --git a/src/capture/window.rs b/src/capture/window.rs index 6470c13..43c87f2 100644 --- a/src/capture/window.rs +++ b/src/capture/window.rs @@ -10,3 +10,74 @@ pub fn capture(backend: &dyn CaptureBackend) -> Result { .context("grim failed to capture active window region")?; Ok(CaptureResult { png_data }) } + +#[cfg(test)] +mod tests { + use super::capture; + use crate::capture::CaptureBackend; + use anyhow::{anyhow, Result}; + use std::cell::RefCell; + + #[derive(Default)] + struct FakeBackend { + calls: RefCell>, + geometry_result: Option, + region_result: Option>, + } + + impl CaptureBackend for FakeBackend { + fn capture_fullscreen(&self) -> Result> { + Ok(vec![]) + } + + fn capture_region(&self, _geometry: &str) -> Result> { + self.calls.borrow_mut().push("capture_region"); + self.region_result + .clone() + .ok_or_else(|| anyhow!("region capture failed")) + } + + fn active_window_geometry(&self) -> Result { + self.calls.borrow_mut().push("active_window_geometry"); + self.geometry_result + .clone() + .ok_or_else(|| anyhow!("geometry lookup failed")) + } + } + + #[test] + fn uses_geometry_then_region_capture() { + let backend = FakeBackend { + geometry_result: Some("5,6 7x8".to_string()), + region_result: Some(vec![9, 8, 7]), + ..FakeBackend::default() + }; + + let result = capture(&backend).unwrap(); + + assert_eq!(result.png_data, vec![9, 8, 7]); + assert_eq!( + backend.calls.borrow().as_slice(), + &["active_window_geometry", "capture_region"] + ); + } + + #[test] + fn returns_geometry_errors_without_region_capture() { + let backend = FakeBackend { + geometry_result: None, + region_result: Some(vec![1, 2, 3]), + ..FakeBackend::default() + }; + + let err = capture(&backend).unwrap_err(); + + assert!(err + .to_string() + .contains("failed to resolve active window geometry from Hyprland")); + assert_eq!( + backend.calls.borrow().as_slice(), + &["active_window_geometry"] + ); + } +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index ef1edcc..e2652bc 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -254,10 +254,6 @@ pub struct PortalRepairResult { pub message: String, } -pub fn auto_repair_portals() -> PortalRepairResult { - repair_portals_with_path(env::var_os("PATH")) -} - pub fn repair_portals() -> PortalRepairResult { repair_portals_with_path(env::var_os("PATH")) } @@ -382,11 +378,11 @@ fn colorize_doctor_report(report: &str) -> String { let colored = if line == "Kiekje Doctor Report" || line == "Desktop Portal Report" { format!("\x1b[1;36m{line}\x1b[0m") } else if line.starts_with("[OK]") { - format!("\x1b[32m{}\x1b[0m", line.replacen("[OK]", "[OK]", 1)) + format!("\x1b[32m{line}\x1b[0m") } else if line.starts_with("[MISS]") { - format!("\x1b[33m{}\x1b[0m", line.replacen("[MISS]", "[MISS]", 1)) + format!("\x1b[33m{line}\x1b[0m") } else if line.starts_with("[WARN]") { - format!("\x1b[35m{}\x1b[0m", line.replacen("[WARN]", "[WARN]", 1)) + format!("\x1b[35m{line}\x1b[0m") } else if line.trim_start().starts_with("Install:") || line.trim_start().starts_with("Hint:") { diff --git a/src/editor/canvas.rs b/src/editor/canvas.rs index f682cc0..499caab 100644 --- a/src/editor/canvas.rs +++ b/src/editor/canvas.rs @@ -526,16 +526,19 @@ fn cairo_surface_to_rgba_image(surface: &mut cairo::ImageSurface) -> Result Result>; - fn capture_region(&self, geometry: &str) -> Result>; - fn active_window_geometry(&self) -> Result; -} - #[derive(Debug, Clone, Default)] pub struct GrimHyprlandBackend { screenshot_tool: S, @@ -25,7 +20,11 @@ impl GrimHyprlandBackend { } } -impl LinuxCaptureBackend for GrimHyprlandBackend +pub fn current_backend() -> GrimHyprlandBackend { + GrimHyprlandBackend::new(GrimCli, HyprctlCli) +} + +impl CaptureBackend for GrimHyprlandBackend where S: ScreenshotTool, W: ActiveWindowGeometrySource, @@ -45,7 +44,8 @@ where #[cfg(test)] mod tests { - use super::{GrimHyprlandBackend, LinuxCaptureBackend}; + use super::GrimHyprlandBackend; +use crate::capture::CaptureBackend; use crate::platform::linux::grim::ScreenshotTool; use crate::platform::linux::hyprctl::ActiveWindowGeometrySource; use anyhow::Result; diff --git a/src/platform/linux/grim.rs b/src/platform/linux/grim.rs index 6aca477..cc9b66f 100644 --- a/src/platform/linux/grim.rs +++ b/src/platform/linux/grim.rs @@ -51,6 +51,7 @@ pub fn capture_region(geometry: &str) -> Result> { GrimCli.capture_region(geometry) } +#[allow(dead_code)] pub fn capture_fullscreen() -> Result> { GrimCli.capture_fullscreen() } diff --git a/src/services/capture.rs b/src/services/capture.rs index 1c35d30..b60fdd3 100644 --- a/src/services/capture.rs +++ b/src/services/capture.rs @@ -34,6 +34,10 @@ pub fn run(mode: CaptureMode, settings: &Settings) -> AppResult( mode: CaptureMode, settings: &Settings, diff --git a/src/services/commands.rs b/src/services/commands.rs new file mode 100644 index 0000000..01dc70c --- /dev/null +++ b/src/services/commands.rs @@ -0,0 +1,174 @@ +//! Serializable command surface for future Tauri IPC bindings. +#![allow(dead_code)] + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Serialize}; + +use crate::services::app::{AppError, AppResult}; +use crate::services::{capture as capture_service, diagnostics, settings as settings_service}; +use crate::settings::config::{CaptureMode, Settings}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureCommandRequest { + pub mode: CaptureMode, + #[serde(default)] + pub settings: Option, + #[serde(default)] + pub include_png: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CaptureCommandResponse { + pub mode: CaptureMode, + pub png_base64: Option, + pub png_byte_len: usize, + pub copied_to_clipboard: bool, + pub saved_path: Option, + pub backend: CaptureBackendInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CaptureBackendInfo { + pub platform: String, + pub backend: String, + pub region_supported: bool, + pub fullscreen_supported: bool, + pub window_supported: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CommandError { + pub code: String, + pub title: String, + pub message: String, + pub missing_dependencies: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MissingDependencyInfo { + pub tool: String, + pub required_for: String, + pub install_command: Option, + pub workaround: Option, +} + +pub type CommandResult = Result; + +pub fn capture(request: CaptureCommandRequest) -> CommandResult { + capture_inner(request).map_err(CommandError::from) +} + +pub fn load_settings() -> CommandResult { + settings_service::load_or_default().map_err(CommandError::from) +} + +pub fn save_settings(settings: Settings) -> CommandResult<()> { + settings_service::save(&settings).map_err(CommandError::from) +} + +pub fn default_settings() -> Settings { + settings_service::default_settings() +} + +pub fn doctor_report() -> String { + diagnostics::doctor_report() +} + +pub fn capture_backend_info() -> CaptureBackendInfo { + CaptureBackendInfo { + platform: "linux".to_string(), + backend: "grim-hyprland".to_string(), + region_supported: true, + fullscreen_supported: true, + window_supported: true, + } +} + +fn capture_inner(request: CaptureCommandRequest) -> AppResult { + let settings = match request.settings { + Some(settings) => settings, + None => settings_service::load_or_default()?, + }; + let include_png = request.include_png.unwrap_or(true); + let execution = capture_service::run(request.mode, &settings)?; + let png_base64 = include_png.then(|| STANDARD.encode(&execution.capture.png_data)); + + Ok(CaptureCommandResponse { + mode: execution.mode, + png_base64, + png_byte_len: execution.capture.png_data.len(), + copied_to_clipboard: execution.copied_to_clipboard, + saved_path: execution + .saved_path + .as_ref() + .map(|path| path.display().to_string()), + backend: capture_backend_info(), + }) +} + +impl From for CommandError { + fn from(error: AppError) -> Self { + let missing_dependencies = error + .missing_dependencies() + .map(|missing| { + missing + .items + .iter() + .map(|item| MissingDependencyInfo { + tool: item.tool.clone(), + required_for: item.required_for.clone(), + install_command: item.install_command.clone(), + workaround: item.workaround.clone(), + }) + .collect() + }) + .unwrap_or_default(); + + Self { + code: error.code().to_string(), + title: error.title().to_string(), + message: error.feedback_body(), + missing_dependencies, + } + } +} + +#[cfg(test)] +mod tests { + use super::{capture_backend_info, CommandError}; + use crate::diagnostics::{MissingDependenciesError, MissingDependency}; + use crate::services::app::AppError; + use crate::settings::config::CaptureMode; + + #[test] + fn reports_backend_capabilities_for_current_platform() { + let info = capture_backend_info(); + + assert!(!info.platform.is_empty()); + assert!(!info.backend.is_empty()); + assert!(info.region_supported); + } + + #[test] + fn command_error_preserves_missing_dependency_details() { + let error = AppError::MissingDependencies(MissingDependenciesError { + items: vec![MissingDependency { + tool: "grim".to_string(), + required_for: "capture backend".to_string(), + install_command: Some("pacman -S grim".to_string()), + workaround: None, + }], + }); + + let command_error = CommandError::from(error); + + assert_eq!(command_error.code, "KIEKJE-E001"); + assert_eq!(command_error.missing_dependencies.len(), 1); + assert_eq!(command_error.missing_dependencies[0].tool, "grim"); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index dc3546f..3340288 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod app; pub mod capture; +pub mod commands; pub mod diagnostics; pub mod export; pub mod settings; diff --git a/src/settings/config.rs b/src/settings/config.rs index bcd8fd2..7a1c546 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -22,6 +22,7 @@ pub struct Settings { pub default_save_location: PathBuf, pub copy_to_clipboard: bool, pub close_after_copy: bool, + pub close_after_save: bool, pub open_after_save: bool, pub open_editor: bool, pub default_capture_mode: CaptureMode, @@ -44,6 +45,7 @@ impl Default for Settings { default_save_location: home.join("Pictures").join("Screenshots"), copy_to_clipboard: true, close_after_copy: false, + close_after_save: false, open_after_save: false, open_editor: true, default_capture_mode: CaptureMode::Region, @@ -252,7 +254,6 @@ mod tests { "default_save_location": "/tmp/shots", "copy_to_clipboard": true, "close_after_copy": false, - "open_after_save": false, "open_editor": true, "default_capture_mode": "region", "auto_save": false, @@ -262,6 +263,8 @@ mod tests { .unwrap(); assert!(!settings.tray_autostart); + assert!(!settings.close_after_save); + assert!(!settings.open_after_save); assert_eq!(settings.shortcut_region, "SUPER SHIFT, S"); assert_eq!(settings.shortcut_fullscreen, "SUPER SHIFT, F"); assert_eq!(settings.shortcut_window, "SUPER SHIFT, W");