diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 96132a2..fe080cd 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -200,6 +200,9 @@ compressed = Compressed {$items} {$items -> *[other] items } from "{$from}" to "{$to}" copy_noun = Copy +pasted-image = Pasted Image +pasted-text = Pasted Text +pasted-video = Pasted Video creating = Creating "{$name}" in "{$parent}" created = Created "{$name}" in "{$parent}" copying = Copying {$items} {$items -> diff --git a/src/app.rs b/src/app.rs index 160350d..b1049db 100644 --- a/src/app.rs +++ b/src/app.rs @@ -72,7 +72,10 @@ use wayland_client::{Proxy, protocol::wl_output::WlOutput}; use crate::{ FxOrderMap, - clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, + clipboard::{ + ClipboardCopy, ClipboardKind, ClipboardPaste, ClipboardPasteImage, ClipboardPasteText, + ClipboardPasteVideo, + }, config::{ AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig, TimeConfig, TypeToSearch, @@ -87,7 +90,7 @@ use crate::{ mounter::{MOUNTERS, MounterAuth, MounterItem, MounterItems, MounterKey, MounterMessage}, operation::{ Controller, Operation, OperationError, OperationErrorType, OperationSelection, - ReplaceResult, + ReplaceResult, copy_unique_path, }, spawn_detached::spawn_detached, tab::{ @@ -385,6 +388,12 @@ pub enum Message { Overlap(window::Id, OverlapNotifyEvent), Paste(Option), PasteContents(PathBuf, ClipboardPaste), + PasteImage(PathBuf), + PasteImageContents(PathBuf, ClipboardPasteImage), + PasteText(PathBuf), + PasteTextContents(PathBuf, ClipboardPasteText), + PasteVideo(PathBuf), + PasteVideoContents(PathBuf, ClipboardPasteVideo), PendingCancel(u64), PendingCancelAll, PendingComplete(u64, OperationSelection), @@ -3488,7 +3497,8 @@ impl Application for App { Some(contents) => { cosmic::action::app(Message::PasteContents(to.clone(), contents)) } - None => cosmic::action::none(), + // No file data in clipboard, try image data + None => cosmic::action::app(Message::PasteImage(to.clone())), } }); } @@ -3509,6 +3519,102 @@ impl Application for App { }; } } + Message::PasteImage(to) => { + return clipboard::read_data::().map(move |contents_opt| { + match contents_opt { + Some(contents) => { + cosmic::action::app(Message::PasteImageContents(to.clone(), contents)) + } + // No image data in clipboard, try video data + None => cosmic::action::app(Message::PasteVideo(to.clone())), + } + }); + } + Message::PasteImageContents(to, contents) => { + let Some(extension) = contents.extension() else { + log::warn!( + "Ignoring paste: unknown image MIME type {:?}", + contents.mime_type + ); + return Task::none(); + }; + + // Generate unique filename for the pasted image + let base_name = format!("{}.{}", fl!("pasted-image"), extension); + let base_path = to.join(&base_name); + let final_path = copy_unique_path(&base_path, &to); + + // Write image data to file + match fs::write(&final_path, &contents.data) { + Ok(_) => { + log::info!("Pasted image saved to {:?}", final_path); + } + Err(err) => { + log::error!("Failed to save pasted image: {}", err); + } + } + } + Message::PasteVideo(to) => { + return clipboard::read_data::().map(move |contents_opt| { + match contents_opt { + Some(contents) => { + cosmic::action::app(Message::PasteVideoContents(to.clone(), contents)) + } + // No video data in clipboard, try text data + None => cosmic::action::app(Message::PasteText(to.clone())), + } + }); + } + Message::PasteVideoContents(to, contents) => { + let Some(extension) = contents.extension() else { + log::warn!( + "Ignoring paste: unknown video MIME type {:?}", + contents.mime_type + ); + return Task::none(); + }; + + // Generate unique filename for the pasted video + let base_name = format!("{}.{}", fl!("pasted-video"), extension); + let base_path = to.join(&base_name); + let final_path = copy_unique_path(&base_path, &to); + + // Write video data to file + match fs::write(&final_path, &contents.data) { + Ok(_) => { + log::info!("Pasted video saved to {:?}", final_path); + } + Err(err) => { + log::error!("Failed to save pasted video: {}", err); + } + } + } + Message::PasteText(to) => { + return clipboard::read_data::().map(move |contents_opt| { + match contents_opt { + Some(contents) => { + cosmic::action::app(Message::PasteTextContents(to.clone(), contents)) + } + None => cosmic::action::none(), + } + }); + } + Message::PasteTextContents(to, contents) => { + // Generate unique filename for the pasted text + let base_name = format!("{}.txt", fl!("pasted-text")); + let base_path = to.join(&base_name); + let final_path = copy_unique_path(&base_path, &to); + + // Write text data to file + match fs::write(&final_path, &contents.data) { + Ok(_) => { + log::info!("Pasted text saved to {:?}", final_path); + } + Err(err) => { + log::error!("Failed to save pasted text: {}", err); + } + } + } Message::PendingCancel(id) => { if let Some((_, controller)) = self.pending_operations.get(&id) { controller.cancel(); diff --git a/src/clipboard.rs b/src/clipboard.rs index 0c17a09..bf84bee 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -162,3 +162,166 @@ impl TryFrom<(Vec, String)> for ClipboardPaste { Ok(Self { kind, paths }) } } + +/// Image data from clipboard for pasting as a new file. +#[derive(Clone, Debug)] +pub struct ClipboardPasteImage { + pub data: Vec, + pub mime_type: String, +} + +impl AllowedMimeTypes for ClipboardPasteImage { + fn allowed() -> Cow<'static, [String]> { + Cow::from(vec![ + "image/png".to_string(), + "image/jpeg".to_string(), + "image/gif".to_string(), + "image/bmp".to_string(), + "image/webp".to_string(), + "image/tiff".to_string(), + "image/x-tiff".to_string(), + "image/svg+xml".to_string(), + "image/x-icon".to_string(), + "image/vnd.microsoft.icon".to_string(), + "image/x-bmp".to_string(), + "image/x-ms-bmp".to_string(), + "image/pjpeg".to_string(), + "image/x-png".to_string(), + "image/avif".to_string(), + "image/heic".to_string(), + "image/heif".to_string(), + "image/jxl".to_string(), + ]) + } +} + +impl TryFrom<(Vec, String)> for ClipboardPasteImage { + type Error = Box; + fn try_from(value: (Vec, String)) -> Result { + let (data, mime) = value; + if data.is_empty() { + return Err("Empty image data".into()); + } + Ok(Self { + data, + mime_type: mime, + }) + } +} + +impl ClipboardPasteImage { + /// Get the file extension for the image based on MIME type. + /// Returns None if the MIME type is not recognized. + pub fn extension(&self) -> Option<&'static str> { + match self.mime_type.as_str() { + "image/png" | "image/x-png" => Some("png"), + "image/jpeg" | "image/pjpeg" => Some("jpg"), + "image/gif" => Some("gif"), + "image/bmp" | "image/x-bmp" | "image/x-ms-bmp" => Some("bmp"), + "image/webp" => Some("webp"), + "image/tiff" | "image/x-tiff" => Some("tiff"), + "image/svg+xml" => Some("svg"), + "image/x-icon" | "image/vnd.microsoft.icon" => Some("ico"), + "image/avif" => Some("avif"), + "image/heic" => Some("heic"), + "image/heif" => Some("heif"), + "image/jxl" => Some("jxl"), + _ => None, + } + } +} + +/// Video data from clipboard for pasting as a new file. +#[derive(Clone, Debug)] +pub struct ClipboardPasteVideo { + pub data: Vec, + pub mime_type: String, +} + +impl AllowedMimeTypes for ClipboardPasteVideo { + fn allowed() -> Cow<'static, [String]> { + Cow::from(vec![ + "video/mp4".to_string(), + "video/webm".to_string(), + "video/ogg".to_string(), + "video/mpeg".to_string(), + "video/quicktime".to_string(), + "video/x-msvideo".to_string(), + "video/x-matroska".to_string(), + "video/x-flv".to_string(), + "video/3gpp".to_string(), + "video/3gpp2".to_string(), + "video/x-ms-wmv".to_string(), + "video/avi".to_string(), + ]) + } +} + +impl TryFrom<(Vec, String)> for ClipboardPasteVideo { + type Error = Box; + fn try_from(value: (Vec, String)) -> Result { + let (data, mime) = value; + if data.is_empty() { + return Err("Empty video data".into()); + } + Ok(Self { + data, + mime_type: mime, + }) + } +} + +impl ClipboardPasteVideo { + /// Get the file extension for the video based on MIME type. + /// Returns None if the MIME type is not recognized. + pub fn extension(&self) -> Option<&'static str> { + match self.mime_type.as_str() { + "video/mp4" => Some("mp4"), + "video/webm" => Some("webm"), + "video/ogg" => Some("ogv"), + "video/mpeg" => Some("mpeg"), + "video/quicktime" => Some("mov"), + "video/x-msvideo" | "video/avi" => Some("avi"), + "video/x-matroska" => Some("mkv"), + "video/x-flv" => Some("flv"), + "video/3gpp" => Some("3gp"), + "video/3gpp2" => Some("3g2"), + "video/x-ms-wmv" => Some("wmv"), + _ => None, + } + } +} + +/// Text data from clipboard for pasting as a new text file. +#[derive(Clone, Debug)] +pub struct ClipboardPasteText { + pub data: String, +} + +impl AllowedMimeTypes for ClipboardPasteText { + fn allowed() -> Cow<'static, [String]> { + Cow::from(vec![ + "text/plain".to_string(), + "text/plain;charset=utf-8".to_string(), + "UTF8_STRING".to_string(), + "STRING".to_string(), + "TEXT".to_string(), + ]) + } +} + +impl TryFrom<(Vec, String)> for ClipboardPasteText { + type Error = Box; + fn try_from(value: (Vec, String)) -> Result { + let (data, _mime) = value; + if data.is_empty() { + return Err("Empty text data".into()); + } + // Use lossy conversion to handle clipboard data that may contain + // invalid UTF-8 (e.g., Latin-1 encoded special characters from browsers) + let text = String::from_utf8_lossy(&data); + Ok(Self { + data: text.into_owned(), + }) + } +} diff --git a/src/operation/mod.rs b/src/operation/mod.rs index d17ef5d..0669624 100644 --- a/src/operation/mod.rs +++ b/src/operation/mod.rs @@ -221,7 +221,7 @@ pub async fn sync_to_disk( .await; } -fn copy_unique_path(from: &Path, to: &Path) -> PathBuf { +pub fn copy_unique_path(from: &Path, to: &Path) -> PathBuf { // List of compound extensions to check const COMPOUND_EXTENSIONS: &[&str] = &[ ".tar.gz",