Skip to content

eframe (Wayland) reliable Ctrl+V paste for clipboard images (payload + preview)

Published: at 06:15 AM

Dependencies used in the example:

  • Native (desktop): arboard for 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:

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
}