From a46785695461d86ac926775b425174fcc380f531 Mon Sep 17 00:00:00 2001 From: Michael Burr Date: Thu, 5 Nov 2026 16:14:28 +0000 Subject: [PATCH] feat(upload): upload the clipboard, from a hotkey, the tray or snapr upload -c "Upload the clipboard" (ToolAction::UploadClipboard) uploads what's on the clipboard to the ticked destinations: files copied in a file manager as they are, an image as a screenshot (saved first if screenshots are, so it's in Recent with its preview, and not copied back), or text as a .txt file. With nothing saved to keep the link in Recent, the link is copied even if "Copy the link to the clipboard" is off. - It's in the tray's Tools menu, can be what a tray click does, and can have hotkeys under Settings > Hotkeys > Tool hotkeys. - `snapr upload -c` / `--clipboard` uploads it from the command line, after any files given; a clipboard that can't be read fails as a file would, without stopping the files. --- docs/GUIDE.md | 11 ++- src/cli.rs | 166 ++++++++++++++++++++++++++++++++-------- src/clipboard_upload.rs | 39 ++++++++++ src/main.rs | 44 +++++++++-- src/output.rs | 104 +++++++++++++++++++++---- src/settings.rs | 8 +- src/settings_search.rs | 2 +- src/tray/macos.rs | 6 ++ 8 files changed, 324 insertions(+), 56 deletions(-) create mode 100644 src/clipboard_upload.rs diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 87882e5..8ed9c43 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -63,6 +63,12 @@ or *Display*, *Fullscreen*, or *Record region…*. to preview, double-click to open, right-click for the same actions, or drag one out into another app. Files dropped on the window are uploaded. Saved screenshots are remembered in `history.txt` next to the settings. +- **Upload the clipboard** (in the tray's *Tools* menu, or give it a hotkey + under *Settings → Hotkeys → Tool hotkeys*) uploads what's on the clipboard + to the ticked destinations: files copied in a file manager as they are, an + image as a screenshot (saved first, if screenshots are saved, and shown in + Recent), or text as a `.txt` file. With nothing saved to keep the link in + Recent, the link is copied even if *Copy the link to the clipboard* is off. - **Combine images** on the Recent page: drag one screenshot onto another and drop it on *Horizontal* or *Vertical*, or Ctrl-click several (they're numbered and combined in the order you click them; Shift-click picks every @@ -341,6 +347,7 @@ snapr upload shot.png log.txt # upload files, print a link for each snapr upload -d "My R2" shot.png # to a particular destination (--destination) snapr upload -n docs/latest shot.png # as docs/latest.png rather than by the key template (--name) snapr upload -n docs/latest -f shot.png # ...replacing the one already there (--force) +snapr upload --clipboard # what's on the clipboard: copied files, an image or text (-c) snapr upload --list # the destinations, and which is the default (-l) ``` @@ -356,7 +363,9 @@ bucket under the same name is left alone and reported as an error, unless `-f` says to replace it (Pomf hosts never replace anything). Links go to stdout, one per line in the order given; errors go to stderr, and the exit status is 1 if any file failed (2 for a mistake in the command). Files are added to Recent, as dropped -files are. It works while snapr is running in the tray. On Windows snapr is a +files are. `-c` uploads the clipboard, after any files given, as *Upload the +clipboard* does: an image is saved first if screenshots are saved, and text +goes as a `.txt` file. It works while snapr is running in the tray. On Windows snapr is a windowed app, which PowerShell doesn't wait for unless its output is piped: use `snapr upload shot.png | Out-String` there (Git Bash and cmd scripts that capture the output wait anyway). diff --git a/src/cli.rs b/src/cli.rs index 45a4f80..4a2aa4e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,20 +5,29 @@ use std::fmt::Arguments; use std::io::Write as _; use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use image::RgbaImage; use crate::history::{self, Remote}; use crate::settings::{Loaded, Settings, Upload}; use crate::upload::{Body, Progress}; +use crate::clipboard_upload::{self, Contents}; use crate::{naming, output, policy, secrets}; const HELP: &str = "snapr upload - upload files and print their links USAGE: snapr upload [OPTIONS] ... + snapr upload [OPTIONS] --clipboard Uploads each file to one destination from Settings and prints its link, one per line, in the order given. They're added to Recent, as dropped files are. Failures are reported on stderr, and the exit status is then 1. +--clipboard uploads what's on the clipboard, after any files given: files +copied in a file manager, an image (saved first, as a screenshot is, if +screenshots are saved) or text (as a .txt file). + Files are named by the destination's object key or file name, unless --name is given. A file already in a bucket under that name is left alone and reported as a failure, unless --force is given. @@ -32,6 +41,7 @@ OPTIONS: are sent it too, though most pick their own -f, --force Replace a file already in the bucket under the same name (Pomf hosts never replace anything) + -c, --clipboard Upload what's on the clipboard -l, --list List the destinations, then exit -h, --help Show this help"; @@ -39,7 +49,7 @@ OPTIONS: /// status. pub fn upload(mut args: impl Iterator) -> i32 { let (mut files, mut wanted, mut list) = (Vec::new(), None, false); - let (mut name, mut force) = (None, false); + let (mut name, mut force, mut clipboard) = (None, false, false); while let Some(arg) = args.next() { match arg.as_str() { "-d" | "--destination" => match args.next() { @@ -51,6 +61,7 @@ pub fn upload(mut args: impl Iterator) -> i32 { None => return usage("--name needs a name to upload the file as"), }, "-f" | "--force" => force = true, + "-c" | "--clipboard" => clipboard = true, "-l" | "--list" => list = true, "-h" | "--help" => { say(format_args!("{HELP}")); @@ -72,10 +83,28 @@ pub fn upload(mut args: impl Iterator) -> i32 { print_list(&settings); return 0; } - if files.is_empty() { + let mut items: Vec = files.into_iter().map(Item::File).collect(); + // A clipboard that can't be read fails, as a file would, without + // stopping the files given. + let mut status = 0; + if clipboard { + // Its errors are written for snapr's window. + let plain = |e: String| e.trim_start_matches("upload failed: ").to_owned(); + match clipboard_upload::read().map_err(plain) { + Ok(Contents::Files(paths)) => items.extend(paths.into_iter().map(Item::File)), + Ok(Contents::Image(image)) => items.push(Item::Image(image)), + Ok(Contents::Text(text)) => items.push(Item::Text(text)), + Err(e) if items.is_empty() => return fail(&e), + Err(e) => { + warn(format_args!("snapr upload: the clipboard: {e}")); + status = 1; + } + } + } + if items.is_empty() { return usage("no files given"); } - if name.is_some() && files.len() > 1 { + if name.is_some() && items.len() > 1 { return usage("--name names one file, but more were given"); } if let Some(n) = &name @@ -109,11 +138,27 @@ pub fn upload(mut args: impl Iterator) -> i32 { }; let target = upload.target(secret); - let mut status = 0; - for file in files { - let result = std::path::absolute(&file) - .map_err(|e| e.to_string()) - .and_then(|path| send(upload, &target, &key, name.as_deref(), force, &path)); + for item in items { + let name = name.as_deref(); + let (what, result) = match item { + Item::File(file) => { + let result = std::path::absolute(&file) + .map_err(|e| e.to_string()) + .and_then(|path| send(upload, &target, &key, name, force, &path)); + (file.display().to_string(), result) + } + Item::Image(image) => { + let result = send_image(&settings, upload, &target, &key, name, force, &image); + ("the clipboard's image".into(), result) + } + Item::Text(text) => { + let mut ctx = naming::Context::new(chrono::Local::now()); + ctx.title = Some(clipboard_upload::TEXT_TITLE.into()); + let object = object_key(&key, name, "txt", &ctx); + let body = Body::Bytes(Arc::new(text.into_bytes())); + ("the clipboard's text".into(), put(upload, &target, &object, &body, "txt", force)) + } + }; match result { Ok(url) => say(format_args!("{url}")), Err(e) => { @@ -122,7 +167,7 @@ pub fn upload(mut args: impl Iterator) -> i32 { } else { "" }; - warn(format_args!("snapr upload: {}: {e}{hint}", file.display())); + warn(format_args!("snapr upload: {what}: {e}{hint}")); status = 1; } } @@ -130,6 +175,78 @@ pub fn upload(mut args: impl Iterator) -> i32 { status } +/// Something to upload: a file, or what was on the clipboard. +enum Item { + File(PathBuf), + Image(RgbaImage), + Text(String), +} + +/// Uploads an image from the clipboard: saved first as a screenshot would +/// be, if they are, and then sent as that file; else sent as a PNG with +/// nothing kept. +fn send_image( + settings: &Settings, + upload: &Upload, + target: &crate::upload::Target, + key: &naming::Template, + name: Option<&str>, + force: bool, + image: &RgbaImage, +) -> Result { + let mut ctx = naming::Context::new(chrono::Local::now()); + (ctx.width, ctx.height) = image.dimensions(); + if settings.save_to_folder { + let naming = settings.naming()?; + if naming.uses_counter() { + ctx.counter = output::next_counter(); + } + let path = naming.path_for(&ctx); + let saved = path + .parent() + .map_or(Ok(()), std::fs::create_dir_all) + .map_err(|e| e.to_string()) + .and_then(|()| image.save(&path).map_err(|e| e.to_string())); + if let Err(e) = saved { + return Err(format!("couldn't save {}: {e}", path.display())); + } + return send(upload, target, key, name, force, &path); + } + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("couldn't encode it: {e}"))?; + let object = object_key(key, name, "png", &ctx); + put(upload, target, &object, &Body::Bytes(Arc::new(png)), "png", force) +} + +/// Sends `body` as `object`; returns its link. +fn put( + upload: &Upload, + target: &crate::upload::Target, + object: &str, + body: &Body, + ext: &str, + force: bool, +) -> Result { + upload.put(target, object, body, output::content_type(ext), force, &Progress::default()) +} + +/// The object key: `name` (checked before anything was sent), or else +/// rendered from the destination's key template. +fn object_key(key: &naming::Template, name: Option<&str>, ext: &str, ctx: &naming::Context) -> String { + match name { + Some(n) => object_name(n, ext).expect("a name"), + None => { + let mut ctx = ctx.clone(); + if key.uses_counter() && ctx.counter == 0 { + ctx.counter = output::next_counter(); + } + key.render_key(&ctx, ext) + } + } +} + /// Uploads one file, as `name` or else named as a dropped file would be, /// replacing what's there already if `force`; returns its link. fn send( @@ -148,30 +265,13 @@ fn send( .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); - let object = match name { - // Checked before anything was sent. - Some(n) => object_name(n, &ext).expect("a name"), - None => { - let mut ctx = naming::Context::new(chrono::Local::now()); - ctx.title = path.file_stem().map(|s| s.to_string_lossy().into_owned()); - if let Ok((w, h)) = image::image_dimensions(path) { - (ctx.width, ctx.height) = (w, h); - } - if key.uses_counter() { - ctx.counter = output::next_counter(); - } - key.render_key(&ctx, &ext) - } - }; - let body = Body::File(path.to_owned()); - let url = upload.put( - target, - &object, - &body, - output::content_type(&ext), - force, - &Progress::default(), - )?; + let mut ctx = naming::Context::new(chrono::Local::now()); + ctx.title = path.file_stem().map(|s| s.to_string_lossy().into_owned()); + if let Ok((w, h)) = image::image_dimensions(path) { + (ctx.width, ctx.height) = (w, h); + } + let object = object_key(key, name, &ext, &ctx); + let url = put(upload, target, &object, &Body::File(path.to_owned()), &ext, force)?; history::add(path); let remote = upload.deletable().then(|| Remote { upload_id: upload.id.clone(), diff --git a/src/clipboard_upload.rs b/src/clipboard_upload.rs new file mode 100644 index 0000000..5ac5187 --- /dev/null +++ b/src/clipboard_upload.rs @@ -0,0 +1,39 @@ +//! What's on the clipboard, to upload: files copied in a file manager, an +//! image, or text (sent as a `.txt` file). + +use std::path::PathBuf; + +use image::RgbaImage; + +/// Names text from the clipboard, for its key and in the uploads panel. +pub const TEXT_TITLE: &str = "Clipboard"; + +pub enum Contents { + /// Files copied in a file manager. + Files(Vec), + Image(RgbaImage), + Text(String), +} + +/// Reads the clipboard: files first, as a file manager can offer a picture +/// of them too, then an image (a browser's Copy image offers its address as +/// well), then text. +pub fn read() -> Result { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| format!("upload failed: clipboard unavailable: {e}"))?; + if let Ok(files) = clipboard.get().file_list() { + let files: Vec<_> = files.into_iter().filter(|f| f.is_file()).collect(); + if !files.is_empty() { + return Ok(Contents::Files(files)); + } + } + if let Ok(image) = clipboard.get_image() { + return RgbaImage::from_raw(image.width as u32, image.height as u32, image.bytes.into_owned()) + .map(Contents::Image) + .ok_or_else(|| "upload failed: couldn't read the image on the clipboard".into()); + } + match clipboard.get_text() { + Ok(text) if !text.trim().is_empty() => Ok(Contents::Text(text)), + _ => Err("upload failed: there's nothing on the clipboard to upload".into()), + } +} diff --git a/src/main.rs b/src/main.rs index 16bd5b2..0d053a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod capture; mod cli; #[cfg(target_os = "linux")] mod clipboard_holder; +mod clipboard_upload; mod color_names; mod combine; mod cursor; @@ -330,13 +331,12 @@ impl App { ToolAction::PickColor => Purpose::PickColor, ToolAction::ScanQr => Purpose::Scan, ToolAction::PinRegion => Purpose::Pin, + // Not while the user is recording a new hotkey. + _ if self.main_window.as_ref().is_some_and(|w| w.form.recording) => return, ToolAction::CaptureDisplay => { - // Not while the user is recording a new hotkey. - if !self.main_window.as_ref().is_some_and(|w| w.form.recording) { - self.grab(event_loop, capture::Target::ActiveDisplay); - } - return; + return self.grab(event_loop, capture::Target::ActiveDisplay); } + ToolAction::UploadClipboard => return self.upload_clipboard(event_loop), }; self.open_session(event_loop, purpose); } @@ -1183,6 +1183,40 @@ impl App { } } + /// Uploads what's on the clipboard to the enabled destinations: copied + /// files as they are, an image as a screenshot (saved first, if they + /// are), or text as a `.txt` file. + fn upload_clipboard(&mut self, event_loop: &ActiveEventLoop) { + if policy::uploads_disabled() { + return self.report_on(event_loop, policy::UPLOADS_OFF.into(), true, Page::Destinations); + } + if !self.settings.uploads.iter().any(|u| u.enabled) { + return self.report_on( + event_loop, + "upload failed: no upload destinations are set up and enabled".into(), + true, + Page::Destinations, + ); + } + let contents = match clipboard_upload::read() { + Ok(c) => c, + Err(e) => return self.report_on(event_loop, e, true, Page::Destinations), + }; + let ctx = naming::Context::new(chrono::Local::now()); + match contents { + clipboard_upload::Contents::Files(paths) => return self.upload_files(event_loop, paths), + clipboard_upload::Contents::Image(image) => self.output.upload_image(image, ctx), + clipboard_upload::Contents::Text(text) => { + let ctx = naming::Context { + title: Some(clipboard_upload::TEXT_TITLE.into()), + ..ctx + }; + self.output.upload_text(text, ctx); + } + } + self.set_status("Uploading the clipboard\u{2026}".into()); + } + /// Deletes an uploaded copy of a screenshot from its bucket. fn delete_remote( &mut self, diff --git a/src/output.rs b/src/output.rs index c3edc97..e997f5a 100644 --- a/src/output.rs +++ b/src/output.rs @@ -94,10 +94,23 @@ pub fn percent_done(transfers: &[Arc]) -> Option { Some((sent * 100).checked_div(total).unwrap_or(0) as u8) } +/// What's done with an image handed to `Output`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Save { + /// A screenshot: saved, copied and uploaded as the settings say. + Capture, + /// Only copied to the clipboard. + Copy, + /// Uploaded (and saved, if screenshots are), but not copied: it came + /// from the clipboard. + Upload, +} + +/// What a file uploaded with nothing saved is called in the uploads panel. +const UNSAVED: &str = "Screenshot"; + enum Job { - /// The image, naming context, and whether this is a normal capture - /// (`false`: only copy it to the clipboard). - Save(RgbaImage, Context, bool), + Save(RgbaImage, Context, Save), Configure(Box), /// Copy an existing screenshot file to the clipboard. CopyFile(PathBuf), @@ -105,6 +118,9 @@ enum Job { UploadFile(PathBuf, Context), CopyImage(RgbaImage), CopyText(String), + /// Upload this, with this extension, without saving it; its link is + /// copied whatever the settings say, as there's no file to keep it. + UploadBytes(Vec, &'static str, Context), /// An upload thread finished. Uploaded { path: Option, @@ -148,9 +164,15 @@ impl Output { let mut next_batch = 0u64; for job in rx { match job { - Job::Save(img, mut ctx, capture) => { + Job::Save(img, mut ctx, how) => { + if how == Save::Upload && config.uploads.is_empty() { + notify(Event::Failed(NO_DESTINATIONS.into())); + continue; + } (ctx.width, ctx.height) = img.dimensions(); let mut path = None; + // Saved and uploaded, not only copied. + let capture = how != Save::Copy; let uploading = capture && !config.uploads.is_empty(); if capture && config.save_to_folder { if config.naming.uses_counter() { @@ -167,7 +189,7 @@ impl Output { Err(e) => notify(Event::Failed(e)), } } - if config.copy_image || !capture { + if (config.copy_image && how == Save::Capture) || how == Save::Copy { match copy_image(&mut clipboard, &img) { Ok(()) if !capture => notify(Event::Copied), Ok(()) => {} @@ -193,12 +215,17 @@ impl Output { *pending.entry(p.clone()).or_default() += config.uploads.len(); } next_batch += 1; - batches.insert(next_batch, Batch::new(&config)); + let mut batch = Batch::new(&config); + // From the clipboard, with no file to keep the + // link: copy it, as for text. + batch.copy_link |= how == Save::Upload && path.is_none(); + batches.insert(next_batch, batch); in_flight += upload_all( &config.uploads, &mut ctx, Body::Bytes(Arc::new(png)), "png", + UNSAVED, path, (next_batch, &uploads_tx), &in_progress, @@ -206,12 +233,35 @@ impl Output { notify(Event::UploadsStarted); } } + Job::UploadBytes(bytes, ext, mut ctx) => { + if config.uploads.is_empty() { + notify(Event::Failed(NO_DESTINATIONS.into())); + continue; + } + let title = ctx.title.clone().unwrap_or_else(|| UNSAVED.into()); + next_batch += 1; + batches.insert( + next_batch, + Batch { + copy_link: true, + ..Batch::new(&config) + }, + ); + in_flight += upload_all( + &config.uploads, + &mut ctx, + Body::Bytes(Arc::new(bytes)), + ext, + &title, + None, + (next_batch, &uploads_tx), + &in_progress, + ); + notify(Event::UploadsStarted); + } Job::UploadFile(file, mut ctx) => { if config.uploads.is_empty() { - notify(Event::Failed( - "upload failed: no upload destinations are set up and enabled" - .into(), - )); + notify(Event::Failed(NO_DESTINATIONS.into())); notify(Event::UploadsFinished(file)); continue; } @@ -236,6 +286,7 @@ impl Output { &mut ctx, body, &ext, + UNSAVED, Some(file), (next_batch, &uploads_tx), &in_progress, @@ -253,6 +304,7 @@ impl Output { in_flight -= 1; let b = batches.entry(batch).or_insert_with(|| Batch::new(&config)); b.left = b.left.saturating_sub(1); + let copy_link = b.copy_link; let link = match result { Ok(url) => { notify(Event::Uploaded { @@ -274,7 +326,7 @@ impl Output { batches.remove(&batch); } if let Some((name, url, remote)) = link { - let copied = config.copy_link + let copied = copy_link && copy_text(&mut clipboard, &url) .map_err(|e| notify(Event::Failed(e))) .is_ok(); @@ -347,8 +399,22 @@ impl Output { self.transfers.clone() } + /// A screenshot (`capture`), or else an image only to copy. pub fn submit(&self, img: RgbaImage, ctx: Context, capture: bool) { - let _ = self.tx.send(Job::Save(img, ctx, capture)); + let how = if capture { Save::Capture } else { Save::Copy }; + let _ = self.tx.send(Job::Save(img, ctx, how)); + } + + /// Uploads an image from the clipboard, saved first as a screenshot + /// would be, but not copied back. + pub fn upload_image(&self, img: RgbaImage, ctx: Context) { + let _ = self.tx.send(Job::Save(img, ctx, Save::Upload)); + } + + /// Uploads text as a `.txt` file, without saving it (`ctx.title` names + /// it in the uploads panel), and copies its link. + pub fn upload_text(&self, text: String, ctx: Context) { + let _ = self.tx.send(Job::UploadBytes(text.into_bytes(), "txt", ctx)); } pub fn copy_file(&self, path: PathBuf) { @@ -380,21 +446,26 @@ impl Output { } } +const NO_DESTINATIONS: &str = "upload failed: no upload destinations are set up and enabled"; + /// Uploads `body` to each destination on threads of its own, listed in -/// `transfers` while they run, reporting back with `Job::Uploaded`. Returns -/// how many uploads were started. +/// `transfers` while they run (as `path`'s file name, or else `unsaved`), +/// reporting back with `Job::Uploaded`. Returns how many uploads were +/// started. +#[allow(clippy::too_many_arguments)] fn upload_all( uploads: &[UploadTarget], ctx: &mut Context, body: Body, ext: &str, + unsaved: &str, path: Option, (batch, tx): (u64, &Sender), transfers: &Transfers, ) -> usize { let file = match &path { Some(p) => p.file_name().unwrap_or_default().to_string_lossy().into_owned(), - None => format!("Screenshot.{ext}"), + None => format!("{unsaved}.{ext}"), }; let content_type = content_type(ext); for u in uploads { @@ -453,6 +524,8 @@ struct Batch { done: bool, /// Uploads still running. left: usize, + /// Copy the link once it's given. + copy_link: bool, } impl Batch { @@ -465,6 +538,7 @@ impl Batch { .any(|u| u.upload.id == *chosen) .then(|| chosen.clone()), left: config.uploads.len(), + copy_link: config.copy_link, ..Self::default() } } diff --git a/src/settings.rs b/src/settings.rs index 7101189..595e12f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -299,14 +299,17 @@ pub enum ToolAction { PinRegion, /// Screenshot the display the focused window is on. CaptureDisplay, + /// Upload what's on the clipboard: copied files, an image or text. + UploadClipboard, } impl ToolAction { /// The tools (the tray's Tools menu, the Tools hotkeys). - pub const ALL: [ToolAction; 3] = [ + pub const ALL: [ToolAction; 4] = [ ToolAction::PickColor, ToolAction::ScanQr, ToolAction::PinRegion, + ToolAction::UploadClipboard, ]; /// The captures that can have a hotkey besides region and record. @@ -323,6 +326,7 @@ impl ToolAction { ToolAction::ScanQr => "scan-qr", ToolAction::PinRegion => "pin-region", ToolAction::CaptureDisplay => "capture-display", + ToolAction::UploadClipboard => "upload-clipboard", } } @@ -332,6 +336,7 @@ impl ToolAction { ToolAction::ScanQr => "Read a QR code", ToolAction::PinRegion => "Pin a region to the screen", ToolAction::CaptureDisplay => "Capture active display", + ToolAction::UploadClipboard => "Upload the clipboard", } } } @@ -376,6 +381,7 @@ impl TrayClick { TrayClick::Tool(ToolAction::ScanQr) => "Reads a QR code", TrayClick::Tool(ToolAction::PinRegion) => "Pins a region to the screen", TrayClick::Tool(ToolAction::CaptureDisplay) => "Captures the active display", + TrayClick::Tool(ToolAction::UploadClipboard) => "Uploads the clipboard", } } } diff --git a/src/settings_search.rs b/src/settings_search.rs index 71faa5e..bd4c0a5 100644 --- a/src/settings_search.rs +++ b/src/settings_search.rs @@ -105,7 +105,7 @@ static INDEX: LazyLock> = LazyLock::new(|| { add(Hotkeys, "capture-hotkey", "Capture region hotkey", "shortcut keyboard key combination screenshot"); add(Hotkeys, "record-hotkey", "Record region hotkey", "shortcut keyboard key combination video recording"); add(Hotkeys, "capture-hotkeys", "Capture active display hotkey", "shortcut keyboard key combination screenshot monitor screen focused window fullscreen"); - add(Hotkeys, "tool-hotkeys", "Tool hotkeys", "shortcut keyboard colour color picker qr code pin tools"); + add(Hotkeys, "tool-hotkeys", "Tool hotkeys", "shortcut keyboard colour color picker qr code pin tools upload clipboard paste"); // Paths & naming add(Naming, "folder", "Save folder", "directory location path where screenshots are saved"); diff --git a/src/tray/macos.rs b/src/tray/macos.rs index f429bbd..483881e 100644 --- a/src/tray/macos.rs +++ b/src/tray/macos.rs @@ -69,6 +69,11 @@ define_class!( (self.ivars().callback)(TrayAction::Tool(ToolAction::PinRegion)); } + #[unsafe(method(uploadClipboard:))] + fn upload_clipboard(&self, _sender: Option<&AnyObject>) { + (self.ivars().callback)(TrayAction::Tool(ToolAction::UploadClipboard)); + } + #[unsafe(method(recent:))] fn recent(&self, _sender: Option<&AnyObject>) { (self.ivars().callback)(TrayAction::Recent); @@ -149,6 +154,7 @@ impl Tray { ToolAction::PickColor => sel!(pickColor:), ToolAction::ScanQr => sel!(scanQr:), ToolAction::PinRegion => sel!(pinRegion:), + ToolAction::UploadClipboard => sel!(uploadClipboard:), // Not a tool: not in `ALL`. ToolAction::CaptureDisplay => continue, };