Files
omarchycn/shell/plugins/clipboard-picker/ClipboardPicker.qml
T

388 lines
12 KiB
QML

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
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace"
property int contentMargin: 18
property int headerHeight: 34
property int contentSpacing: 6
property int cardWidth: 800
property int cardHeight: 600
property int rowHeight: 50
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 isPassword = (entry.meta === "password" || entry.preview_type === "password")
var textMatch = false
if (isPassword) {
textMatch = false // Passwords shouldn't match plain text search queries
} else {
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",
isPassword: isPassword,
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"])
}
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" }
}
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
Row {
anchors.fill: parent
spacing: root.contentSpacing
ListView {
id: resultList
width: parent.width / 2 - root.contentSpacing / 2
height: parent.height
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 previewType
required property bool isPassword
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 {
width: parent.width
height: parent.height
text: parent.parent.isPassword ? "••••••••" : (parent.parent.previewType === "text" ? parent.parent.previewText : "Image")
color: index === root.selectedIndex ? root.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: 14
font.italic: parent.parent.previewType === "file" || parent.parent.isPassword
opacity: (parent.parent.previewType === "file" || parent.parent.isPassword) ? 0.6 : 1.0
elide: Text.ElideRight
wrapMode: Text.NoWrap
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.selectedIndex = index
root.activateIndex(index)
}
}
}
}
Rectangle {
width: parent.width / 2 - root.contentSpacing / 2
height: parent.height
radius: root.cornerRadius
color: root.withAlpha(root.background, 0.5)
border.color: root.withAlpha(root.border, 0.1)
border.width: 1
clip: true
property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
Text {
visible: parent.activeRow && parent.activeRow.previewType === "text"
anchors.fill: parent
anchors.margins: 16
text: parent.activeRow ? (parent.activeRow.isPassword ? "••••••••" : parent.activeRow.previewText) : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: 14
wrapMode: Text.WrapAnywhere
elide: Text.ElideRight
verticalAlignment: Text.AlignTop
}
Image {
visible: parent.activeRow && parent.activeRow.previewType === "file"
anchors.fill: parent
anchors.margins: 16
source: parent.activeRow ? parent.activeRow.previewImage : ""
fillMode: Image.PreserveAspectFit
}
}
}
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
}
}
}
}
}
}
}