← All solutions
Caelestia Shell · Clipboard

Clipboard History Integration
in Caelestia Sidebar

How I implemented a native, lightweight clipboard history manager with text & image preview support inside Caelestia Shell. It integrates cleanly into the sidebar's navigation system, resolving layer-shell window input blocking and performance issues.

Caelestia Shell Quickshell / QML cliphist + wl-clipboard
The Objective

Having a historical log of clipboard copies is essential for productivity. My goal was to build a system that:

  • Saves and displays the last 30 copied items (both plain text and images).
  • Allows copying items back, deleting specific items, or clearing the entire history.
  • Runs smoothly as a native tab inside the sidebar next to Notifications, avoiding separate, full-screen Wayland overlay windows that hijack desktop pointer input.
Architecture

The system consists of three main parts:

  • 1. Backend Daemon integration: I leverage cliphist (which watches the clipboard via wl-paste) to log clipboard states, and wl-copy to send items back.
  • 2. Clipboard Service Singleton: A QML singleton ClipboardService.qml that spawns asynchronous processes to fetch history, decodes image streams to /tmp/caelestia-clip/ in the background, and populates a ListModel.
  • 3. Unified Layout View: A native QML component ClipboardView.qml added to the Caelestia sidebar, structured under a tab header in Content.qml to swap between Notifications and Clipboard dynamically.
Implementation Details

Here are the key code blocks I implemented to make it work:

A. Decoupled Clipboard Service (ClipboardService.qml)

qml
// Singleton to interface with cliphist
property ListModel historyModel: ListModel {}

function copyToClipboard(id, isImage) {
    if (isImage) {
        Quickshell.execDetached(["bash", "-c", "cliphist decode " + id + " | wl-copy -t image/png"]);
    } else {
        Quickshell.execDetached(["bash", "-c", "cliphist decode " + id + " | wl-copy"]);
    }
}

B. UI Component with Strict QML Scope (ClipboardView.qml)

I resolved text rendering bugs by declaring strict delegates with required property definitions and binding text as text: delegateRoot.text to avoid self-referential property shadowing:

qml
delegate: StyledRect {
    id: delegateRoot
    required property int index
    required property string itemId
    required property bool isImage
    required property string text
    required property string imageSource

    StyledText {
        text: delegateRoot.text
        color: Colours.palette.m3onSurface
    }
}

C. Tab Switcher inside the Sidebar (Content.qml)

Instead of running in an overlay window, I placed a tab navigation header inside the sidebar container so it's possible to switch views seamlessly - supporting both clicking and scrolling over the header to toggle tabs:

qml
RowLayout {
    // Scroll interaction overlay (wheel event listener with click propagation)
    MouseArea {
        anchors.fill: parent
        propagateComposedEvents: true
        onPressed: mouse => mouse.accepted = false
        onReleased: mouse => mouse.accepted = false
        onClicked: mouse => mouse.accepted = false
        onDoubleClicked: mouse => mouse.accepted = false

        onWheel: event => {
            if (event.angleDelta.y < 0) {
                root.activeTab = "clipboard";
            } else if (event.angleDelta.y > 0) {
                root.activeTab = "notifications";
            }
        }
    }
    // Notifications and Clipboard tab MouseAreas...
}
Item {
    NotifDock { visible: root.activeTab === "notifications" }
    ClipboardView { visible: root.activeTab === "clipboard" }
}
When copying images back to the clipboard, you must explicitly pass -t image/png to wl-copy. Without a MIME type hint, wl-copy treats the binary stream as plain text and the paste fails silently in most applications.