diff --git a/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml index 58f07be23..f7bc293e9 100644 --- a/quickshell/Modals/Clipboard/ClipboardContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardContent.qml @@ -145,6 +145,7 @@ Item { onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) + onEditRequested: clipboardContent.modal.editEntry(modelData) } } @@ -204,6 +205,7 @@ Item { onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) + onEditRequested: clipboardContent.modal.editEntry(modelData) } } diff --git a/quickshell/Modals/Clipboard/ClipboardEntry.qml b/quickshell/Modals/Clipboard/ClipboardEntry.qml index 53a2ececd..d0383ff9e 100644 --- a/quickshell/Modals/Clipboard/ClipboardEntry.qml +++ b/quickshell/Modals/Clipboard/ClipboardEntry.qml @@ -17,6 +17,7 @@ Rectangle { signal deleteRequested signal pinRequested signal unpinRequested + signal editRequested readonly property string entryType: modal ? modal.getEntryType(entry) : "text" readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" @@ -70,6 +71,20 @@ Rectangle { onClicked: entry.pinned ? unpinRequested() : pinRequested() } + DankActionButton { + iconName: "edit" + iconSize: Theme.iconSize - 6 + iconColor: Theme.surfaceText + + onClicked: { + if (entryType === "image") { + // TODO - forward to editing software + } else { + editRequested(); + } + } + } + DankActionButton { iconName: "close" iconSize: Theme.iconSize - 6 @@ -142,8 +157,11 @@ Rectangle { MouseArea { id: mouseArea - anchors.fill: parent - anchors.rightMargin: 80 + anchors.left: parent.left + anchors.right: actionButtons.left + anchors.rightMargin: Theme.spacingS + anchors.top: parent.top + anchors.bottom: parent.bottom hoverEnabled: true cursorShape: Qt.PointingHandCursor onPressed: mouse => { diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml index 411f03fd3..b67873082 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml @@ -1,11 +1,13 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Controls import Quickshell.Hyprland import qs.Common import qs.Modals.Clipboard import qs.Modals.Common import qs.Services +import qs.Widgets DankModal { id: clipboardHistoryModal @@ -22,6 +24,7 @@ DankModal { ClipboardService.selectedIndex = 0; ClipboardService.keyboardNavigationActive = false; } + property var editClipboardModal: null property bool showKeyboardHints: false property Component clipboardContent property int activeImageLoads: 0 @@ -43,6 +46,18 @@ DankModal { service: ClipboardService } + property string mode: "history" + onModeChanged: { + if (mode !== "history") { + return; + } + Qt.callLater(function () { + if (contentLoader.item?.searchField) { + contentLoader.item.searchField.forceActiveFocus(); + } + }); + } + function updateFilteredModel() { ClipboardService.updateFilteredModel(); } @@ -65,6 +80,7 @@ DankModal { return; } open(); + mode = "history"; activeImageLoads = 0; shouldHaveFocus = true; ClipboardService.reset(); @@ -125,6 +141,21 @@ DankModal { return ClipboardService.getEntryType(entry); } + function editEntry(entry) { + if (!entry) { + return; + } + if (entry.isImage) { + return; + } + const editor = contentLoader.item?.editorView; + if (!editor) { + return; + } + editor.setEntry(entry); + mode = "editor"; + } + visible: false modalWidth: ClipboardConstants.modalWidth modalHeight: ClipboardConstants.modalHeight @@ -133,8 +164,37 @@ DankModal { borderColor: Theme.outlineMedium borderWidth: 1 enableShadow: true + closeOnEscapeKey: mode !== "editor" onBackgroundClicked: hide() modalFocusScope.Keys.onPressed: function (event) { + if (mode === "history" && (event.modifiers & Qt.ControlModifier) && (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab)) { + activeTab = activeTab === "recents" ? "saved" : "recents"; + event.accepted = true; + return; + } + if (mode === "history" && (event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_S) { + const entries = activeTab === "saved" ? pinnedEntries : unpinnedEntries; + if (entries && entries.length > 0) { + const index = ClipboardService.selectedIndex >= 0 && ClipboardService.selectedIndex < entries.length ? ClipboardService.selectedIndex : 0; + const entry = entries[index]; + if (activeTab === "saved") { + unpinEntry(entry); + } else { + pinEntry(entry); + } + } + event.accepted = true; + return; + } + if (mode === "history" && (event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_E) { + const entries = activeTab === "saved" ? pinnedEntries : unpinnedEntries; + if (entries && entries.length > 0) { + const index = ClipboardService.selectedIndex >= 0 && ClipboardService.selectedIndex < entries.length ? ClipboardService.selectedIndex : 0; + editEntry(entries[index]); + } + event.accepted = true; + return; + } keyboardController.handleKey(event); } content: clipboardContent @@ -169,9 +229,561 @@ DankModal { property var confirmDialog: clearConfirmDialog clipboardContent: Component { - ClipboardContent { - modal: clipboardHistoryModal - clearConfirmDialog: clipboardHistoryModal.confirmDialog + Item { + id: viewContainer + + property alias editorView: editorView + property alias searchField: historyContent.searchField + + anchors.fill: parent + + Item { + id: historyView + + anchors.fill: parent + opacity: 1 + scale: 1 + visible: opacity > 0.01 + enabled: clipboardHistoryModal.mode === "history" + + ClipboardContent { + id: historyContent + anchors.fill: parent + modal: clipboardHistoryModal + clearConfirmDialog: clipboardHistoryModal.confirmDialog + } + } + + Item { + id: editorView + + anchors.fill: parent + opacity: 0 + scale: 0.98 + visible: opacity > 0.01 + enabled: clipboardHistoryModal.mode === "editor" + focus: clipboardHistoryModal.mode === "editor" + + Shortcut { + sequences: ["Escape"] + enabled: clipboardHistoryModal.mode === "editor" + onActivated: clipboardHistoryModal.mode = "history" + } + + property var entry: null + property string editorText: "" + + function setEntry(newEntry) { + entry = newEntry; + editorText = newEntry?.text ?? newEntry?.preview ?? ""; + if (editField) { + editField.text = editorText; + } + Qt.callLater(function () { + if (editField) { + editField.forceActiveFocus(); + } + }); + + if (!newEntry || newEntry.isImage) { + return; + } + + const requestedId = newEntry.id; + DMSService.sendRequest("clipboard.getEntry", { + "id": requestedId + }, function (response) { + if (response.error) { + return; + } + if (!editorView.entry || editorView.entry.id !== requestedId) { + return; + } + const rawText = response.result?.text ?? response.result?.content ?? response.result?.data ?? ""; + let fullText = rawText; + try { + const sanitized = rawText.replace(/\s+/g, ""); + const decoder = (typeof Qt !== "undefined" && typeof Qt.atob === "function") ? Qt.atob : (typeof atob === "function" ? atob : null); + if (decoder) { + const decoded = decoder(sanitized); + fullText = decoded; + try { + fullText = decodeURIComponent(escape(decoded)); + } catch (e) { + fullText = decoded; + } + } + } catch (e) { + fullText = rawText; + } + + if (!fullText || fullText.length === 0) { + return; + } + editorView.editorText = fullText; + if (editField) { + editField.text = fullText; + } + }); + } + + function saveEntry(action) { + const saveAction = action ?? "history"; + DMSService.sendRequest("clipboard.copy", { + "text": editorView.editorText + }, function (response) { + if (response.error) { + ToastService.showError(I18n.tr("Failed to update clipboard")); + return; + } + if (saveAction === "history") { + clipboardHistoryModal.mode = "history"; + Qt.callLater(function () { + ClipboardService.reset(); + ClipboardService.refresh(); + keyboardController.reset(); + }); + return; + } + if (saveAction === "close") { + clipboardHistoryModal.hide(); + return; + } + if (saveAction === "paste") { + ClipboardService.pasteClipboard(clipboardHistoryModal.hide); + } + }); + } + + function toggleSaveMenu() { + if (saveMenu.visible) { + saveMenu.close(); + return; + } + saveMenu.open(); + const pos = saveButton.mapToItem(Overlay.overlay, 0, 0); + const popupW = saveMenu.width; + const popupH = saveMenu.height; + const overlayW = Overlay.overlay.width; + const overlayH = Overlay.overlay.height; + + let x = pos.x + (saveButton.width - popupW) / 2; + let y = pos.y + saveButton.height + 4; + if (y + popupH > overlayH) { + y = pos.y - popupH - 4; + } + + x = Math.max(8, Math.min(x, overlayW - popupW - 8)); + y = Math.max(8, y); + + saveMenu.x = x; + saveMenu.y = y; + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingM + + Item { + id: editorHeader + width: parent.width + height: ClipboardConstants.headerHeight + + DankActionButton { + iconName: "arrow_back" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + onClicked: clipboardHistoryModal.mode = "history" + } + + StyledText { + text: I18n.tr("Edit Clipboard") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.centerIn: parent + } + + DankActionButton { + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + onClicked: clipboardHistoryModal.mode = "history" + } + } + + StyledRect { + id: editFieldContainer + width: parent.width + height: Math.max(Theme.fontSizeMedium * 8, parent.height - editorHeader.height - editorActions.height - Theme.spacingM * 2) + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + border.color: editField.activeFocus ? Theme.primary : Theme.outlineMedium + border.width: editField.activeFocus ? 2 : 1 + clip: true + + DankIcon { + id: editIcon + name: "edit" + size: Theme.iconSize + color: editField.activeFocus ? Theme.primary : Theme.surfaceVariantText + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.top: parent.top + anchors.topMargin: Theme.spacingM + } + + DankFlickable { + id: editScroll + anchors.left: editIcon.right + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.rightMargin: Theme.spacingM + anchors.topMargin: Theme.spacingS + anchors.bottomMargin: Theme.spacingS + clip: true + contentWidth: width + contentHeight: editField.height + + TextEdit { + id: editField + width: editScroll.width + height: Math.max(editScroll.height, contentHeight) + text: editorView.editorText + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + wrapMode: TextEdit.Wrap + selectByMouse: true + onTextChanged: editorView.editorText = text + Keys.onPressed: function (event) { + const hasCtrl = (event.modifiers & Qt.ControlModifier) !== 0; + const hasShift = (event.modifiers & Qt.ShiftModifier) !== 0; + + if (hasCtrl && event.key === Qt.Key_S) { + editorView.saveEntry(hasShift ? "close" : "history"); + event.accepted = true; + return; + } + if (hasCtrl && hasShift && event.key === Qt.Key_V) { + editorView.saveEntry("paste"); + event.accepted = true; + return; + } + } + } + } + + StyledText { + text: I18n.tr("Edit clipboard text") + font.pixelSize: Theme.fontSizeMedium + color: Theme.outlineButton + anchors.left: editScroll.left + anchors.right: editScroll.right + anchors.top: editScroll.top + anchors.bottom: editScroll.bottom + visible: editField.text.length === 0 && !editField.activeFocus + wrapMode: Text.WordWrap + } + } + + Row { + id: editorActions + width: parent.width + spacing: Theme.spacingS + + Item { + id: buttonSpacer + width: Math.max(0, parent.width - cancelButton.width - saveButton.width - Theme.spacingS) + height: 1 + } + + DankButton { + id: cancelButton + text: I18n.tr("Cancel") + backgroundColor: Theme.surfaceContainerHigh + textColor: Theme.surfaceText + onClicked: clipboardHistoryModal.mode = "history" + } + + Item { + id: saveButton + property int arrowWidth: 32 + property int horizontalPadding: Theme.spacingL + width: cancelButton.width + height: 40 + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: Theme.primary + } + + Item { + id: saveMainArea + anchors.left: parent.left + anchors.right: saveArrowArea.left + anchors.top: parent.top + anchors.bottom: parent.bottom + } + + StyledText { + id: saveLabel + text: I18n.tr("Save") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.onPrimary + anchors.centerIn: saveMainArea + } + + Item { + id: saveArrowArea + width: saveButton.arrowWidth + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + + Rectangle { + width: 1 + height: parent.height - Theme.spacingM + color: Theme.withAlpha(Theme.onPrimary, 0.2) + anchors.right: saveArrowArea.left + anchors.verticalCenter: parent.verticalCenter + } + + DankIcon { + name: saveMenu.visible ? "expand_less" : "expand_more" + size: Theme.iconSizeSmall + color: Theme.onPrimary + anchors.centerIn: saveArrowArea + } + + StateLayer { + anchors.fill: saveMainArea + stateColor: Theme.onPrimary + onClicked: editorView.saveEntry("history") + } + + StateLayer { + anchors.fill: saveArrowArea + stateColor: Theme.onPrimary + onClicked: editorView.toggleSaveMenu() + } + } + } + + Popup { + id: saveMenu + parent: Overlay.overlay + width: 220 + padding: Theme.spacingM + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: StyledRect { + radius: Theme.cornerRadius + color: Theme.surfaceContainer + border.color: Theme.outlineMedium + border.width: 1 + } + + contentItem: Column { + id: saveMenuColumn + spacing: Theme.spacingXS + + StyledRect { + width: saveMenu.width - saveMenu.padding * 2 + height: 32 + radius: Theme.cornerRadius + color: saveMenuSaveArea.containsMouse ? Theme.surfaceVariant : "transparent" + + Row { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: "save" + size: Theme.iconSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Save") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: saveMenuSaveArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + saveMenu.close(); + editorView.saveEntry("history"); + } + } + } + + StyledRect { + width: saveMenu.width - saveMenu.padding * 2 + height: 32 + radius: Theme.cornerRadius + color: saveMenuCloseArea.containsMouse ? Theme.surfaceVariant : "transparent" + + Row { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: "close" + size: Theme.iconSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Save and close") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: saveMenuCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + saveMenu.close(); + editorView.saveEntry("close"); + } + } + } + + StyledRect { + width: saveMenu.width - saveMenu.padding * 2 + height: 32 + radius: Theme.cornerRadius + color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : "transparent" + opacity: clipboardHistoryModal.wtypeAvailable ? 1 : 0.5 + + Row { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: "content_paste" + size: Theme.iconSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Save and paste") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: saveMenuPasteArea + anchors.fill: parent + hoverEnabled: true + enabled: clipboardHistoryModal.wtypeAvailable + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + saveMenu.close(); + editorView.saveEntry("paste"); + } + } + } + } + } + } + } + + states: [ + State { + name: "history" + when: clipboardHistoryModal.mode === "history" + PropertyChanges { + target: historyView + opacity: 1 + scale: 1 + } + PropertyChanges { + target: editorView + opacity: 0 + scale: 0.98 + } + }, + State { + name: "editor" + when: clipboardHistoryModal.mode === "editor" + PropertyChanges { + target: historyView + opacity: 0 + scale: 0.98 + } + PropertyChanges { + target: editorView + opacity: 1 + scale: 1 + } + } + ] + + transitions: [ + Transition { + from: "history" + to: "editor" + ParallelAnimation { + NumberAnimation { + property: "opacity" + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + NumberAnimation { + property: "scale" + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } + }, + Transition { + from: "editor" + to: "history" + ParallelAnimation { + NumberAnimation { + property: "opacity" + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + NumberAnimation { + property: "scale" + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } + } + ] } } } diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml index f2a457ee3..82a2a455d 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml @@ -56,7 +56,9 @@ QtObject { function handleKey(event) { switch (event.key) { case Qt.Key_Escape: - if (ClipboardService.keyboardNavigationActive) { + if (modal.mode === "editor") { + modal.mode = "history"; + } else if (ClipboardService.keyboardNavigationActive) { ClipboardService.keyboardNavigationActive = false; } else { modal.hide(); diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml index edfb9cb65..6984bde96 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml @@ -10,7 +10,7 @@ Rectangle { readonly property string hintsText: { if (!wtypeAvailable) return I18n.tr("Shift+Del: Clear All • Esc: Close"); - return enterToPaste ? I18n.tr("Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close"); + return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close"); } height: ClipboardConstants.keyboardHintsHeight @@ -22,13 +22,17 @@ Rectangle { z: 100 Column { + width: parent.width - Theme.spacingL * 2 anchors.centerIn: parent spacing: 2 StyledText { - text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : "↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help" + text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter anchors.horizontalCenter: parent.horizontalCenter } @@ -36,6 +40,9 @@ Rectangle { text: keyboardHints.hintsText font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter anchors.horizontalCenter: parent.horizontalCenter } } diff --git a/quickshell/Services/ClipboardService.qml b/quickshell/Services/ClipboardService.qml index 19d4fd7f1..18857ab8a 100644 --- a/quickshell/Services/ClipboardService.qml +++ b/quickshell/Services/ClipboardService.qml @@ -113,6 +113,17 @@ Singleton { }); } + function pasteClipboard(closeCallback) { + if (!wtypeAvailable) { + ToastService.showError(I18n.tr("wtype not available - install wtype for paste support")); + return; + } + if (closeCallback) { + closeCallback(); + } + pasteTimer.start(); + } + function pasteEntry(entry, closeCallback) { if (!wtypeAvailable) { ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));