Krolyxon
Commit 4c93d7a

Branches

Refs →

aurora4c93d7a

aurora: add clipboard history window (cliphist, image previews)

Author
krolyxon
Date
2026-10-17 14:59 +0530
Parent
6d5cbb7
Commit
4c93d7af10ff8f86c96a2f9de025af330925d077

+318 −0

4 files changed

  1. A .config/quickshell/aurora/Clip.qml +82 −0
  2. A .config/quickshell/aurora/ClipWindow.qml +222 −0
  3. M .config/quickshell/aurora/HomePage.qml +7 −0
  4. M .config/quickshell/aurora/shell.qml +7 −0
A .config/quickshell/aurora/Clip.qml View
@@ -0,0 +1,82 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+
6+// Clipboard history on top of cliphist (the wl-paste --watch cliphist store daemons from hyprland.lua).
7+// Hides its UI if cliphist isn't installed.
8+Singleton {
9+ id: root
10+ property bool available: false
11+ property var entries: [] // [{id, text, image, fmt}], newest first
12+ property var ready: ({}) // id -> true once its image preview is decoded
13+ readonly property string imgDir: Quickshell.env("HOME") + "/.cache/aurora/clip"
14+
15+ function imageOf(e) { return imgDir + "/" + e.id + "." + e.fmt }
16+
17+ function refresh() { if (available) listProc.running = true }
18+ function copy(id) {
19+ Quickshell.execDetached(["sh", "-c", 'cliphist decode "$1" | wl-copy', "sh", String(id)])
20+ }
21+ function remove(id) {
22+ delProc.command = ["sh", "-c", 'printf "%s\\t\\n" "$1" | cliphist delete', "sh", String(id)]
23+ delProc.running = true
24+ var out = []
25+ for (var i = 0; i < entries.length; i++) if (entries[i].id !== id) out.push(entries[i])
26+ entries = out
27+ }
28+ function wipe() {
29+ Quickshell.execDetached(["sh", "-c", 'cliphist wipe; rm -rf "$1"', "sh", imgDir])
30+ entries = []
31+ ready = {}
32+ }
33+
34+ Process {
35+ command: ["sh", "-c", "command -v cliphist"]
36+ running: true
37+ onExited: code => root.available = code === 0
38+ }
39+ Process { id: delProc }
40+
41+ Process {
42+ id: listProc
43+ command: ["cliphist", "list"]
44+ stdout: StdioCollector {
45+ onStreamFinished: {
46+ var out = [], imgs = [], l = text.split("\n")
47+ for (var i = 0; i < l.length; i++) {
48+ var t = l[i].indexOf("\t")
49+ if (t <= 0) continue
50+ var e = { id: l[i].substring(0, t), text: l[i].substring(t + 1), image: false, fmt: "" }
51+ // images are listed as "[[ binary data 12 KiB png 100x100 ]]"
52+ var m = e.text.match(/^\[\[ binary data .* (\w+) (\d+)x(\d+) \]\]$/)
53+ if (m) {
54+ e.image = true
55+ e.fmt = m[1]
56+ e.text = m[1] + " " + m[2] + "x" + m[3]
57+ imgs.push(e.id, root.imageOf(e))
58+ }
59+ out.push(e)
60+ }
61+ root.entries = out
62+ if (imgs.length) {
63+ imgProc.command = ["sh", "-c",
64+ 'mkdir -p "$1"; shift; while [ $# -ge 2 ]; do ' +
65+ '[ -s "$2" ] || cliphist decode "$1" > "$2"; [ -s "$2" ] && echo "$1"; shift 2; done',
66+ "sh", root.imgDir].concat(imgs)
67+ imgProc.running = true
68+ }
69+ }
70+ }
71+ }
72+ Process {
73+ id: imgProc
74+ stdout: StdioCollector {
75+ onStreamFinished: {
76+ var r = {}, l = text.split("\n")
77+ for (var i = 0; i < l.length; i++) if (l[i] !== "") r[l[i]] = true
78+ root.ready = r
79+ }
80+ }
81+ }
82+}
A .config/quickshell/aurora/ClipWindow.qml View
@@ -0,0 +1,222 @@
1+import QtQuick
2+import QtQuick.Layouts
3+import Quickshell
4+import Quickshell.Wayland
5+import Quickshell.Hyprland
6+
7+// Clipboard history: type to filter, Up/Down, Enter copies + closes, Delete removes, Esc closes.
8+PanelWindow {
9+ id: win
10+ property bool opened: false
11+ signal closeRequested()
12+
13+ visible: opened
14+ implicitWidth: 620
15+ implicitHeight: 560
16+ exclusiveZone: 0
17+ color: "transparent"
18+ WlrLayershell.layer: WlrLayer.Overlay
19+ WlrLayershell.namespace: "aurora-clipboard"
20+ WlrLayershell.keyboardFocus: opened ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
21+
22+ HyprlandFocusGrab {
23+ windows: [win]
24+ active: win.opened
25+ onCleared: win.closeRequested()
26+ }
27+
28+ onOpenedChanged: {
29+ if (!opened) return
30+ filter.text = ""
31+ armed = false
32+ Clip.refresh()
33+ list.currentIndex = 0
34+ filter.forceActiveFocus()
35+ }
36+
37+ property bool armed: false
38+ Timer { id: disarm; interval: 3000; onTriggered: win.armed = false }
39+
40+ readonly property var shown: {
41+ var q = filter.text.toLowerCase(), out = [], l = Clip.entries
42+ for (var i = 0; i < l.length; i++)
43+ if (q === "" || l[i].text.toLowerCase().indexOf(q) >= 0) out.push(l[i])
44+ return out
45+ }
46+ function pick(e) {
47+ if (!e) return
48+ Clip.copy(e.id)
49+ closeRequested()
50+ }
51+
52+ Rectangle {
53+ anchors.fill: parent
54+ color: Theme.bgA
55+ border.width: 2
56+ border.color: Theme.accent
57+
58+ ColumnLayout {
59+ anchors.fill: parent
60+ anchors.margins: 12
61+ spacing: 10
62+
63+ RowLayout {
64+ Layout.fillWidth: true
65+ spacing: 6
66+ Rectangle {
67+ Layout.fillWidth: true
68+ implicitHeight: 30
69+ color: Theme.surface
70+ border.width: 1
71+ border.color: filter.activeFocus ? Theme.accent : Theme.line
72+ Text {
73+ anchors.left: parent.left
74+ anchors.leftMargin: 10
75+ anchors.verticalCenter: parent.verticalCenter
76+ text: Theme.iClipboard
77+ color: Theme.dim
78+ font.family: Theme.font
79+ font.pixelSize: Theme.fontSize
80+ }
81+ TextInput {
82+ id: filter
83+ anchors.fill: parent
84+ anchors.leftMargin: 32
85+ anchors.rightMargin: 10
86+ verticalAlignment: TextInput.AlignVCenter
87+ color: Theme.fg
88+ selectionColor: Theme.accent
89+ selectedTextColor: Theme.bg
90+ font.family: Theme.font
91+ font.pixelSize: Theme.fontSize
92+ clip: true
93+ onTextChanged: list.currentIndex = 0
94+ Keys.onEscapePressed: win.closeRequested()
95+ Keys.onReturnPressed: win.pick(win.shown[list.currentIndex])
96+ Keys.onEnterPressed: win.pick(win.shown[list.currentIndex])
97+ Keys.onUpPressed: list.decrementCurrentIndex()
98+ Keys.onDownPressed: list.incrementCurrentIndex()
99+ Keys.onPressed: event => {
100+ if (event.key === Qt.Key_Delete && list.currentIndex >= 0 && win.shown.length) {
101+ Clip.remove(win.shown[list.currentIndex].id)
102+ event.accepted = true
103+ } else if (event.key === Qt.Key_PageDown) {
104+ list.currentIndex = Math.min(win.shown.length - 1, list.currentIndex + 10); event.accepted = true
105+ } else if (event.key === Qt.Key_PageUp) {
106+ list.currentIndex = Math.max(0, list.currentIndex - 10); event.accepted = true
107+ }
108+ }
109+ }
110+ }
111+ Text {
112+ text: win.shown.length
113+ color: Theme.dim
114+ font.family: Theme.font
115+ font.pixelSize: Theme.fontSize - 1
116+ }
117+ IconBtn {
118+ icon: Theme.iTrash
119+ label: win.armed ? "sure?" : "wipe"
120+ active: win.armed
121+ onClicked: {
122+ if (!win.armed) { win.armed = true; disarm.restart(); return }
123+ win.armed = false
124+ Clip.wipe()
125+ }
126+ }
127+ IconBtn { icon: Theme.iClose; onClicked: win.closeRequested() }
128+ }
129+
130+ ListView {
131+ id: list
132+ Layout.fillWidth: true
133+ Layout.fillHeight: true
134+ clip: true
135+ model: win.shown
136+ spacing: 2
137+ boundsBehavior: Flickable.StopAtBounds
138+ highlightMoveDuration: 0
139+
140+ delegate: Rectangle {
141+ id: row
142+ required property var modelData
143+ required property int index
144+ readonly property bool selected: list.currentIndex === index
145+ readonly property bool hasImg: modelData.image && Clip.ready[modelData.id] === true
146+ width: list.width
147+ height: hasImg ? 96 : 28
148+ color: selected ? Theme.surfaceHi : (rm.containsMouse ? Theme.surface : "transparent")
149+ border.width: selected ? 1 : 0
150+ border.color: Theme.accent
151+
152+ Rectangle { // selection marker, like the bar's active workspace
153+ visible: row.selected
154+ width: 2
155+ height: parent.height
156+ color: Theme.accent
157+ }
158+ Row {
159+ anchors.fill: parent
160+ anchors.leftMargin: 10
161+ anchors.rightMargin: 34
162+ spacing: 10
163+ Image {
164+ visible: row.hasImg
165+ anchors.verticalCenter: parent.verticalCenter
166+ width: visible ? 150 : 0
167+ height: 84
168+ fillMode: Image.PreserveAspectFit
169+ asynchronous: true
170+ sourceSize.width: 300
171+ sourceSize.height: 168
172+ source: row.hasImg ? "file://" + Clip.imageOf(row.modelData) : ""
173+ }
174+ Text {
175+ anchors.verticalCenter: parent.verticalCenter
176+ width: parent.width - (row.hasImg ? 160 : 0)
177+ text: (row.modelData.image ? Theme.iImage + " " : "") + row.modelData.text
178+ elide: Text.ElideRight
179+ maximumLineCount: 1
180+ color: row.selected ? Theme.fg : (row.modelData.image ? Theme.accent : Theme.fg)
181+ font.family: Theme.font
182+ font.pixelSize: Theme.fontSize
183+ }
184+ }
185+ MouseArea {
186+ id: rm
187+ anchors.fill: parent
188+ hoverEnabled: true
189+ cursorShape: Qt.PointingHandCursor
190+ onClicked: win.pick(row.modelData)
191+ }
192+ Text { // delete
193+ anchors.right: parent.right
194+ anchors.rightMargin: 10
195+ anchors.verticalCenter: parent.verticalCenter
196+ visible: row.selected
197+ text: Theme.iClose
198+ color: dm.containsMouse ? Theme.err : Theme.dim
199+ font.family: Theme.font
200+ font.pixelSize: Theme.fontSize
201+ MouseArea {
202+ id: dm
203+ anchors.fill: parent
204+ anchors.margins: -4
205+ hoverEnabled: true
206+ cursorShape: Qt.PointingHandCursor
207+ onClicked: Clip.remove(row.modelData.id)
208+ }
209+ }
210+ }
211+ }
212+
213+ Text {
214+ Layout.fillWidth: true
215+ text: Clip.available ? "enter copy del remove esc close" : "cliphist not installed"
216+ color: Clip.available ? Theme.dim : Theme.err
217+ font.family: Theme.font
218+ font.pixelSize: Theme.fontSize - 2
219+ }
220+ }
221+ }
222+}
M .config/quickshell/aurora/HomePage.qml View
@@ -113,6 +113,13 @@ ColumnLayout {
113113 label: "random"
114114 onClicked: Wall.random()
115115 }
116+ IconBtn {
117+ Layout.fillWidth: true
118+ visible: Clip.available
119+ icon: Theme.iClipboard
120+ label: "clipboard"
121+ onClicked: page.overlayRequested("clip")
122+ }
116123 }
117124
118125 SectionLabel { text: "capture" }
M .config/quickshell/aurora/shell.qml View
@@ -76,6 +76,12 @@ ShellRoot {
7676 onOverlayRequested: name => { root.close(); root.toggleOverlay(name) }
7777 }
7878
79+ ClipWindow {
80+ screen: root.overlayScreen
81+ opened: root.overlay === "clip"
82+ onCloseRequested: root.overlay = ""
83+ }
84+
7985 WallPicker {
8086 screen: root.overlayScreen
8187 opened: root.overlay === "wall"
@@ -104,6 +110,7 @@ ShellRoot {
104110 function dnd(): void { Notifs.dnd = !Notifs.dnd }
105111 function shot(mode: string): void { root.close(); Shot.take(mode) } // region | window | output | ocr
106112 function shotsave(): void { Shot.save = !Shot.save }
113+ function clip(): void { root.toggleOverlay("clip") }
107114 function wall(): void { root.toggleOverlay("wall") }
108115 function wallrandom(): void { Wall.random() }
109116 function bright(dir: string): void { Bright.step(dir === "down" ? -1 : 1) } // up | down