noctalia
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user