The menu, emoji picker, clipboard, OSD, reminder flow and Wi-Fi QR code mapped a fresh surface on every open. Qt drew its first frames before Hyprland sent the surface's fractional scale: the pixel ratio stepped 2, 1, then 1.6, so on a 1.6 display each overlay showed blurry for ~350ms before going sharp. A new OverlayWindow keeps the surface. Hidden, it parks as a 1x1, input-less layer below windows, off the overlay layer so it never blocks direct scanout. Showing only resizes and raises it, so the scale is already settled. Content stays hidden until the surface has grown, so no 1x1 frame is stretched across the screen, and the window follows the focused monitor each time it is shown. The image picker's own fullscreen parking moves onto it too. Each parked overlay keeps a Qt window alive: at rest the shell holds ~40 MiB more RSS and ~20 MiB more GPU memory. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
609 lines
20 KiB
QML
609 lines
20 KiB
QML
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import qs.Commons
|
|
import qs.Ui
|
|
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 bool clearConfirmOpen: 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"
|
|
// 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 var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
|
|
property color scrim: Color.menu.scrim
|
|
property color selectedBackground: Color.menu.selectedBackground
|
|
property color selectedText: Color.menu.selectedText
|
|
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(875), 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)
|
|
property int historyLimit: 500
|
|
|
|
function open(payloadJson) {
|
|
root.opened = true
|
|
root.filterText = ""
|
|
root.selectedIndex = 0
|
|
root.cursorActive = true
|
|
root.disarmPointer()
|
|
root.rebuildDisplay()
|
|
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
|
}
|
|
|
|
function close() {
|
|
root.cancelClearHistory()
|
|
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, root.historyLimit), null, 2) + "\n")
|
|
}
|
|
|
|
function addClipboardEntry(entry) {
|
|
var normalized = ClipboardHistory.normalizeEntry(entry)
|
|
if (!normalized) return
|
|
|
|
root.history = ClipboardHistory.addEntry(root.history, normalized, root.historyLimit)
|
|
root.saveHistory()
|
|
if (root.opened) root.rebuildDisplay()
|
|
}
|
|
|
|
function addClipboardJson(line) {
|
|
root.addClipboardEntry(ClipboardHistory.parseEntryJson(line))
|
|
}
|
|
|
|
function requestClearHistory() {
|
|
if (root.history.length === 0) return
|
|
clearConfirm.selectedIndex = 1
|
|
root.clearConfirmOpen = true
|
|
}
|
|
|
|
function cancelClearHistory() {
|
|
root.clearConfirmOpen = false
|
|
root.disarmPointer()
|
|
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
|
}
|
|
|
|
function confirmClearHistory() {
|
|
root.history = ClipboardHistory.clearHistory()
|
|
root.saveHistory()
|
|
root.selectedIndex = 0
|
|
root.cursorActive = false
|
|
root.disarmPointer()
|
|
root.clearConfirmOpen = false
|
|
root.rebuildDisplay()
|
|
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
|
}
|
|
|
|
function removeDisplayIndex(index) {
|
|
if (index < 0 || index >= displayModel.count) return
|
|
|
|
var row = displayModel.get(index)
|
|
root.history = ClipboardHistory.removeEntryAt(root.history, row.historyIndex)
|
|
root.saveHistory()
|
|
|
|
if (displayModel.count <= 1) {
|
|
root.selectedIndex = 0
|
|
root.cursorActive = false
|
|
} else if (root.selectedIndex >= displayModel.count - 1) {
|
|
root.selectedIndex = displayModel.count - 2
|
|
}
|
|
|
|
root.disarmPointer()
|
|
root.rebuildDisplay()
|
|
}
|
|
|
|
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,
|
|
historyIndex: 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
|
|
root.disarmPointer()
|
|
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 selectAbsolute(index) {
|
|
if (displayModel.count === 0) return
|
|
root.disarmPointer()
|
|
root.cursorActive = true
|
|
root.selectedIndex = Math.max(0, Math.min(index, displayModel.count - 1))
|
|
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
|
}
|
|
|
|
function setFilter(nextFilter) {
|
|
root.filterText = nextFilter
|
|
root.selectedIndex = 0
|
|
root.cursorActive = true
|
|
root.disarmPointer()
|
|
root.rebuildDisplay()
|
|
}
|
|
|
|
function disarmPointer() {
|
|
pointerGate.reset()
|
|
}
|
|
|
|
function selectFromPointer(index, item, mouse) {
|
|
if (!pointerGate.moved(item, mouse)) return
|
|
root.cursorActive = true
|
|
root.selectedIndex = index
|
|
}
|
|
|
|
function activateIndex(index) {
|
|
if (index < 0 || index >= displayModel.count) return
|
|
var row = displayModel.get(index)
|
|
root.applySelected(row)
|
|
}
|
|
|
|
function copyIndex(index) {
|
|
if (index < 0 || index >= displayModel.count) return
|
|
var row = displayModel.get(index)
|
|
root.copySelected(row)
|
|
}
|
|
|
|
function openIndex(index) {
|
|
if (index < 0 || index >= displayModel.count) return
|
|
var row = displayModel.get(index)
|
|
root.openSelected(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", "--history-index", String(row.historyIndex)])
|
|
}
|
|
}
|
|
|
|
function copySelected(row) {
|
|
if (!row) return
|
|
root.opened = false
|
|
if (row.entryType === "image") {
|
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-file", "--copy-only", row.mime, row.path])
|
|
} else if (row.fullText) {
|
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-text", "--copy-only", "--history-index", String(row.historyIndex)])
|
|
}
|
|
}
|
|
|
|
function openSelected(row) {
|
|
if (!row) return
|
|
root.opened = false
|
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-open", "--history-index", String(row.historyIndex)])
|
|
}
|
|
|
|
Component.onCompleted: initProc.running = true
|
|
|
|
ListModel { id: displayModel }
|
|
|
|
PointerMoveGate {
|
|
id: pointerGate
|
|
referenceItem: card
|
|
}
|
|
|
|
FileView {
|
|
id: historyFile
|
|
path: root.historyPath
|
|
watchChanges: true
|
|
atomicWrites: true
|
|
printErrors: false
|
|
onLoaded: root.loadHistory(text())
|
|
onLoadFailed: root.loadHistory("[]")
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
// Reap watchers left behind by a previous shell instance, then start our
|
|
// own. The pdeathsig on the watchers makes the kernel kill them whenever
|
|
// the shell exits, however it exits, so no further lifecycle management.
|
|
Process {
|
|
id: initProc
|
|
command: ["pkill", "-f", "wl-paste .*--watch .*/shell/plugins/clipboard/capture\\.sh"]
|
|
onExited: {
|
|
currentProc.running = true
|
|
textWatchProc.running = true
|
|
imageWatchProc.running = true
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: currentProc
|
|
command: [root.captureScript]
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: root.addClipboardJson(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: textWatchProc
|
|
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "text", "--watch", root.captureScript, "text"]
|
|
onExited: watchRestartTimer.restart()
|
|
stdout: SplitParser {
|
|
onRead: function(data) { root.addClipboardJson(data) }
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: imageWatchProc
|
|
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "image/png", "--watch", root.captureScript, "image/png"]
|
|
onExited: watchRestartTimer.restart()
|
|
stdout: SplitParser {
|
|
onRead: function(data) { root.addClipboardJson(data) }
|
|
}
|
|
}
|
|
|
|
// A watcher that dies takes clipboard history with it, silently: copying still
|
|
// works, the picker still opens, and the old entries are all still there, so
|
|
// nothing recorded until the next shell reload. Bring it back instead.
|
|
Timer {
|
|
id: watchRestartTimer
|
|
interval: 1000
|
|
repeat: false
|
|
onTriggered: {
|
|
if (!textWatchProc.running) textWatchProc.running = true
|
|
if (!imageWatchProc.running) imageWatchProc.running = true
|
|
}
|
|
}
|
|
|
|
OverlayWindow {
|
|
id: panel
|
|
shown: root.opened
|
|
WlrLayershell.namespace: "omarchy-clipboard"
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: root.scrim
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.close()
|
|
}
|
|
|
|
BorderSurface {
|
|
id: card
|
|
width: root.cardWidth
|
|
height: root.cardHeight
|
|
radius: root.cornerRadius
|
|
anchors.centerIn: parent
|
|
color: root.background
|
|
borderSpec: root.borderSpec
|
|
padding: root.contentMargin
|
|
|
|
MouseArea { anchors.fill: parent; onClicked: {} }
|
|
|
|
Item {
|
|
id: keyCatcher
|
|
anchors.fill: parent
|
|
z: root.clearConfirmOpen ? 20 : 0
|
|
focus: true
|
|
|
|
Keys.priority: Keys.BeforeItem
|
|
Keys.onPressed: function(event) {
|
|
if (root.clearConfirmOpen) {
|
|
if (clearConfirm.handleKey(event)) event.accepted = true
|
|
return
|
|
}
|
|
|
|
if (event.key === Qt.Key_Escape) {
|
|
if (root.filterText) root.setFilter("")
|
|
else root.close()
|
|
event.accepted = true
|
|
} else if (Util.editsFilter(event, root.filterText)) {
|
|
root.setFilter(Util.editedFilter(event, root.filterText))
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_Delete) {
|
|
if (event.modifiers & Qt.ShiftModifier) root.requestClearHistory()
|
|
else root.removeDisplayIndex(root.selectedIndex)
|
|
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_Home) {
|
|
root.selectAbsolute(0)
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_End) {
|
|
root.selectAbsolute(displayModel.count - 1)
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
|
if (root.cursorActive && (event.modifiers & Qt.AltModifier)) root.openIndex(root.selectedIndex)
|
|
else if (root.cursorActive && (event.modifiers & Qt.ShiftModifier)) root.copyIndex(root.selectedIndex)
|
|
else 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
|
|
}
|
|
}
|
|
|
|
ConfirmDialog {
|
|
id: clearConfirm
|
|
|
|
anchors.fill: parent
|
|
opened: root.clearConfirmOpen
|
|
z: 10
|
|
message: "Delete entire clipboard history?"
|
|
confirmText: "Delete"
|
|
background: root.background
|
|
foreground: root.foreground
|
|
scrim: root.scrim
|
|
selectedBackground: root.selectedBackground
|
|
selectedText: root.selectedText
|
|
fontFamily: root.fontFamily
|
|
cornerRadius: root.cornerRadius
|
|
onCanceled: root.cancelClearHistory()
|
|
onConfirmed: root.confirmClearHistory()
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.topMargin: card.contentTopInset
|
|
anchors.rightMargin: card.contentRightInset
|
|
anchors.bottomMargin: card.contentBottomInset
|
|
anchors.leftMargin: card.contentLeftInset
|
|
spacing: root.contentSpacing
|
|
|
|
Rectangle {
|
|
width: parent.width
|
|
height: root.headerHeight
|
|
radius: root.cornerRadius
|
|
color: "transparent"
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
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: 0
|
|
|
|
Item {
|
|
width: parent.width / 2
|
|
height: parent.height
|
|
clip: true
|
|
|
|
ListView {
|
|
id: resultList
|
|
anchors.fill: parent
|
|
anchors.rightMargin: root.contentMargin
|
|
model: displayModel
|
|
clip: true
|
|
spacing: Style.space(4)
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
delegate: Rectangle {
|
|
id: row
|
|
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"
|
|
|
|
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.previewImage.length > 0
|
|
width: visible ? parent.height : 0
|
|
height: parent.height
|
|
source: parent.parent.previewImage
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
smooth: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width - (parent.parent.previewImage.length > 0 ? 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
|
|
opacity: parent.parent.entryType === "image" || parent.parent.entryType === "file" ? 0.72 : 1.0
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onPositionChanged: function(mouse) {
|
|
root.selectFromPointer(row.index, row, mouse)
|
|
}
|
|
onClicked: {
|
|
root.cursorActive = true
|
|
root.selectedIndex = row.index
|
|
root.activateIndex(row.index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: parent.width / 2
|
|
height: parent.height
|
|
clip: true
|
|
|
|
property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
|
|
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
width: Style.normalBorderWidth
|
|
color: Util.alpha(root.border, 0.28)
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: parent.activeRow && !parent.activeRow.previewImage
|
|
anchors.fill: parent
|
|
anchors.leftMargin: root.contentMargin
|
|
anchors.rightMargin: 0
|
|
anchors.topMargin: 0
|
|
anchors.bottomMargin: 0
|
|
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.previewImage
|
|
anchors.fill: parent
|
|
anchors.leftMargin: root.contentMargin
|
|
anchors.rightMargin: 0
|
|
anchors.topMargin: 0
|
|
anchors.bottomMargin: 0
|
|
source: parent.activeRow ? parent.activeRow.previewImage : ""
|
|
fillMode: Image.PreserveAspectFit
|
|
verticalAlignment: Image.AlignTop
|
|
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 {
|
|
textFormat: Text.PlainText
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|