From 2713d43f35ac7768ed488471ae8fe436667b712d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 17 May 2026 10:12:36 +0200 Subject: [PATCH] Replace Walker clipboard with native Quickshell clipboard picker --- bin/omarchy-menu-clipboard | 6 + default/hypr/bindings/clipboard.lua | 2 +- .../omarchy-shell/plugins/README.md | 1 + .../clipboard-picker/ClipboardPicker.qml | 385 ++++++++++++++++++ .../plugins/clipboard-picker/manifest.json | 14 + migrations/1779004065.sh | 5 + 6 files changed, 412 insertions(+), 1 deletion(-) create mode 100755 bin/omarchy-menu-clipboard create mode 100644 default/quickshell/omarchy-shell/plugins/clipboard-picker/ClipboardPicker.qml create mode 100644 default/quickshell/omarchy-shell/plugins/clipboard-picker/manifest.json create mode 100644 migrations/1779004065.sh diff --git a/bin/omarchy-menu-clipboard b/bin/omarchy-menu-clipboard new file mode 100755 index 00000000..f0655595 --- /dev/null +++ b/bin/omarchy-menu-clipboard @@ -0,0 +1,6 @@ +#!/bin/bash +# omarchy:summary=Launch the clipboard manager +# omarchy:group=menu +# omarchy:examples=omarchy menu clipboard + +omarchy-shell-ipc shell toggle omarchy.clipboard-picker "{}" diff --git a/default/hypr/bindings/clipboard.lua b/default/hypr/bindings/clipboard.lua index 415b71f6..791a5680 100644 --- a/default/hypr/bindings/clipboard.lua +++ b/default/hypr/bindings/clipboard.lua @@ -13,4 +13,4 @@ end o.bind("SUPER + C", "Universal copy", send_shortcut_once("CTRL", "Insert")) o.bind("SUPER + V", "Universal paste", send_shortcut_once("SHIFT", "Insert")) o.bind("SUPER + X", "Universal cut", send_shortcut_once("CTRL", "X")) -o.bind("SUPER + CTRL + V", "Clipboard manager", { omarchy = "walker -m clipboard" }) +o.bind("SUPER + CTRL + V", "Clipboard manager", { omarchy = "omarchy-shell-ipc shell toggle omarchy.clipboard-picker \"{}\"" }) diff --git a/default/quickshell/omarchy-shell/plugins/README.md b/default/quickshell/omarchy-shell/plugins/README.md index 63a52c06..11755bd3 100644 --- a/default/quickshell/omarchy-shell/plugins/README.md +++ b/default/quickshell/omarchy-shell/plugins/README.md @@ -14,6 +14,7 @@ User-installed plugins live alongside these conceptually but on disk under | Bar settings | `omarchy.settings` | `panel` | on-demand | `settings/SettingsPanel.qml` | | Image picker | `omarchy.image-picker` | `overlay` | on-demand | `image-picker/ImagePicker.qml` | | Emoji picker | `omarchy.emoji-picker` | `overlay` | on-demand | `emoji-picker/EmojiPicker.qml` | +| Clipboard mgr | `omarchy.clipboard-picker`| `overlay` | on-demand | `clipboard-picker/ClipboardPicker.qml`| | Omarchy menu | `omarchy.menu` | `menu` | on-demand | `menu/Menu.qml` | | Notifications | `omarchy.notifications` | `service` | persistent | `notifications/Service.qml` | | OSD | `omarchy.osd` | `panel` | persistent | `osd/Osd.qml` | diff --git a/default/quickshell/omarchy-shell/plugins/clipboard-picker/ClipboardPicker.qml b/default/quickshell/omarchy-shell/plugins/clipboard-picker/ClipboardPicker.qml new file mode 100644 index 00000000..965d786f --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/clipboard-picker/ClipboardPicker.qml @@ -0,0 +1,385 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import qs.Commons + +Item { + id: root + + property string omarchyPath: Quickshell.env("OMARCHY_PATH") || (Quickshell.env("HOME") + "/.local/share/omarchy") + property var shell: null + property var manifest: null + property var pluginRegistry: null + + property bool opened: false + property string filterText: "" + property int selectedIndex: 0 + property var items: [] + + property color accent: Color.menu.selected + property color background: Color.menu.background + property color foreground: Color.menu.text + property color border: foreground + property int cornerRadius: 0 + property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace" + property string styleFile: Quickshell.env("OMARCHY_MENU_STYLE_FILE") || (Quickshell.env("HOME") + "/.local/state/omarchy/toggles/quickshell-menu.json") + + property int contentMargin: 18 + property int headerHeight: 34 + property int contentSpacing: 6 + property int cardWidth: 500 + property int cardHeight: 600 + property int rowHeight: 60 + + function open(payloadJson) { + root.opened = true + root.filterText = "" + root.selectedIndex = 0 + + // Trigger fetch + fetchProc.collected = "" + fetchProc.command = ["bash", "-lc", "elephant query --json 'clipboard;;100'"] + fetchProc.running = true + + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + function close() { + root.opened = false + } + + function toggle() { + if (root.opened) root.close() + else root.open("{}") + } + + function withAlpha(color, alpha) { + return Qt.rgba(color.r, color.g, color.b, alpha) + } + + function rebuildDisplay() { + var query = root.filterText.trim().toLowerCase() + + displayModel.clear() + var outCount = 0 + + for (var i = 0; i < root.items.length; i++) { + var entry = root.items[i] + var textMatch = (entry.preview && entry.preview.toLowerCase().indexOf(query) >= 0) + + if (!query || textMatch) { + displayModel.append({ + identifier: entry.identifier, + previewText: entry.preview_type === "text" ? entry.preview.replace(/\n/g, " ") : "", + previewImage: entry.preview_type === "file" ? ("file://" + entry.preview) : "", + previewType: entry.preview_type || "text", + index: outCount + }) + outCount++ + if (outCount >= 50) break + } + } + + if (displayModel.count === 0) selectedIndex = 0 + else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1 + else if (selectedIndex < 0) selectedIndex = 0 + + Qt.callLater(function() { + if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) + }) + } + + function select(delta) { + if (displayModel.count === 0) return + selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count + resultList.positionViewAtIndex(selectedIndex, ListView.Contain) + } + + function setFilter(nextFilter) { + root.filterText = nextFilter + root.selectedIndex = 0 + root.rebuildDisplay() + } + + function activateIndex(index) { + if (index < 0 || index >= displayModel.count) return + var row = displayModel.get(index) + root.applySelected(row.identifier) + } + + function applySelected(identifier) { + if (!identifier) return + root.opened = false + var escId = identifier.replace(/'/g, "'\\''") + Quickshell.execDetached(["bash", "-lc", "elephant activate 'clipboard;" + escId + ";copy;;'; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"]) + } + + function loadStyle(raw) { + try { + var style = JSON.parse(raw || "{}") + root.cornerRadius = Number(style.radius || 0) + } catch (e) {} + } + + ListModel { id: displayModel } + + Process { + id: fetchProc + property string collected: "" + stdout: SplitParser { + onRead: function(data) { fetchProc.collected += data + "\n" } + } + onExited: { + var lines = fetchProc.collected.split("\n") + var newItems = [] + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim() + if (!line) continue + try { + var parsed = JSON.parse(line) + if (parsed && parsed.item) { + newItems.push(parsed.item) + } + } catch(e) {} + } + root.items = newItems + root.rebuildDisplay() + } + } + + IpcHandler { + target: "clipboard-picker" + function summon(): string { root.open("{}"); return "ok" } + function hide(): string { root.close(); return "ok" } + function toggle(): string { root.toggle(); return "ok" } + function ping(): string { return "ok" } + } + + FileView { + path: root.styleFile + watchChanges: true + onLoaded: root.loadStyle(text()) + onFileChanged: { reload(); root.loadStyle(text()) } + } + + PanelWindow { + id: panel + visible: root.opened + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + WlrLayershell.namespace: "omarchy-clipboard-picker" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + + Rectangle { + anchors.fill: parent + color: root.withAlpha(root.background, 0.5) + } + + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + Rectangle { + id: card + width: root.cardWidth + height: root.cardHeight + radius: root.cornerRadius + anchors.centerIn: parent + color: root.background + border.color: root.border + border.width: 2 + + MouseArea { anchors.fill: parent; onClicked: {} } + + Item { + id: keyCatcher + anchors.fill: parent + focus: true + + Keys.priority: Keys.BeforeItem + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + if (root.filterText) root.setFilter("") + else root.close() + event.accepted = true + } else if (event.key === Qt.Key_Backspace) { + if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1)) + event.accepted = true + } else if (event.key === Qt.Key_Up) { + root.select(-1) + event.accepted = true + } else if (event.key === Qt.Key_Down) { + root.select(1) + event.accepted = true + } else if (event.key === Qt.Key_PageUp) { + root.select(-6) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + root.select(6) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.activateIndex(root.selectedIndex) + event.accepted = true + } else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) { + root.setFilter(root.filterText + event.text) + event.accepted = true + } + } + } + + Column { + anchors.fill: parent + anchors.margins: root.contentMargin + spacing: root.contentSpacing + + Rectangle { + width: parent.width + height: root.headerHeight + radius: root.cornerRadius + color: "transparent" + border.width: 0 + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.filterText || "Search clipboard…" + color: root.foreground + opacity: root.filterText ? 1 : 0.58 + font.family: root.fontFamily + font.pixelSize: 16 + elide: Text.ElideRight + } + } + + Item { + width: parent.width + height: parent.height - root.headerHeight - root.contentSpacing + + ListView { + id: resultList + anchors.fill: parent + model: displayModel + clip: true + spacing: 4 + boundsBehavior: Flickable.StopAtBounds + + delegate: Rectangle { + required property int index + required property string identifier + required property string previewText + required property string previewImage + required property string previewType + + width: ListView.view.width + height: root.rowHeight + radius: root.cornerRadius + color: index === root.selectedIndex ? root.withAlpha(root.foreground, 0.08) : root.withAlpha(root.foreground, mouseArea.containsMouse ? 0.045 : 0) + + Rectangle { + visible: false + width: 4 + height: parent.height - 18 + radius: Math.min(root.cornerRadius, 4) + color: root.accent + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + } + + Item { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.topMargin: 8 + anchors.bottomMargin: 8 + + Text { + visible: parent.parent.previewType === "text" + width: parent.width + height: parent.height + text: parent.parent.previewText + color: index === root.selectedIndex ? root.accent : root.foreground + font.family: root.fontFamily + font.pixelSize: 14 + elide: Text.ElideRight + wrapMode: Text.NoWrap + verticalAlignment: Text.AlignVCenter + } + + Item { + visible: parent.parent.previewType === "file" + width: parent.width + height: parent.height + + Image { + source: parent.parent.parent.previewImage + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: height * 1.5 + fillMode: Image.PreserveAspectFit + } + + Text { + anchors.left: parent.children[0].right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Image" + color: root.foreground + opacity: 0.6 + font.family: root.fontFamily + font.pixelSize: 14 + font.italic: true + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.selectedIndex = index + root.activateIndex(index) + } + } + } + } + + Column { + anchors.centerIn: parent + spacing: 8 + visible: displayModel.count === 0 + + Text { + text: "󰅌" + color: root.accent + opacity: 0.8 + font.family: root.fontFamily + font.pixelSize: 28 + horizontalAlignment: Text.AlignHCenter + width: parent.width + } + + Text { + text: root.items.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”" + color: root.foreground + opacity: 0.7 + font.family: root.fontFamily + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + width: parent.width + } + } + } + } + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/clipboard-picker/manifest.json b/default/quickshell/omarchy-shell/plugins/clipboard-picker/manifest.json new file mode 100644 index 00000000..3fc56c36 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/clipboard-picker/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.clipboard-picker", + "name": "Clipboard picker", + "version": "1.0.0", + "author": "Omarchy", + "description": "A clipboard manager to view and paste history", + "kinds": ["overlay"], + "activation": "on-demand", + "keepLoaded": true, + "entryPoints": { + "overlay": "ClipboardPicker.qml" + } +} diff --git a/migrations/1779004065.sh b/migrations/1779004065.sh new file mode 100644 index 00000000..c4f98e3c --- /dev/null +++ b/migrations/1779004065.sh @@ -0,0 +1,5 @@ +echo "Migrate clipboard manager shortcut to use Quickshell clipboard picker" + +if [[ -f ~/.config/hypr/bindings/clipboard.lua ]]; then + sed -i 's/walker -m clipboard/omarchy-shell-ipc shell toggle omarchy.clipboard-picker "{}"/' ~/.config/hypr/bindings/clipboard.lua +fi