Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 7991c0acbc Paste: copied files before the clipboard image; require arboard 3.6
Finder puts a copied file's icon on the clipboard as an image next to
the file URL, so reading the image first pasted the icon on macOS. The
file list is now read first and the plain image is the fallback.
file_list() needs arboard 3.5 or later, so the requirement says 3.6
(what the lockfile already has).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:01:05 +07:00
Armaan Sandhu 8fac89f448 Paste: a copied image file pastes its pixels (part of #338)
Copying a file in GNOME Files, Finder or Explorer puts its path on the clipboard, not pixels, so Edit > Paste found no image and said the clipboard was empty. When the clipboard has no image, the native clipboard service now reads arboard's file list and pastes the first file that decodes (upright, as RGBA8), skipping non-images after reading only their header. TGA, which has no magic number, also needs its extension.

arboard splits text/uri-list on LF only, but the list is CRLF-terminated (RFC 2483) and GTK writes it that way, so paths copied in GNOME Files arrive with a trailing '\r'; it is trimmed before opening.
2026-10-07 10:59:45 +07:00
2 changed files with 105 additions and 1 deletions

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { version = "3", features = ["wayland-data-control"] }
arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
[target.'cfg(target_os = "macos")'.dependencies]
+104
View File
@@ -71,6 +71,29 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
paths.iter().find_map(|path| {
// text/uri-list lines end in CRLF (RFC 2483, and GTK writes them so), but arboard splits
// on LF only, so a path copied in GNOME Files arrives with a trailing '\r'.
let path = path.to_str().map_or_else(|| path.clone(), |s| PathBuf::from(s.trim_end_matches('\r')));
let mut head = Vec::with_capacity(256);
std::fs::File::open(&path).ok()?.take(256).read_to_end(&mut head).ok()?;
// TGA has no magic number and is only guessed from the header, so trust the extension
// too before reading what may be a large non-image file.
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match photocraft_codecs::detect(&head)? {
photocraft_codecs::Format::Tga if photocraft_codecs::from_extension(ext) != Some(photocraft_codecs::Format::Tga) => return None,
_ => {}
}
let img = photocraft_codecs::decode(&std::fs::read(&path).ok()?).ok()?;
Some((img.width(), img.height(), img.to_rgba8()))
})
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
let savers2 = savers.clone();
@@ -146,6 +169,11 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})
@@ -250,3 +278,79 @@ pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
};
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-services-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn red_png(w: u32, h: u32) -> Vec<u8> {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).unwrap()
}
/// A 1×1 uncompressed 32-bit TGA (TGA has no magic number).
fn tga_1x1() -> Vec<u8> {
let mut b = vec![0u8, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8];
b.extend_from_slice(&[0, 0, 255, 255]); // BGRA red
b
}
#[test]
fn copied_image_file_pastes_its_pixels() {
let dir = temp("png");
let png = dir.join("red.png");
std::fs::write(&png, red_png(3, 2)).unwrap();
let (w, h, px) = image_from_files(&[png]).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(px, [255u8, 0, 0, 255].repeat(6));
}
#[test]
fn crlf_from_a_uri_list_is_ignored() {
// arboard splits text/uri-list on LF, so GNOME Files' CRLF list leaves a '\r' behind.
let dir = temp("crlf");
let png = dir.join("red image.png");
std::fs::write(&png, red_png(2, 2)).unwrap();
let with_cr = PathBuf::from(format!("{}\r", png.display()));
assert_eq!(image_from_files(&[with_cr]).map(|(w, h, _)| (w, h)), Some((2, 2)));
}
#[test]
fn non_images_are_skipped_for_the_first_image() {
let dir = temp("mixed");
let (txt, png) = (dir.join("notes.txt"), dir.join("red.png"));
std::fs::write(&txt, b"not an image").unwrap();
std::fs::write(&png, red_png(4, 1)).unwrap();
let missing = dir.join("gone.png");
assert_eq!(image_from_files(&[missing.clone(), txt.clone(), dir.clone(), png]).map(|(w, h, _)| (w, h)), Some((4, 1)));
assert!(image_from_files(&[missing, txt, dir]).is_none());
assert!(image_from_files(&[]).is_none());
}
#[test]
fn a_damaged_image_is_skipped_not_a_panic() {
let dir = temp("damaged");
let bad = dir.join("cut.png");
std::fs::write(&bad, &red_png(8, 8)[..20]).unwrap();
assert!(image_from_files(&[bad]).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
let (tga, bin) = (dir.join("red.tga"), dir.join("red.bin"));
std::fs::write(&tga, tga_1x1()).unwrap();
std::fs::write(&bin, tga_1x1()).unwrap();
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_files(&[bin]).is_none());
}
}