Dependencies used in the example:
- Native (desktop):
arboardfor clipboard access- Web (WASM): browser Clipboard API (via
web-sys)image(optional): decode bytes for preview; the clipboard layer preserves encoded bytes
eframe (Wayland): reliable Ctrl+V paste for clipboard images (payload + preview)
On some Linux Wayland setups, Ctrl+V might not arrive as a “key pressed” event in egui (only the release does). The simplest reliable approach is: consume the raw key events and treat Ctrl+V as triggered by key release, then read the clipboard.
This post uses a small helper from my personal project that follows a “payload-first” model:
- Best case: you obtain the original encoded bytes (PNG/JPEG/WebP/…) along with best-effort
mime_typeandfilename, so you can save/export without forcing an early decode. - Common case (desktop): clipboard APIs only give you a decoded bitmap (RGBA8). You can still keep a bytes-first API by re-encoding to PNG, but that result is synthesized (not the original bytes).
- UI reality: egui textures are typically RGBA8, so generating a preview may require downconverting. Keep that conversion strictly in the preview layer.
use egui::{Context, InputState, Key, Modifiers};
/// Image payload from the clipboard.
///
/// Prefer preserving the original encoded bytes (plus MIME/filename when possible).
/// If the platform only provides pixels, the payload may be synthesized by encoding
/// those pixels (e.g. as PNG).
#[derive(Debug, Clone)]
pub struct ClipboardImagePayload {
/// Encoded image bytes (ideally the original clipboard representation).
///
/// Note: depending on platform/clipboard APIs, this may be synthesized
/// (e.g. encoded from a provided bitmap). Treat as best-effort.
pub bytes: Vec<u8>,
/// MIME type of the encoded payload (e.g., "image/png", "image/jpeg").
pub mime_type: String,
/// Suggested filename for the image.
pub filename: String,
/// Whether this payload was synthesized (e.g. bitmap -> encoded PNG) because the
/// platform did not provide the original encoded bytes.
pub synthesized: bool,
}
/// Minimal clipboard abstraction.
///
/// Keep this app-facing trait small; platform-specific implementations can live behind it.
pub trait ClipboardProvider {
fn get_image_payload(&mut self) -> Option<ClipboardImagePayload>;
}
/// Optional: decode for preview (RGBA8).
///
/// egui textures are typically 8-bit, so any on-screen preview will usually require
/// converting to RGBA8 at some point. The important part is: the clipboard layer
/// preserves encoded bytes, so you can save/export/process without additional loss.
///
/// `image::load_from_memory` auto-detects common formats depending on enabled features.
#[derive(Debug, Clone)]
pub struct ClipboardImageRgba8 {
pub width: usize,
pub height: usize,
/// RGBA8, row-major, tightly packed: `width * height * 4`
pub bytes: Vec<u8>,
}
pub fn decode_image_for_preview(payload: &ClipboardImagePayload) -> Option<ClipboardImageRgba8> {
let dyn_img = image::load_from_memory(&payload.bytes).ok()?;
let rgba = dyn_img.to_rgba8();
let (w, h) = rgba.dimensions();
Some(ClipboardImageRgba8 {
width: w as usize,
height: h as usize,
bytes: rgba.into_raw(),
})
}
//
// -------- Native (desktop) clipboard implementation --------
//
#[cfg(not(target_arch = "wasm32"))]
pub struct ArboardClipboard {
inner: arboard::Clipboard,
}
#[cfg(not(target_arch = "wasm32"))]
impl ArboardClipboard {
pub fn new() -> Result<Self, arboard::Error> {
Ok(Self {
inner: arboard::Clipboard::new()?,
})
}
}
#[cfg(not(target_arch = "wasm32"))]
impl ClipboardProvider for ArboardClipboard {
fn get_image_payload(&mut self) -> Option<ClipboardImagePayload> {
// Some platforms/targets can provide encoded bytes for images.
// When available, this is ideal because it preserves bit depth and metadata.
if let Ok(bytes) = self.inner.get() {
// `arboard` doesn't always expose the MIME type here; default to PNG.
// In practice, this path often yields PNG-like bytes when it works.
return Some(ClipboardImagePayload {
mime: "image/png".to_string(),
bytes,
synthesized: false,
});
}
// Fallback: if the platform only provides a decoded bitmap via arboard, we can
// still produce a payload by encoding it (PNG) so the rest of the app stays
// "bytes-first". This *does* downconvert to 8-bit RGBA because the platform
// already gave us 8-bit pixels.
let img = self.inner.get_image().ok()?;
// Encode as PNG to get a stable payload format.
let width = img.width as u32;
let height = img.height as u32;
let rgba_bytes = img.bytes.into_owned();
let mut out = Vec::new();
{
// `png` encoder is provided by the `image` crate when its PNG feature is enabled.
use image::codecs::png::PngEncoder;
use image::ColorType;
use image::ImageEncoder;
let encoder = PngEncoder::new(&mut out);
encoder
.write_image(&rgba_bytes, width, height, ColorType::Rgba8)
.ok()?;
}
Some(ClipboardImagePayload {
mime: "image/png".to_string(),
bytes: out,
synthesized: true,
})
}
}
//
// -------- Web (WASM) clipboard implementation --------
//
// Requirements:
// - Secure context (https or localhost)
// - A user gesture (your Ctrl+V interaction qualifies)
// - Browser permissions/policies allowing clipboard read
//
// Browser clipboard access is async. This helper stores pending state;
// call `poll()` each frame, and `take_image_payload()` when it completes.
#[cfg(target_arch = "wasm32")]
pub struct WebClipboard {
pending: bool,
last_payload: Option<ClipboardImagePayload>,
}
#[cfg(target_arch = "wasm32")]
impl Default for WebClipboard {
fn default() -> Self {
Self {
pending: false,
last_payload: None,
}
}
}
#[cfg(target_arch = "wasm32")]
impl WebClipboard {
/// Kick off an async clipboard read (idempotent while pending).
pub fn request_paste(&mut self) {
if self.pending {
return;
}
self.pending = true;
// Spawn an async task without blocking the UI thread.
wasm_bindgen_futures::spawn_local(async {
let maybe_payload = read_clipboard_image_payload().await;
// Store result in a global so the egui app can poll it.
WEB_CLIPBOARD_RESULT.with(|cell| {
*cell.borrow_mut() = Some(maybe_payload);
});
});
}
/// Poll for the completion of the async clipboard read.
pub fn poll(&mut self) {
let mut done = None;
WEB_CLIPBOARD_RESULT.with(|cell| {
done = cell.borrow_mut().take();
});
if let Some(result) = done {
self.pending = false;
self.last_payload = result;
}
}
pub fn take_image_payload(&mut self) -> Option<ClipboardImagePayload> {
self.last_payload.take()
}
}
// Global handoff from async task -> app (single-threaded on wasm).
#[cfg(target_arch = "wasm32")]
thread_local! {
static WEB_CLIPBOARD_RESULT: std::cell::RefCell<Option<Option<ClipboardImagePayload>>> =
std::cell::RefCell::new(None);
}
#[cfg(target_arch = "wasm32")]
async fn read_clipboard_image_payload() -> Option<ClipboardImagePayload> {
use wasm_bindgen::JsCast;
// navigator.clipboard.read() -> Promise<ClipboardItems[]>
let window = web_sys::window()?;
let navigator = window.navigator();
let clipboard = navigator.clipboard()?;
// `read()` is not available in all browsers/policies.
let items_js = wasm_bindgen_futures::JsFuture::from(clipboard.read().ok()?)
.await
.ok()?;
let items: js_sys::Array = items_js.dyn_into().ok()?;
for item in items.iter() {
let item: web_sys::ClipboardItem = item.dyn_into().ok()?;
let types = item.types();
// Prefer PNG first; then common image MIME types.
let candidates = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
];
for mime in candidates {
if !types.includes(&wasm_bindgen::JsValue::from_str(mime), 0) {
continue;
}
// item.getType(mime) -> Promise<Blob>
let blob_js = wasm_bindgen_futures::JsFuture::from(item.get_type(mime).ok()?)
.await
.ok()?;
let blob: web_sys::Blob = blob_js.dyn_into().ok()?;
// Blob -> ArrayBuffer -> Vec<u8>
let ab_js = wasm_bindgen_futures::JsFuture::from(blob.array_buffer().ok()?)
.await
.ok()?;
let u8 = js_sys::Uint8Array::new(&ab_js);
let mut bytes = vec![0u8; u8.length() as usize];
u8.copy_to(&mut bytes);
return Some(ClipboardImagePayload {
mime: mime.to_string(),
bytes,
synthesized: false,
});
}
}
None
}
/// Paste helper for eframe/egui.
///
/// - `Ctrl+V` (Linux/Windows): triggers on key *release* to work around Wayland event quirks.
/// - `Cmd+V` (macOS): triggers on key *press*.
///
/// Recommended usage:
/// - Store the returned `ClipboardImagePayload` for export/save.
/// - Generate a separate RGBA8 preview for egui display (optional).
pub fn paste_image_on_shortcut<C: ClipboardProvider>(
ctx: &Context,
clipboard: &mut C,
) -> Option<ClipboardImagePayload> {
let paste = ctx.input_mut(|i| {
consume_key(i, Modifiers::CTRL, Key::V, /*trigger_on_release=*/ true)
|| consume_key(i, Modifiers::COMMAND, Key::V, /*trigger_on_release=*/ false)
});
if paste {
clipboard.get_image_payload()
} else {
None
}
}
#[cfg(target_arch = "wasm32")]
pub fn paste_image_on_shortcut_web(ctx: &Context, clipboard: &mut WebClipboard) {
let paste = ctx.input_mut(|i| {
consume_key(i, Modifiers::CTRL, Key::V, /*trigger_on_release=*/ true)
|| consume_key(i, Modifiers::COMMAND, Key::V, /*trigger_on_release=*/ false)
});
if paste {
clipboard.request_paste();
}
}
/// Consume a key event from egui's event queue.
fn consume_key(
input: &mut InputState,
mods: Modifiers,
key: Key,
trigger_on_release: bool,
) -> bool {
let mut found = false;
input.events.retain(|event| {
let is_match = matches!(
event,
egui::Event::Key { key: k, modifiers: m, pressed, .. }
if *k == key
&& m.matches_exact(mods)
&& (*pressed == !trigger_on_release)
);
found |= is_match;
!is_match
});
found
}