gardesk/garshot / c985aa3

Browse files

add delay timer, desktop notifications, and stdout output

- --delay <secs> flag for countdown before capture
- --notify flag for desktop notification on save
- -o - for stdout output mode (piping to other tools)
- Updated README with new options and libnotify dependency
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c985aa337aeee938e6f4c13ae7e350f8163a36dc
Parents
754344f
Tree
cd1ae40

2 changed files

StatusFile+-
M README.md 10 4
M garshot/src/main.rs 278 113
README.mdmodified
@@ -22,20 +22,21 @@ Build:
2222
 Runtime:
2323
 - X11
2424
 - xclip (for clipboard)
25
+- libnotify (for --notify, optional)
2526
 
2627
 ### Fedora/RHEL
2728
 ```sh
28
-sudo dnf install libxcb-devel libX11-devel cairo-devel xclip
29
+sudo dnf install libxcb-devel libX11-devel cairo-devel xclip libnotify
2930
 ```
3031
 
3132
 ### Debian/Ubuntu
3233
 ```sh
33
-sudo apt install libxcb1-dev libx11-dev libcairo2-dev xclip
34
+sudo apt install libxcb1-dev libx11-dev libcairo2-dev xclip libnotify-bin
3435
 ```
3536
 
3637
 ### Arch
3738
 ```sh
38
-sudo pacman -S libxcb libx11 cairo xclip
39
+sudo pacman -S libxcb libx11 cairo xclip libnotify
3940
 ```
4041
 
4142
 ## Build
@@ -65,6 +66,9 @@ garshot screen # All monitors
6566
 garshot screen -m DP-1             # Specific monitor
6667
 garshot screen -c                  # Include cursor
6768
 garshot screen -f jpeg -o shot.jpg # JPEG output
69
+garshot screen --delay 3           # 3 second countdown
70
+garshot screen --notify            # Desktop notification on save
71
+garshot screen -o - | feh -        # Pipe to viewer
6872
 ```
6973
 
7074
 ### Interactive selection
@@ -99,10 +103,12 @@ garshot monitors
99103
 
100104
 | Option | Description |
101105
 |--------|-------------|
102
-| `-o, --output <PATH>` | Output file path |
106
+| `-o, --output <PATH>` | Output file path (use `-` for stdout) |
103107
 | `-f, --format <FMT>` | png, jpeg, webp (default: png) |
104108
 | `-c, --cursor` | Include cursor in capture |
105109
 | `--no-clipboard` | Don't copy to clipboard |
110
+| `--delay <SECS>` | Delay before capture (screen/region/window) |
111
+| `--notify` | Show desktop notification on save |
106112
 
107113
 ## Configuration
108114
 
garshot/src/main.rsmodified
@@ -3,7 +3,9 @@
33
 //! This is the main entry point for the garshot daemon. It can also be used
44
 //! for one-shot captures without running the daemon.
55
 
6
+use std::io::Write;
67
 use std::path::PathBuf;
8
+use std::time::Duration;
79
 
810
 use anyhow::Context;
911
 use clap::{Parser, Subcommand};
@@ -32,7 +34,7 @@ enum Command {
3234
 
3335
     /// Capture full screen (one-shot, no daemon required).
3436
     Screen {
35
-        /// Output file path.
37
+        /// Output file path (use "-" for stdout).
3638
         #[arg(short, long)]
3739
         output: Option<PathBuf>,
3840
 
@@ -51,6 +53,14 @@ enum Command {
5153
         /// Don't copy to clipboard (default: copy to clipboard).
5254
         #[arg(long)]
5355
         no_clipboard: bool,
56
+
57
+        /// Delay in seconds before capture.
58
+        #[arg(long)]
59
+        delay: Option<u64>,
60
+
61
+        /// Show desktop notification on save.
62
+        #[arg(long)]
63
+        notify: bool,
5464
     },
5565
 
5666
     /// Capture a region by geometry.
@@ -59,7 +69,7 @@ enum Command {
5969
         #[arg(short, long)]
6070
         geometry: String,
6171
 
62
-        /// Output file path.
72
+        /// Output file path (use "-" for stdout).
6373
         #[arg(short, long)]
6474
         output: Option<PathBuf>,
6575
 
@@ -74,6 +84,14 @@ enum Command {
7484
         /// Don't copy to clipboard (default: copy to clipboard).
7585
         #[arg(long)]
7686
         no_clipboard: bool,
87
+
88
+        /// Delay in seconds before capture.
89
+        #[arg(long)]
90
+        delay: Option<u64>,
91
+
92
+        /// Show desktop notification on save.
93
+        #[arg(long)]
94
+        notify: bool,
7795
     },
7896
 
7997
     /// Capture a window.
@@ -86,7 +104,7 @@ enum Command {
86104
         #[arg(short, long)]
87105
         decorations: bool,
88106
 
89
-        /// Output file path.
107
+        /// Output file path (use "-" for stdout).
90108
         #[arg(short, long)]
91109
         output: Option<PathBuf>,
92110
 
@@ -101,11 +119,19 @@ enum Command {
101119
         /// Don't copy to clipboard (default: copy to clipboard).
102120
         #[arg(long)]
103121
         no_clipboard: bool,
122
+
123
+        /// Delay in seconds before capture.
124
+        #[arg(long)]
125
+        delay: Option<u64>,
126
+
127
+        /// Show desktop notification on save.
128
+        #[arg(long)]
129
+        notify: bool,
104130
     },
105131
 
106132
     /// Interactive region selection with blur overlay.
107133
     Select {
108
-        /// Output file path.
134
+        /// Output file path (use "-" for stdout).
109135
         #[arg(short, long)]
110136
         output: Option<PathBuf>,
111137
 
@@ -124,6 +150,10 @@ enum Command {
124150
         /// Don't copy to clipboard (default: copy to clipboard).
125151
         #[arg(long)]
126152
         no_clipboard: bool,
153
+
154
+        /// Show desktop notification on save.
155
+        #[arg(long)]
156
+        notify: bool,
127157
     },
128158
 
129159
     /// List available monitors.
@@ -160,13 +190,31 @@ fn main() -> anyhow::Result<()> {
160190
             monitor,
161191
             cursor,
162192
             no_clipboard,
193
+            delay,
194
+            notify,
163195
         }) => {
164
-            let output = output.unwrap_or_else(|| default_output(&config, &format));
165
-            capture_screen(&output, &format, monitor.as_deref(), cursor)?;
166
-            if !no_clipboard {
167
-                copy_to_clipboard(&output)?;
196
+            if let Some(secs) = delay {
197
+                sleep_with_countdown(secs);
198
+            }
199
+            let is_stdout = output.as_ref().map(|p| p.as_os_str() == "-").unwrap_or(false);
200
+            let output_path = if is_stdout {
201
+                None
202
+            } else {
203
+                Some(output.unwrap_or_else(|| default_output(&config, &format)))
204
+            };
205
+            let (data, width, height) = capture_screen_raw(&format, monitor.as_deref(), cursor)?;
206
+            if let Some(ref path) = output_path {
207
+                save_image(&data, width, height, path, &format)?;
208
+                if !no_clipboard {
209
+                    copy_to_clipboard(path)?;
210
+                }
211
+                if notify {
212
+                    send_notification(path, width, height);
213
+                }
214
+                println!("{}", path.display());
215
+            } else {
216
+                write_stdout(&data, width, height, &format)?;
168217
             }
169
-            println!("{}", output.display());
170218
         }
171219
 
172220
         Some(Command::Region {
@@ -175,13 +223,31 @@ fn main() -> anyhow::Result<()> {
175223
             format,
176224
             cursor,
177225
             no_clipboard,
226
+            delay,
227
+            notify,
178228
         }) => {
179
-            let output = output.unwrap_or_else(|| default_output(&config, &format));
180
-            capture_region_cmd(&output, &format, &geometry, cursor)?;
181
-            if !no_clipboard {
182
-                copy_to_clipboard(&output)?;
229
+            if let Some(secs) = delay {
230
+                sleep_with_countdown(secs);
231
+            }
232
+            let is_stdout = output.as_ref().map(|p| p.as_os_str() == "-").unwrap_or(false);
233
+            let output_path = if is_stdout {
234
+                None
235
+            } else {
236
+                Some(output.unwrap_or_else(|| default_output(&config, &format)))
237
+            };
238
+            let (data, width, height) = capture_region_raw(&geometry, cursor)?;
239
+            if let Some(ref path) = output_path {
240
+                save_image(&data, width, height, path, &format)?;
241
+                if !no_clipboard {
242
+                    copy_to_clipboard(path)?;
243
+                }
244
+                if notify {
245
+                    send_notification(path, width, height);
246
+                }
247
+                println!("{}", path.display());
248
+            } else {
249
+                write_stdout(&data, width, height, &format)?;
183250
             }
184
-            println!("{}", output.display());
185251
         }
186252
 
187253
         Some(Command::Window {
@@ -191,13 +257,31 @@ fn main() -> anyhow::Result<()> {
191257
             format,
192258
             cursor,
193259
             no_clipboard,
260
+            delay,
261
+            notify,
194262
         }) => {
195
-            let output = output.unwrap_or_else(|| default_output(&config, &format));
196
-            capture_window_cmd(&output, &format, id.as_deref(), decorations, cursor)?;
197
-            if !no_clipboard {
198
-                copy_to_clipboard(&output)?;
263
+            if let Some(secs) = delay {
264
+                sleep_with_countdown(secs);
265
+            }
266
+            let is_stdout = output.as_ref().map(|p| p.as_os_str() == "-").unwrap_or(false);
267
+            let output_path = if is_stdout {
268
+                None
269
+            } else {
270
+                Some(output.unwrap_or_else(|| default_output(&config, &format)))
271
+            };
272
+            let (data, width, height) = capture_window_raw(id.as_deref(), decorations, cursor)?;
273
+            if let Some(ref path) = output_path {
274
+                save_image(&data, width, height, path, &format)?;
275
+                if !no_clipboard {
276
+                    copy_to_clipboard(path)?;
277
+                }
278
+                if notify {
279
+                    send_notification(path, width, height);
280
+                }
281
+                println!("{}", path.display());
282
+            } else {
283
+                write_stdout(&data, width, height, &format)?;
199284
             }
200
-            println!("{}", output.display());
201285
         }
202286
 
203287
         Some(Command::Select {
@@ -206,13 +290,28 @@ fn main() -> anyhow::Result<()> {
206290
             cursor,
207291
             blur,
208292
             no_clipboard,
293
+            notify,
209294
         }) => {
210
-            let output = output.unwrap_or_else(|| default_output(&config, &format));
211
-            capture_select_cmd(&output, &format, cursor, blur)?;
212
-            if !no_clipboard {
213
-                copy_to_clipboard(&output)?;
295
+            let is_stdout = output.as_ref().map(|p| p.as_os_str() == "-").unwrap_or(false);
296
+            let output_path = if is_stdout {
297
+                None
298
+            } else {
299
+                Some(output.unwrap_or_else(|| default_output(&config, &format)))
300
+            };
301
+            if let Some((data, width, height)) = capture_select_raw(cursor, blur)? {
302
+                if let Some(ref path) = output_path {
303
+                    save_image(&data, width, height, path, &format)?;
304
+                    if !no_clipboard {
305
+                        copy_to_clipboard(path)?;
306
+                    }
307
+                    if notify {
308
+                        send_notification(path, width, height);
309
+                    }
310
+                    println!("{}", path.display());
311
+                } else {
312
+                    write_stdout(&data, width, height, &format)?;
313
+                }
214314
             }
215
-            println!("{}", output.display());
216315
         }
217316
 
218317
         Some(Command::Monitors) => {
@@ -292,24 +391,164 @@ fn capture_screen(
292391
     save_image(&result.data, result.width, result.height, output, format)
293392
 }
294393
 
295
-fn capture_region_cmd(
394
+
395
+fn save_image(
396
+    data: &[u8],
397
+    width: u32,
398
+    height: u32,
296399
     output: &PathBuf,
297400
     format: &str,
401
+) -> anyhow::Result<()> {
402
+    match format {
403
+        "png" => encode_png(data, width, height, output).context("Failed to encode PNG")?,
404
+        _ => anyhow::bail!("Unsupported format: {}", format),
405
+    }
406
+    tracing::info!("Saved {}x{} to {}", width, height, output.display());
407
+    Ok(())
408
+}
409
+
410
+fn parse_window_id(s: &str) -> anyhow::Result<u32> {
411
+    let s = s.trim();
412
+    if s.starts_with("0x") || s.starts_with("0X") {
413
+        u32::from_str_radix(&s[2..], 16).context("Invalid hex window ID")
414
+    } else {
415
+        s.parse().context("Invalid decimal window ID")
416
+    }
417
+}
418
+
419
+fn copy_to_clipboard(path: &PathBuf) -> anyhow::Result<()> {
420
+    let mime_type = match path.extension().and_then(|e| e.to_str()) {
421
+        Some("png") => "image/png",
422
+        Some("jpg") | Some("jpeg") => "image/jpeg",
423
+        Some("webp") => "image/webp",
424
+        Some("ppm") | Some("pam") => "image/x-portable-pixmap",
425
+        _ => "image/png", // Default to PNG
426
+    };
427
+
428
+    // Try xclip first, then xsel
429
+    let result = std::process::Command::new("xclip")
430
+        .args(["-selection", "clipboard", "-t", mime_type, "-i"])
431
+        .arg(path)
432
+        .status();
433
+
434
+    match result {
435
+        Ok(status) if status.success() => {
436
+            tracing::debug!("Copied {} to clipboard via xclip", path.display());
437
+            Ok(())
438
+        }
439
+        _ => {
440
+            // Fallback to xsel (doesn't support MIME types as well)
441
+            let result = std::process::Command::new("xsel")
442
+                .args(["--clipboard", "--input"])
443
+                .stdin(std::fs::File::open(path)?)
444
+                .status();
445
+
446
+            match result {
447
+                Ok(status) if status.success() => {
448
+                    tracing::debug!("Copied {} to clipboard via xsel", path.display());
449
+                    Ok(())
450
+                }
451
+                Ok(status) => {
452
+                    tracing::warn!("xsel exited with status: {}", status);
453
+                    anyhow::bail!("Failed to copy to clipboard")
454
+                }
455
+                Err(e) => {
456
+                    tracing::warn!("Neither xclip nor xsel available: {}", e);
457
+                    anyhow::bail!("No clipboard tool available (install xclip or xsel)")
458
+                }
459
+            }
460
+        }
461
+    }
462
+}
463
+
464
+fn sleep_with_countdown(secs: u64) {
465
+    for i in (1..=secs).rev() {
466
+        eprint!("\rCapturing in {}... ", i);
467
+        std::io::stderr().flush().ok();
468
+        std::thread::sleep(Duration::from_secs(1));
469
+    }
470
+    eprintln!("\rCapturing now!     ");
471
+}
472
+
473
+fn send_notification(path: &PathBuf, width: u32, height: u32) {
474
+    let summary = "Screenshot saved";
475
+    let body = format!("{}x{} → {}", width, height, path.display());
476
+
477
+    // Try notify-send (most common)
478
+    let result = std::process::Command::new("notify-send")
479
+        .args(["-i", "camera-photo", "-a", "garshot", summary, &body])
480
+        .status();
481
+
482
+    if result.is_err() || !result.unwrap().success() {
483
+        tracing::debug!("notify-send not available or failed");
484
+    }
485
+}
486
+
487
+fn write_stdout(data: &[u8], width: u32, height: u32, format: &str) -> anyhow::Result<()> {
488
+    let encoded = encode_to_vec(data, width, height, format)?;
489
+    std::io::stdout().write_all(&encoded)?;
490
+    std::io::stdout().flush()?;
491
+    Ok(())
492
+}
493
+
494
+fn encode_to_vec(data: &[u8], width: u32, height: u32, format: &str) -> anyhow::Result<Vec<u8>> {
495
+    garshot::encode::encode_to_vec(data, width, height, format, 90)
496
+        .context("Failed to encode image")
497
+}
498
+
499
+fn capture_screen_raw(
500
+    _format: &str,
501
+    monitor: Option<&str>,
502
+    include_cursor: bool,
503
+) -> anyhow::Result<(Vec<u8>, u32, u32)> {
504
+    let conn = Connection::new().context("Failed to connect to X11")?;
505
+    let buffer_size = conn.width as usize * conn.height as usize * 4;
506
+    let shm = ShmCapture::new(&conn, buffer_size).context("Failed to create SHM buffer")?;
507
+
508
+    let mut result = if let Some(monitor_name) = monitor {
509
+        tracing::info!("Capturing monitor {}", monitor_name);
510
+        capture_monitor(&conn, &shm, monitor_name)?
511
+    } else {
512
+        tracing::info!("Capturing full screen");
513
+        let r = capture_full_screen(&conn, &shm)?;
514
+        garshot::capture::RegionCaptureResult {
515
+            data: r.data,
516
+            width: r.width,
517
+            height: r.height,
518
+            region: Region::new(0, 0, conn.width, conn.height),
519
+        }
520
+    };
521
+
522
+    if include_cursor {
523
+        if let Ok(cursor) = get_cursor_image(&conn) {
524
+            blend_cursor(
525
+                &mut result.data,
526
+                result.width,
527
+                result.height,
528
+                &result.region,
529
+                &cursor,
530
+            );
531
+        }
532
+    }
533
+
534
+    Ok((result.data, result.width, result.height))
535
+}
536
+
537
+fn capture_region_raw(
298538
     geometry: &str,
299539
     include_cursor: bool,
300
-) -> anyhow::Result<()> {
540
+) -> anyhow::Result<(Vec<u8>, u32, u32)> {
301541
     let conn = Connection::new().context("Failed to connect to X11")?;
302542
     let buffer_size = conn.width as usize * conn.height as usize * 4;
303543
     let shm = ShmCapture::new(&conn, buffer_size).context("Failed to create SHM buffer")?;
304544
 
305545
     let region = Region::from_geometry(geometry).context("Invalid geometry")?;
306546
     tracing::info!(
307
-        "Capturing region {}x{}+{}+{} to {}",
547
+        "Capturing region {}x{}+{}+{}",
308548
         region.width,
309549
         region.height,
310550
         region.x,
311
-        region.y,
312
-        output.display()
551
+        region.y
313552
     );
314553
 
315554
     let mut result = capture_region(&conn, &shm, &region)?;
@@ -326,16 +565,14 @@ fn capture_region_cmd(
326565
         }
327566
     }
328567
 
329
-    save_image(&result.data, result.width, result.height, output, format)
568
+    Ok((result.data, result.width, result.height))
330569
 }
331570
 
332
-fn capture_window_cmd(
333
-    output: &PathBuf,
334
-    format: &str,
571
+fn capture_window_raw(
335572
     window_id: Option<&str>,
336573
     decorations: bool,
337574
     include_cursor: bool,
338
-) -> anyhow::Result<()> {
575
+) -> anyhow::Result<(Vec<u8>, u32, u32)> {
339576
     use garshot::capture::{capture_active_window, capture_window, get_active_window};
340577
 
341578
     let conn = Connection::new().context("Failed to connect to X11")?;
@@ -344,11 +581,11 @@ fn capture_window_cmd(
344581
 
345582
     let mut result = if let Some(id_str) = window_id {
346583
         let id = parse_window_id(id_str)?;
347
-        tracing::info!("Capturing window 0x{:x} to {}", id, output.display());
584
+        tracing::info!("Capturing window 0x{:x}", id);
348585
         capture_window(&conn, &shm, id, decorations)?
349586
     } else {
350587
         let active = get_active_window(&conn)?;
351
-        tracing::info!("Capturing active window 0x{:x} to {}", active, output.display());
588
+        tracing::info!("Capturing active window 0x{:x}", active);
352589
         capture_active_window(&conn, &shm, decorations)?
353590
     };
354591
 
@@ -364,39 +601,13 @@ fn capture_window_cmd(
364601
         }
365602
     }
366603
 
367
-    save_image(&result.data, result.width, result.height, output, format)
368
-}
369
-
370
-fn save_image(
371
-    data: &[u8],
372
-    width: u32,
373
-    height: u32,
374
-    output: &PathBuf,
375
-    format: &str,
376
-) -> anyhow::Result<()> {
377
-    match format {
378
-        "png" => encode_png(data, width, height, output).context("Failed to encode PNG")?,
379
-        _ => anyhow::bail!("Unsupported format: {}", format),
380
-    }
381
-    tracing::info!("Saved {}x{} to {}", width, height, output.display());
382
-    Ok(())
604
+    Ok((result.data, result.width, result.height))
383605
 }
384606
 
385
-fn parse_window_id(s: &str) -> anyhow::Result<u32> {
386
-    let s = s.trim();
387
-    if s.starts_with("0x") || s.starts_with("0X") {
388
-        u32::from_str_radix(&s[2..], 16).context("Invalid hex window ID")
389
-    } else {
390
-        s.parse().context("Invalid decimal window ID")
391
-    }
392
-}
393
-
394
-fn capture_select_cmd(
395
-    output: &PathBuf,
396
-    format: &str,
607
+fn capture_select_raw(
397608
     include_cursor: bool,
398609
     blur_radius: usize,
399
-) -> anyhow::Result<()> {
610
+) -> anyhow::Result<Option<(Vec<u8>, u32, u32)>> {
400611
     let conn = Connection::new().context("Failed to connect to X11")?;
401612
     let buffer_size = conn.width as usize * conn.height as usize * 4;
402613
     let shm = ShmCapture::new(&conn, buffer_size).context("Failed to create SHM buffer")?;
@@ -412,7 +623,7 @@ fn capture_select_cmd(
412623
         Some(region) => region,
413624
         None => {
414625
             tracing::info!("Selection cancelled");
415
-            return Ok(());
626
+            return Ok(None);
416627
         }
417628
     };
418629
 
@@ -424,7 +635,6 @@ fn capture_select_cmd(
424635
         region.y
425636
     );
426637
 
427
-    // Capture the selected region
428638
     let mut result = capture_region(&conn, &shm, &region)?;
429639
 
430640
     if include_cursor {
@@ -439,52 +649,7 @@ fn capture_select_cmd(
439649
         }
440650
     }
441651
 
442
-    save_image(&result.data, result.width, result.height, output, format)
443
-}
444
-
445
-fn copy_to_clipboard(path: &PathBuf) -> anyhow::Result<()> {
446
-    let mime_type = match path.extension().and_then(|e| e.to_str()) {
447
-        Some("png") => "image/png",
448
-        Some("jpg") | Some("jpeg") => "image/jpeg",
449
-        Some("webp") => "image/webp",
450
-        Some("ppm") | Some("pam") => "image/x-portable-pixmap",
451
-        _ => "image/png", // Default to PNG
452
-    };
453
-
454
-    // Try xclip first, then xsel
455
-    let result = std::process::Command::new("xclip")
456
-        .args(["-selection", "clipboard", "-t", mime_type, "-i"])
457
-        .arg(path)
458
-        .status();
459
-
460
-    match result {
461
-        Ok(status) if status.success() => {
462
-            tracing::debug!("Copied {} to clipboard via xclip", path.display());
463
-            Ok(())
464
-        }
465
-        _ => {
466
-            // Fallback to xsel (doesn't support MIME types as well)
467
-            let result = std::process::Command::new("xsel")
468
-                .args(["--clipboard", "--input"])
469
-                .stdin(std::fs::File::open(path)?)
470
-                .status();
471
-
472
-            match result {
473
-                Ok(status) if status.success() => {
474
-                    tracing::debug!("Copied {} to clipboard via xsel", path.display());
475
-                    Ok(())
476
-                }
477
-                Ok(status) => {
478
-                    tracing::warn!("xsel exited with status: {}", status);
479
-                    anyhow::bail!("Failed to copy to clipboard")
480
-                }
481
-                Err(e) => {
482
-                    tracing::warn!("Neither xclip nor xsel available: {}", e);
483
-                    anyhow::bail!("No clipboard tool available (install xclip or xsel)")
484
-                }
485
-            }
486
-        }
487
-    }
652
+    Ok(Some((result.data, result.width, result.height)))
488653
 }
489654
 
490655
 fn run_daemon() -> anyhow::Result<()> {