From 9e235a65427407b493f589747b871735711cac73 Mon Sep 17 00:00:00 2001 From: Remco Stoeten Date: Thu, 23 Apr 2026 20:02:00 +0200 Subject: [PATCH 1/2] feat: abstract linux capture backend --- src/capture/fullscreen.rs | 9 ++-- src/capture/mod.rs | 89 +++++++++++++++++++++++++++++-- src/capture/region.rs | 8 +-- src/capture/window.rs | 13 ++--- src/platform/linux/backend.rs | 99 +++++++++++++++++++++++++++++++++++ src/platform/linux/grim.rs | 76 +++++++++++++++++---------- src/platform/linux/hyprctl.rs | 39 +++++++++----- src/platform/linux/mod.rs | 1 + 8 files changed, 275 insertions(+), 59 deletions(-) create mode 100644 src/platform/linux/backend.rs diff --git a/src/capture/fullscreen.rs b/src/capture/fullscreen.rs index f235c46..8a23ead 100644 --- a/src/capture/fullscreen.rs +++ b/src/capture/fullscreen.rs @@ -1,8 +1,9 @@ -use super::CaptureResult; -use crate::platform::linux::grim; +use super::{CaptureBackend, CaptureResult}; use anyhow::{Context, Result}; -pub fn capture() -> Result { - let png_data = grim::capture_fullscreen().context("grim failed to capture fullscreen")?; +pub fn capture(backend: &dyn CaptureBackend) -> Result { + let png_data = backend + .capture_fullscreen() + .context("grim failed to capture fullscreen")?; Ok(CaptureResult { png_data }) } diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 0764d79..6989c88 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -2,6 +2,7 @@ mod fullscreen; mod region; mod window; +use crate::platform::linux::{grim, hyprctl}; use crate::settings::config::CaptureMode; use anyhow::Result; @@ -10,10 +11,92 @@ pub struct CaptureResult { pub png_data: Vec, } +pub trait CaptureBackend { + fn capture_fullscreen(&self) -> Result>; + fn capture_region(&self, geometry: &str) -> Result>; + fn active_window_geometry(&self) -> Result; +} + +struct LinuxCaptureBackend; + +impl CaptureBackend for LinuxCaptureBackend { + fn capture_fullscreen(&self) -> Result> { + grim::capture_fullscreen() + } + + fn capture_region(&self, geometry: &str) -> Result> { + grim::capture_region(geometry) + } + + fn active_window_geometry(&self) -> Result { + hyprctl::active_window_geometry() + } +} + pub fn capture(mode: CaptureMode) -> Result { + capture_with_backend(mode, &LinuxCaptureBackend) +} + +fn capture_with_backend(mode: CaptureMode, backend: &dyn CaptureBackend) -> Result { match mode { - CaptureMode::Region => region::capture(), - CaptureMode::Fullscreen => fullscreen::capture(), - CaptureMode::Window => window::capture(), + CaptureMode::Region => region::capture(backend), + CaptureMode::Fullscreen => fullscreen::capture(backend), + CaptureMode::Window => window::capture(backend), + } +} + +#[cfg(test)] +mod tests { + use super::{capture_with_backend, CaptureBackend}; + use crate::settings::config::CaptureMode; + use anyhow::{bail, Result}; + use std::cell::RefCell; + + #[derive(Default)] + struct FakeBackend { + calls: RefCell>, + } + + impl CaptureBackend for FakeBackend { + fn capture_fullscreen(&self) -> Result> { + self.calls.borrow_mut().push("capture_fullscreen"); + Ok(vec![1, 2, 3]) + } + + fn capture_region(&self, geometry: &str) -> Result> { + self.calls.borrow_mut().push("capture_region"); + if geometry.is_empty() { + bail!("geometry should not be empty"); + } + Ok(vec![4, 5, 6]) + } + + fn active_window_geometry(&self) -> Result { + self.calls.borrow_mut().push("active_window_geometry"); + Ok("10,20 30x40".to_string()) + } + } + + #[test] + fn fullscreen_capture_uses_backend() { + let backend = FakeBackend::default(); + + let capture = capture_with_backend(CaptureMode::Fullscreen, &backend).unwrap(); + + assert_eq!(capture.png_data, vec![1, 2, 3]); + assert_eq!(backend.calls.borrow().as_slice(), &["capture_fullscreen"]); + } + + #[test] + fn window_capture_uses_geometry_then_region_backend_calls() { + let backend = FakeBackend::default(); + + let capture = capture_with_backend(CaptureMode::Window, &backend).unwrap(); + + assert_eq!(capture.png_data, vec![4, 5, 6]); + assert_eq!( + backend.calls.borrow().as_slice(), + &["active_window_geometry", "capture_region"] + ); } } diff --git a/src/capture/region.rs b/src/capture/region.rs index 3b53d84..f42af3b 100644 --- a/src/capture/region.rs +++ b/src/capture/region.rs @@ -1,12 +1,12 @@ -use super::CaptureResult; +use super::{CaptureBackend, CaptureResult}; use crate::app::region_selector::{choose_region_or_fullscreen, SelectionChoice, SelectionRect}; -use crate::platform::linux::grim; use anyhow::{bail, Context, Result}; use image::{imageops, ImageFormat}; use std::io::Cursor; -pub fn capture() -> Result { - let fullscreen_png = grim::capture_fullscreen() +pub fn capture(backend: &dyn CaptureBackend) -> Result { + let fullscreen_png = backend + .capture_fullscreen() .context("grim failed to capture fullscreen for area selection")?; let png_data = match choose_region_or_fullscreen(&fullscreen_png)? { diff --git a/src/capture/window.rs b/src/capture/window.rs index 3625f14..6470c13 100644 --- a/src/capture/window.rs +++ b/src/capture/window.rs @@ -1,11 +1,12 @@ -use super::CaptureResult; -use crate::platform::linux::{grim, hyprctl}; +use super::{CaptureBackend, CaptureResult}; use anyhow::{Context, Result}; -pub fn capture() -> Result { - let geometry = hyprctl::active_window_geometry() +pub fn capture(backend: &dyn CaptureBackend) -> Result { + let geometry = backend + .active_window_geometry() .context("failed to resolve active window geometry from Hyprland")?; - let png_data = - grim::capture_region(&geometry).context("grim failed to capture active window region")?; + let png_data = backend + .capture_region(&geometry) + .context("grim failed to capture active window region")?; Ok(CaptureResult { png_data }) } diff --git a/src/platform/linux/backend.rs b/src/platform/linux/backend.rs new file mode 100644 index 0000000..e16e6f4 --- /dev/null +++ b/src/platform/linux/backend.rs @@ -0,0 +1,99 @@ +#![allow(dead_code)] + +use super::grim::{GrimCli, ScreenshotTool}; +use super::hyprctl::{ActiveWindowGeometrySource, HyprctlCli}; +use anyhow::Result; + +pub trait LinuxCaptureBackend { + fn capture_fullscreen(&self) -> Result>; + fn capture_region(&self, geometry: &str) -> Result>; + fn active_window_geometry(&self) -> Result; +} + +#[derive(Debug, Clone, Default)] +pub struct GrimHyprlandBackend { + screenshot_tool: S, + active_window_source: W, +} + +impl GrimHyprlandBackend { + pub fn new(screenshot_tool: S, active_window_source: W) -> Self { + Self { + screenshot_tool, + active_window_source, + } + } +} + +impl LinuxCaptureBackend for GrimHyprlandBackend +where + S: ScreenshotTool, + W: ActiveWindowGeometrySource, +{ + fn capture_fullscreen(&self) -> Result> { + self.screenshot_tool.capture_fullscreen() + } + + fn capture_region(&self, geometry: &str) -> Result> { + self.screenshot_tool.capture_region(geometry) + } + + fn active_window_geometry(&self) -> Result { + self.active_window_source.active_window_geometry() + } +} + +#[cfg(test)] +mod tests { + use super::{GrimHyprlandBackend, LinuxCaptureBackend}; + use crate::platform::linux::grim::ScreenshotTool; + use crate::platform::linux::hyprctl::ActiveWindowGeometrySource; + use anyhow::Result; + + struct FakeScreenshotTool; + + impl ScreenshotTool for FakeScreenshotTool { + fn capture_region(&self, geometry: &str) -> Result> { + Ok(format!("region:{geometry}").into_bytes()) + } + + fn capture_fullscreen(&self) -> Result> { + Ok(b"fullscreen".to_vec()) + } + } + + struct FakeActiveWindowSource; + + impl ActiveWindowGeometrySource for FakeActiveWindowSource { + fn active_window_geometry(&self) -> Result { + Ok("10,20 300x400".to_string()) + } + } + + #[test] + fn delegates_fullscreen_capture_to_screenshot_tool() { + let backend = GrimHyprlandBackend::new(FakeScreenshotTool, FakeActiveWindowSource); + + let png = backend.capture_fullscreen().unwrap(); + + assert_eq!(png, b"fullscreen"); + } + + #[test] + fn delegates_region_capture_to_screenshot_tool() { + let backend = GrimHyprlandBackend::new(FakeScreenshotTool, FakeActiveWindowSource); + + let png = backend.capture_region("1,2 3x4").unwrap(); + + assert_eq!(png, b"region:1,2 3x4"); + } + + #[test] + fn delegates_active_window_geometry_to_window_source() { + let backend = GrimHyprlandBackend::new(FakeScreenshotTool, FakeActiveWindowSource); + + let geometry = backend.active_window_geometry().unwrap(); + + assert_eq!(geometry, "10,20 300x400"); + } +} diff --git a/src/platform/linux/grim.rs b/src/platform/linux/grim.rs index 58c1246..6aca477 100644 --- a/src/platform/linux/grim.rs +++ b/src/platform/linux/grim.rs @@ -1,38 +1,56 @@ use anyhow::{bail, Context, Result}; use std::process::Command; -pub fn capture_region(geometry: &str) -> Result> { - let output = Command::new("grim") - .arg("-g") - .arg(geometry) - .arg("-") - .output() - .context("failed to execute grim for region capture")?; - - if !output.status.success() { - bail!( - "grim region capture failed (status {}): {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); +pub trait ScreenshotTool { + fn capture_region(&self, geometry: &str) -> Result>; + fn capture_fullscreen(&self) -> Result>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct GrimCli; + +impl ScreenshotTool for GrimCli { + fn capture_region(&self, geometry: &str) -> Result> { + let output = Command::new("grim") + .arg("-g") + .arg(geometry) + .arg("-") + .output() + .context("failed to execute grim for region capture")?; + + if !output.status.success() { + bail!( + "grim region capture failed (status {}): {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(output.stdout) } - Ok(output.stdout) -} + fn capture_fullscreen(&self) -> Result> { + let output = Command::new("grim") + .arg("-") + .output() + .context("failed to execute grim for fullscreen capture")?; -pub fn capture_fullscreen() -> Result> { - let output = Command::new("grim") - .arg("-") - .output() - .context("failed to execute grim for fullscreen capture")?; - - if !output.status.success() { - bail!( - "grim fullscreen capture failed (status {}): {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); + if !output.status.success() { + bail!( + "grim fullscreen capture failed (status {}): {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(output.stdout) } +} - Ok(output.stdout) +pub fn capture_region(geometry: &str) -> Result> { + GrimCli.capture_region(geometry) +} + +pub fn capture_fullscreen() -> Result> { + GrimCli.capture_fullscreen() } diff --git a/src/platform/linux/hyprctl.rs b/src/platform/linux/hyprctl.rs index c585d29..59085a3 100644 --- a/src/platform/linux/hyprctl.rs +++ b/src/platform/linux/hyprctl.rs @@ -8,22 +8,35 @@ struct ActiveWindow { size: Option<(i32, i32)>, } -pub fn active_window_geometry() -> Result { - let output = Command::new("hyprctl") - .arg("activewindow") - .arg("-j") - .output() - .context("failed to execute hyprctl activewindow -j")?; +pub trait ActiveWindowGeometrySource { + fn active_window_geometry(&self) -> Result; +} - if !output.status.success() { - bail!( - "hyprctl activewindow failed (status {}): {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); +#[derive(Debug, Clone, Copy, Default)] +pub struct HyprctlCli; + +impl ActiveWindowGeometrySource for HyprctlCli { + fn active_window_geometry(&self) -> Result { + let output = Command::new("hyprctl") + .arg("activewindow") + .arg("-j") + .output() + .context("failed to execute hyprctl activewindow -j")?; + + if !output.status.success() { + bail!( + "hyprctl activewindow failed (status {}): {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + + parse_active_window_geometry_from_json(&output.stdout) } +} - parse_active_window_geometry_from_json(&output.stdout) +pub fn active_window_geometry() -> Result { + HyprctlCli.active_window_geometry() } pub(crate) fn parse_active_window_geometry_from_json(raw: &[u8]) -> Result { diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index dedb947..efac066 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -1,3 +1,4 @@ +pub mod backend; pub mod grim; pub mod hyprctl; pub mod integration; From 48bb0db54424cb903719a07fa6caa8c670f8e435 Mon Sep 17 00:00:00 2001 From: Remco Stoeten Date: Tue, 5 May 2026 00:19:57 +0200 Subject: [PATCH 2/2] work on backend abstraction --- .gitignore | 7 ++ Cargo.lock | 7 ++ Cargo.toml | 1 + src/app/tray.rs | 8 +- src/app/window.rs | 27 +++++- src/capture/fullscreen.rs | 5 +- src/capture/mod.rs | 37 ++----- src/capture/region.rs | 5 +- src/capture/window.rs | 76 ++++++++++++++- src/diagnostics/mod.rs | 10 +- src/editor/canvas.rs | 21 ++-- src/main.rs | 1 + src/platform/capture.rs | 29 ++++++ src/platform/linux/backend.rs | 18 ++-- src/platform/linux/grim.rs | 2 + src/platform/linux/hyprctl.rs | 1 + src/platform/mod.rs | 1 + src/services/capture.rs | 4 + src/services/commands.rs | 178 ++++++++++++++++++++++++++++++++++ src/services/mod.rs | 1 + src/settings/config.rs | 5 +- 21 files changed, 382 insertions(+), 62 deletions(-) create mode 100644 src/platform/capture.rs create mode 100644 src/services/commands.rs 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/fullscreen.rs b/src/capture/fullscreen.rs index 8a23ead..d558c1b 100644 --- a/src/capture/fullscreen.rs +++ b/src/capture/fullscreen.rs @@ -1,7 +1,8 @@ -use super::{CaptureBackend, CaptureResult}; +use super::CaptureResult; +use crate::platform::capture::CaptureBackend; use anyhow::{Context, Result}; -pub fn capture(backend: &dyn CaptureBackend) -> Result { +pub fn capture(backend: &B) -> Result { let png_data = backend .capture_fullscreen() .context("grim failed to capture fullscreen")?; diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 6989c88..2e70ef0 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -2,42 +2,24 @@ mod fullscreen; mod region; mod window; -use crate::platform::linux::{grim, hyprctl}; +use crate::platform::capture::{self as platform_capture, CaptureBackend}; use crate::settings::config::CaptureMode; use anyhow::Result; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct CaptureResult { pub png_data: Vec, } -pub trait CaptureBackend { - fn capture_fullscreen(&self) -> Result>; - fn capture_region(&self, geometry: &str) -> Result>; - fn active_window_geometry(&self) -> Result; -} - -struct LinuxCaptureBackend; - -impl CaptureBackend for LinuxCaptureBackend { - fn capture_fullscreen(&self) -> Result> { - grim::capture_fullscreen() - } - - fn capture_region(&self, geometry: &str) -> Result> { - grim::capture_region(geometry) - } - - fn active_window_geometry(&self) -> Result { - hyprctl::active_window_geometry() - } -} - pub fn capture(mode: CaptureMode) -> Result { - capture_with_backend(mode, &LinuxCaptureBackend) + let backend = platform_capture::current_backend(); + capture_with_backend(mode, &backend) } -fn capture_with_backend(mode: CaptureMode, backend: &dyn CaptureBackend) -> Result { +fn capture_with_backend( + mode: CaptureMode, + backend: &B, +) -> Result { match mode { CaptureMode::Region => region::capture(backend), CaptureMode::Fullscreen => fullscreen::capture(backend), @@ -47,7 +29,8 @@ fn capture_with_backend(mode: CaptureMode, backend: &dyn CaptureBackend) -> Resu #[cfg(test)] mod tests { - use super::{capture_with_backend, CaptureBackend}; + use super::capture_with_backend; + use crate::platform::capture::CaptureBackend; use crate::settings::config::CaptureMode; use anyhow::{bail, Result}; use std::cell::RefCell; diff --git a/src/capture/region.rs b/src/capture/region.rs index f42af3b..2bf5350 100644 --- a/src/capture/region.rs +++ b/src/capture/region.rs @@ -1,10 +1,11 @@ -use super::{CaptureBackend, CaptureResult}; +use super::CaptureResult; use crate::app::region_selector::{choose_region_or_fullscreen, SelectionChoice, SelectionRect}; +use crate::platform::capture::CaptureBackend; use anyhow::{bail, Context, Result}; use image::{imageops, ImageFormat}; use std::io::Cursor; -pub fn capture(backend: &dyn CaptureBackend) -> Result { +pub fn capture(backend: &B) -> Result { let fullscreen_png = backend .capture_fullscreen() .context("grim failed to capture fullscreen for area selection")?; diff --git a/src/capture/window.rs b/src/capture/window.rs index 6470c13..5839497 100644 --- a/src/capture/window.rs +++ b/src/capture/window.rs @@ -1,7 +1,8 @@ -use super::{CaptureBackend, CaptureResult}; +use super::CaptureResult; +use crate::platform::capture::CaptureBackend; use anyhow::{Context, Result}; -pub fn capture(backend: &dyn CaptureBackend) -> Result { +pub fn capture(backend: &B) -> Result { let geometry = backend .active_window_geometry() .context("failed to resolve active window geometry from Hyprland")?; @@ -10,3 +11,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::platform::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 e083f96..f725905 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -129,10 +129,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")) } @@ -217,11 +213,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; +} + +pub fn current_backend() -> impl CaptureBackend { + crate::platform::linux::backend::current_backend() +} + +pub fn platform_name() -> &'static str { + "linux" +} + +#[allow(dead_code)] +pub fn backend_name() -> &'static str { + "grim-hyprland" +} + +#[allow(dead_code)] +pub fn mode_supported(mode: CaptureMode) -> bool { + matches!( + mode, + CaptureMode::Region | CaptureMode::Fullscreen | CaptureMode::Window + ) +} diff --git a/src/platform/linux/backend.rs b/src/platform/linux/backend.rs index e16e6f4..777ce8d 100644 --- a/src/platform/linux/backend.rs +++ b/src/platform/linux/backend.rs @@ -1,15 +1,8 @@ -#![allow(dead_code)] - use super::grim::{GrimCli, ScreenshotTool}; use super::hyprctl::{ActiveWindowGeometrySource, HyprctlCli}; +use crate::platform::capture::CaptureBackend; use anyhow::Result; -pub trait LinuxCaptureBackend { - fn capture_fullscreen(&self) -> 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 +18,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 +42,8 @@ where #[cfg(test)] mod tests { - use super::{GrimHyprlandBackend, LinuxCaptureBackend}; + use super::GrimHyprlandBackend; + use crate::platform::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..b17ca82 100644 --- a/src/platform/linux/grim.rs +++ b/src/platform/linux/grim.rs @@ -47,10 +47,12 @@ impl ScreenshotTool for GrimCli { } } +#[allow(dead_code)] 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/platform/linux/hyprctl.rs b/src/platform/linux/hyprctl.rs index 59085a3..8c4296f 100644 --- a/src/platform/linux/hyprctl.rs +++ b/src/platform/linux/hyprctl.rs @@ -35,6 +35,7 @@ impl ActiveWindowGeometrySource for HyprctlCli { } } +#[allow(dead_code)] pub fn active_window_geometry() -> Result { HyprctlCli.active_window_geometry() } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 057cec9..9c3b8b2 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1 +1,2 @@ +pub mod capture; pub mod linux; 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..674d91c --- /dev/null +++ b/src/services/commands.rs @@ -0,0 +1,178 @@ +//! Serializable command surface for future Tauri IPC bindings. +#![allow(dead_code)] + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Serialize}; + +use crate::platform; +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: platform::capture::platform_name().to_string(), + backend: platform::capture::backend_name().to_string(), + region_supported: platform::capture::mode_supported(CaptureMode::Region), + fullscreen_supported: platform::capture::mode_supported(CaptureMode::Fullscreen), + window_supported: platform::capture::mode_supported(CaptureMode::Window), + } +} + +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_eq!( + info.region_supported, + crate::platform::capture::mode_supported(CaptureMode::Region) + ); + } + + #[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");