mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-22 11:05:22 -04:00
Enhance/clipboard history interactions (#2668)
* feat(clipboard): add entry context menu * feat(clipboard): add click to paste option * feat(clipboard): add optional copy and paste action buttons * fix(clipboard): default selection and esc behavior * fix(clipboard): prevent clear and filter overlap * Update to remove dead kb nav & add escape action on context menu --------- Co-authored-by: purian23 <purian23@gmail.com>
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
visible: false
|
||||
width: 0
|
||||
height: 0
|
||||
|
||||
property var entry: null
|
||||
property var modal: null
|
||||
property var parentHandler: null
|
||||
property real menuMargin: 8
|
||||
property var targetScreen: null
|
||||
property real anchorX: 0
|
||||
property real anchorY: 0
|
||||
property bool openState: false
|
||||
property bool renderActive: false
|
||||
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
|
||||
|
||||
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
|
||||
readonly property bool canEditEntry: !!entry && !(entry.isImage ?? false)
|
||||
readonly property string pinText: entry?.pinned || hasPinnedDuplicate ? I18n.tr("Unpin") : I18n.tr("Pin")
|
||||
readonly property string pinIcon: entry?.pinned || hasPinnedDuplicate ? "keep_off" : "push_pin"
|
||||
|
||||
readonly property var menuItems: {
|
||||
const items = [
|
||||
{
|
||||
type: "item",
|
||||
icon: "content_copy",
|
||||
text: I18n.tr("Copy"),
|
||||
action: copyEntry
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
icon: pinIcon,
|
||||
text: pinText,
|
||||
action: togglePin
|
||||
}
|
||||
];
|
||||
|
||||
if (canEditEntry) {
|
||||
items.push({
|
||||
type: "item",
|
||||
icon: "edit",
|
||||
text: I18n.tr("Edit"),
|
||||
action: editEntry
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: "item",
|
||||
icon: "delete",
|
||||
text: I18n.tr("Delete"),
|
||||
action: deleteEntry
|
||||
}, {
|
||||
type: "separator"
|
||||
}, {
|
||||
type: "item",
|
||||
icon: "content_paste",
|
||||
text: I18n.tr("Paste"),
|
||||
action: pasteEntry
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
readonly property real minMenuWidth: 160
|
||||
readonly property real maxMenuWidth: Math.max(0, (targetScreen?.width ?? 500) - menuMargin * 2)
|
||||
readonly property real maxMenuHeight: Math.max(0, (targetScreen?.height ?? 600) - menuMargin * 2)
|
||||
readonly property string longestMenuText: {
|
||||
let longest = "";
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
const text = menuItems[i].text || "";
|
||||
if (text.length > longest.length)
|
||||
longest = text;
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
readonly property real naturalMenuWidth: Math.max(minMenuWidth, menuTextMetrics.width + Theme.iconSize + Theme.spacingS * 5)
|
||||
readonly property real effectiveMenuWidth: Math.max(0, Math.min(maxMenuWidth, naturalMenuWidth))
|
||||
readonly property real naturalMenuHeight: menuItemsHeight() + Theme.spacingS * 2
|
||||
readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight)
|
||||
readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5
|
||||
|
||||
TextMetrics {
|
||||
id: menuTextMetrics
|
||||
text: root.longestMenuText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Normal
|
||||
}
|
||||
|
||||
function menuItemsHeight() {
|
||||
let h = 0;
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
h += menuItems[i].type === "separator" ? 5 : 32;
|
||||
}
|
||||
if (menuItems.length > 1)
|
||||
h += menuItems.length - 1;
|
||||
return h;
|
||||
}
|
||||
|
||||
function show(x, y, targetEntry) {
|
||||
if (!targetEntry)
|
||||
return;
|
||||
|
||||
entry = targetEntry;
|
||||
|
||||
const host = modal?.surfaceHost ?? null;
|
||||
const modalWindow = modal?.Window?.window ?? null;
|
||||
const screenRef = host?.effectiveScreen ?? host?.screen ?? modalWindow?.screen ?? parentHandler?.Window?.window?.screen ?? null;
|
||||
const screenX = screenRef?.x || 0;
|
||||
const screenY = screenRef?.y || 0;
|
||||
const hostX = host?.alignedX;
|
||||
const hostY = host?.renderedAlignedY ?? host?.alignedY;
|
||||
const globalPos = (!isNaN(hostX) && !isNaN(hostY)) ? ({
|
||||
x: screenX + hostX + x,
|
||||
y: screenY + hostY + y
|
||||
}) : (parentHandler ? parentHandler.mapToGlobal(x, y) : ({
|
||||
x: screenX + x,
|
||||
y: screenY + y
|
||||
}));
|
||||
|
||||
targetScreen = screenRef;
|
||||
anchorX = globalPos.x - screenX + 4;
|
||||
anchorY = globalPos.y - screenY + 4;
|
||||
renderActive = true;
|
||||
openState = true;
|
||||
|
||||
Qt.callLater(() => menuFlickable.contentY = 0);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!renderActive)
|
||||
return;
|
||||
openState = false;
|
||||
}
|
||||
|
||||
function showFromWindowPoint(x, y) {
|
||||
if (!parentHandler || typeof parentHandler.contextEntryAtScreen !== "function") {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const hit = parentHandler.contextEntryAtScreen(x, y);
|
||||
|
||||
if (!hit || !hit.entry) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
show(hit.x, hit.y, hit.entry);
|
||||
}
|
||||
|
||||
function copyEntry() {
|
||||
if (!entry)
|
||||
return;
|
||||
modal?.copyEntry(entry);
|
||||
hide();
|
||||
}
|
||||
|
||||
function togglePin() {
|
||||
if (!entry)
|
||||
return;
|
||||
if (entry.pinned) {
|
||||
modal?.unpinEntry(entry);
|
||||
} else {
|
||||
const duplicate = ClipboardService.getPinnedEntryByHash(entry.hash);
|
||||
if (duplicate)
|
||||
modal?.unpinEntry(duplicate);
|
||||
else
|
||||
modal?.pinEntry(entry);
|
||||
}
|
||||
hide();
|
||||
}
|
||||
|
||||
function editEntry() {
|
||||
if (!entry || !canEditEntry)
|
||||
return;
|
||||
modal?.editEntry(entry);
|
||||
hide();
|
||||
}
|
||||
|
||||
function deleteEntry() {
|
||||
if (!entry)
|
||||
return;
|
||||
if (entry.pinned)
|
||||
modal?.deletePinnedEntry(entry);
|
||||
else
|
||||
modal?.deleteEntry(entry);
|
||||
hide();
|
||||
}
|
||||
|
||||
function pasteEntry() {
|
||||
if (!entry)
|
||||
return;
|
||||
modal?.pasteEntry(entry);
|
||||
hide();
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: menuWindow
|
||||
|
||||
screen: root.targetScreen
|
||||
visible: root.renderActive
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: "dms:clipboard-context-menu"
|
||||
WlrLayershell.layer: WlrLayershell.Overlay
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: menuWindow
|
||||
blurX: root.blurActive ? menuContainer.x : 0
|
||||
blurY: root.blurActive ? menuContainer.y : 0
|
||||
blurWidth: root.blurActive ? menuContainer.width : 0
|
||||
blurHeight: root.blurActive ? menuContainer.height : 0
|
||||
blurRadius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
enabled: root.renderActive
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
root.showFromWindowPoint(mouse.x, mouse.y);
|
||||
return;
|
||||
}
|
||||
root.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
Rectangle {
|
||||
id: menuContainer
|
||||
x: Math.max(root.menuMargin, Math.min(menuWindow.width - width - root.menuMargin, root.anchorX))
|
||||
y: Math.max(root.menuMargin, Math.min(menuWindow.height - height - root.menuMargin, root.anchorY))
|
||||
width: root.effectiveMenuWidth
|
||||
height: root.effectiveMenuHeight
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
||||
opacity: root.openState ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
onRunningChanged: {
|
||||
if (!running && !root.openState) {
|
||||
root.renderActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: 4
|
||||
anchors.leftMargin: 2
|
||||
anchors.rightMargin: -2
|
||||
anchors.bottomMargin: -4
|
||||
radius: parent.radius
|
||||
color: Qt.rgba(0, 0, 0, 0.15)
|
||||
z: -1
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: menuFlickable
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingS
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: menuColumn.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
interactive: root.menuScrolls
|
||||
|
||||
Column {
|
||||
id: menuColumn
|
||||
width: menuFlickable.width
|
||||
spacing: 1
|
||||
|
||||
Repeater {
|
||||
model: root.menuItems
|
||||
|
||||
Item {
|
||||
id: menuItemDelegate
|
||||
required property var modelData
|
||||
|
||||
width: menuColumn.width
|
||||
height: modelData.type === "separator" ? 5 : 32
|
||||
|
||||
Rectangle {
|
||||
visible: menuItemDelegate.modelData.type === "separator"
|
||||
width: parent.width - Theme.spacingS * 2
|
||||
height: parent.height
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: menuItemDelegate.modelData.type === "item"
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
radius: Theme.cornerRadius
|
||||
color: itemMouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Item {
|
||||
width: Theme.iconSize - 2
|
||||
height: Theme.iconSize - 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
DankIcon {
|
||||
visible: (menuItemDelegate.modelData?.icon ?? "").length > 0
|
||||
name: menuItemDelegate.modelData?.icon ?? ""
|
||||
size: Theme.iconSize - 2
|
||||
color: Theme.surfaceText
|
||||
opacity: 0.7
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: menuItemDelegate.modelData.text || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Normal
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
elide: Text.ElideRight
|
||||
width: parent.width - (Theme.iconSize - 2) - Theme.spacingS
|
||||
}
|
||||
}
|
||||
|
||||
DankRipple {
|
||||
id: menuItemRipple
|
||||
rippleColor: Theme.surfaceText
|
||||
cornerRadius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: itemMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onPressed: mouse => menuItemRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
root.hide();
|
||||
return;
|
||||
}
|
||||
const menuItem = menuItemDelegate.modelData;
|
||||
if (menuItem.action)
|
||||
menuItem.action();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user