515 lines
18 KiB
QML
515 lines
18 KiB
QML
|
|
import QtQuick
|
||
|
|
import QtQuick.Controls
|
||
|
|
import QtQuick.Layouts
|
||
|
|
import QtQuick.Shapes
|
||
|
|
import Quickshell
|
||
|
|
import Quickshell.Io
|
||
|
|
import "root:/Data" as Data
|
||
|
|
|
||
|
|
// Clipboard history manager with cliphist integration
|
||
|
|
Item {
|
||
|
|
id: root
|
||
|
|
required property var shell
|
||
|
|
property string selectedWidget: "cliphist"
|
||
|
|
|
||
|
|
property bool isVisible: false
|
||
|
|
property real bgOpacity: 0.0
|
||
|
|
|
||
|
|
transformOrigin: Item.Center
|
||
|
|
|
||
|
|
function show() { showAnimation.start() }
|
||
|
|
function hide() { hideAnimation.start() }
|
||
|
|
function toggle() { isVisible ? hide() : show() }
|
||
|
|
|
||
|
|
// Smooth show/hide animations
|
||
|
|
ParallelAnimation {
|
||
|
|
id: showAnimation
|
||
|
|
PropertyAction { target: root; property: "isVisible"; value: true }
|
||
|
|
PropertyAnimation { target: root; property: "opacity"; from: 0.0; to: 1.0; duration: 200; easing.type: Easing.OutCubic }
|
||
|
|
PropertyAnimation { target: root; property: "scale"; from: 0.9; to: 1.0; duration: 200; easing.type: Easing.OutCubic }
|
||
|
|
}
|
||
|
|
|
||
|
|
ParallelAnimation {
|
||
|
|
id: hideAnimation
|
||
|
|
PropertyAnimation { target: root; property: "opacity"; to: 0.0; duration: 150; easing.type: Easing.InCubic }
|
||
|
|
PropertyAnimation { target: root; property: "scale"; to: 0.95; duration: 150; easing.type: Easing.InCubic }
|
||
|
|
PropertyAction { target: root; property: "isVisible"; value: false }
|
||
|
|
}
|
||
|
|
|
||
|
|
ColumnLayout {
|
||
|
|
id: contentColumn
|
||
|
|
anchors.fill: parent
|
||
|
|
spacing: 12
|
||
|
|
|
||
|
|
// Header
|
||
|
|
RowLayout {
|
||
|
|
Layout.fillWidth: true
|
||
|
|
Layout.preferredHeight: 30
|
||
|
|
|
||
|
|
Label {
|
||
|
|
text: "Clipboard History"
|
||
|
|
font.pixelSize: 16
|
||
|
|
font.weight: Font.Medium
|
||
|
|
color: Data.ThemeManager.fgColor
|
||
|
|
Layout.fillWidth: true
|
||
|
|
}
|
||
|
|
|
||
|
|
Button {
|
||
|
|
id: clearButton
|
||
|
|
text: "Clear"
|
||
|
|
implicitWidth: 60
|
||
|
|
implicitHeight: 25
|
||
|
|
background: Rectangle {
|
||
|
|
radius: 12
|
||
|
|
color: parent.down ? Qt.darker(Data.ThemeManager.accentColor, 1.2) :
|
||
|
|
parent.hovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) :
|
||
|
|
Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.8)
|
||
|
|
}
|
||
|
|
contentItem: Label {
|
||
|
|
text: parent.text
|
||
|
|
font.pixelSize: 11
|
||
|
|
color: Data.ThemeManager.fgColor
|
||
|
|
horizontalAlignment: Text.AlignHCenter
|
||
|
|
verticalAlignment: Text.AlignVCenter
|
||
|
|
}
|
||
|
|
onClicked: {
|
||
|
|
clearClipboardHistory()
|
||
|
|
clickScale.target = clearButton
|
||
|
|
clickScale.start()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scrollable clipboard history list
|
||
|
|
Item {
|
||
|
|
Layout.fillWidth: true
|
||
|
|
Layout.fillHeight: true
|
||
|
|
|
||
|
|
ScrollView {
|
||
|
|
id: scrollView
|
||
|
|
anchors.fill: parent
|
||
|
|
clip: true
|
||
|
|
ScrollBar.vertical: ScrollBar {
|
||
|
|
policy: ScrollBar.AsNeeded
|
||
|
|
interactive: true
|
||
|
|
visible: cliphistList.contentHeight > cliphistList.height
|
||
|
|
contentItem: Rectangle {
|
||
|
|
implicitWidth: 6
|
||
|
|
radius: width / 2
|
||
|
|
color: parent.pressed ? Data.ThemeManager.accentColor
|
||
|
|
: parent.hovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.2)
|
||
|
|
: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.7)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||
|
|
|
||
|
|
ListView {
|
||
|
|
id: cliphistList
|
||
|
|
model: cliphistModel
|
||
|
|
spacing: 6
|
||
|
|
cacheBuffer: 50 // Memory optimization
|
||
|
|
reuseItems: true
|
||
|
|
boundsBehavior: Flickable.StopAtBounds
|
||
|
|
maximumFlickVelocity: 2500
|
||
|
|
flickDeceleration: 1500
|
||
|
|
|
||
|
|
// Smooth scrolling behavior
|
||
|
|
property real targetY: contentY
|
||
|
|
Behavior on targetY {
|
||
|
|
NumberAnimation {
|
||
|
|
duration: 200
|
||
|
|
easing.type: Easing.OutQuad
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onTargetYChanged: {
|
||
|
|
if (!moving && !dragging) {
|
||
|
|
contentY = targetY
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
delegate: Rectangle {
|
||
|
|
width: cliphistList.width
|
||
|
|
height: Math.max(50, contentText.contentHeight + 20)
|
||
|
|
radius: 8
|
||
|
|
color: mouseArea.containsMouse ? Qt.darker(Data.ThemeManager.bgColor, 1.15) : Qt.darker(Data.ThemeManager.bgColor, 1.1)
|
||
|
|
border.color: Data.ThemeManager.accentColor
|
||
|
|
border.width: 1
|
||
|
|
|
||
|
|
// View optimization - only render visible items
|
||
|
|
visible: y + height > cliphistList.contentY - height &&
|
||
|
|
y < cliphistList.contentY + cliphistList.height + height
|
||
|
|
|
||
|
|
RowLayout {
|
||
|
|
anchors.fill: parent
|
||
|
|
anchors.margins: 10
|
||
|
|
spacing: 10
|
||
|
|
|
||
|
|
// Content type icon
|
||
|
|
Label {
|
||
|
|
text: model.type === "image" ? "🖼️" : model.type === "url" ? "🔗" : "📝"
|
||
|
|
font.pixelSize: 16
|
||
|
|
Layout.alignment: Qt.AlignTop
|
||
|
|
}
|
||
|
|
|
||
|
|
ColumnLayout {
|
||
|
|
Layout.fillWidth: true
|
||
|
|
Layout.fillHeight: true
|
||
|
|
spacing: 4
|
||
|
|
|
||
|
|
Label {
|
||
|
|
id: contentText
|
||
|
|
text: model.type === "image" ? "[Image Data]" :
|
||
|
|
(model.content.length > 100 ? model.content.substring(0, 100) + "..." : model.content)
|
||
|
|
font.pixelSize: 12
|
||
|
|
color: Data.ThemeManager.fgColor
|
||
|
|
wrapMode: Text.WordWrap
|
||
|
|
Layout.fillWidth: true
|
||
|
|
Layout.fillHeight: true
|
||
|
|
elide: Text.ElideRight
|
||
|
|
maximumLineCount: 4
|
||
|
|
}
|
||
|
|
|
||
|
|
RowLayout {
|
||
|
|
Layout.fillWidth: true
|
||
|
|
Item { Layout.fillWidth: true }
|
||
|
|
Label {
|
||
|
|
text: model.type === "image" ? "Image" : (model.content.length + " chars")
|
||
|
|
font.pixelSize: 10
|
||
|
|
color: Qt.darker(Data.ThemeManager.fgColor, 1.5)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
MouseArea {
|
||
|
|
id: mouseArea
|
||
|
|
anchors.fill: parent
|
||
|
|
hoverEnabled: true
|
||
|
|
onClicked: mouse => {
|
||
|
|
if (mouse.button === Qt.LeftButton) {
|
||
|
|
copyToClipboard(model.id, model.type)
|
||
|
|
clickScale.target = parent
|
||
|
|
clickScale.start()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Empty state message
|
||
|
|
Label {
|
||
|
|
anchors.centerIn: parent
|
||
|
|
text: "No clipboard history\nCopy something to get started"
|
||
|
|
font.pixelSize: 14
|
||
|
|
color: Qt.darker(Data.ThemeManager.fgColor, 1.5)
|
||
|
|
horizontalAlignment: Text.AlignHCenter
|
||
|
|
visible: cliphistList.count === 0
|
||
|
|
opacity: 0.7
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Click feedback animation
|
||
|
|
NumberAnimation {
|
||
|
|
id: clickScale
|
||
|
|
property Item target
|
||
|
|
properties: "scale"
|
||
|
|
from: 0.95
|
||
|
|
to: 1.0
|
||
|
|
duration: 150
|
||
|
|
easing.type: Easing.OutCubic
|
||
|
|
}
|
||
|
|
|
||
|
|
ListModel { id: cliphistModel }
|
||
|
|
|
||
|
|
property var currentEntries: []
|
||
|
|
|
||
|
|
// Main cliphist process for fetching clipboard history
|
||
|
|
Process {
|
||
|
|
id: cliphistProcess
|
||
|
|
command: ["cliphist", "list"]
|
||
|
|
running: false
|
||
|
|
|
||
|
|
property var tempEntries: []
|
||
|
|
|
||
|
|
onRunningChanged: {
|
||
|
|
if (running) {
|
||
|
|
tempEntries = []
|
||
|
|
} else {
|
||
|
|
// Process completed, apply smart diff update
|
||
|
|
updateModelIfChanged(tempEntries)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
stdout: SplitParser {
|
||
|
|
onRead: data => {
|
||
|
|
try {
|
||
|
|
const line = data.toString().trim()
|
||
|
|
|
||
|
|
// Skip empty lines and error messages
|
||
|
|
if (line === "" || line.includes("ERROR") || line.includes("WARN") ||
|
||
|
|
line.includes("error:") || line.includes("warning:")) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse cliphist output format: ID + spaces + content
|
||
|
|
const match = line.match(/^(\d+)\s+(.+)$/)
|
||
|
|
if (match) {
|
||
|
|
const id = match[1]
|
||
|
|
const content = match[2]
|
||
|
|
|
||
|
|
cliphistProcess.tempEntries.push({
|
||
|
|
id: id,
|
||
|
|
content: content,
|
||
|
|
type: detectContentType(content)
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
console.log("Failed to parse line:", line)
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error("Error parsing cliphist line:", e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear entire clipboard history
|
||
|
|
Process {
|
||
|
|
id: clearCliphistProcess
|
||
|
|
command: ["cliphist", "wipe"]
|
||
|
|
running: false
|
||
|
|
|
||
|
|
onRunningChanged: {
|
||
|
|
if (!running) {
|
||
|
|
cliphistModel.clear()
|
||
|
|
currentEntries = []
|
||
|
|
console.log("Clipboard history cleared")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
stderr: SplitParser {
|
||
|
|
onRead: data => {
|
||
|
|
console.error("Clear clipboard error:", data.toString())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete specific clipboard entry
|
||
|
|
Process {
|
||
|
|
id: deleteEntryProcess
|
||
|
|
property string entryId: ""
|
||
|
|
command: ["cliphist", "delete-query", entryId]
|
||
|
|
running: false
|
||
|
|
|
||
|
|
onRunningChanged: {
|
||
|
|
if (!running && entryId !== "") {
|
||
|
|
// Remove deleted entry from model
|
||
|
|
for (let i = 0; i < cliphistModel.count; i++) {
|
||
|
|
if (cliphistModel.get(i).id === entryId) {
|
||
|
|
cliphistModel.remove(i)
|
||
|
|
currentEntries = currentEntries.filter(entry => entry.id !== entryId)
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
console.log("Deleted entry:", entryId)
|
||
|
|
entryId = ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
stderr: SplitParser {
|
||
|
|
onRead: data => {
|
||
|
|
console.error("Delete entry error:", data.toString())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy plain text to clipboard
|
||
|
|
Process {
|
||
|
|
id: copyTextProcess
|
||
|
|
property string textToCopy: ""
|
||
|
|
command: ["wl-copy", textToCopy]
|
||
|
|
running: false
|
||
|
|
|
||
|
|
stderr: SplitParser {
|
||
|
|
onRead: data => {
|
||
|
|
console.error("wl-copy error:", data.toString())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy from clipboard history
|
||
|
|
Process {
|
||
|
|
id: copyHistoryProcess
|
||
|
|
property string entryId: ""
|
||
|
|
command: ["sh", "-c", "printf '%s' '" + entryId + "' | cliphist decode | wl-copy"]
|
||
|
|
running: false
|
||
|
|
|
||
|
|
stderr: SplitParser {
|
||
|
|
onRead: data => {
|
||
|
|
console.error("Copy history error:", data.toString())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Periodic refresh timer (disabled by default)
|
||
|
|
Timer {
|
||
|
|
id: refreshTimer
|
||
|
|
interval: 30000
|
||
|
|
running: false // Only enable when needed
|
||
|
|
repeat: true
|
||
|
|
onTriggered: {
|
||
|
|
if (!cliphistProcess.running && root.isVisible) {
|
||
|
|
refreshClipboardHistory()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Component initialization
|
||
|
|
Component.onCompleted: {
|
||
|
|
refreshClipboardHistory()
|
||
|
|
}
|
||
|
|
|
||
|
|
onIsVisibleChanged: {
|
||
|
|
if (isVisible && cliphistModel.count === 0) {
|
||
|
|
refreshClipboardHistory()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Smart model update - only changes when content differs
|
||
|
|
function updateModelIfChanged(newEntries) {
|
||
|
|
// Quick length check
|
||
|
|
if (newEntries.length !== currentEntries.length) {
|
||
|
|
updateModel(newEntries)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Compare content for changes
|
||
|
|
let hasChanges = false
|
||
|
|
for (let i = 0; i < newEntries.length; i++) {
|
||
|
|
if (i >= currentEntries.length ||
|
||
|
|
newEntries[i].id !== currentEntries[i].id ||
|
||
|
|
newEntries[i].content !== currentEntries[i].content) {
|
||
|
|
hasChanges = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hasChanges) {
|
||
|
|
updateModel(newEntries)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Efficient model update with scroll position preservation
|
||
|
|
function updateModel(newEntries) {
|
||
|
|
const scrollPos = cliphistList.contentY
|
||
|
|
|
||
|
|
// Remove obsolete items
|
||
|
|
for (let i = cliphistModel.count - 1; i >= 0; i--) {
|
||
|
|
const modelItem = cliphistModel.get(i)
|
||
|
|
const found = newEntries.some(entry => entry.id === modelItem.id)
|
||
|
|
if (!found) {
|
||
|
|
cliphistModel.remove(i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add or update items
|
||
|
|
for (let i = 0; i < newEntries.length; i++) {
|
||
|
|
const newEntry = newEntries[i]
|
||
|
|
let found = false
|
||
|
|
|
||
|
|
// Check if item exists and update position
|
||
|
|
for (let j = 0; j < cliphistModel.count; j++) {
|
||
|
|
const modelItem = cliphistModel.get(j)
|
||
|
|
if (modelItem.id === newEntry.id) {
|
||
|
|
if (modelItem.content !== newEntry.content) {
|
||
|
|
cliphistModel.set(j, newEntry)
|
||
|
|
}
|
||
|
|
if (j !== i && i < cliphistModel.count) {
|
||
|
|
cliphistModel.move(j, i, 1)
|
||
|
|
}
|
||
|
|
found = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add new item
|
||
|
|
if (!found) {
|
||
|
|
if (i < cliphistModel.count) {
|
||
|
|
cliphistModel.insert(i, newEntry)
|
||
|
|
} else {
|
||
|
|
cliphistModel.append(newEntry)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restore scroll position
|
||
|
|
cliphistList.contentY = scrollPos
|
||
|
|
currentEntries = newEntries.slice()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Content type detection based on patterns
|
||
|
|
function detectContentType(content) {
|
||
|
|
// Binary/image data detection
|
||
|
|
if (content.includes('\x00') || content.startsWith('\x89PNG') || content.startsWith('\xFF\xD8\xFF')) {
|
||
|
|
return "image"
|
||
|
|
}
|
||
|
|
if (content.includes('[[ binary data ') || content.includes('<selection>')) {
|
||
|
|
return "image"
|
||
|
|
}
|
||
|
|
// URL detection
|
||
|
|
if (/^https?:\/\/\S+$/.test(content.trim())) return "url"
|
||
|
|
// Code detection
|
||
|
|
if (content.includes('\n') && (content.includes('{') || content.includes('function') || content.includes('=>'))) return "code"
|
||
|
|
// Command detection
|
||
|
|
if (content.startsWith('sudo ') || content.startsWith('pacman ') || content.startsWith('apt ')) return "command"
|
||
|
|
return "text"
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTimestamp(timestamp) {
|
||
|
|
const now = new Date()
|
||
|
|
const entryDate = new Date(parseInt(timestamp))
|
||
|
|
const diff = (now - entryDate) / 1000
|
||
|
|
|
||
|
|
if (diff < 60) return "Just now"
|
||
|
|
if (diff < 3600) return Math.floor(diff / 60) + " min ago"
|
||
|
|
if (diff < 86400) return Math.floor(diff / 3600) + " hour" + (Math.floor(diff / 3600) === 1 ? "" : "s") + " ago"
|
||
|
|
return Qt.formatDateTime(entryDate, "MMM d h:mm AP")
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearClipboardHistory() {
|
||
|
|
clearCliphistProcess.running = true
|
||
|
|
}
|
||
|
|
|
||
|
|
function deleteClipboardEntry(entryId) {
|
||
|
|
deleteEntryProcess.entryId = entryId
|
||
|
|
deleteEntryProcess.running = true
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshClipboardHistory() {
|
||
|
|
cliphistProcess.running = true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy handler - chooses appropriate method based on content type
|
||
|
|
function copyToClipboard(entryIdOrText, contentType) {
|
||
|
|
if (contentType === "image" || typeof entryIdOrText === "string" && entryIdOrText.match(/^\d+$/)) {
|
||
|
|
// Use cliphist decode for binary data and numbered entries
|
||
|
|
copyHistoryProcess.entryId = entryIdOrText
|
||
|
|
copyHistoryProcess.running = true
|
||
|
|
} else {
|
||
|
|
// Use wl-copy for plain text
|
||
|
|
copyTextProcess.textToCopy = entryIdOrText
|
||
|
|
copyTextProcess.running = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clean up all processes on destruction
|
||
|
|
Component.onDestruction: {
|
||
|
|
cliphistProcess.running = false
|
||
|
|
clearCliphistProcess.running = false
|
||
|
|
deleteEntryProcess.running = false
|
||
|
|
copyTextProcess.running = false
|
||
|
|
copyHistoryProcess.running = false
|
||
|
|
}
|
||
|
|
}
|