Qt 6.12 shadows the bare Color singleton, so the bar and every Color role read undefined on fresh installs. Keep our I18n text in the four conflicting files, qualify #14433's notification button color, give the new dictation menu rows and notifications Chinese, and drop table entries for the removed Voxtype and FIDO2 texts.
610 lines
20 KiB
QML
610 lines
20 KiB
QML
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import qs.Commons
|
|
import qs.Commons as 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: Commons.Color.menu.background
|
|
property color foreground: Commons.Color.menu.text
|
|
property color border: Commons.Color.menu.border
|
|
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
|
|
property color scrim: Commons.Color.menu.scrim
|
|
property color selectedBackground: Commons.Color.menu.selectedBackground
|
|
property color selectedText: Commons.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: I18n.tr("Delete entire clipboard history?")
|
|
confirmText: I18n.tr("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 || I18n.tr("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 ? I18n.tr("Clipboard is empty") : I18n.tr("No matches for “%1”", root.filterText)
|
|
color: root.foreground
|
|
opacity: 0.7
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
horizontalAlignment: Text.AlignHCenter
|
|
width: parent.width
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|