Files
arthur-os/shell/plugins/clipboard/Clipboard.qml
T

432 lines
15 KiB
QML

import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import "ClipboardHistory.js" as ClipboardHistory
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property var history: []
property string historyPath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history.json"
property string captureScript: root.omarchyPath + "/shell/plugins/clipboard/capture.sh"
property string watchCommand: "script=$1\ntrap 'kill $(jobs -p) 2>/dev/null' EXIT\nwl-paste --watch \"$script\" &\nOMARCHY_CLIPBOARD_WATCH_MIME=image/png wl-paste --type image/png --watch \"$script\" 2>/dev/null &\nOMARCHY_CLIPBOARD_WATCH_MIME=image/jpeg wl-paste --type image/jpeg --watch \"$script\" 2>/dev/null &\nOMARCHY_CLIPBOARD_WATCH_MIME=image/webp wl-paste --type image/webp --watch \"$script\" 2>/dev/null &\nOMARCHY_CLIPBOARD_WATCH_MIME=image/gif wl-paste --type image/gif --watch \"$script\" 2>/dev/null &\nOMARCHY_CLIPBOARD_WATCH_MIME=image/bmp wl-paste --type image/bmp --watch \"$script\" 2>/dev/null &\nOMARCHY_CLIPBOARD_WATCH_MIME=image/tiff wl-paste --type image/tiff --watch \"$script\" 2>/dev/null &\nwait"
// Shares the [menu] surface tokens — themes that style the menu also
// style the clipboard. Selected-row colors composed in the
// singleton so consumers drop them straight into Rectangle bindings.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
property color selectedBorder: Color.menu.selectedBorder
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Style.font.menuFamily
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md
property int cardWidth: Math.min(Style.space(800), panel.width - Style.gapsOut * 2)
property int cardHeight: Math.min(Style.space(600), panel.height - Style.gapsOut * 2)
property int rowHeight: Math.max(Style.space(50), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2)
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = false
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function toggle() {
if (root.opened) root.close()
else root.open("{}")
}
function normalizeEntry(value) {
return ClipboardHistory.normalizeEntry(value)
}
function entryKey(entry) {
return ClipboardHistory.entryKey(entry)
}
function loadHistory(raw) {
root.history = ClipboardHistory.parseHistory(raw)
if (root.opened) root.rebuildDisplay()
}
function saveHistory() {
historyFile.setText(JSON.stringify(root.history.slice(0, 100), null, 2) + "\n")
}
function addClipboardEntry(entry) {
var normalized = ClipboardHistory.normalizeEntry(entry)
if (!normalized) return
root.history = ClipboardHistory.addEntry(root.history, normalized, 100)
root.saveHistory()
if (root.opened) root.rebuildDisplay()
}
function addClipboardJson(line) {
root.addClipboardEntry(ClipboardHistory.parseEntryJson(line))
}
function rebuildDisplay() {
var rows = ClipboardHistory.displayRows(root.history, root.filterText, 50)
displayModel.clear()
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
displayModel.append({
entryType: row.entryType,
fullText: row.fullText,
previewText: row.previewText,
previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "",
path: row.path,
mime: row.mime,
index: row.index
})
}
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
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
} else {
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
}
resultList.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = false
root.rebuildDisplay()
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row)
}
function applySelected(row) {
if (!row) return
root.opened = false
if (row.entryType === "image") {
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-file", row.mime, row.path])
} else if (row.fullText) {
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-text", "--shift-insert", row.fullText])
}
}
Component.onCompleted: initProc.running = true
ListModel { id: displayModel }
FileView {
id: historyFile
path: root.historyPath
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: root.loadHistory(text())
onLoadFailed: root.loadHistory("[]")
onFileChanged: reload()
}
Process {
id: initProc
command: ["bash", "-c", "mkdir -p ~/.local/state/omarchy\nscript=$1\nfor pid in $(pgrep -x wl-paste || true); do\n cmdline=$(tr '\\0' ' ' <\"/proc/$pid/cmdline\" 2>/dev/null || true)\n if [[ $cmdline == *\"wl-paste --watch $script \"* || $cmdline == *\"wl-paste --type \"*\" --watch $script \"* ]]; then\n kill \"$pid\" 2>/dev/null || true\n fi\ndone", "clipboard-init", root.captureScript]
onExited: {
currentProc.command = [root.captureScript]
currentProc.running = true
watchProc.command = ["bash", "-c", root.watchCommand, "clipboard-watch", root.captureScript]
watchProc.running = true
}
}
Process {
id: currentProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.addClipboardJson(text)
}
}
Process {
id: watchProc
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-clipboard"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
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: Math.max(1, Style.space(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) {
if (root.cursorActive) root.activateIndex(root.selectedIndex)
else if (displayModel.count > 0) root.cursorActive = true
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: Style.font.heading
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: Style.space(4)
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
required property int index
required property string entryType
required property string previewText
required property string fullText
required property string previewImage
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
width: ListView.view.width
height: root.rowHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
border.color: hasCursor ? root.selectedBorder : "transparent"
border.width: (hasCursor && root.selectedBorder.a > 0) ? Style.hoverBorderWidth : 0
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.topMargin: Style.space(8)
anchors.bottomMargin: Style.space(8)
spacing: Style.space(10)
Image {
visible: parent.parent.entryType === "image"
width: visible ? parent.height : 0
height: parent.height
source: parent.parent.previewImage
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
}
Text {
width: parent.width - (parent.parent.entryType === "image" ? parent.height + parent.spacing : 0)
height: parent.height
text: parent.parent.previewText
color: parent.parent.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.italic: parent.parent.entryType === "image"
opacity: parent.parent.entryType === "image" ? 0.72 : 1.0
elide: Text.ElideRight
wrapMode: Text.NoWrap
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) {
root.cursorActive = true
root.selectedIndex = index
}
onClicked: {
root.cursorActive = true
root.selectedIndex = index
root.activateIndex(index)
}
}
}
}
Rectangle {
width: parent.width / 2 - root.contentSpacing / 2
height: parent.height
radius: root.cornerRadius
color: Util.alpha(root.background, 0.5)
border.color: Util.alpha(root.border, 0.1)
border.width: Style.normalBorderWidth
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.entryType === "text"
anchors.fill: parent
anchors.margins: Style.space(16)
text: parent.activeRow ? parent.activeRow.fullText : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
wrapMode: Text.WrapAnywhere
elide: Text.ElideRight
verticalAlignment: Text.AlignTop
}
Image {
visible: parent.activeRow && parent.activeRow.entryType === "image"
anchors.fill: parent
anchors.margins: Style.space(16)
source: parent.activeRow ? parent.activeRow.previewImage : ""
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
}
}
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
Text {
text: "󰅌"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}