// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Io import QtQuick import QtQuick.Controls import "../.." import "Clip.js" as Clip // Search, a newest-first list, copy on click or Enter, delete per row, clear // all behind a confirmation. Entries go to cliphist as arguments or stdin, // never inside a command string: clipboard text is untrusted. Item { id: page signal copied() property var entries: [] readonly property var shown: Clip.filter(entries, search.text) // Thumbnails decode here, on tmpfs, and are reused until a reboot. readonly property string thumbDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/qs-clip" function copy(entry) { Quickshell.execDetached(["sh", "-c", 'printf %s "$1" | cliphist decode | wl-copy', "sh", entry.line]); page.copied(); } function remove(entry) { Quickshell.execDetached(["sh", "-c", 'printf %s "$1" | cliphist delete', "sh", entry.line]); page.entries = page.entries.filter(e => e.id !== entry.id); } Process { running: true command: ["cliphist", "list"] stdout: StdioCollector { onStreamFinished: page.entries = Clip.parse(this.text) } } Row { id: bar anchors { top: parent.top; left: parent.left; right: parent.right } spacing: 8 TextField { id: search width: bar.width - clear.width - bar.spacing height: 32 placeholderText: "Search" color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } background: Rectangle { radius: 6 color: Qt.alpha(Theme.surface, 0.9) border.width: 1 border.color: Qt.alpha(Theme.text, 0.15) } Component.onCompleted: forceActiveFocus() onTextChanged: list.currentIndex = 0 Keys.onUpPressed: list.decrementCurrentIndex() Keys.onDownPressed: list.incrementCurrentIndex() Keys.onReturnPressed: if (list.currentIndex >= 0 && page.shown.length) page.copy(page.shown[list.currentIndex]) Keys.onEnterPressed: if (list.currentIndex >= 0 && page.shown.length) page.copy(page.shown[list.currentIndex]) } // Two clicks: the first arms it for 3 seconds. Button { id: clear anchors.verticalCenter: parent.verticalCenter danger: true text: confirm.running ? "Confirm?" : "Clear all" enabled: page.entries.length > 0 onClicked: { if (!confirm.running) { confirm.start(); return; } confirm.stop(); Quickshell.execDetached(["cliphist", "wipe"]); page.entries = []; } Timer { id: confirm; interval: 3000 } } } ListView { id: list anchors { top: bar.bottom; topMargin: 10; left: parent.left; right: parent.right; bottom: parent.bottom } clip: true spacing: 4 model: page.shown highlightMoveDuration: 0 boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar {} Text { anchors.centerIn: parent visible: page.shown.length === 0 text: page.entries.length === 0 ? "Clipboard history is empty" : "No match" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } delegate: Rectangle { id: row required property var modelData required property int index width: list.width height: modelData.image ? 96 : 36 radius: 8 color: rowArea.containsMouse || ListView.isCurrentItem ? Qt.alpha(Theme.accent, 0.18) : Qt.alpha(Theme.surface, 0.35) // Decoded once per entry, then reused from thumbDir. The id and // line are arguments, the paths are built by the shell from them. property string thumb: "" Process { running: row.modelData.image command: ["sh", "-c", 'mkdir -p "$1" && f="$1/$2.png" && { [ -s "$f" ] || printf %s "$3" | cliphist decode > "$f"; } && printf %s "$f"', "sh", page.thumbDir, row.modelData.id, row.modelData.line] stdout: StdioCollector { onStreamFinished: row.thumb = this.text } } MouseArea { id: rowArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: page.copy(row.modelData) } Image { id: thumbImg visible: row.modelData.image anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } width: 140 height: 80 fillMode: Image.PreserveAspectFit asynchronous: true sourceSize: Qt.size(280, 160) source: row.thumb ? "file://" + row.thumb : "" } Text { anchors { left: row.modelData.image ? thumbImg.right : parent.left leftMargin: 10 right: del.left; rightMargin: 8 verticalCenter: parent.verticalCenter } elide: Text.ElideRight maximumLineCount: 1 textFormat: Text.PlainText // One line: a multi-line entry shows its first line's worth. text: row.modelData.text.replace(/\s+/g, " ") color: row.modelData.image ? Theme.subtext : Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } Rectangle { id: del anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter } width: 26; height: 26; radius: 13 color: delArea.containsMouse ? Qt.alpha(Theme.red, 0.3) : "transparent" Text { anchors.centerIn: parent text: "" font { family: Theme.iconFamily; pixelSize: 12 } color: delArea.containsMouse ? Theme.red : Theme.subtext } MouseArea { id: delArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: page.remove(row.modelData) } } } } }