Files
omarchy/shell/plugins/clipboard/Clipboard.qml
T
David Heinemeier HanssonandClaude Opus 5.5 48de7823da Keep overlay surfaces mapped so they open sharp (#13419)
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>
2026-09-27 18:03:01 +02:00

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
}
}
}
}
}
}
}