1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

clipboard: introduce native clipboard, clip-persist, clip-storage functionality

This commit is contained in:
bbedward
2025-12-11 09:41:07 -05:00
parent 7c88865d67
commit 6d62229b5f
41 changed files with 4372 additions and 547 deletions

View File

@@ -865,4 +865,32 @@ Item {
target: "plugins"
}
IpcHandler {
function open(): string {
if (!PopoutService.clipboardHistoryModal) {
return "CLIPBOARD_NOT_AVAILABLE";
}
PopoutService.clipboardHistoryModal.show();
return "CLIPBOARD_OPEN_SUCCESS";
}
function close(): string {
if (!PopoutService.clipboardHistoryModal) {
return "CLIPBOARD_NOT_AVAILABLE";
}
PopoutService.clipboardHistoryModal.hide();
return "CLIPBOARD_CLOSE_SUCCESS";
}
function toggle(): string {
if (!PopoutService.clipboardHistoryModal) {
return "CLIPBOARD_NOT_AVAILABLE";
}
PopoutService.clipboardHistoryModal.toggle();
return "CLIPBOARD_TOGGLE_SUCCESS";
}
target: "clipboard"
}
}

View File

@@ -1,14 +1,12 @@
import QtQuick
import Quickshell
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
@@ -22,7 +20,6 @@ Item {
spacing: Theme.spacingL
focus: false
// Header
ClipboardHeader {
id: header
width: parent.width
@@ -31,14 +28,13 @@ Item {
onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
onClearAllClicked: {
clearConfirmDialog.show(I18n.tr("Clear All History?"), I18n.tr("This will permanently delete all clipboard history."), function () {
modal.clearAll()
modal.hide()
}, function () {})
modal.clearAll();
modal.hide();
}, function () {});
}
onCloseClicked: modal.hide()
}
// Search Field
DankTextField {
id: searchField
width: parent.width
@@ -49,30 +45,29 @@ Item {
ignoreTabKeys: true
keyForwardTargets: [modal.modalFocusScope]
onTextChanged: {
modal.searchText = text
modal.updateFilteredModel()
modal.searchText = text;
modal.updateFilteredModel();
}
Keys.onEscapePressed: function (event) {
modal.hide()
event.accepted = true
modal.hide();
event.accepted = true;
}
Component.onCompleted: {
Qt.callLater(function () {
forceActiveFocus()
})
forceActiveFocus();
});
}
Connections {
target: modal
function onOpened() {
Qt.callLater(function () {
searchField.forceActiveFocus()
})
searchField.forceActiveFocus();
});
}
}
}
// List Container
Rectangle {
width: parent.width
height: parent.height - ClipboardConstants.headerHeight - 70
@@ -83,7 +78,10 @@ Item {
DankListView {
id: clipboardListView
anchors.fill: parent
model: filteredModel
model: ScriptModel {
values: clipboardContent.modal.clipboardEntries
objectProp: "id"
}
currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
spacing: Theme.spacingXS
@@ -97,21 +95,21 @@ Item {
function ensureVisible(index) {
if (index < 0 || index >= count) {
return
return;
}
const itemHeight = ClipboardConstants.itemHeight + spacing
const itemY = index * itemHeight
const itemBottom = itemY + itemHeight
const itemHeight = ClipboardConstants.itemHeight + spacing;
const itemY = index * itemHeight;
const itemBottom = itemY + itemHeight;
if (itemY < contentY) {
contentY = itemY
contentY = itemY;
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height
contentY = itemBottom - height;
}
}
onCurrentIndexChanged: {
if (clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && currentIndex >= 0) {
ensureVisible(currentIndex)
if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) {
ensureVisible(currentIndex);
}
}
@@ -120,28 +118,27 @@ Item {
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: filteredModel.count === 0
visible: clipboardContent.modal.clipboardEntries.length === 0
}
delegate: ClipboardEntry {
required property int index
required property var model
required property var modelData
width: clipboardListView.width
height: ClipboardConstants.itemHeight
entryData: model.entry
entry: modelData
entryIndex: index + 1
itemIndex: index
isSelected: clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
modal: clipboardContent.modal
listView: clipboardListView
onCopyRequested: clipboardContent.modal.copyEntry(model.entry)
onDeleteRequested: clipboardContent.modal.deleteEntry(model.entry)
onCopyRequested: clipboardContent.modal.copyEntry(modelData)
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
}
}
}
// Spacer for keyboard hints
Item {
width: parent.width
height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingL : 0
@@ -155,7 +152,6 @@ Item {
}
}
// Keyboard Hints Overlay
ClipboardKeyboardHints {
anchors.bottom: parent.bottom
anchors.left: parent.left

View File

@@ -1,14 +1,11 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Io
import qs.Common
import qs.Widgets
import qs.Modals.Clipboard
Rectangle {
id: entry
id: root
required property string entryData
required property var entry
required property int entryIndex
required property int itemIndex
required property bool isSelected
@@ -18,15 +15,15 @@ Rectangle {
signal copyRequested
signal deleteRequested
readonly property string entryType: modal ? modal.getEntryType(entryData) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entryData) : entryData
readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
radius: Theme.cornerRadius
color: {
if (isSelected) {
return Theme.primaryPressed
return Theme.primaryPressed;
}
return mouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
return mouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency);
}
Row {
@@ -35,7 +32,6 @@ Rectangle {
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingL
// Index indicator
Rectangle {
width: 24
height: 24
@@ -52,25 +48,22 @@ Rectangle {
}
}
// 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
entry: root.entry
entryType: root.entryType
modal: root.modal
listView: root.listView
itemIndex: root.itemIndex
}
// Text content
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM
@@ -80,11 +73,11 @@ Rectangle {
text: {
switch (entryType) {
case "image":
return I18n.tr("Image") + " • " + entryPreview
return I18n.tr("Image") + " • " + entryPreview;
case "long_text":
return I18n.tr("Long Text")
return I18n.tr("Long Text");
default:
return I18n.tr("Text")
return I18n.tr("Text");
}
}
font.pixelSize: Theme.fontSizeSmall
@@ -107,7 +100,6 @@ Rectangle {
}
}
// Delete button
DankActionButton {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
@@ -118,7 +110,6 @@ Rectangle {
onClicked: deleteRequested()
}
// Click area
MouseArea {
id: mouseArea
anchors.fill: parent

View File

@@ -1,9 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import qs.Common
import qs.Modals.Common
import qs.Services
@@ -27,33 +25,27 @@ DankModal {
property Component clipboardContent
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: DMSService.isConnected && DMSService.capabilities.includes("clipboard")
function updateFilteredModel() {
filteredClipboardModel.clear();
for (var i = 0; i < clipboardModel.count; i++) {
const entry = clipboardModel.get(i).entry;
if (searchText.trim().length === 0) {
filteredClipboardModel.append({
"entry": entry
});
} else {
const content = getEntryPreview(entry).toLowerCase();
if (content.includes(searchText.toLowerCase())) {
filteredClipboardModel.append({
"entry": entry
});
}
}
const query = searchText.trim();
if (query.length === 0) {
clipboardEntries = internalEntries;
} else {
const lowerQuery = query.toLowerCase();
clipboardEntries = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery));
}
clipboardHistoryModal.totalCount = filteredClipboardModel.count;
if (filteredClipboardModel.count === 0) {
totalCount = clipboardEntries.length;
if (clipboardEntries.length === 0) {
keyboardNavigationActive = false;
selectedIndex = 0;
} else if (selectedIndex >= filteredClipboardModel.count) {
selectedIndex = filteredClipboardModel.count - 1;
} else if (selectedIndex >= clipboardEntries.length) {
selectedIndex = clipboardEntries.length - 1;
}
}
property var internalEntries: []
function toggle() {
if (shouldBeVisible) {
hide();
@@ -63,15 +55,19 @@ DankModal {
}
function show() {
if (!clipboardAvailable) {
ToastService.showError(I18n.tr("Clipboard service not available"));
return;
}
open();
clipboardHistoryModal.searchText = "";
clipboardHistoryModal.activeImageLoads = 0;
clipboardHistoryModal.shouldHaveFocus = true;
searchText = "";
activeImageLoads = 0;
shouldHaveFocus = true;
refreshClipboard();
keyboardController.reset();
Qt.callLater(function () {
if (contentLoader.item && contentLoader.item.searchField) {
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
}
@@ -80,60 +76,90 @@ DankModal {
function hide() {
close();
clipboardHistoryModal.searchText = "";
clipboardHistoryModal.activeImageLoads = 0;
updateFilteredModel();
searchText = "";
activeImageLoads = 0;
internalEntries = [];
clipboardEntries = [];
keyboardController.reset();
cleanupTempFiles();
}
function cleanupTempFiles() {
Quickshell.execDetached(["sh", "-c", "rm -f /tmp/clipboard_*.png"]);
}
function refreshClipboard() {
clipboardProcesses.refresh();
DMSService.sendRequest("clipboard.getHistory", null, function (response) {
if (response.error) {
console.warn("ClipboardHistoryModal: Failed to get history:", response.error);
return;
}
internalEntries = response.result || [];
updateFilteredModel();
});
}
function copyEntry(entry) {
const entryId = entry.split('\t')[0];
Quickshell.execDetached(["sh", "-c", `cliphist decode ${entryId} | wl-copy`]);
ToastService.showInfo(I18n.tr("Copied to clipboard"));
hide();
DMSService.sendRequest("clipboard.getEntry", {
"id": entry.id
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to copy entry"));
return;
}
const fullEntry = response.result;
if (fullEntry.isImage) {
ToastService.showInfo(I18n.tr("Image copied to clipboard"));
} else {
DMSService.sendRequest("clipboard.copy", {
"text": fullEntry.data
}, function (copyResponse) {
if (copyResponse.error) {
ToastService.showError(I18n.tr("Failed to copy"));
return;
}
ToastService.showInfo(I18n.tr("Copied to clipboard"));
});
}
hide();
});
}
function deleteEntry(entry) {
clipboardProcesses.deleteEntry(entry);
DMSService.sendRequest("clipboard.deleteEntry", {
"id": entry.id
}, function (response) {
if (response.error) {
console.warn("ClipboardHistoryModal: Failed to delete entry:", response.error);
return;
}
internalEntries = internalEntries.filter(e => e.id !== entry.id);
updateFilteredModel();
if (clipboardEntries.length === 0) {
keyboardNavigationActive = false;
selectedIndex = 0;
} else if (selectedIndex >= clipboardEntries.length) {
selectedIndex = clipboardEntries.length - 1;
}
});
}
function clearAll() {
clipboardProcesses.clearAll();
DMSService.sendRequest("clipboard.clearHistory", null, function (response) {
if (response.error) {
console.warn("ClipboardHistoryModal: Failed to clear history:", response.error);
return;
}
internalEntries = [];
clipboardEntries = [];
totalCount = 0;
});
}
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)) {
const dimensionMatch = content.match(/(\d+)x(\d+)/);
if (dimensionMatch) {
return `Image ${dimensionMatch[1]}×${dimensionMatch[2]}`;
}
const typeMatch = content.match(/\b(png|jpg|jpeg|gif|bmp|webp)\b/i);
if (typeMatch) {
return `Image (${typeMatch[1].toUpperCase()})`;
}
return "Image";
}
if (content.length > ClipboardConstants.previewLength) {
return content.substring(0, ClipboardConstants.previewLength) + "...";
}
return content;
return entry.preview || "";
}
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.isImage) {
return "image";
}
if (entry.length > ClipboardConstants.longTextThreshold) {
if (entry.size > ClipboardConstants.longTextThreshold) {
return "long_text";
}
return "text";
@@ -168,55 +194,18 @@ DankModal {
} else if (clipboardHistoryModal.shouldBeVisible) {
clipboardHistoryModal.shouldHaveFocus = true;
clipboardHistoryModal.modalFocusScope.forceActiveFocus();
if (clipboardHistoryModal.contentLoader.item && clipboardHistoryModal.contentLoader.item.searchField) {
if (clipboardHistoryModal.contentLoader.item?.searchField) {
clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus();
}
}
}
}
property alias filteredClipboardModel: filteredClipboardModel
property alias clipboardModel: clipboardModel
property var confirmDialog: clearConfirmDialog
ListModel {
id: clipboardModel
}
ListModel {
id: filteredClipboardModel
}
ClipboardProcesses {
id: clipboardProcesses
modal: clipboardHistoryModal
clipboardModel: clipboardModel
filteredClipboardModel: filteredClipboardModel
}
IpcHandler {
function open(): string {
clipboardHistoryModal.show();
return "CLIPBOARD_OPEN_SUCCESS";
}
function close(): string {
clipboardHistoryModal.hide();
return "CLIPBOARD_CLOSE_SUCCESS";
}
function toggle(): string {
clipboardHistoryModal.toggle();
return "CLIPBOARD_TOGGLE_SUCCESS";
}
target: "clipboard"
}
clipboardContent: Component {
ClipboardContent {
modal: clipboardHistoryModal
filteredModel: filteredClipboardModel
clearConfirmDialog: clipboardHistoryModal.confirmDialog
}
}

View File

@@ -1,5 +1,4 @@
import QtQuick
import qs.Common
QtObject {
id: keyboardController
@@ -7,125 +6,133 @@ QtObject {
required property var modal
function reset() {
modal.selectedIndex = 0
modal.keyboardNavigationActive = false
modal.showKeyboardHints = false
modal.selectedIndex = 0;
modal.keyboardNavigationActive = false;
modal.showKeyboardHints = false;
}
function selectNext() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
return
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) {
return;
}
modal.keyboardNavigationActive = true
modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.filteredClipboardModel.count - 1)
modal.keyboardNavigationActive = true;
modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.clipboardEntries.length - 1);
}
function selectPrevious() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
return
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) {
return;
}
modal.keyboardNavigationActive = true
modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0)
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
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) {
return;
}
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
modal.copyEntry(selectedEntry)
const selectedEntry = modal.clipboardEntries[modal.selectedIndex];
modal.copyEntry(selectedEntry);
}
function deleteSelected() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) {
return
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) {
return;
}
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
modal.deleteEntry(selectedEntry)
const selectedEntry = modal.clipboardEntries[modal.selectedIndex];
modal.deleteEntry(selectedEntry);
}
function handleKey(event) {
if (event.key === Qt.Key_Escape) {
switch (event.key) {
case Qt.Key_Escape:
if (modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = false
event.accepted = true
modal.keyboardNavigationActive = false;
} else {
modal.hide()
event.accepted = true
modal.hide();
}
} else if (event.key === Qt.Key_Down || event.key === Qt.Key_Tab) {
event.accepted = true;
return;
case Qt.Key_Down:
case Qt.Key_Tab:
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
event.accepted = true
modal.keyboardNavigationActive = true;
modal.selectedIndex = 0;
} else {
selectNext()
event.accepted = true
selectNext();
}
} else if (event.key === Qt.Key_Up || event.key === Qt.Key_Backtab) {
event.accepted = true;
return;
case Qt.Key_Up:
case Qt.Key_Backtab:
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
event.accepted = true
modal.keyboardNavigationActive = true;
modal.selectedIndex = 0;
} else if (modal.selectedIndex === 0) {
modal.keyboardNavigationActive = false
event.accepted = true
modal.keyboardNavigationActive = false;
} else {
selectPrevious()
event.accepted = true
selectPrevious();
}
} else if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) {
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
} else {
selectNext()
}
event.accepted = true
} else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) {
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
} else if (modal.selectedIndex === 0) {
modal.keyboardNavigationActive = false
} else {
selectPrevious()
}
event.accepted = true
} else if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) {
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
} else {
selectNext()
}
event.accepted = true
} else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) {
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true
modal.selectedIndex = 0
} else if (modal.selectedIndex === 0) {
modal.keyboardNavigationActive = false
} 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
event.accepted = true;
return;
case Qt.Key_F10:
modal.showKeyboardHints = !modal.showKeyboardHints;
event.accepted = true;
return;
}
if (event.modifiers & Qt.ControlModifier) {
switch (event.key) {
case Qt.Key_N:
case Qt.Key_J:
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true;
modal.selectedIndex = 0;
} else {
selectNext();
}
event.accepted = true;
return;
case Qt.Key_P:
case Qt.Key_K:
if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true;
modal.selectedIndex = 0;
} else if (modal.selectedIndex === 0) {
modal.keyboardNavigationActive = false;
} else {
selectPrevious();
}
event.accepted = true;
return;
case Qt.Key_C:
if (modal.keyboardNavigationActive) {
copySelected();
event.accepted = true;
}
return;
}
}
if (event.key === Qt.Key_F10) {
modal.showKeyboardHints = !modal.showKeyboardHints
event.accepted = true
if (event.modifiers & Qt.ShiftModifier && event.key === Qt.Key_Delete) {
modal.clearAll();
modal.hide();
event.accepted = true;
return;
}
if (modal.keyboardNavigationActive) {
switch (event.key) {
case Qt.Key_Return:
case Qt.Key_Enter:
copySelected();
event.accepted = true;
return;
case Qt.Key_Delete:
deleteSelected();
event.accepted = true;
return;
}
}
}
}

View File

@@ -1,7 +1,6 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modals.Clipboard
Rectangle {
id: keyboardHints

View File

@@ -1,94 +0,0 @@
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

@@ -1,14 +1,13 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modals.Clipboard
Item {
id: thumbnail
required property string entryData
required property var entry
required property string entryType
required property var modal
required property var listView
@@ -17,13 +16,12 @@ Item {
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: ""
source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : ""
fillMode: Image.PreserveAspectCrop
smooth: true
cache: false
@@ -32,53 +30,66 @@ Item {
sourceSize.width: 128
sourceSize.height: 128
onCachedImageDataChanged: {
if (cachedImageData) {
source = ""
source = `data:image/png;base64,${cachedImageData}`
function tryLoadImage() {
if (loadQueued || entryType !== "image" || cachedImageData) {
return;
}
loadQueued = true;
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++;
loadImage();
} else {
retryTimer.restart();
}
}
function tryLoadImage() {
if (!loadQueued && entryType === "image" && !cachedImageData) {
loadQueued = true
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++
imageLoader.running = true
} else {
retryTimer.restart()
function loadImage() {
DMSService.sendRequest("clipboard.getEntry", {
"id": entry.id
}, function (response) {
loadQueued = false;
if (modal.activeImageLoads > 0) {
modal.activeImageLoads--;
}
}
if (response.error) {
console.warn("ClipboardThumbnail: Failed to load image:", entry.id);
return;
}
const data = response.result?.data;
if (data) {
cachedImageData = data;
}
});
}
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()
}
if (!thumbnailImage.loadQueued) {
return;
}
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++;
thumbnailImage.loadImage();
} else {
retryTimer.restart();
}
}
}
Component.onCompleted: {
if (entryType !== "image") {
return
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)
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()
tryLoadImage();
}
}
@@ -86,48 +97,22 @@ Item {
target: listView
function onContentYChanged() {
if (entryType !== "image") {
return
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)
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()
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
@@ -155,17 +140,17 @@ Item {
}
}
// Fallback icon
DankIcon {
visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "")
name: {
if (entryType === "image") {
return "image"
switch (entryType) {
case "image":
return "image";
case "long_text":
return "subject";
default:
return "content_copy";
}
if (entryType === "long_text") {
return "subject"
}
return "content_copy"
}
size: Theme.iconSize
color: Theme.primary

View File

@@ -399,5 +399,21 @@ FocusScope {
}
}
}
Loader {
id: clipboardLoader
anchors.fill: parent
active: root.currentIndex === 23
visible: active
focus: active
sourceComponent: ClipboardTab {}
onActiveChanged: {
if (active && item) {
Qt.callLater(() => item.forceActiveFocus());
}
}
}
}
}

View File

@@ -144,12 +144,6 @@ Rectangle {
"tabIndex": 2,
"shortcutsOnly": true
},
{
"id": "displays",
"text": I18n.tr("Displays"),
"icon": "monitor",
"tabIndex": 6
},
{
"id": "network",
"text": I18n.tr("Network"),
@@ -158,11 +152,32 @@ Rectangle {
"dmsOnly": true
},
{
"id": "printers",
"text": I18n.tr("Printers"),
"icon": "print",
"tabIndex": 8,
"cupsOnly": true
"id": "system",
"text": I18n.tr("System"),
"icon": "computer",
"collapsedByDefault": true,
"children": [
{
"id": "displays",
"text": I18n.tr("Displays"),
"icon": "monitor",
"tabIndex": 6
},
{
"id": "printers",
"text": I18n.tr("Printers"),
"icon": "print",
"tabIndex": 8,
"cupsOnly": true
},
{
"id": "clipboard",
"text": I18n.tr("Clipboard"),
"icon": "content_paste",
"tabIndex": 23,
"clipboardOnly": true
}
]
},
{
"id": "power_security",
@@ -213,6 +228,8 @@ Rectangle {
return false;
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
return false;
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
return false;
return true;
}

View File

@@ -0,0 +1,273 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
property var config: ({})
property bool configLoaded: false
property bool configError: false
property bool saving: false
readonly property var maxHistoryOptions: [
{ text: "25", value: 25 },
{ text: "50", value: 50 },
{ text: "100", value: 100 },
{ text: "200", value: 200 },
{ text: "500", value: 500 },
{ text: "1000", value: 1000 }
]
readonly property var maxEntrySizeOptions: [
{ text: "1 MB", value: 1048576 },
{ text: "2 MB", value: 2097152 },
{ text: "5 MB", value: 5242880 },
{ text: "10 MB", value: 10485760 },
{ text: "20 MB", value: 20971520 },
{ text: "50 MB", value: 52428800 }
]
readonly property var autoClearOptions: [
{ text: I18n.tr("Never"), value: 0 },
{ text: I18n.tr("1 day"), value: 1 },
{ text: I18n.tr("3 days"), value: 3 },
{ text: I18n.tr("7 days"), value: 7 },
{ text: I18n.tr("14 days"), value: 14 },
{ text: I18n.tr("30 days"), value: 30 },
{ text: I18n.tr("90 days"), value: 90 }
]
function getMaxHistoryText(value) {
for (let opt of maxHistoryOptions) {
if (opt.value === value)
return opt.text;
}
return String(value);
}
function getMaxEntrySizeText(value) {
for (let opt of maxEntrySizeOptions) {
if (opt.value === value)
return opt.text;
}
const mb = Math.round(value / 1048576);
return mb + " MB";
}
function getAutoClearText(value) {
for (let opt of autoClearOptions) {
if (opt.value === value)
return opt.text;
}
return value + " " + I18n.tr("days");
}
function loadConfig() {
configLoaded = false;
configError = false;
DMSService.sendRequest("clipboard.getConfig", null, response => {
if (response.error) {
configError = true;
return;
}
config = response.result || {};
configLoaded = true;
});
}
function saveConfig(key, value) {
const params = {};
params[key] = value;
saving = true;
DMSService.sendRequest("clipboard.setConfig", params, response => {
saving = false;
if (response.error) {
ToastService.showError(I18n.tr("Failed to save clipboard setting"));
return;
}
loadConfig();
});
}
Component.onCompleted: {
if (DMSService.isConnected)
loadConfig();
}
Connections {
target: DMSService
function onIsConnectedChanged() {
if (DMSService.isConnected)
loadConfig();
}
}
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: mainColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: mainColumn
width: Math.min(550, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
Rectangle {
width: parent.width
height: warningContent.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12)
visible: !DMSService.isConnected || configError
Row {
id: warningContent
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "info"
size: Theme.iconSizeSmall
color: Theme.warning
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
font.pixelSize: Theme.fontSizeSmall
text: !DMSService.isConnected
? I18n.tr("DMS service is not connected. Clipboard settings are unavailable.")
: I18n.tr("Failed to load clipboard configuration.")
wrapMode: Text.WordWrap
width: parent.width - Theme.iconSizeSmall - Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
}
}
}
SettingsCard {
tab: "clipboard"
tags: ["clipboard", "history", "limit"]
title: I18n.tr("History Settings")
iconName: "history"
visible: configLoaded
SettingsDropdownRow {
tab: "clipboard"
tags: ["clipboard", "history", "max", "limit"]
settingKey: "maxHistory"
text: I18n.tr("Maximum History")
description: I18n.tr("Maximum number of clipboard entries to keep")
currentValue: root.getMaxHistoryText(root.config.maxHistory ?? 100)
options: root.maxHistoryOptions.map(opt => opt.text)
onValueChanged: value => {
for (let opt of root.maxHistoryOptions) {
if (opt.text === value) {
root.saveConfig("maxHistory", opt.value);
return;
}
}
}
}
SettingsDropdownRow {
tab: "clipboard"
tags: ["clipboard", "entry", "size", "limit"]
settingKey: "maxEntrySize"
text: I18n.tr("Maximum Entry Size")
description: I18n.tr("Maximum size per clipboard entry")
currentValue: root.getMaxEntrySizeText(root.config.maxEntrySize ?? 5242880)
options: root.maxEntrySizeOptions.map(opt => opt.text)
onValueChanged: value => {
for (let opt of root.maxEntrySizeOptions) {
if (opt.text === value) {
root.saveConfig("maxEntrySize", opt.value);
return;
}
}
}
}
SettingsDropdownRow {
tab: "clipboard"
tags: ["clipboard", "auto", "clear", "days"]
settingKey: "autoClearDays"
text: I18n.tr("Auto-Clear After")
description: I18n.tr("Automatically delete entries older than this")
currentValue: root.getAutoClearText(root.config.autoClearDays ?? 0)
options: root.autoClearOptions.map(opt => opt.text)
onValueChanged: value => {
for (let opt of root.autoClearOptions) {
if (opt.text === value) {
root.saveConfig("autoClearDays", opt.value);
return;
}
}
}
}
}
SettingsCard {
tab: "clipboard"
tags: ["clipboard", "behavior"]
title: I18n.tr("Behavior")
iconName: "settings"
visible: configLoaded
SettingsToggleRow {
tab: "clipboard"
tags: ["clipboard", "clear", "startup"]
settingKey: "clearAtStartup"
text: I18n.tr("Clear at Startup")
description: I18n.tr("Clear all history when server starts")
checked: root.config.clearAtStartup ?? false
onToggled: checked => root.saveConfig("clearAtStartup", checked)
}
}
SettingsCard {
tab: "clipboard"
tags: ["clipboard", "advanced", "disable"]
title: I18n.tr("Advanced")
iconName: "tune"
collapsible: true
expanded: false
visible: configLoaded
SettingsToggleRow {
tab: "clipboard"
tags: ["clipboard", "disable", "manager"]
settingKey: "disabled"
text: I18n.tr("Disable Clipboard Manager")
description: I18n.tr("Disable clipboard manager entirely (requires restart)")
checked: root.config.disabled ?? false
onToggled: checked => root.saveConfig("disabled", checked)
}
SettingsToggleRow {
tab: "clipboard"
tags: ["clipboard", "disable", "history"]
settingKey: "disableHistory"
text: I18n.tr("Disable History Persistence")
description: I18n.tr("Clipboard works but nothing saved to disk")
checked: root.config.disableHistory ?? false
onToggled: checked => root.saveConfig("disableHistory", checked)
}
SettingsToggleRow {
tab: "clipboard"
tags: ["clipboard", "disable", "persist", "ownership"]
settingKey: "disablePersist"
text: I18n.tr("Disable Clipboard Ownership")
description: I18n.tr("Don't preserve clipboard when apps close")
checked: root.config.disablePersist ?? false
onToggled: checked => root.saveConfig("disablePersist", checked)
}
}
}
}
}

View File

@@ -7,6 +7,7 @@ The `PopoutService` singleton provides plugins with access to all DankMaterialSh
## Automatic Injection
The `popoutService` property is automatically injected into:
- Widget plugins (loaded in DankBar)
- Daemon plugins (background services)
- Plugin settings components
@@ -23,36 +24,36 @@ property var popoutService: null
### Popouts (DankPopout-based)
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
| Component | Open | Close | Toggle |
| ------------------- | -------------------------- | --------------------------- | ---------------------------- |
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
### Modals (DankModal-based)
| Modal | Show | Hide | Notes |
|-------|------|------|-------|
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Cliphist integration |
| Spotlight | `openSpotlight()` | `closeSpotlight()` | Command launcher |
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Fullscreen version, has `toggleProcessListModal()` |
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network authentication |
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
| Modal | Show | Hide | Notes |
| ------------------ | ------------------------- | ------------------------- | -------------------------------------------------- |
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
| Spotlight | `openSpotlight()` | `closeSpotlight()` | Command launcher |
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Fullscreen version, has `toggleProcessListModal()` |
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network authentication |
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
### Slideouts
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
| Component | Open | Close | Toggle |
| --------- | --------------- | ---------------- | ----------------- |
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
## Usage Examples
@@ -205,6 +206,7 @@ LazyLoader {
The service is injected in three locations:
1. **DMSShell.qml** (daemon plugins):
```qml
Instantiator {
delegate: Loader {
@@ -218,6 +220,7 @@ Instantiator {
```
2. **WidgetHost.qml** (widget plugins):
```qml
onLoaded: {
if (item.popoutService !== undefined) {
@@ -227,6 +230,7 @@ onLoaded: {
```
3. **CenterSection.qml** (center widgets):
```qml
onLoaded: {
if (item.popoutService !== undefined) {
@@ -236,6 +240,7 @@ onLoaded: {
```
4. **PluginsTab.qml** (settings):
```qml
onLoaded: {
if (item && typeof PopoutService !== "undefined") {
@@ -247,11 +252,13 @@ onLoaded: {
## Best Practices
1. **Use Optional Chaining**: Always use `?.` to handle null cases
```qml
popoutService?.toggleControlCenter()
```
2. **Check Availability**: Some popouts may not be available
```qml
if (popoutService && popoutService.controlCenterPopout) {
popoutService.toggleControlCenter()
@@ -261,6 +268,7 @@ onLoaded: {
3. **Lazy Loading**: First access may activate lazy loaders - this is normal
4. **Feature Detection**: Some popouts require specific features
```qml
if (BatteryService.batteryAvailable) {
popoutService?.openBattery()
@@ -272,6 +280,7 @@ onLoaded: {
## Example Plugin
See `PLUGINS/PopoutControlExample/` for a complete working example that demonstrates:
- Widget creation with popout controls
- Menu-based popout selection
- Proper service usage

View File

@@ -24,6 +24,7 @@ Singleton {
readonly property string socketPath: Quickshell.env("DMS_SOCKET")
property var pendingRequests: ({})
property var clipboardRequestIds: ({})
property int requestIdCounter: 0
property bool shownOutdatedError: false
property string updateCommand: "dms update"
@@ -179,17 +180,19 @@ Singleton {
parser: SplitParser {
onRead: line => {
if (!line || line.length === 0) {
if (!line || line.length === 0)
return;
}
console.log("DMSService: Request socket <<", line);
try {
const response = JSON.parse(line);
const isClipboard = clipboardRequestIds[response.id];
if (isClipboard)
delete clipboardRequestIds[response.id];
else
console.log("DMSService: Request socket <<", line);
handleResponse(response);
} catch (e) {
console.warn("DMSService: Failed to parse request response:", line, e);
console.warn("DMSService: Failed to parse request response");
}
}
}
@@ -209,17 +212,16 @@ Singleton {
parser: SplitParser {
onRead: line => {
if (!line || line.length === 0) {
if (!line || line.length === 0)
return;
}
console.log("DMSService: Subscribe socket <<", line);
try {
const response = JSON.parse(line);
if (!line.includes("clipboard"))
console.log("DMSService: Subscribe socket <<", line);
handleSubscriptionEvent(response);
} catch (e) {
console.warn("DMSService: Failed to parse subscription event:", line, e);
console.warn("DMSService: Failed to parse subscription event");
}
}
}
@@ -394,11 +396,14 @@ Singleton {
request.params = params;
}
if (callback) {
if (callback)
pendingRequests[id] = callback;
}
console.log("DMSService.sendRequest: Sending request id=" + id + " method=" + method);
if (method.startsWith("clipboard")) {
clipboardRequestIds[id] = true;
} else {
console.log("DMSService.sendRequest: Sending request id=" + id + " method=" + method);
}
requestSocket.send(request);
}