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.
The system consists of three main parts:
-
1. Backend Daemon integration: I leverage
cliphist(which watches the clipboard viawl-paste) to log clipboard states, andwl-copyto send items back. -
2. Clipboard Service Singleton: A QML singleton
ClipboardService.qmlthat spawns asynchronous processes to fetch history, decodes image streams to/tmp/caelestia-clip/in the background, and populates aListModel. -
3. Unified Layout View: A native QML component
ClipboardView.qmladded to the Caelestia sidebar, structured under a tab header inContent.qmlto swap between Notifications and Clipboard dynamically.
Here are the key code blocks I implemented to make it work:
A. Decoupled Clipboard Service (ClipboardService.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:
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:
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" }
}
-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.