1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-06 05:25:41 -05:00

cleanup clipboard history modal

This commit is contained in:
bbedward
2025-09-02 23:19:13 -04:00
parent 531d6334fb
commit 2a8087cd27
10 changed files with 867 additions and 746 deletions

View File

@@ -0,0 +1,19 @@
pragma Singleton
import QtQuick
import Quickshell
Singleton {
id: root
readonly property int previewLength: 100
readonly property int longTextThreshold: 200
readonly property int modalWidth: 650
readonly property int modalHeight: 550
readonly property int itemHeight: 72
readonly property int thumbnailSize: 48
readonly property int retryInterval: 50
readonly property int viewportBuffer: 100
readonly property int extendedBuffer: 200
readonly property int keyboardHintsHeight: 80
readonly property int headerHeight: 40
}

View File

@@ -0,0 +1,120 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modals.Clipboard
Item {
id: clipboardContent
required property var modal
required property var filteredModel
required property var clearConfirmDialog
property alias searchField: searchField
property alias clipboardListView: clipboardListView
anchors.fill: parent
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
focus: false
// Header
ClipboardHeader {
id: header
width: parent.width
totalCount: modal.totalCount
showKeyboardHints: modal.showKeyboardHints
onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
onClearAllClicked: {
clearConfirmDialog.show("Clear All History?", "This will permanently delete all clipboard history.", function () {
modal.clearAll()
modal.hide()
}, function () {} // No action on cancel
)
}
onCloseClicked: modal.hide()
}
// Search Field
DankTextField {
id: searchField
width: parent.width
placeholderText: ""
leftIconName: "search"
showClearButton: true
focus: true
ignoreLeftRightKeys: true
keyForwardTargets: [modal.modalFocusScope]
onTextChanged: {
modal.searchText = text
modal.updateFilteredModel()
}
Keys.onEscapePressed: function (event) {
modal.hide()
event.accepted = true
}
Component.onCompleted: {
Qt.callLater(function () {
forceActiveFocus()
})
}
Connections {
target: modal
function onOpened() {
Qt.callLater(function () {
searchField.forceActiveFocus()
})
}
}
}
// List Container
Rectangle {
width: parent.width
height: parent.height - ClipboardConstants.headerHeight - 70
radius: Theme.cornerRadius
color: Theme.surfaceLight
border.color: Theme.outlineLight
border.width: 1
clip: true
ClipboardListView {
id: clipboardListView
anchors.fill: parent
anchors.margins: Theme.spacingS
model: filteredModel
clipboardModal: clipboardContent.modal
selectedIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
keyboardNavigationActive: clipboardContent.modal ? clipboardContent.modal.keyboardNavigationActive : false
}
}
// Spacer for keyboard hints
Item {
width: parent.width
height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingL : 0
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
// Keyboard Hints Overlay
ClipboardKeyboardHints {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
visible: modal.showKeyboardHints
}
}

View File

@@ -0,0 +1,138 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Io
import qs.Common
import qs.Widgets
import qs.Modals.Clipboard
Rectangle {
id: entry
required property string entryData
required property int entryIndex
required property int itemIndex
required property bool isSelected
required property var modal
required property var listView
signal copyRequested
signal deleteRequested
readonly property string entryType: modal ? modal.getEntryType(entryData) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entryData) : entryData
radius: Theme.cornerRadius
color: {
if (isSelected) {
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.2)
}
return mouseArea.containsMouse ? Theme.primaryHover : Theme.primaryBackground
}
border.color: {
if (isSelected) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5)
}
return Theme.outlineStrong
}
border.width: isSelected ? 1.5 : 1
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingL
// Index indicator
Rectangle {
width: 24
height: 24
radius: 12
color: Theme.primarySelected
anchors.verticalCenter: parent.verticalCenter
StyledText {
anchors.centerIn: parent
text: entryIndex.toString()
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
color: Theme.primary
}
}
// Content area
Row {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 68
spacing: Theme.spacingM
// Thumbnail/Icon
ClipboardThumbnail {
width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
entryData: entry.entryData
entryType: entry.entryType
modal: entry.modal
listView: entry.listView
itemIndex: entry.itemIndex
}
// Text content
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM
spacing: Theme.spacingXS
StyledText {
text: {
switch (entryType) {
case "image":
return "Image • " + entryPreview
case "long_text":
return "Long Text"
default:
return "Text"
}
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
}
StyledText {
text: entryPreview
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: entryType === "long_text" ? 3 : 1
elide: Text.ElideRight
}
}
}
}
// Delete button
DankActionButton {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
iconName: "close"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
hoverColor: Theme.surfaceHover
onClicked: deleteRequested()
}
// Click area
MouseArea {
id: mouseArea
anchors.fill: parent
anchors.rightMargin: 40
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: copyRequested()
}
}

View File

@@ -0,0 +1,68 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modals.Clipboard
Item {
id: header
property int totalCount: 0
property bool showKeyboardHints: false
signal keyboardHintsToggled
signal clearAllClicked
signal closeClicked
height: ClipboardConstants.headerHeight
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "content_paste"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: `Clipboard History (${totalCount})`
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankActionButton {
iconName: "info"
iconSize: Theme.iconSize - 4
iconColor: showKeyboardHints ? Theme.primary : Theme.surfaceText
hoverColor: Theme.primaryHover
onClicked: keyboardHintsToggled()
}
DankActionButton {
iconName: "delete_sweep"
iconSize: Theme.iconSize
iconColor: Theme.surfaceText
hoverColor: Theme.surfaceHover
onClicked: clearAllClicked()
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
hoverColor: Theme.surfaceHover
onClicked: closeClicked()
}
}
}

View File

@@ -0,0 +1,95 @@
import QtQuick
import qs.Common
QtObject {
id: keyboardController
required property var modal
function reset() {
modal.selectedIndex = 0
modal.keyboardNavigationActive = false
modal.showKeyboardHints = false
}
function selectNext() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
return
}
modal.keyboardNavigationActive = true
modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.filteredClipboardModel.count - 1)
}
function selectPrevious() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
return
}
modal.keyboardNavigationActive = true
modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0)
}
function copySelected() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) {
return
}
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
modal.copyEntry(selectedEntry)
}
function deleteSelected() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) {
return
}
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
modal.deleteEntry(selectedEntry)
}
function handleKey(event) {
if (event.key === Qt.Key_Escape) {
if (modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = false
event.accepted = true
} else {
modal.hide()
event.accepted = true
}
} else if (event.key === Qt.Key_Down) {
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
event.accepted = true
} else {
selectNext()
event.accepted = true
}
} else if (event.key === Qt.Key_Up) {
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
event.accepted = true
} else if (modal.selectedIndex === 0) {
modal.keyboardNavigationActive = false
event.accepted = true
} else {
selectPrevious()
event.accepted = true
}
} else if (event.key === Qt.Key_Delete && (event.modifiers & Qt.ShiftModifier)) {
modal.clearAll()
modal.hide()
event.accepted = true
} else if (modal.keyboardNavigationActive) {
if ((event.key === Qt.Key_C && (event.modifiers & Qt.ControlModifier)) || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
copySelected()
event.accepted = true
} else if (event.key === Qt.Key_Delete) {
deleteSelected()
event.accepted = true
}
}
if (event.key === Qt.Key_F10) {
modal.showKeyboardHints = !modal.showKeyboardHints
event.accepted = true
}
}
}

View File

@@ -0,0 +1,42 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modals.Clipboard
Rectangle {
id: keyboardHints
height: ClipboardConstants.keyboardHintsHeight
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
border.color: Theme.primary
border.width: 2
opacity: visible ? 1 : 0
z: 100
Column {
anchors.centerIn: parent
spacing: 2
StyledText {
text: "↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: "Shift+Del: Clear All • Esc: Close"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}

View File

@@ -0,0 +1,76 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Widgets
import qs.Modals.Clipboard
DankListView {
id: clipboardListView
required property var clipboardModal
required property int selectedIndex
required property bool keyboardNavigationActive
function ensureVisible(index) {
if (index < 0 || index >= count) {
return
}
const itemHeight = ClipboardConstants.itemHeight + spacing
const itemY = index * itemHeight
const itemBottom = itemY + itemHeight
if (itemY < contentY) {
contentY = itemY
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height
}
}
clip: true
currentIndex: selectedIndex
spacing: Theme.spacingXS
interactive: true
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.DragAndOvershootBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
onCurrentIndexChanged: {
if (keyboardNavigationActive && currentIndex >= 0) {
ensureVisible(currentIndex)
}
}
StyledText {
text: "No clipboard entries found"
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: model.count === 0
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AlwaysOff
}
delegate: ClipboardEntry {
required property int index
required property var model
width: clipboardListView.width
height: ClipboardConstants.itemHeight
entryData: model.entry
entryIndex: index + 1
itemIndex: index
isSelected: clipboardListView.keyboardNavigationActive && index === clipboardListView.selectedIndex
modal: clipboardListView.clipboardModal
listView: clipboardListView
onCopyRequested: clipboardListView.clipboardModal.copyEntry(model.entry)
onDeleteRequested: clipboardListView.clipboardModal.deleteEntry(model.entry)
}
}

View File

@@ -0,0 +1,94 @@
import QtQuick
import Quickshell.Io
QtObject {
id: clipboardProcesses
required property var modal
required property var clipboardModel
required property var filteredClipboardModel
// Load clipboard entries
property var loadProcess: Process {
id: loadProcess
command: ["cliphist", "list"]
running: false
stdout: StdioCollector {
onStreamFinished: {
clipboardModel.clear()
const lines = text.trim().split('\n')
for (const line of lines) {
if (line.trim().length > 0) {
clipboardModel.append({
"entry": line
})
}
}
modal.updateFilteredModel()
}
}
}
// Delete single entry
property var deleteProcess: Process {
id: deleteProcess
property string deletedEntry: ""
running: false
onExited: exitCode => {
if (exitCode === 0) {
for (var i = 0; i < clipboardModel.count; i++) {
if (clipboardModel.get(i).entry === deleteProcess.deletedEntry) {
clipboardModel.remove(i)
break
}
}
for (var j = 0; j < filteredClipboardModel.count; j++) {
if (filteredClipboardModel.get(j).entry === deleteProcess.deletedEntry) {
filteredClipboardModel.remove(j)
break
}
}
modal.totalCount = filteredClipboardModel.count
if (filteredClipboardModel.count === 0) {
modal.keyboardNavigationActive = false
modal.selectedIndex = 0
} else if (modal.selectedIndex >= filteredClipboardModel.count) {
modal.selectedIndex = filteredClipboardModel.count - 1
}
} else {
console.warn("Failed to delete clipboard entry")
}
}
}
// Clear all entries
property var clearProcess: Process {
id: clearProcess
command: ["cliphist", "wipe"]
running: false
onExited: exitCode => {
if (exitCode === 0) {
clipboardModel.clear()
filteredClipboardModel.clear()
modal.totalCount = 0
}
}
}
function refresh() {
loadProcess.running = true
}
function deleteEntry(entry) {
deleteProcess.deletedEntry = entry
deleteProcess.command = ["sh", "-c", `echo '${entry.replace(/'/g, "'\\''")}' | cliphist delete`]
deleteProcess.running = true
}
function clearAll() {
clearProcess.running = true
}
}

View File

@@ -0,0 +1,174 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Io
import qs.Common
import qs.Widgets
import qs.Modals.Clipboard
Item {
id: thumbnail
required property string entryData
required property string entryType
required property var modal
required property var listView
required property int itemIndex
Image {
id: thumbnailImage
property string entryId: entryData.split('\t')[0]
property bool isVisible: false
property string cachedImageData: ""
property bool loadQueued: false
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
smooth: true
cache: false
visible: false
asynchronous: true
sourceSize.width: 128
sourceSize.height: 128
onCachedImageDataChanged: {
if (cachedImageData) {
source = ""
source = `data:image/png;base64,${cachedImageData}`
}
}
function tryLoadImage() {
if (!loadQueued && entryType === "image" && !cachedImageData) {
loadQueued = true
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++
imageLoader.running = true
} else {
retryTimer.restart()
}
}
}
Timer {
id: retryTimer
interval: ClipboardConstants.retryInterval
onTriggered: {
if (thumbnailImage.loadQueued && !imageLoader.running) {
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++
imageLoader.running = true
} else {
retryTimer.restart()
}
}
}
}
Component.onCompleted: {
if (entryType !== "image") {
return
}
// Check if item is visible on screen initially
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing)
const viewTop = listView.contentY
const viewBottom = viewTop + listView.height
isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom)
if (isVisible) {
tryLoadImage()
}
}
Connections {
target: listView
function onContentYChanged() {
if (entryType !== "image") {
return
}
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing)
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom)
if (nowVisible && !thumbnailImage.isVisible) {
thumbnailImage.isVisible = true
thumbnailImage.tryLoadImage()
}
}
}
Process {
id: imageLoader
running: false
command: ["sh", "-c", `cliphist decode ${thumbnailImage.entryId} | base64 -w 0`]
stdout: StdioCollector {
onStreamFinished: {
const imageData = text.trim()
if (imageData && imageData.length > 0) {
thumbnailImage.cachedImageData = imageData
}
}
}
onExited: exitCode => {
thumbnailImage.loadQueued = false
if (modal.activeImageLoads > 0) {
modal.activeImageLoads--
}
if (exitCode !== 0) {
console.warn("Failed to load clipboard image:", thumbnailImage.entryId)
}
}
}
}
// Rounded mask effect for images
MultiEffect {
anchors.fill: parent
anchors.margins: 2
source: thumbnailImage
maskEnabled: true
maskSource: clipboardCircularMask
visible: entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != ""
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Item {
id: clipboardCircularMask
width: ClipboardConstants.thumbnailSize - 4
height: ClipboardConstants.thumbnailSize - 4
layer.enabled: true
layer.smooth: true
visible: false
Rectangle {
anchors.fill: parent
radius: width / 2
color: "black"
antialiasing: true
}
}
// Fallback icon
DankIcon {
visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "")
name: {
if (entryType === "image") {
return "image"
}
if (entryType === "long_text") {
return "subject"
}
return "content_copy"
}
size: Theme.iconSize
color: Theme.primary
anchors.centerIn: parent
}
}

View File

@@ -1,17 +1,18 @@
pragma ComponentBehavior
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modals.Clipboard
DankModal {
id: clipboardHistoryModal
property int totalCount: 0
property var activeTheme: Theme
property var clipboardEntries: []
property string searchText: ""
property int selectedIndex: 0
@@ -31,14 +32,14 @@ DankModal {
})
} else {
const content = getEntryPreview(entry).toLowerCase()
if (content.includes(searchText.toLowerCase()))
if (content.includes(searchText.toLowerCase())) {
filteredClipboardModel.append({
"entry": entry
})
}
}
}
clipboardHistoryModal.totalCount = filteredClipboardModel.count
// Clamp selectedIndex to valid range
if (filteredClipboardModel.count === 0) {
keyboardNavigationActive = false
selectedIndex = 0
@@ -48,18 +49,17 @@ DankModal {
}
function toggle() {
if (shouldBeVisible)
if (shouldBeVisible) {
hide()
else
} else {
show()
}
}
function show() {
open()
clipboardHistoryModal.searchText = ""
clipboardHistoryModal.activeImageLoads = 0
initializeThumbnailSystem()
refreshClipboard()
keyboardController.reset()
@@ -75,213 +75,86 @@ DankModal {
close()
clipboardHistoryModal.searchText = ""
clipboardHistoryModal.activeImageLoads = 0
updateFilteredModel()
keyboardController.reset()
cleanupTempFiles()
}
function initializeThumbnailSystem() {}
function cleanupTempFiles() {
Quickshell.execDetached(["sh", "-c", "rm -f /tmp/clipboard_*.png"])
}
function generateThumbnails() {}
function refreshClipboard() {
clipboardProcess.running = true
clipboardProcesses.refresh()
}
function copyEntry(entry) {
const entryId = entry.split('\t')[0]
Quickshell.execDetached(
["sh", "-c", `cliphist decode ${entryId} | wl-copy`])
Quickshell.execDetached(["sh", "-c", `cliphist decode ${entryId} | wl-copy`])
ToastService.showInfo("Copied to clipboard")
hide()
}
function deleteEntry(entry) {
deleteProcess.deletedEntry = entry
deleteProcess.command = ["sh", "-c", `echo '${entry.replace(
/'/g, "'\\''")}' | cliphist delete`]
deleteProcess.running = true
clipboardProcesses.deleteEntry(entry)
}
function clearAll() {
clearProcess.running = true
clipboardProcesses.clearAll()
}
function getEntryPreview(entry) {
let content = entry.replace(/^\s*\d+\s+/, "")
if (content.includes("image/") || content.includes("binary data")
|| /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(content)) {
if (content.includes("image/") || content.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(content)) {
const dimensionMatch = content.match(/(\d+)x(\d+)/)
if (dimensionMatch)
if (dimensionMatch) {
return `Image ${dimensionMatch[1]}×${dimensionMatch[2]}`
}
const typeMatch = content.match(/\b(png|jpg|jpeg|gif|bmp|webp)\b/i)
if (typeMatch)
if (typeMatch) {
return `Image (${typeMatch[1].toUpperCase()})`
}
return "Image"
}
if (content.length > 100)
return content.substring(0, 100) + "..."
if (content.length > ClipboardConstants.previewLength) {
return content.substring(0, ClipboardConstants.previewLength) + "..."
}
return content
}
function getEntryType(entry) {
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))
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)
}
if (entry.length > ClipboardConstants.longTextThreshold) {
return "long_text"
}
return "text"
}
visible: false
width: 650
height: 550
width: ClipboardConstants.modalWidth
height: ClipboardConstants.modalHeight
backgroundColor: Theme.popupBackground()
cornerRadius: Theme.cornerRadius
borderColor: Theme.outlineMedium
borderWidth: 1
enableShadow: true
onBackgroundClicked: {
hide()
}
onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event)
}
content: clipboardContent
QtObject {
ClipboardKeyboardController {
id: keyboardController
function reset() {
selectedIndex = 0
keyboardNavigationActive = false
showKeyboardHints = false
if (typeof clipboardListView !== 'undefined' && clipboardListView)
clipboardListView.keyboardActive = false
}
function selectNext() {
if (filteredClipboardModel.count === 0)
return
keyboardNavigationActive = true
selectedIndex = Math.min(selectedIndex + 1,
filteredClipboardModel.count - 1)
}
function selectPrevious() {
if (filteredClipboardModel.count === 0)
return
keyboardNavigationActive = true
selectedIndex = Math.max(selectedIndex - 1, 0)
}
function copySelected() {
if (filteredClipboardModel.count === 0 || selectedIndex < 0
|| selectedIndex >= filteredClipboardModel.count)
return
var selectedEntry = filteredClipboardModel.get(selectedIndex).entry
copyEntry(selectedEntry)
}
function deleteSelected() {
if (filteredClipboardModel.count === 0 || selectedIndex < 0
|| selectedIndex >= filteredClipboardModel.count)
return
var selectedEntry = filteredClipboardModel.get(selectedIndex).entry
deleteEntry(selectedEntry)
}
function handleKey(event) {
if (event.key === Qt.Key_Escape) {
if (keyboardNavigationActive) {
keyboardNavigationActive = false
if (typeof clipboardListView !== 'undefined'
&& clipboardListView)
clipboardListView.keyboardActive = false
event.accepted = true
} else {
hide()
event.accepted = true
}
} else if (event.key === Qt.Key_Down) {
if (!keyboardNavigationActive) {
keyboardNavigationActive = true
selectedIndex = 0
if (typeof clipboardListView !== 'undefined'
&& clipboardListView)
clipboardListView.keyboardActive = true
event.accepted = true
} else {
selectNext()
event.accepted = true
}
} else if (event.key === Qt.Key_Up) {
if (!keyboardNavigationActive) {
keyboardNavigationActive = true
selectedIndex = 0
if (typeof clipboardListView !== 'undefined'
&& clipboardListView)
clipboardListView.keyboardActive = true
event.accepted = true
} else if (selectedIndex === 0) {
keyboardNavigationActive = false
if (typeof clipboardListView !== 'undefined'
&& clipboardListView)
clipboardListView.keyboardActive = false
event.accepted = true
} else {
selectPrevious()
event.accepted = true
}
} else if (event.key === Qt.Key_Delete
&& (event.modifiers & Qt.ShiftModifier)) {
clearAll()
hide()
event.accepted = true
} else if (keyboardNavigationActive) {
if ((event.key === Qt.Key_C
&& (event.modifiers & Qt.ControlModifier))
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter) {
copySelected()
event.accepted = true
} else if (event.key === Qt.Key_Delete) {
deleteSelected()
event.accepted = true
}
}
if (event.key === Qt.Key_F10) {
showKeyboardHints = !showKeyboardHints
event.accepted = true
}
}
modal: clipboardHistoryModal
}
ConfirmModal {
id: clearConfirmDialog
confirmButtonText: "Clear All"
confirmButtonColor: Theme.primary
onVisibleChanged: {
if (visible) {
clipboardHistoryModal.shouldHaveFocus = false
@@ -295,6 +168,9 @@ DankModal {
}
}
property alias filteredClipboardModel: filteredClipboardModel
property alias clipboardModel: clipboardModel
ListModel {
id: clipboardModel
}
@@ -303,78 +179,11 @@ DankModal {
id: filteredClipboardModel
}
Process {
id: clipboardProcess
command: ["cliphist", "list"]
running: false
stdout: StdioCollector {
onStreamFinished: {
clipboardModel.clear()
const lines = text.trim().split('\n')
for (const line of lines) {
if (line.trim().length > 0)
clipboardModel.append({
"entry": line
})
}
updateFilteredModel()
}
}
}
Process {
id: deleteProcess
property string deletedEntry: ""
running: false
onExited: exitCode => {
if (exitCode === 0) {
// Just remove the item from models instead of re-fetching everything
for (var i = 0; i < clipboardModel.count; i++) {
if (clipboardModel.get(
i).entry === deleteProcess.deletedEntry) {
clipboardModel.remove(i)
break
}
}
for (var j = 0; j < filteredClipboardModel.count; j++) {
if (filteredClipboardModel.get(
j).entry === deleteProcess.deletedEntry) {
filteredClipboardModel.remove(j)
break
}
}
clipboardHistoryModal.totalCount = filteredClipboardModel.count
// Clamp selectedIndex to valid range
if (filteredClipboardModel.count === 0) {
keyboardNavigationActive = false
selectedIndex = 0
} else if (selectedIndex >= filteredClipboardModel.count) {
selectedIndex = filteredClipboardModel.count - 1
}
} else {
console.warn("Failed to delete clipboard entry")
}
}
}
Process {
id: clearProcess
command: ["cliphist", "wipe"]
running: false
onExited: exitCode => {
if (exitCode === 0) {
clipboardModel.clear()
filteredClipboardModel.clear()
totalCount = 0
} else {
}
}
ClipboardProcesses {
id: clipboardProcesses
modal: clipboardHistoryModal
clipboardModel: clipboardModel
filteredClipboardModel: filteredClipboardModel
}
IpcHandler {
@@ -397,524 +206,10 @@ DankModal {
}
clipboardContent: Component {
Item {
id: clipboardContent
property alias searchField: searchField
anchors.fill: parent
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
focus: false
Item {
width: parent.width
height: 40
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "content_paste"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: `Clipboard History (${totalCount})`
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankActionButton {
iconName: "info"
iconSize: Theme.iconSize - 4
iconColor: showKeyboardHints ? Theme.primary : Theme.surfaceText
hoverColor: Theme.primaryHover
onClicked: {
showKeyboardHints = !showKeyboardHints
}
}
DankActionButton {
iconName: "delete_sweep"
iconSize: Theme.iconSize
iconColor: Theme.surfaceText
hoverColor: Theme.surfaceHover
onClicked: {
clearConfirmDialog.show(
"Clear All History?",
"This will permanently delete all clipboard history.",
function() {
clearAll()
hide()
},
function() {} // No action on cancel
)
}
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
hoverColor: Theme.surfaceHover
onClicked: hide()
}
}
}
DankTextField {
id: searchField
width: parent.width
placeholderText: ""
leftIconName: "search"
showClearButton: true
focus: true
ignoreLeftRightKeys: true
keyForwardTargets: [modalFocusScope]
onTextChanged: {
clipboardHistoryModal.searchText = text
updateFilteredModel()
}
Keys.onEscapePressed: function (event) {
hide()
event.accepted = true
}
Component.onCompleted: {
Qt.callLater(function () {
forceActiveFocus()
})
}
Connections {
target: clipboardHistoryModal
function onOpened() {
Qt.callLater(function () {
searchField.forceActiveFocus()
})
}
}
}
Rectangle {
width: parent.width
height: parent.height - 110
radius: Theme.cornerRadius
color: Theme.surfaceLight
border.color: Theme.outlineLight
border.width: 1
clip: true
DankListView {
id: clipboardListView
function ensureVisible(index) {
if (index < 0 || index >= count)
return
var itemHeight = 72 + spacing
var itemY = index * itemHeight
var itemBottom = itemY + itemHeight
if (itemY < contentY)
contentY = itemY
else if (itemBottom > contentY + height)
contentY = itemBottom - height
}
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
model: filteredClipboardModel
currentIndex: selectedIndex
spacing: Theme.spacingXS
interactive: true
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.DragAndOvershootBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
onCurrentIndexChanged: {
if (keyboardNavigationActive && currentIndex >= 0)
ensureVisible(currentIndex)
}
StyledText {
text: "No clipboard entries found"
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: filteredClipboardModel.count === 0
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AlwaysOff
}
delegate: Rectangle {
property string entryType: getEntryType(model.entry)
property string entryPreview: getEntryPreview(
model.entry)
property int entryIndex: index + 1
property string entryData: model.entry
property alias thumbnailImageSource: thumbnailImageSource
width: clipboardListView.width
height: 72
radius: Theme.cornerRadius
color: {
if (keyboardNavigationActive
&& index === selectedIndex)
return Qt.rgba(Theme.surfaceVariant.r,
Theme.surfaceVariant.g,
Theme.surfaceVariant.b, 0.2)
return mouseArea.containsMouse ? Theme.primaryHover : Theme.primaryBackground
}
border.color: {
if (keyboardNavigationActive
&& index === selectedIndex)
return Qt.rgba(Theme.primary.r,
Theme.primary.g,
Theme.primary.b, 0.5)
return Theme.outlineStrong
}
border.width: keyboardNavigationActive
&& index === selectedIndex ? 1.5 : 1
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingL
Rectangle {
width: 24
height: 24
radius: 12
color: Theme.primarySelected
anchors.verticalCenter: parent.verticalCenter
StyledText {
anchors.centerIn: parent
text: entryIndex.toString()
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
color: Theme.primary
}
}
Row {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 68
spacing: Theme.spacingM
Item {
width: entryType === "image" ? 48 : Theme.iconSize
height: entryType === "image" ? 48 : Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
Image {
id: thumbnailImageSource
property string entryId: model.entry.split(
'\t')[0]
property bool isVisible: false
property string cachedImageData: ""
property bool loadQueued: false
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
smooth: true
cache: false // Disable Qt's cache to control it ourselves
visible: false
asynchronous: true
sourceSize.width: 128
sourceSize.height: 128
onCachedImageDataChanged: {
if (cachedImageData) {
source = ""
source = `data:image/png;base64,${cachedImageData}`
}
}
function tryLoadImage() {
if (!loadQueued && entryType === "image" && !cachedImageData) {
loadQueued = true
if (clipboardHistoryModal.activeImageLoads < clipboardHistoryModal.maxConcurrentLoads) {
clipboardHistoryModal.activeImageLoads++
imageLoader.running = true
} else {
// Retry after delay
retryTimer.restart()
}
}
}
Timer {
id: retryTimer
interval: 50
onTriggered: {
if (thumbnailImageSource.loadQueued && !imageLoader.running) {
if (clipboardHistoryModal.activeImageLoads < clipboardHistoryModal.maxConcurrentLoads) {
clipboardHistoryModal.activeImageLoads++
imageLoader.running = true
} else {
retryTimer.restart()
}
}
}
}
Component.onCompleted: {
if (entryType !== "image") return
// Check if item is visible on screen initially
let itemY = index * (72 + clipboardListView.spacing)
let viewTop = clipboardListView.contentY
let viewBottom = viewTop + clipboardListView.height
isVisible = (itemY + 72 >= viewTop && itemY <= viewBottom)
if (isVisible) {
tryLoadImage()
}
}
Connections {
target: clipboardListView
function onContentYChanged() {
if (entryType !== "image") return
let itemY = index * (72 + clipboardListView.spacing)
let viewTop = clipboardListView.contentY - 100 // Preload slightly before visible
let viewBottom = viewTop + clipboardListView.height + 200
let nowVisible = (itemY + 72 >= viewTop && itemY <= viewBottom)
if (nowVisible && !thumbnailImageSource.isVisible) {
thumbnailImageSource.isVisible = true
thumbnailImageSource.tryLoadImage()
}
}
}
Process {
id: imageLoader
running: false
command: ["sh", "-c", `cliphist decode ${thumbnailImageSource.entryId} | base64 -w 0`]
stdout: StdioCollector {
onStreamFinished: {
let imageData = text.trim()
if (imageData && imageData.length > 0) {
thumbnailImageSource.cachedImageData = imageData
}
}
}
onExited: exitCode => {
thumbnailImageSource.loadQueued = false
if (clipboardHistoryModal.activeImageLoads > 0) {
clipboardHistoryModal.activeImageLoads--
}
if (exitCode !== 0) {
console.warn("Failed to load clipboard image:", thumbnailImageSource.entryId)
}
}
}
}
MultiEffect {
anchors.fill: parent
anchors.margins: 2
source: thumbnailImageSource
maskEnabled: true
maskSource: clipboardCircularMask
visible: entryType === "image"
&& thumbnailImageSource.status === Image.Ready
&& thumbnailImageSource.source != ""
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Item {
id: clipboardCircularMask
width: 48 - 4
height: 48 - 4
layer.enabled: true
layer.smooth: true
visible: false
Rectangle {
anchors.fill: parent
radius: width / 2
color: "black"
antialiasing: true
}
}
DankIcon {
visible: !(entryType === "image"
&& thumbnailImageSource.status === Image.Ready
&& thumbnailImageSource.source != "")
name: {
if (entryType === "image")
return "image"
if (entryType === "long_text")
return "subject"
return "content_copy"
}
size: Theme.iconSize
color: Theme.primary
anchors.centerIn: parent
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - (entryType === "image" ? 48 : Theme.iconSize) - Theme.spacingM
spacing: Theme.spacingXS
StyledText {
text: {
switch (entryType) {
case "image":
return "Image • " + entryPreview
case "long_text":
return "Long Text"
default:
return "Text"
}
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
}
StyledText {
id: contentText
text: entryPreview
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: entryType === "long_text" ? 3 : 1
elide: Text.ElideRight
}
}
}
}
DankActionButton {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
iconName: "close"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
hoverColor: Theme.surfaceHover
onClicked: {
deleteEntry(model.entry)
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
anchors.rightMargin: 40
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: copyEntry(model.entry)
}
}
}
}
Item {
width: parent.width
height: showKeyboardHints ? 80 + Theme.spacingL : 0
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
height: 80
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r,
Theme.surfaceContainer.g,
Theme.surfaceContainer.b, 0.95)
border.color: Theme.primary
border.width: 2
opacity: showKeyboardHints ? 1 : 0
z: 100
Column {
anchors.centerIn: parent
spacing: 2
StyledText {
text: "↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: "Shift+Del: Clear All • Esc: Close"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
ClipboardContent {
modal: clipboardHistoryModal
filteredModel: filteredClipboardModel
clearConfirmDialog: clearConfirmDialog
}
}
}