Merge pull request #1533 from FreddyFunk/feat/clipboard-paste-media

feat: paste images, videos, and text from clipboard
This commit is contained in:
Levi Portenier 2026-02-04 13:03:15 -07:00 committed by GitHub
commit 055d9e371c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 276 additions and 4 deletions

View file

@ -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 ->

View file

@ -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<Entity>),
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::<ClipboardPasteImage>().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::<ClipboardPasteVideo>().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::<ClipboardPasteText>().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();

View file

@ -162,3 +162,166 @@ impl TryFrom<(Vec<u8>, 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<u8>,
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<u8>, String)> for ClipboardPasteImage {
type Error = Box<dyn Error>;
fn try_from(value: (Vec<u8>, String)) -> Result<Self, Self::Error> {
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<u8>,
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<u8>, String)> for ClipboardPasteVideo {
type Error = Box<dyn Error>;
fn try_from(value: (Vec<u8>, String)) -> Result<Self, Self::Error> {
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<u8>, String)> for ClipboardPasteText {
type Error = Box<dyn Error>;
fn try_from(value: (Vec<u8>, String)) -> Result<Self, Self::Error> {
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(),
})
}
}

View file

@ -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",