diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index a97d6894..ba7c9b3c 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -527,6 +527,20 @@ Item { } } + LazyLoader { + id: clipboardHistoryPopoutLoader + + active: false + + ClipboardHistoryPopout { + id: clipboardHistoryPopout + + Component.onCompleted: { + PopoutService.clipboardHistoryPopout = clipboardHistoryPopout; + } + } + } + ClipboardHistoryModal { id: clipboardHistoryModalPopup @@ -553,7 +567,7 @@ Item { viewMode: SettingsData.appPickerViewMode || "grid" onViewModeChanged: { - SettingsData.set("appPickerViewMode", viewMode) + SettingsData.set("appPickerViewMode", viewMode); } function shellEscape(str) { diff --git a/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml index 123e1707..83db9794 100644 --- a/quickshell/Modals/Clipboard/ClipboardContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardContent.qml @@ -15,7 +15,10 @@ Item { anchors.fill: parent Column { - anchors.fill: parent + id: headerColumn + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right anchors.margins: Theme.spacingM spacing: Theme.spacingM focus: false @@ -72,148 +75,178 @@ Item { } } } + } - Rectangle { - width: parent.width - height: parent.height - y - keyboardHintsContainer.height - Theme.spacingL - radius: Theme.cornerRadius - color: "transparent" - clip: true + Item { + id: listContainer + anchors.top: headerColumn.bottom + anchors.topMargin: Theme.spacingM + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: modal.showKeyboardHints ? (ClipboardConstants.keyboardHintsHeight + Theme.spacingM * 2) : 0 + clip: true - // Recents Tab - DankListView { - id: clipboardListView - anchors.fill: parent - model: ScriptModel { - values: clipboardContent.modal.unpinnedEntries - objectProp: "id" + DankListView { + id: clipboardListView + anchors.fill: parent + model: ScriptModel { + values: clipboardContent.modal.unpinnedEntries + objectProp: "id" + } + visible: modal.activeTab === "recents" + + currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0 + spacing: Theme.spacingXS + interactive: true + flickDeceleration: 1500 + maximumFlickVelocity: 2000 + boundsBehavior: Flickable.DragAndOvershootBounds + boundsMovement: Flickable.FollowBoundsBehavior + pressDelay: 0 + flickableDirection: Flickable.VerticalFlick + + function ensureVisible(index) { + if (index < 0 || index >= count) { + return; } - visible: modal.activeTab === "recents" - - currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0 - spacing: Theme.spacingXS - interactive: true - flickDeceleration: 1500 - maximumFlickVelocity: 2000 - boundsBehavior: Flickable.DragAndOvershootBounds - boundsMovement: Flickable.FollowBoundsBehavior - pressDelay: 0 - flickableDirection: Flickable.VerticalFlick - - 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; - } - } - - onCurrentIndexChanged: { - if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) { - ensureVisible(currentIndex); - } - } - - StyledText { - text: I18n.tr("No recent clipboard entries found") - anchors.centerIn: parent - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceVariantText - visible: clipboardContent.modal.unpinnedEntries.length === 0 - } - - delegate: ClipboardEntry { - required property int index - required property var modelData - - width: clipboardListView.width - height: ClipboardConstants.itemHeight - entry: modelData - entryIndex: index + 1 - itemIndex: index - isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex - modal: clipboardContent.modal - listView: clipboardListView - onCopyRequested: clipboardContent.modal.copyEntry(modelData) - onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) - onPinRequested: clipboardContent.modal.pinEntry(modelData) - onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) + 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; } } - // Saved Tab - DankListView { - id: savedListView - anchors.fill: parent - model: ScriptModel { - values: clipboardContent.modal.pinnedEntries - objectProp: "id" + onCurrentIndexChanged: { + if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) { + ensureVisible(currentIndex); } - visible: modal.activeTab === "saved" + } - spacing: Theme.spacingXS - interactive: true - flickDeceleration: 1500 - maximumFlickVelocity: 2000 - boundsBehavior: Flickable.DragAndOvershootBounds - boundsMovement: Flickable.FollowBoundsBehavior - pressDelay: 0 - flickableDirection: Flickable.VerticalFlick + StyledText { + text: I18n.tr("No recent clipboard entries found") + anchors.centerIn: parent + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + visible: clipboardContent.modal.unpinnedEntries.length === 0 + } - StyledText { - text: I18n.tr("No saved clipboard entries") - anchors.centerIn: parent - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceVariantText - visible: clipboardContent.modal.pinnedEntries.length === 0 - } + delegate: ClipboardEntry { + required property int index + required property var modelData - delegate: ClipboardEntry { - required property int index - required property var modelData - - width: savedListView.width - height: ClipboardConstants.itemHeight - entry: modelData - entryIndex: index + 1 - itemIndex: index - isSelected: false - modal: clipboardContent.modal - listView: savedListView - onCopyRequested: clipboardContent.modal.copyEntry(modelData) - onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) - onPinRequested: clipboardContent.modal.pinEntry(modelData) - onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) - } + width: clipboardListView.width + height: ClipboardConstants.itemHeight + entry: modelData + entryIndex: index + 1 + itemIndex: index + isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex + modal: clipboardContent.modal + listView: clipboardListView + onCopyRequested: clipboardContent.modal.copyEntry(modelData) + onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) + onPinRequested: clipboardContent.modal.pinEntry(modelData) + onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) } } - Item { - id: keyboardHintsContainer - width: parent.width - height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingM : 0 + DankListView { + id: savedListView + anchors.fill: parent + model: ScriptModel { + values: clipboardContent.modal.pinnedEntries + objectProp: "id" + } + visible: modal.activeTab === "saved" - Behavior on height { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing + spacing: Theme.spacingXS + interactive: true + flickDeceleration: 1500 + maximumFlickVelocity: 2000 + boundsBehavior: Flickable.DragAndOvershootBounds + boundsMovement: Flickable.FollowBoundsBehavior + pressDelay: 0 + flickableDirection: Flickable.VerticalFlick + + StyledText { + text: I18n.tr("No saved clipboard entries") + anchors.centerIn: parent + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + visible: clipboardContent.modal.pinnedEntries.length === 0 + } + + delegate: ClipboardEntry { + required property int index + required property var modelData + + width: savedListView.width + height: ClipboardConstants.itemHeight + entry: modelData + entryIndex: index + 1 + itemIndex: index + isSelected: false + modal: clipboardContent.modal + listView: savedListView + onCopyRequested: clipboardContent.modal.copyEntry(modelData) + onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) + onPinRequested: clipboardContent.modal.pinEntry(modelData) + onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) + } + } + + Rectangle { + id: bottomFade + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 24 + z: 100 + visible: { + const listView = modal.activeTab === "recents" ? clipboardListView : savedListView; + if (listView.contentHeight <= listView.height) + return false; + const atBottom = listView.contentY >= listView.contentHeight - listView.height - 5; + return !atBottom; + } + gradient: Gradient { + GradientStop { + position: 0.0 + color: "transparent" + } + GradientStop { + position: 1.0 + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) } } } } - ClipboardKeyboardHints { + Loader { + id: keyboardHintsLoader anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right - anchors.margins: Theme.spacingM - visible: modal.showKeyboardHints - wtypeAvailable: modal.wtypeAvailable + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: active ? Theme.spacingM : 0 + active: modal.showKeyboardHints + height: active ? ClipboardConstants.keyboardHintsHeight : 0 + + Behavior on height { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + sourceComponent: ClipboardKeyboardHints { + wtypeAvailable: modal.wtypeAvailable + } } } diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml index cd1e3ca9..9d422ddd 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml @@ -2,8 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell.Hyprland -import Quickshell.Io import qs.Common +import qs.Modals.Clipboard import qs.Modals.Common import qs.Services @@ -17,86 +17,35 @@ DankModal { active: clipboardHistoryModal.useHyprlandFocusGrab && clipboardHistoryModal.shouldHaveFocus } - property int totalCount: 0 - property var clipboardEntries: [] - property var pinnedEntries: [] - property int pinnedCount: 0 - property string searchText: "" - property int selectedIndex: 0 - property bool keyboardNavigationActive: false + property string activeTab: "recents" property bool showKeyboardHints: false property Component clipboardContent property int activeImageLoads: 0 readonly property int maxConcurrentLoads: 3 - readonly property bool clipboardAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("clipboard")) - readonly property bool wtypeAvailable: SessionService.wtypeAvailable - Process { - id: wtypeProcess - command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"] - running: false - } + readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable + readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable + readonly property int totalCount: ClipboardService.totalCount + readonly property var clipboardEntries: ClipboardService.clipboardEntries + readonly property var pinnedEntries: ClipboardService.pinnedEntries + readonly property int pinnedCount: ClipboardService.pinnedCount + readonly property var unpinnedEntries: ClipboardService.unpinnedEntries + readonly property int selectedIndex: ClipboardService.selectedIndex + readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive + property string searchText: ClipboardService.searchText + onSearchTextChanged: ClipboardService.searchText = searchText - Timer { - id: pasteTimer - interval: 200 - repeat: false - onTriggered: wtypeProcess.running = true - } - - function pasteSelected() { - if (!keyboardNavigationActive || clipboardEntries.length === 0 || selectedIndex < 0 || selectedIndex >= clipboardEntries.length) { - return; - } - if (!wtypeAvailable) { - ToastService.showError(I18n.tr("wtype not available - install wtype for paste support")); - return; - } - const entry = clipboardEntries[selectedIndex]; - DMSService.sendRequest("clipboard.copyEntry", { - "id": entry.id - }, function (response) { - if (response.error) { - ToastService.showError(I18n.tr("Failed to copy entry")); - return; - } - instantClose(); - pasteTimer.start(); - }); + Ref { + service: ClipboardService } function updateFilteredModel() { - const query = searchText.trim(); - let filtered = []; - - if (query.length === 0) { - filtered = internalEntries; - } else { - const lowerQuery = query.toLowerCase(); - filtered = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery)); - } - - // Sort: pinned first, then by ID descending - filtered.sort((a, b) => { - if (a.pinned !== b.pinned) - return b.pinned ? 1 : -1; - return b.id - a.id; - }); - - clipboardEntries = filtered; - unpinnedEntries = filtered.filter(e => !e.pinned); - totalCount = clipboardEntries.length; - if (unpinnedEntries.length === 0) { - keyboardNavigationActive = false; - selectedIndex = 0; - } else if (selectedIndex >= unpinnedEntries.length) { - selectedIndex = unpinnedEntries.length - 1; - } + ClipboardService.updateFilteredModel(); } - property var internalEntries: [] - property var unpinnedEntries: [] - property string activeTab: "recents" + function pasteSelected() { + ClipboardService.pasteSelected(instantClose); + } function toggle() { if (shouldBeVisible) { @@ -112,10 +61,10 @@ DankModal { return; } open(); - searchText = ""; activeImageLoads = 0; shouldHaveFocus = true; - refreshClipboard(); + ClipboardService.reset(); + ClipboardService.refresh(); keyboardController.reset(); Qt.callLater(function () { @@ -128,141 +77,48 @@ DankModal { function hide() { close(); - searchText = ""; + } + + onDialogClosed: { activeImageLoads = 0; - internalEntries = []; - clipboardEntries = []; + ClipboardService.reset(); keyboardController.reset(); } function refreshClipboard() { - DMSService.sendRequest("clipboard.getHistory", null, function (response) { - if (response.error) { - console.warn("ClipboardHistoryModal: Failed to get history:", response.error); - return; - } - internalEntries = response.result || []; - - pinnedEntries = internalEntries.filter(e => e.pinned); - pinnedCount = pinnedEntries.length; - - updateFilteredModel(); - }); + ClipboardService.refresh(); } function copyEntry(entry) { - DMSService.sendRequest("clipboard.copyEntry", { - "id": entry.id - }, function (response) { - if (response.error) { - ToastService.showError(I18n.tr("Failed to copy entry")); - return; - } - ToastService.showInfo(entry.isImage ? I18n.tr("Image copied to clipboard") : I18n.tr("Copied to clipboard")); - hide(); - }); + ClipboardService.copyEntry(entry, hide); } function 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; - } - }); + ClipboardService.deleteEntry(entry); } function deletePinnedEntry(entry) { - clearConfirmDialog.show(I18n.tr("Delete Saved Item?"), I18n.tr("This will permanently remove this saved clipboard item. This action cannot be undone."), function () { - 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(); - ToastService.showInfo(I18n.tr("Saved item deleted")); - }); - }, function () {}); + ClipboardService.deletePinnedEntry(entry, clearConfirmDialog); } function pinEntry(entry) { - DMSService.sendRequest("clipboard.getPinnedCount", null, function (countResponse) { - if (countResponse.error) { - ToastService.showError(I18n.tr("Failed to check pin limit")); - return; - } - - const maxPinned = 25; // TODO: Get from config - if (countResponse.result.count >= maxPinned) { - ToastService.showError(I18n.tr("Maximum pinned entries reached") + " (" + maxPinned + ")"); - return; - } - - DMSService.sendRequest("clipboard.pinEntry", { - "id": entry.id - }, function (response) { - if (response.error) { - ToastService.showError(I18n.tr("Failed to pin entry")); - return; - } - ToastService.showInfo(I18n.tr("Entry pinned")); - refreshClipboard(); - }); - }); + ClipboardService.pinEntry(entry); } function unpinEntry(entry) { - DMSService.sendRequest("clipboard.unpinEntry", { - "id": entry.id - }, function (response) { - if (response.error) { - ToastService.showError(I18n.tr("Failed to unpin entry")); - return; - } - ToastService.showInfo(I18n.tr("Entry unpinned")); - refreshClipboard(); - }); + ClipboardService.unpinEntry(entry); } function clearAll() { - const hasPinned = pinnedCount > 0; - DMSService.sendRequest("clipboard.clearHistory", null, function (response) { - if (response.error) { - console.warn("ClipboardHistoryModal: Failed to clear history:", response.error); - return; - } - refreshClipboard(); - if (hasPinned) { - ToastService.showInfo(I18n.tr("History cleared. %1 pinned entries kept.").arg(pinnedCount)); - } - }); + ClipboardService.clearAll(); } function getEntryPreview(entry) { - return entry.preview || ""; + return ClipboardService.getEntryPreview(entry); } function getEntryType(entry) { - if (entry.isImage) { - return "image"; - } - if (entry.size > ClipboardConstants.longTextThreshold) { - return "long_text"; - } - return "text"; + return ClipboardService.getEntryType(entry); } visible: false @@ -284,20 +140,6 @@ DankModal { modal: clipboardHistoryModal } - Connections { - target: DMSService - function onClipboardStateUpdate(data) { - if (!clipboardHistoryModal.shouldBeVisible) { - return; - } - const newHistory = data.history || []; - internalEntries = newHistory; - pinnedEntries = newHistory.filter(e => e.pinned); - pinnedCount = pinnedEntries.length; - updateFilteredModel(); - } - } - ConfirmModal { id: clearConfirmDialog confirmButtonText: I18n.tr("Clear All") diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml b/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml new file mode 100644 index 00000000..d37893d7 --- /dev/null +++ b/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml @@ -0,0 +1,200 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modals.Clipboard +import qs.Modals.Common +import qs.Services +import qs.Widgets + +DankPopout { + id: root + + layerNamespace: "dms:clipboard-popout" + + property var parentWidget: null + property var triggerScreen: null + property string activeTab: "recents" + property bool showKeyboardHints: false + property int activeImageLoads: 0 + readonly property int maxConcurrentLoads: 3 + + readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable + readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable + readonly property int totalCount: ClipboardService.totalCount + readonly property var clipboardEntries: ClipboardService.clipboardEntries + readonly property var pinnedEntries: ClipboardService.pinnedEntries + readonly property int pinnedCount: ClipboardService.pinnedCount + readonly property var unpinnedEntries: ClipboardService.unpinnedEntries + readonly property int selectedIndex: ClipboardService.selectedIndex + readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive + property string searchText: ClipboardService.searchText + onSearchTextChanged: ClipboardService.searchText = searchText + + readonly property var modalFocusScope: contentLoader.item ?? null + + Ref { + service: ClipboardService + } + + function updateFilteredModel() { + ClipboardService.updateFilteredModel(); + } + + function pasteSelected() { + ClipboardService.pasteSelected(instantClose); + } + + function instantClose() { + close(); + } + + function show() { + if (!clipboardAvailable) { + ToastService.showError(I18n.tr("Clipboard service not available")); + return; + } + open(); + activeImageLoads = 0; + ClipboardService.reset(); + ClipboardService.refresh(); + keyboardController.reset(); + + Qt.callLater(function () { + if (contentLoader.item?.searchField) { + contentLoader.item.searchField.text = ""; + contentLoader.item.searchField.forceActiveFocus(); + } + }); + } + + function hide() { + close(); + activeImageLoads = 0; + ClipboardService.reset(); + keyboardController.reset(); + } + + function refreshClipboard() { + ClipboardService.refresh(); + } + + function copyEntry(entry) { + ClipboardService.copyEntry(entry, hide); + } + + function deleteEntry(entry) { + ClipboardService.deleteEntry(entry); + } + + function deletePinnedEntry(entry) { + ClipboardService.deletePinnedEntry(entry, clearConfirmDialog); + } + + function pinEntry(entry) { + ClipboardService.pinEntry(entry); + } + + function unpinEntry(entry) { + ClipboardService.unpinEntry(entry); + } + + function clearAll() { + ClipboardService.clearAll(); + } + + function getEntryPreview(entry) { + return ClipboardService.getEntryPreview(entry); + } + + function getEntryType(entry) { + return ClipboardService.getEntryType(entry); + } + + popupWidth: ClipboardConstants.modalWidth + popupHeight: ClipboardConstants.modalHeight + triggerWidth: 55 + positioning: "" + screen: triggerScreen + shouldBeVisible: false + contentHandlesKeys: true + + onBackgroundClicked: hide() + + onShouldBeVisibleChanged: { + if (!shouldBeVisible) { + return; + } + ClipboardService.refresh(); + keyboardController.reset(); + Qt.callLater(function () { + if (contentLoader.item?.searchField) { + contentLoader.item.searchField.text = ""; + contentLoader.item.searchField.forceActiveFocus(); + } + }); + } + + onPopoutClosed: { + activeImageLoads = 0; + ClipboardService.reset(); + keyboardController.reset(); + } + + ClipboardKeyboardController { + id: keyboardController + modal: root + } + + ConfirmModal { + id: clearConfirmDialog + confirmButtonText: I18n.tr("Clear All") + confirmButtonColor: Theme.primary + } + + property var confirmDialog: clearConfirmDialog + + content: Component { + FocusScope { + id: contentFocusScope + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + focus: true + + property alias searchField: clipboardContentItem.searchField + + Keys.onPressed: function (event) { + keyboardController.handleKey(event); + } + + Component.onCompleted: { + if (root.shouldBeVisible) + forceActiveFocus(); + } + + Connections { + target: root + function onShouldBeVisibleChanged() { + if (root.shouldBeVisible) { + Qt.callLater(() => contentFocusScope.forceActiveFocus()); + } + } + function onOpened() { + Qt.callLater(() => { + if (clipboardContentItem.searchField) { + clipboardContentItem.searchField.forceActiveFocus(); + } + }); + } + } + + ClipboardContent { + id: clipboardContentItem + modal: root + clearConfirmDialog: root.confirmDialog + } + } + } +} diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml index e3a14ec7..3d6ebacb 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml @@ -1,4 +1,5 @@ import QtQuick +import qs.Services QtObject { id: keyboardController @@ -6,48 +7,48 @@ QtObject { required property var modal function reset() { - modal.selectedIndex = 0; - modal.keyboardNavigationActive = false; + ClipboardService.selectedIndex = 0; + ClipboardService.keyboardNavigationActive = false; modal.showKeyboardHints = false; } function selectNext() { - if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) { + if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0) { return; } - modal.keyboardNavigationActive = true; - modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.clipboardEntries.length - 1); + ClipboardService.keyboardNavigationActive = true; + ClipboardService.selectedIndex = Math.min(ClipboardService.selectedIndex + 1, ClipboardService.clipboardEntries.length - 1); } function selectPrevious() { - if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) { + if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0) { return; } - modal.keyboardNavigationActive = true; - modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0); + ClipboardService.keyboardNavigationActive = true; + ClipboardService.selectedIndex = Math.max(ClipboardService.selectedIndex - 1, 0); } function copySelected() { - if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) { + if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= ClipboardService.clipboardEntries.length) { return; } - const selectedEntry = modal.clipboardEntries[modal.selectedIndex]; + const selectedEntry = ClipboardService.clipboardEntries[ClipboardService.selectedIndex]; modal.copyEntry(selectedEntry); } function deleteSelected() { - if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) { + if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= ClipboardService.clipboardEntries.length) { return; } - const selectedEntry = modal.clipboardEntries[modal.selectedIndex]; + const selectedEntry = ClipboardService.clipboardEntries[ClipboardService.selectedIndex]; modal.deleteEntry(selectedEntry); } function handleKey(event) { switch (event.key) { case Qt.Key_Escape: - if (modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = false; + if (ClipboardService.keyboardNavigationActive) { + ClipboardService.keyboardNavigationActive = false; } else { modal.hide(); } @@ -55,9 +56,9 @@ QtObject { return; case Qt.Key_Down: case Qt.Key_Tab: - if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true; - modal.selectedIndex = 0; + if (!ClipboardService.keyboardNavigationActive) { + ClipboardService.keyboardNavigationActive = true; + ClipboardService.selectedIndex = 0; } else { selectNext(); } @@ -65,11 +66,11 @@ QtObject { return; case Qt.Key_Up: case Qt.Key_Backtab: - if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true; - modal.selectedIndex = 0; - } else if (modal.selectedIndex === 0) { - modal.keyboardNavigationActive = false; + if (!ClipboardService.keyboardNavigationActive) { + ClipboardService.keyboardNavigationActive = true; + ClipboardService.selectedIndex = 0; + } else if (ClipboardService.selectedIndex === 0) { + ClipboardService.keyboardNavigationActive = false; } else { selectPrevious(); } @@ -85,9 +86,9 @@ QtObject { switch (event.key) { case Qt.Key_N: case Qt.Key_J: - if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true; - modal.selectedIndex = 0; + if (!ClipboardService.keyboardNavigationActive) { + ClipboardService.keyboardNavigationActive = true; + ClipboardService.selectedIndex = 0; } else { selectNext(); } @@ -95,18 +96,18 @@ QtObject { 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; + if (!ClipboardService.keyboardNavigationActive) { + ClipboardService.keyboardNavigationActive = true; + ClipboardService.selectedIndex = 0; + } else if (ClipboardService.selectedIndex === 0) { + ClipboardService.keyboardNavigationActive = false; } else { selectPrevious(); } event.accepted = true; return; case Qt.Key_C: - if (modal.keyboardNavigationActive) { + if (ClipboardService.keyboardNavigationActive) { copySelected(); event.accepted = true; } @@ -123,7 +124,7 @@ QtObject { return; case Qt.Key_Return: case Qt.Key_Enter: - if (modal.keyboardNavigationActive) { + if (ClipboardService.keyboardNavigationActive) { modal.pasteSelected(); event.accepted = true; } @@ -131,7 +132,7 @@ QtObject { } } - if (modal.keyboardNavigationActive) { + if (ClipboardService.keyboardNavigationActive) { switch (event.key) { case Qt.Key_Return: case Qt.Key_Enter: diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index a78dcd0b..b8d327d4 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -555,14 +555,57 @@ Item { id: clipboardComponent ClipboardButton { + id: clipboardWidget widgetThickness: barWindow.widgetThickness barThickness: barWindow.effectiveBarThickness axis: barWindow.axis section: topBarContent.getWidgetSection(parent) parentScreen: barWindow.screen - clipboardHistoryModal: PopoutService.clipboardHistoryModal - onClicked: { - clipboardHistoryModalPopup.toggle(); + popoutTarget: { + clipboardHistoryPopoutLoader.active = true; + return clipboardHistoryPopoutLoader.item; + } + + function openClipboardPopout(initialTab) { + clipboardHistoryPopoutLoader.active = true; + if (!clipboardHistoryPopoutLoader.item) { + return; + } + const popout = clipboardHistoryPopoutLoader.item; + const effectiveBarConfig = topBarContent.barConfig; + const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); + if (popout.setBarContext) { + popout.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); + } + if (popout.setTriggerPosition) { + const globalPos = clipboardWidget.mapToItem(null, 0, 0); + const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, clipboardWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); + const widgetSection = topBarContent.getWidgetSection(parent) || "right"; + popout.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); + } + if (initialTab) { + popout.activeTab = initialTab; + } + PopoutManager.requestPopout(popout, undefined, "clipboard"); + } + + onClipboardClicked: openClipboardPopout("recents") + + onShowSavedItemsRequested: openClipboardPopout("saved") + + onClearAllRequested: { + clipboardHistoryPopoutLoader.active = true; + const popout = clipboardHistoryPopoutLoader.item; + if (!popout?.confirmDialog) { + return; + } + const hasPinned = popout.pinnedCount > 0; + const message = hasPinned ? I18n.tr("This will delete all unpinned entries. %1 pinned entries will be kept.").arg(popout.pinnedCount) : I18n.tr("This will permanently delete all clipboard history."); + popout.confirmDialog.show(I18n.tr("Clear History?"), message, function () { + if (popout && typeof popout.clearAll === "function") { + popout.clearAll(); + } + }, function () {}); } } } diff --git a/quickshell/Modules/DankBar/Widgets/ClipboardButton.qml b/quickshell/Modules/DankBar/Widgets/ClipboardButton.qml index 62337b14..68e83111 100644 --- a/quickshell/Modules/DankBar/Widgets/ClipboardButton.qml +++ b/quickshell/Modules/DankBar/Widgets/ClipboardButton.qml @@ -4,30 +4,30 @@ import Quickshell.Wayland import qs.Common import qs.Modules.Plugins import qs.Widgets -import qs.Services BasePill { id: root property bool isActive: false - property var clipboardHistoryModal: null + property var popoutTarget: null property var parentScreen: null property Item windowRoot: (Window.window ? Window.window.contentItem : null) property bool isAutoHideBar: false + signal clipboardClicked + signal showSavedItemsRequested + signal clearAllRequested + readonly property real minTooltipY: { if (!parentScreen || !(axis?.isVertical ?? false)) { return 0; } - if (isAutoHideBar) { return 0; } - if (parentScreen.y > 0) { return barThickness + barSpacing; } - return 0; } @@ -51,15 +51,11 @@ BasePill { let anchorY = relativeY; if (isVertical) { - anchorX = edge === "left" - ? (root.barThickness + root.barSpacing + gap) - : (screen.width - (root.barThickness + root.barSpacing + gap)); + anchorX = edge === "left" ? (root.barThickness + root.barSpacing + gap) : (screen.width - (root.barThickness + root.barSpacing + gap)); anchorY = relativeY + root.minTooltipY; } else { anchorX = relativeX; - anchorY = edge === "bottom" - ? (screen.height - (root.barThickness + root.barSpacing + gap)) - : (root.barThickness + root.barSpacing + gap); + anchorY = edge === "bottom" ? (screen.height - (root.barThickness + root.barSpacing + gap)) : (root.barThickness + root.barSpacing + gap); } contextMenuWindow.showAt(anchorX, anchorY, isVertical, edge, screen); @@ -67,10 +63,16 @@ BasePill { MouseArea { anchors.fill: parent - acceptedButtons: Qt.RightButton - onClicked: function(mouse) { - if (mouse.button === Qt.RightButton) { + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: function (mouse) { + switch (mouse.button) { + case Qt.RightButton: openContextMenu(); + break; + case Qt.LeftButton: + clipboardClicked(); + break; } } } @@ -232,23 +234,7 @@ BasePill { cursorShape: Qt.PointingHandCursor onClicked: { contextMenuWindow.closeMenu(); - if (root.clipboardHistoryModal && root.clipboardHistoryModal.confirmDialog) { - const hasPinned = root.clipboardHistoryModal.pinnedCount > 0; - const message = hasPinned - ? I18n.tr("This will delete all unpinned entries. %1 pinned entries will be kept.").arg(root.clipboardHistoryModal.pinnedCount) - : I18n.tr("This will permanently delete all clipboard history."); - - root.clipboardHistoryModal.confirmDialog.show( - I18n.tr("Clear History?"), - message, - function () { - if (root.clipboardHistoryModal && typeof root.clipboardHistoryModal.clearAll === "function") { - root.clipboardHistoryModal.clearAll(); - } - }, - function () {} - ); - } + root.clearAllRequested(); } } } @@ -288,16 +274,7 @@ BasePill { cursorShape: Qt.PointingHandCursor onClicked: { contextMenuWindow.closeMenu(); - if (root.clipboardHistoryModal) { - if (typeof root.clipboardHistoryModal.show === "function") { - root.clipboardHistoryModal.show(); - } - Qt.callLater(function () { - if (root.clipboardHistoryModal) { - root.clipboardHistoryModal.activeTab = "saved"; - } - }); - } + root.showSavedItemsRequested(); } } } diff --git a/quickshell/Services/ClipboardService.qml b/quickshell/Services/ClipboardService.qml new file mode 100644 index 00000000..1d586a0c --- /dev/null +++ b/quickshell/Services/ClipboardService.qml @@ -0,0 +1,262 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common + +Singleton { + id: root + + readonly property int longTextThreshold: 200 + + readonly property bool clipboardAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("clipboard")) + readonly property bool wtypeAvailable: SessionService.wtypeAvailable + + property var internalEntries: [] + property var clipboardEntries: [] + property var unpinnedEntries: [] + property var pinnedEntries: [] + property int pinnedCount: 0 + property int totalCount: 0 + property string searchText: "" + property int selectedIndex: 0 + property bool keyboardNavigationActive: false + property int refCount: 0 + + signal historyCopied + signal historyCleared + + Process { + id: wtypeProcess + command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"] + running: false + } + + Timer { + id: pasteTimer + interval: 200 + repeat: false + onTriggered: wtypeProcess.running = true + } + + function updateFilteredModel() { + const query = searchText.trim(); + let filtered = []; + + if (query.length === 0) { + filtered = internalEntries; + } else { + const lowerQuery = query.toLowerCase(); + filtered = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery)); + } + + filtered.sort((a, b) => { + if (a.pinned !== b.pinned) + return b.pinned ? 1 : -1; + return b.id - a.id; + }); + + clipboardEntries = filtered; + unpinnedEntries = filtered.filter(e => !e.pinned); + totalCount = clipboardEntries.length; + + if (unpinnedEntries.length === 0) { + keyboardNavigationActive = false; + selectedIndex = 0; + return; + } + if (selectedIndex >= unpinnedEntries.length) { + selectedIndex = unpinnedEntries.length - 1; + } + } + + function refresh() { + if (!clipboardAvailable) { + return; + } + DMSService.sendRequest("clipboard.getHistory", null, function (response) { + if (response.error) { + console.warn("ClipboardService: Failed to get history:", response.error); + return; + } + internalEntries = response.result || []; + pinnedEntries = internalEntries.filter(e => e.pinned); + pinnedCount = pinnedEntries.length; + updateFilteredModel(); + }); + } + + function reset() { + searchText = ""; + selectedIndex = 0; + keyboardNavigationActive = false; + internalEntries = []; + clipboardEntries = []; + unpinnedEntries = []; + } + + function copyEntry(entry, closeCallback) { + DMSService.sendRequest("clipboard.copyEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + ToastService.showError(I18n.tr("Failed to copy entry")); + return; + } + ToastService.showInfo(entry.isImage ? I18n.tr("Image copied to clipboard") : I18n.tr("Copied to clipboard")); + historyCopied(); + if (closeCallback) { + closeCallback(); + } + }); + } + + function pasteEntry(entry, closeCallback) { + if (!wtypeAvailable) { + ToastService.showError(I18n.tr("wtype not available - install wtype for paste support")); + return; + } + DMSService.sendRequest("clipboard.copyEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + ToastService.showError(I18n.tr("Failed to copy entry")); + return; + } + if (closeCallback) { + closeCallback(); + } + pasteTimer.start(); + }); + } + + function pasteSelected(closeCallback) { + if (!keyboardNavigationActive || clipboardEntries.length === 0 || selectedIndex < 0 || selectedIndex >= clipboardEntries.length) { + return; + } + pasteEntry(clipboardEntries[selectedIndex], closeCallback); + } + + function deleteEntry(entry) { + DMSService.sendRequest("clipboard.deleteEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + console.warn("ClipboardService: Failed to delete entry:", response.error); + return; + } + internalEntries = internalEntries.filter(e => e.id !== entry.id); + updateFilteredModel(); + if (clipboardEntries.length === 0) { + keyboardNavigationActive = false; + selectedIndex = 0; + return; + } + if (selectedIndex >= clipboardEntries.length) { + selectedIndex = clipboardEntries.length - 1; + } + }); + } + + function deletePinnedEntry(entry, confirmDialog) { + if (!confirmDialog) { + return; + } + confirmDialog.show(I18n.tr("Delete Saved Item?"), I18n.tr("This will permanently remove this saved clipboard item. This action cannot be undone."), function () { + DMSService.sendRequest("clipboard.deleteEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + console.warn("ClipboardService: Failed to delete entry:", response.error); + return; + } + internalEntries = internalEntries.filter(e => e.id !== entry.id); + updateFilteredModel(); + ToastService.showInfo(I18n.tr("Saved item deleted")); + }); + }, function () {}); + } + + function pinEntry(entry) { + DMSService.sendRequest("clipboard.getPinnedCount", null, function (countResponse) { + if (countResponse.error) { + ToastService.showError(I18n.tr("Failed to check pin limit")); + return; + } + + const maxPinned = 25; + if (countResponse.result.count >= maxPinned) { + ToastService.showError(I18n.tr("Maximum pinned entries reached") + " (" + maxPinned + ")"); + return; + } + + DMSService.sendRequest("clipboard.pinEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + ToastService.showError(I18n.tr("Failed to pin entry")); + return; + } + ToastService.showInfo(I18n.tr("Entry pinned")); + refresh(); + }); + }); + } + + function unpinEntry(entry) { + DMSService.sendRequest("clipboard.unpinEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + ToastService.showError(I18n.tr("Failed to unpin entry")); + return; + } + ToastService.showInfo(I18n.tr("Entry unpinned")); + refresh(); + }); + } + + function clearAll() { + const hasPinned = pinnedCount > 0; + const savedCount = pinnedCount; + DMSService.sendRequest("clipboard.clearHistory", null, function (response) { + if (response.error) { + console.warn("ClipboardService: Failed to clear history:", response.error); + return; + } + refresh(); + historyCleared(); + if (hasPinned) { + ToastService.showInfo(I18n.tr("History cleared. %1 pinned entries kept.").arg(savedCount)); + } + }); + } + + function getEntryPreview(entry) { + return entry.preview || ""; + } + + function getEntryType(entry) { + if (entry.isImage) { + return "image"; + } + if (entry.size > longTextThreshold) { + return "long_text"; + } + return "text"; + } + + Connections { + target: DMSService + enabled: root.refCount > 0 + function onClipboardStateUpdate(data) { + const newHistory = data.history || []; + internalEntries = newHistory; + pinnedEntries = newHistory.filter(e => e.pinned); + pinnedCount = pinnedEntries.length; + updateFilteredModel(); + } + } +} diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml index d91e6bb5..10a92d7b 100644 --- a/quickshell/Services/PopoutService.qml +++ b/quickshell/Services/PopoutService.qml @@ -20,6 +20,7 @@ Singleton { property var settingsModal: null property var settingsModalLoader: null property var clipboardHistoryModal: null + property var clipboardHistoryPopout: null property var dankLauncherV2Modal: null property var dankLauncherV2ModalLoader: null property var powerMenuModal: null