Files
dotfiles/noctalia/plugins/clipper/ClipboardCard.qml
T
Domipoke 955801c666 noctalia
2026-06-25 19:58:23 +02:00

427 lines
15 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Keyboard
import qs.Services.Noctalia
import qs.Services.UI
import qs.Widgets
Rectangle {
id: root
focus: true
property var clipboardItem: null
property var pluginApi: null
property var screen: null
property var panelRoot: null
property string clipboardId: clipboardItem ? clipboardItem.id : ""
property string mime: clipboardItem ? clipboardItem.mime : ""
property string preview: clipboardItem ? clipboardItem.preview : ""
property string pinnedImageDataUrl: "" // For pinned images - data URL
// Content type detection
readonly property bool isImage: clipboardItem && clipboardItem.isImage
readonly property bool isColor: {
if (isImage || !preview)
return false;
const trimmed = preview.trim();
return /^#[A-Fa-f0-9]{6}$/.test(trimmed) || /^#[A-Fa-f0-9]{3}$/.test(trimmed) || /^[A-Fa-f0-9]{6}$/.test(trimmed) || /^rgba?\(.*\)$/i.test(trimmed);
}
readonly property bool isLink: !isImage && !isColor && preview && /^https?:\/\//.test(preview.trim())
readonly property bool isCode: !isImage && !isColor && !isLink && preview && (preview.includes("function") || preview.includes("import ") || preview.includes("const ") || preview.includes("let ") || preview.includes("var ") || preview.includes("class ") || preview.includes("def ") || preview.includes("return ") || /^[\{\[\(<]/.test(preview.trim()))
readonly property bool isEmoji: {
if (isImage || isColor || isLink || isCode || !preview)
return false;
const trimmed = preview.trim();
return trimmed.length <= 4 && trimmed.charCodeAt(0) > 255;
}
readonly property bool isFile: !isImage && !isColor && !isLink && !isCode && !isEmoji && preview && /^(\/|~|file:\/\/)/.test(preview.trim())
readonly property bool isText: !isImage && !isColor && !isLink && !isCode && !isEmoji && !isFile
// Helper to safely access Color singleton
function getColor(propName, fallback) {
if (typeof Color !== "undefined" && Color[propName])
return Color[propName];
return fallback;
}
readonly property string colorValue: {
if (!isColor || !preview)
return "";
const trimmed = preview.trim();
if (/^#[A-Fa-f0-9]{3,6}$/.test(trimmed))
return trimmed;
if (/^[A-Fa-f0-9]{6}$/.test(trimmed))
return "#" + trimmed;
return trimmed;
}
readonly property string typeLabel: isImage ? "Image" : isColor ? "Color" : isLink ? "Link" : isCode ? "Code" : isEmoji ? "Emoji" : isFile ? "File" : "Text"
readonly property string typeIcon: isImage ? "photo" : isColor ? "palette" : isLink ? "link" : isCode ? "code" : isEmoji ? "mood-smile" : isFile ? "file" : "align-left"
// Default colors per card type
readonly property var defaultCardColors: {
"Text": {
bg: "mOutline",
separator: "mSurface",
fg: "mOnSurface"
},
"Image": {
bg: "mTertiary",
separator: "mSurface",
fg: "mOnTertiary"
},
"Link": {
bg: "mPrimary",
separator: "mSurface",
fg: "mOnPrimary"
},
"Code": {
bg: "mSecondary",
separator: "mSurface",
fg: "mOnSecondary"
},
"Color": {
bg: "mSecondary",
separator: "mSurface",
fg: "mOnSecondary"
},
"Emoji": {
bg: "mHover",
separator: "mSurface",
fg: "mOnHover"
},
"File": {
bg: "mError",
separator: "mSurface",
fg: "mOnError"
}
}
// Get color setting for current card type
function getCardColorSetting(colorType) {
const settings = pluginApi?.pluginSettings?.cardColors;
const customColors = pluginApi?.pluginSettings?.customColors;
const cardType = typeLabel;
if (settings && settings[cardType] && settings[cardType][colorType]) {
const colorKey = settings[cardType][colorType];
if (colorKey === "custom" && customColors && customColors[cardType]) {
return customColors[cardType][colorType] || "#888888";
}
return getColor(colorKey, defaultCardColors[cardType]?.[colorType] || "#888888");
}
// Fallback to default
const defaultKey = defaultCardColors[cardType]?.[colorType];
return getColor(defaultKey, "#888888");
}
// Colors from settings or defaults
readonly property color accentColor: getCardColorSetting("bg")
readonly property color accentFgColor: getCardColorSetting("fg")
readonly property color separatorColor: getCardColorSetting("separator")
signal clicked
signal deleteClicked
signal addToTodoClicked
signal pinClicked
signal rightClicked
property bool selected: false
property bool enableTodoIntegration: false
property bool isPinned: false
width: 250
height: parent.height
// Get ToDo pages from plugin
function getTodoPages() {
const todoApi = PluginService.getPluginAPI("todo");
if (!todoApi || !todoApi.pluginSettings || !todoApi.pluginSettings.pages) {
return [
{
id: 0,
name: "General"
}
];
}
return todoApi.pluginSettings.pages;
}
// Build menu model from ToDo pages
function buildTodoMenuModel() {
const pages = getTodoPages();
const model = [];
for (let i = 0; i < pages.length; i++) {
model.push({
"label": pages[i].name,
"action": "page-" + pages[i].id,
"icon": "checkbox"
});
}
return model;
}
// Anchor point for context menu positioning
Item {
id: menuAnchor
width: 0
height: 0
visible: false
}
// Context menu for ToDo page selection
NPopupContextMenu {
id: todoContextMenu
screen: root.screen
anchorItem: menuAnchor
onTriggered: action => {
todoContextMenu.visible = false;
if (action.startsWith("page-")) {
const pageId = parseInt(action.replace("page-", ""));
if (root.preview) {
root.pluginApi?.mainInstance?.addTodoWithText(root.preview.substring(0, 200), pageId);
}
}
}
onVisibleChanged: {
if (!visible) {
// Cleanup when menu closes
root.focus = true;
if (root.panelRoot && root.panelRoot.activeContextMenu === todoContextMenu) {
root.panelRoot.activeContextMenu = null;
}
}
}
}
// Click outside to close menu
MouseArea {
anchors.fill: parent
enabled: todoContextMenu.visible
z: todoContextMenu.visible ? 999 : -1
onClicked: {
todoContextMenu.visible = false;
}
}
// Body background - Same as accent color
color: selected ? Qt.darker(accentColor, 1.1) : (mouseArea.containsMouse ? Qt.lighter(accentColor, 1.1) : accentColor)
radius: (typeof Style !== "undefined") ? Style.radiusM : 16
border.width: 2
border.color: accentColor // Border same as background
// Visual indicator for pinned status (small icon in corner)
NIcon {
visible: root.isPinned
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 4
z: 100
icon: "pin-filled"
pointSize: 10
color: root.accentFgColor
opacity: 0.6
}
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
id: headerBar
Layout.fillWidth: true
Layout.preferredHeight: 35
color: root.accentColor // Header same as background
topLeftRadius: (typeof Style !== "undefined") ? Style.radiusM : 16
topRightRadius: (typeof Style !== "undefined") ? Style.radiusM : 16
bottomLeftRadius: 0
bottomRightRadius: 0
RowLayout {
id: headerContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 8
spacing: 8
NIcon {
icon: root.typeIcon
pointSize: 13
color: root.accentFgColor
}
NText {
text: root.typeLabel
color: root.accentFgColor
font.bold: true
}
Item {
Layout.fillWidth: true
}
NIconButton {
id: todoButton
visible: root.enableTodoIntegration && !root.isImage
icon: "checkbox"
tooltipText: pluginApi?.tr("card.add-todo")
colorFg: root.accentFgColor
colorBg: "transparent"
colorBgHover: Qt.rgba(0, 0, 0, 0.1)
colorBorder: "transparent"
colorBorderHover: "transparent"
onClicked: {
// Close any previously open menu
if (root.panelRoot && root.panelRoot.activeContextMenu) {
root.panelRoot.activeContextMenu.visible = false;
}
// Position anchor at button location
const pos = todoButton.mapToItem(root, 0, todoButton.height);
menuAnchor.x = pos.x;
menuAnchor.y = pos.y;
// Show menu and register it as active
todoContextMenu.model = root.buildTodoMenuModel();
todoContextMenu.visible = true;
if (root.panelRoot) {
root.panelRoot.activeContextMenu = todoContextMenu;
}
}
}
NIconButton {
visible: !root.isPinned && (pluginApi?.pluginSettings?.pincardsEnabled ?? true) // Hide pin button if pinned or if feature disabled
icon: "pin"
tooltipText: pluginApi?.tr("card.pin")
colorFg: root.accentFgColor
colorBg: "transparent"
colorBgHover: Qt.rgba(0, 0, 0, 0.1)
colorBorder: "transparent"
colorBorderHover: "transparent"
onClicked: root.pinClicked()
}
NIconButton {
icon: "trash"
tooltipText: pluginApi?.tr("card.delete")
colorFg: root.accentFgColor
colorBg: "transparent"
colorBgHover: Qt.rgba(0, 0, 0, 0.1)
colorBorder: "transparent"
colorBorderHover: "transparent"
onClicked: root.deleteClicked()
}
}
MouseArea {
anchors.fill: parent
z: -1
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
root.rightClicked()
} else {
root.clicked()
}
}
}
}
Rectangle {
width: parent.width - 10
Layout.alignment: Qt.AlignHCenter
height: 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0.0
color: "transparent"
}
GradientStop {
position: 0.5
color: root.separatorColor
}
GradientStop {
position: 1.0
color: "transparent"
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.margins: 8
clip: true
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
root.rightClicked()
} else {
root.clicked()
}
}
}
Rectangle {
visible: root.isColor
anchors.fill: parent
radius: 8
color: root.colorValue || "transparent"
border.width: 1
border.color: root.accentFgColor // Use FG color for border contrast
}
NText {
visible: !root.isColor && !root.isImage
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
text: root.preview || ""
wrapMode: Text.Wrap
elide: Text.ElideRight
color: root.accentFgColor
font.pointSize: 11
verticalAlignment: Text.AlignTop
}
NImageRounded {
visible: root.isImage
anchors.fill: parent
radius: 8
imageFillMode: Image.PreserveAspectFit
imagePath: {
// For pinned images, use data URL directly
if (root.pinnedImageDataUrl) {
return root.pinnedImageDataUrl;
}
// For cliphist images, use cache (reactive binding via imageCacheRevision)
if (root.isImage && root.pluginApi?.mainInstance) {
// Force re-evaluation when cache changes by referencing revision
const revision = root.pluginApi.mainInstance.imageCacheRevision;
const cache = root.pluginApi.mainInstance.imageCache;
return cache[root.clipboardId] || "";
}
return "";
}
Component.onCompleted: {
// Only decode if not pinned (pinned images have data URL already)
if (!root.pinnedImageDataUrl && root.isImage && root.clipboardId && root.pluginApi?.mainInstance) {
root.pluginApi.mainInstance.decodeToDataUrl(root.clipboardId, root.mime, null);
}
}
}
}
}
Component.onDestruction: {
if (todoContextMenu && todoContextMenu.visible) {
todoContextMenu.visible = false;
}
}
}