1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-07 14:05:38 -05:00
Files
DankMaterialShell/Modules/ClipboardHistory.qml
2025-07-19 10:56:32 -04:00

945 lines
35 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Common
import qs.Widgets
PanelWindow {
id: clipboardHistory
property bool isVisible: false
property int totalCount: 0
// Use the global Theme singleton
property var activeTheme: Theme
// Confirmation dialog state
property bool showClearConfirmation: false
// Clipboard entries model
property var clipboardEntries: []
function updateFilteredModel() {
filteredClipboardModel.clear();
for (let i = 0; i < clipboardModel.count; i++) {
const entry = clipboardModel.get(i).entry;
if (searchField.text.trim().length === 0) {
filteredClipboardModel.append({
"entry": entry
});
} else {
const content = getEntryPreview(entry).toLowerCase();
if (content.includes(searchField.text.toLowerCase()))
filteredClipboardModel.append({
"entry": entry
});
}
}
// Update total count
clipboardHistory.totalCount = filteredClipboardModel.count;
}
function toggle() {
if (isVisible)
hide();
else
show();
}
function show() {
clipboardHistory.isVisible = true;
searchField.focus = true;
refreshClipboard();
console.log("ClipboardHistory: Opening and refreshing");
}
function hide() {
clipboardHistory.isVisible = false;
searchField.focus = false;
searchField.text = "";
// Clean up temporary image files
cleanupTempFiles();
}
function cleanupTempFiles() {
cleanupProcess.command = ["sh", "-c", "rm -f /tmp/clipboard_preview_*.png"];
cleanupProcess.running = true;
}
function refreshClipboard() {
clipboardProcess.running = true;
}
function copyEntry(entry) {
const entryId = entry.split('\t')[0];
copyProcess.command = ["sh", "-c", `cliphist decode ${entryId} | wl-copy`];
copyProcess.running = true;
// Simply hide the clipboard interface
console.log("ClipboardHistory: Entry copied, hiding interface");
hide();
}
function deleteEntry(entry) {
// Use the full entry line for deletion
console.log("Deleting entry:", entry);
deleteProcess.command = ["sh", "-c", `echo '${entry.replace(/'/g, "'\\''")}' | cliphist delete`];
deleteProcess.running = true;
}
function clearAll() {
clearProcess.running = true;
}
function getEntryPreview(entry) {
// Remove cliphist ID prefix and clean up content
let content = entry.replace(/^\s*\d+\s+/, "");
// Handle different content types
if (content.includes("image/") || content.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(content)) {
// Extract dimensions if available
const dimensionMatch = content.match(/(\d+)x(\d+)/);
if (dimensionMatch)
return `Image ${dimensionMatch[1]}×${dimensionMatch[2]}`;
// Extract file type if available
const typeMatch = content.match(/\b(png|jpg|jpeg|gif|bmp|webp)\b/i);
if (typeMatch)
return `Image (${typeMatch[1].toUpperCase()})`;
return "Image";
}
// Truncate long text
if (content.length > 100)
return content.substring(0, 100) + "...";
return content;
}
function getEntryType(entry) {
// Improved image detection
if (entry.includes("image/") || entry.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(entry) || /\b(png|jpg|jpeg|gif|bmp|webp)\b/i.test(entry))
return "image";
if (entry.length > 200)
return "long_text";
return "text";
}
// Window properties
color: "transparent"
visible: isVisible
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
anchors {
top: true
left: true
right: true
bottom: true
}
ListModel {
id: clipboardModel
}
ListModel {
id: filteredClipboardModel
}
// Background overlay
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.5)
opacity: clipboardHistory.isVisible ? 1 : 0
visible: clipboardHistory.isVisible
MouseArea {
anchors.fill: parent
enabled: clipboardHistory.isVisible
onClicked: clipboardHistory.hide()
}
Behavior on opacity {
NumberAnimation {
duration: activeTheme.mediumDuration
easing.type: activeTheme.emphasizedEasing
}
}
}
// Main clipboard container
Rectangle {
id: clipboardContainer
width: Math.min(500, parent.width - 200)
height: Math.min(500, parent.height - 100)
anchors.centerIn: parent
color: activeTheme.popupBackground()
radius: activeTheme.cornerRadiusXLarge
border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.08)
border.width: 1
opacity: clipboardHistory.isVisible ? 1 : 0
scale: clipboardHistory.isVisible ? 1 : 0.9
// Header section
Column {
id: headerSection
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: activeTheme.spacingXL
spacing: activeTheme.spacingL
// Title and actions
Item {
width: parent.width
height: 40
Text {
id: titleText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Clipboard History" + (clipboardHistory.totalCount > 0 ? ` (${clipboardHistory.totalCount})` : "")
font.pixelSize: activeTheme.fontSizeLarge + 4
font.weight: Font.Bold
color: activeTheme.surfaceText
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: activeTheme.spacingS
// Clear all button
Rectangle {
id: clearAllButton
width: 40
height: 32
radius: activeTheme.cornerRadius
color: clearArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.12) : "transparent"
visible: clipboardHistory.totalCount > 0
DankIcon {
anchors.centerIn: parent
name: "delete_sweep"
size: activeTheme.iconSize
color: clearArea.containsMouse ? activeTheme.primary : activeTheme.surfaceText
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: showClearConfirmation = true
}
Behavior on color {
ColorAnimation {
duration: activeTheme.shortDuration
}
}
}
// Close button
Rectangle {
width: 40
height: 32
radius: activeTheme.cornerRadius
color: closeArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.12) : "transparent"
DankIcon {
anchors.centerIn: parent
name: "close"
size: activeTheme.iconSize
color: closeArea.containsMouse ? activeTheme.primary : activeTheme.surfaceText
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: clipboardHistory.hide()
}
Behavior on color {
ColorAnimation {
duration: activeTheme.shortDuration
}
}
}
}
}
// Search field
Rectangle {
width: parent.width
height: 48
radius: activeTheme.cornerRadiusLarge
color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, activeTheme.getContentBackgroundAlpha() * 0.4)
border.color: searchField.focus ? activeTheme.primary : Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.08)
border.width: searchField.focus ? 2 : 1
Row {
anchors.left: parent.left
anchors.leftMargin: activeTheme.spacingL
anchors.verticalCenter: parent.verticalCenter
spacing: activeTheme.spacingM
DankIcon {
name: "search"
size: activeTheme.iconSize
color: searchField.focus ? activeTheme.primary : Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.6)
anchors.verticalCenter: parent.verticalCenter
}
TextInput {
id: searchField
width: parent.parent.width - 80
height: parent.parent.height
font.pixelSize: activeTheme.fontSizeLarge
color: activeTheme.surfaceText
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
onTextChanged: updateFilteredModel()
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Escape)
clipboardHistory.hide();
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.IBeamCursor
acceptedButtons: Qt.NoButton
}
// Placeholder text
Text {
text: "Search clipboard entries..."
font: searchField.font
color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.6)
anchors.verticalCenter: parent.verticalCenter
visible: searchField.text.length === 0 && !searchField.focus
}
}
}
Behavior on border.color {
ColorAnimation {
duration: activeTheme.shortDuration
}
}
}
}
// Clipboard entries
Rectangle {
anchors.top: headerSection.bottom
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: activeTheme.spacingXL
anchors.topMargin: activeTheme.spacingL
color: "transparent"
ScrollView {
anchors.fill: parent
clip: true
// Improve scrolling responsiveness
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ScrollBar.vertical.width: 12
ScrollBar.vertical.minimumSize: 0.1 // Minimum scrollbar handle size
// Enable faster scrolling
wheelEnabled: true
ListView {
id: clipboardList
// Make mouse wheel scrolling more responsive
property real wheelStepSize: 60
model: filteredClipboardModel
spacing: activeTheme.spacingS
// Improve scrolling performance
cacheBuffer: 100
boundsBehavior: Flickable.StopAtBounds
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: (wheel) => {
var delta = wheel.angleDelta.y;
var steps = delta / 120; // Standard wheel step
clipboardList.contentY -= steps * clipboardList.wheelStepSize;
// Ensure we stay within bounds
if (clipboardList.contentY < 0)
clipboardList.contentY = 0;
else if (clipboardList.contentY > clipboardList.contentHeight - clipboardList.height)
clipboardList.contentY = Math.max(0, clipboardList.contentHeight - clipboardList.height);
}
}
delegate: Rectangle {
property string entryType: getEntryType(model.entry)
property string entryPreview: getEntryPreview(model.entry)
property int entryIndex: index + 1
width: clipboardList.width - 16 // Account for scrollbar space
height: Math.max(60, contentColumn.implicitHeight + activeTheme.spacingM * 2)
radius: activeTheme.cornerRadius
color: entryArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.08) : Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.05)
border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.1)
border.width: 1
Row {
anchors.fill: parent
anchors.margins: activeTheme.spacingM
spacing: activeTheme.spacingL
// Index number
Rectangle {
width: 24
height: 24
radius: 12
color: Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.2)
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: entryIndex.toString()
font.pixelSize: activeTheme.fontSizeSmall
font.weight: Font.Bold
color: activeTheme.primary
}
}
// Entry content
Row {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 80 // Adjusted for index number and delete button
spacing: activeTheme.spacingM
// Image preview - actual image display for images
Rectangle {
property string entryId: model.entry ? model.entry.split('\t')[0] : ""
property string tempImagePath: "/tmp/clipboard_preview_" + entryId + ".png"
width: entryType === "image" ? 48 : 0
height: entryType === "image" ? 36 : 0
radius: activeTheme.cornerRadiusSmall
color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.1)
border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.2)
border.width: 1
visible: entryType === "image"
clip: true
// Actual image preview using cliphist decode
Image {
id: imagePreview
anchors.fill: parent
anchors.margins: 1
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
source: parent.entryType === "image" && parent.entryId ? "file://" + parent.tempImagePath : ""
Component.onCompleted: {
if (parent.entryType === "image" && parent.entryId) {
// Simple approach: use shell redirection to write to file
imageDecodeProcess.entryId = parent.entryId;
imageDecodeProcess.tempPath = parent.tempImagePath;
imageDecodeProcess.imagePreview = imagePreview;
imageDecodeProcess.command = ["sh", "-c", `cliphist decode ${parent.entryId} > "${parent.tempImagePath}" 2>/dev/null`];
imageDecodeProcess.running = true;
}
}
onStatusChanged: {
if (status === Image.Error)
console.warn("Failed to load clipboard image from:", source);
}
// Fallback icon when image fails to load or is loading
DankIcon {
anchors.centerIn: parent
name: imagePreview.status === Image.Loading ? "hourglass_empty" : imagePreview.status === Image.Error ? "broken_image" : "photo"
size: imagePreview.status === Image.Loading ? 14 : 18
color: imagePreview.status === Image.Error ? activeTheme.error : activeTheme.primary
visible: imagePreview.status !== Image.Ready
SequentialAnimation on opacity {
running: imagePreview.status === Image.Loading
loops: Animation.Infinite
NumberAnimation {
to: 0.3
duration: 500
}
NumberAnimation {
to: 1
duration: 500
}
}
}
}
}
Column {
id: contentColumn
anchors.verticalCenter: parent.verticalCenter
width: parent.width - (entryType === "image" ? 60 : 0)
spacing: activeTheme.spacingXS
Text {
text: {
switch (entryType) {
case "image":
return "Image • " + entryPreview;
case "long_text":
return "Long Text";
default:
return "Text";
}
}
font.pixelSize: activeTheme.fontSizeSmall
color: activeTheme.primary
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
}
Text {
text: entryPreview
font.pixelSize: activeTheme.fontSizeMedium
color: activeTheme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: entryType === "long_text" ? 3 : 1
elide: Text.ElideRight
visible: true // Show preview for all entry types including images
}
}
}
// Actions - Single centered delete button
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
radius: activeTheme.cornerRadius
color: deleteArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.12) : "transparent"
z: 100 // Ensure it's above other elements
DankIcon {
anchors.centerIn: parent
name: "delete"
size: activeTheme.iconSize - 4
color: deleteArea.containsMouse ? activeTheme.primary : activeTheme.surfaceText
}
MouseArea {
id: deleteArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
z: 101 // Ensure click area is above everything
onClicked: (mouse) => {
console.log("Delete clicked for entry:", model.entry);
deleteEntry(model.entry);
// Prevent the click from propagating to the entry area
mouse.accepted = true;
}
}
Behavior on color {
ColorAnimation {
duration: activeTheme.shortDuration
}
}
}
}
MouseArea {
id: entryArea
anchors.fill: parent
anchors.rightMargin: 40 // Leave space for delete button
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: copyEntry(model.entry)
}
Behavior on color {
ColorAnimation {
duration: activeTheme.shortDuration
}
}
}
}
// Empty state
Column {
anchors.centerIn: parent
spacing: activeTheme.spacingL
visible: clipboardHistory.totalCount === 0
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "content_paste_off"
size: activeTheme.iconSizeLarge + 16
color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.3)
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "No clipboard history"
font.pixelSize: activeTheme.fontSizeLarge
color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.6)
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Copy something to see it here"
font.pixelSize: activeTheme.fontSizeMedium
color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.4)
}
}
}
}
// Clear All Confirmation Dialog
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.4)
visible: showClearConfirmation
z: 999
MouseArea {
anchors.fill: parent
onClicked: clipboardHistory.showClearConfirmation = false
}
}
Rectangle {
anchors.centerIn: parent
width: 350
height: 200 // Increased height for better spacing
radius: activeTheme.cornerRadiusLarge
color: activeTheme.popupBackground()
border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.08)
border.width: 1
visible: showClearConfirmation
z: 1000
Column {
anchors.centerIn: parent
spacing: activeTheme.spacingL
width: parent.width - 40
// Add top padding
Item {
width: 1
height: activeTheme.spacingM
}
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "warning"
size: activeTheme.iconSizeLarge
color: activeTheme.error
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Clear All Clipboard History?"
font.pixelSize: activeTheme.fontSizeLarge
font.weight: Font.Bold
color: activeTheme.surfaceText
horizontalAlignment: Text.AlignHCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "This action cannot be undone. All clipboard entries will be permanently deleted."
font.pixelSize: activeTheme.fontSizeMedium
color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.7)
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
width: parent.width
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: activeTheme.spacingM
// Cancel button
Rectangle {
width: 100
height: 40
radius: activeTheme.cornerRadius
color: cancelArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.08) : "transparent"
border.color: activeTheme.primary
border.width: 1
Text {
anchors.centerIn: parent
text: "Cancel"
font.pixelSize: activeTheme.fontSizeMedium
font.weight: Font.Medium
color: activeTheme.primary
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: clipboardHistory.showClearConfirmation = false
}
Behavior on color {
ColorAnimation {
duration: activeTheme.shortDuration
}
}
}
// Clear button
Rectangle {
width: 100
height: 40
radius: activeTheme.cornerRadius
color: confirmArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.8) : activeTheme.primary
Text {
anchors.centerIn: parent
text: "Clear All"
font.pixelSize: activeTheme.fontSizeMedium
font.weight: Font.Medium
color: activeTheme.surface
}
MouseArea {
id: confirmArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
clipboardHistory.showClearConfirmation = false;
clearAll();
}
}
Behavior on color {
ColorAnimation {
duration: activeTheme.shortDuration
}
}
}
}
// Add some bottom padding
Item {
width: 1
height: activeTheme.spacingM
}
}
}
Behavior on opacity {
NumberAnimation {
duration: activeTheme.mediumDuration
easing.type: activeTheme.emphasizedEasing
}
}
Behavior on scale {
NumberAnimation {
duration: activeTheme.mediumDuration
easing.type: activeTheme.emphasizedEasing
}
}
}
// Clipboard processes
Process {
id: cleanupProcess
running: false
onExited: (exitCode) => {
if (exitCode === 0)
console.log("Temporary image files cleaned up");
}
}
Process {
// Force the Image component to reload
id: imageDecodeProcess
property string entryId: ""
property string tempPath: ""
property var imagePreview: null
running: false
onExited: (exitCode) => {
if (exitCode === 0 && imagePreview && tempPath)
Qt.callLater(function() {
imagePreview.source = "";
imagePreview.source = "file://" + tempPath;
});
}
onStarted: {
console.log("Starting image decode for entry:", entryId, "to path:", tempPath);
}
}
Process {
id: clipboardProcess
command: ["cliphist", "list"]
running: false
onStarted: {
clipboardHistory.clipboardEntries = [];
clipboardModel.clear();
console.log("ClipboardHistory: Starting cliphist process...");
}
onExited: (exitCode) => {
if (exitCode === 0)
updateFilteredModel();
else
console.warn("ClipboardHistory: Failed to load clipboard history");
}
stdout: SplitParser {
splitMarker: "\n"
onRead: (line) => {
if (line.trim()) {
clipboardHistory.clipboardEntries.push(line);
clipboardModel.append({
"entry": line
});
}
}
}
}
Process {
id: copyProcess
running: false
onExited: (exitCode) => {
if (exitCode !== 0)
console.warn("ClipboardHistory: Failed to copy entry");
}
}
Process {
id: deleteProcess
running: false
onExited: (exitCode) => {
if (exitCode === 0)
refreshClipboard();
}
}
Process {
id: clearProcess
command: ["cliphist", "wipe"]
running: false
onExited: (exitCode) => {
if (exitCode === 0) {
clipboardHistory.clipboardEntries = [];
clipboardModel.clear();
updateFilteredModel();
}
}
}
IpcHandler {
function open() {
console.log("ClipboardHistory: IPC open() called");
clipboardHistory.show();
return "CLIPBOARD_OPEN_SUCCESS";
}
function close() {
console.log("ClipboardHistory: IPC close() called");
clipboardHistory.hide();
return "CLIPBOARD_CLOSE_SUCCESS";
}
function toggle() {
console.log("ClipboardHistory: IPC toggle() called");
clipboardHistory.toggle();
return "CLIPBOARD_TOGGLE_SUCCESS";
}
target: "clipboard"
}
}