remove legacy path that was still dumping into text clipboard

This commit is contained in:
Skyler Lehmkuhl 2026-03-02 07:54:14 -05:00
parent 75e35b0ac6
commit c1266c0377
2 changed files with 96 additions and 117 deletions

View File

@ -37,9 +37,6 @@ use uuid::Uuid;
/// MIME type used for cross-process Lightningbeam clipboard data. /// MIME type used for cross-process Lightningbeam clipboard data.
pub const LIGHTNINGBEAM_MIME: &str = "application/x-lightningbeam"; pub const LIGHTNINGBEAM_MIME: &str = "application/x-lightningbeam";
/// JSON text-clipboard prefix (arboard fallback).
const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:";
// ─────────────────────────────── Layer type tag ───────────────────────────── // ─────────────────────────────── Layer type tag ─────────────────────────────
/// Layer type tag for clipboard — tells paste where clip instances can go. /// Layer type tag for clipboard — tells paste where clip instances can go.
@ -460,15 +457,32 @@ fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap<Uuid, Uuid>) -> AnyLay
} }
} }
// ──────────────────────────── PNG encoding helper ──────────────────────────── // ──────────────────────── Pixel format conversion helpers ────────────────────
/// Encode sRGB premultiplied RGBA pixels as PNG bytes. /// Convert straight-alpha RGBA bytes to premultiplied RGBA.
/// fn straight_to_premul(bytes: &[u8]) -> Vec<u8> {
/// Returns `None` on encoding failure (logged to stderr). bytes
pub(crate) fn encode_raster_as_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> { .chunks_exact(4)
use image::RgbaImage; .flat_map(|p| {
// Un-premultiply before encoding (same as try_set_raster_image). let a = p[3];
let straight: Vec<u8> = pixels if a == 0 {
[0u8, 0, 0, 0]
} else {
let scale = a as f32 / 255.0;
[
(p[0] as f32 * scale).round() as u8,
(p[1] as f32 * scale).round() as u8,
(p[2] as f32 * scale).round() as u8,
a,
]
}
})
.collect()
}
/// Convert premultiplied RGBA bytes to straight-alpha RGBA.
fn premul_to_straight(bytes: &[u8]) -> Vec<u8> {
bytes
.chunks_exact(4) .chunks_exact(4)
.flat_map(|p| { .flat_map(|p| {
let a = p[3]; let a = p[3];
@ -484,8 +498,17 @@ pub(crate) fn encode_raster_as_png(pixels: &[u8], width: u32, height: u32) -> Op
] ]
} }
}) })
.collect(); .collect()
let img = RgbaImage::from_raw(width, height, straight)?; }
// ──────────────────────────── PNG encoding helper ────────────────────────────
/// Encode sRGB premultiplied RGBA pixels as PNG bytes.
///
/// Returns `None` on encoding failure (logged to stderr).
pub(crate) fn encode_raster_as_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
use image::RgbaImage;
let img = RgbaImage::from_raw(width, height, premul_to_straight(pixels))?;
match crate::brush_engine::encode_png(&img) { match crate::brush_engine::encode_png(&img) {
Ok(bytes) => Some(bytes), Ok(bytes) => Some(bytes),
Err(e) => { Err(e) => {
@ -526,74 +549,42 @@ impl ClipboardManager {
} }
} }
// Ordering note: on macOS/Windows arboard must go first because set_text() clipboard_platform::set(
// calls clearContents/EmptyClipboard, after which clipboard_platform::set() &entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
// appends the custom types. On Linux the order is reversed so that arboard );
// ends up as the final clipboard owner with text/plain available — egui reads
// text/plain to generate Event::Paste for Ctrl+V.
//
// try_set_raster_image() is intentionally omitted: it calls arboard.set_image()
// which calls clearContents again, wiping the text and custom types we just set.
// The image/png entry in `entries` covers external-app image interop instead.
#[cfg(target_os = "linux")]
{
// Linux: platform first, then arboard.set_text() becomes the final owner.
clipboard_platform::set(
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
);
if let Some(sys) = self.system.as_mut() {
let _ = sys.set_text(format!("{}{}", CLIPBOARD_PREFIX, json));
}
}
#[cfg(not(target_os = "linux"))]
{
// macOS/Windows: arboard first (clears clipboard), then append custom types.
if let Some(sys) = self.system.as_mut() {
let _ = sys.set_text(format!("{}{}", CLIPBOARD_PREFIX, json));
}
clipboard_platform::set(
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
);
}
self.internal = Some(content); self.internal = Some(content);
} }
/// Try to paste content. /// Try to paste content.
/// ///
/// Priority: /// Checks the platform custom MIME type first. If our content is still on
/// 1. Internal cache (same-process fast path). /// the clipboard the internal cache is returned (avoids re-deserializing).
/// 2. Platform custom MIME type (cross-process LB → LB). /// If another app has taken the clipboard since we last copied, the internal
/// 3. arboard text fallback (terminals, remote desktops, older LB builds). /// cache is cleared and `None` is returned so the caller can try other
/// sources (e.g. `try_get_raster_image`).
pub fn paste(&mut self) -> Option<ClipboardContent> { pub fn paste(&mut self) -> Option<ClipboardContent> {
// 1. Internal cache. match clipboard_platform::get(&[LIGHTNINGBEAM_MIME]) {
if let Some(content) = &self.internal { Some((_, data)) => {
return Some(content.clone()); // Our MIME type is still on the clipboard — prefer the internal
} // cache to avoid a round-trip through JSON.
if let Some(content) = &self.internal {
// 2. Platform custom MIME type. return Some(content.clone());
if let Some((_, data)) = clipboard_platform::get(&[LIGHTNINGBEAM_MIME]) {
if let Ok(s) = std::str::from_utf8(&data) {
if let Ok(content) = serde_json::from_str::<ClipboardContent>(s) {
return Some(content);
} }
} // Cross-process paste (internal cache absent): deserialize.
} if let Ok(s) = std::str::from_utf8(&data) {
if let Ok(content) = serde_json::from_str::<ClipboardContent>(s) {
// 3. arboard text fallback.
if let Some(sys) = self.system.as_mut() {
if let Ok(text) = sys.get_text() {
if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) {
if let Ok(content) = serde_json::from_str::<ClipboardContent>(json) {
return Some(content); return Some(content);
} }
} }
None
}
None => {
// Another app owns the clipboard — internal cache is stale.
self.internal = None;
None
} }
} }
None
} }
/// Copy raster pixels to the system clipboard as an image. /// Copy raster pixels to the system clipboard as an image.
@ -602,23 +593,7 @@ impl ClipboardManager {
/// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors. /// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors.
pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) { pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) {
let Some(system) = self.system.as_mut() else { return }; let Some(system) = self.system.as_mut() else { return };
let straight: Vec<u8> = pixels let straight = premul_to_straight(pixels);
.chunks_exact(4)
.flat_map(|p| {
let a = p[3];
if a == 0 {
[0u8, 0, 0, 0]
} else {
let inv = 255.0 / a as f32;
[
(p[0] as f32 * inv).round().min(255.0) as u8,
(p[1] as f32 * inv).round().min(255.0) as u8,
(p[2] as f32 * inv).round().min(255.0) as u8,
a,
]
}
})
.collect();
let img = arboard::ImageData { let img = arboard::ImageData {
width: width as usize, width: width as usize,
height: height as usize, height: height as usize,
@ -632,40 +607,34 @@ impl ClipboardManager {
/// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if /// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if
/// no image is available. Silently ignores errors. /// no image is available. Silently ignores errors.
pub fn try_get_raster_image(&mut self) -> Option<(Vec<u8>, u32, u32)> { pub fn try_get_raster_image(&mut self) -> Option<(Vec<u8>, u32, u32)> {
let img = self.system.as_mut()?.get_image().ok()?; // On Linux arboard's get_image() does not reliably read clipboard images
let width = img.width as u32; // set by other apps on Wayland. Use clipboard_platform (wl-clipboard-rs /
let height = img.height as u32; // x11-clipboard) to read the raw image bytes then decode with the image crate.
let premul: Vec<u8> = img #[cfg(target_os = "linux")]
.bytes {
.chunks_exact(4) let (_, data) = clipboard_platform::get(&[
.flat_map(|p| { "image/png",
let a = p[3]; "image/jpeg",
if a == 0 { "image/bmp",
[0u8, 0, 0, 0] "image/tiff",
} else { ])?;
let scale = a as f32 / 255.0; let img = image::load_from_memory(&data).ok()?.into_rgba8();
[ let (width, height) = img.dimensions();
(p[0] as f32 * scale).round() as u8, let premul = straight_to_premul(img.as_raw());
(p[1] as f32 * scale).round() as u8, return Some((premul, width, height));
(p[2] as f32 * scale).round() as u8, }
a,
] // macOS / Windows: arboard handles image clipboard natively.
} #[cfg(not(target_os = "linux"))]
}) {
.collect(); let img = self.system.as_mut()?.get_image().ok()?;
Some((premul, width, height)) let premul = straight_to_premul(&img.bytes);
Some((premul, img.width as u32, img.height as u32))
}
} }
/// Check if there is content available to paste. /// Check if there is content available to paste.
pub fn has_content(&mut self) -> bool { pub fn has_content(&self) -> bool {
if self.internal.is_some() { self.internal.is_some()
return true;
}
if let Some(sys) = self.system.as_mut() {
if let Ok(text) = sys.get_text() {
return text.starts_with(CLIPBOARD_PREFIX);
}
}
false
} }
} }

View File

@ -5816,6 +5816,16 @@ impl eframe::App for EditorApp {
egui::Event::Paste(_) => { egui::Event::Paste(_) => {
self.handle_menu_action(MenuAction::Paste); self.handle_menu_action(MenuAction::Paste);
} }
// When text/plain is absent from the system clipboard egui-winit
// falls through to a Key event instead of Event::Paste.
egui::Event::Key {
key: egui::Key::V,
pressed: true,
modifiers,
..
} if modifiers.ctrl || modifiers.command => {
self.handle_menu_action(MenuAction::Paste);
}
_ => {} _ => {}
} }
} }