From 724ee9422dc20174d6c97e95e838129a06c17214 Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 14 Jul 2026 11:25:10 -0400 Subject: [PATCH] plugins: use grid-style plugin browser port 1.5 (cherry picked from commit 27703575bc03e0f1b9bcb740ee2d18265a170c37) --- quickshell/Modules/Settings/PluginBadge.qml | 41 + quickshell/Modules/Settings/PluginBrowser.qml | 1619 +++++++++++------ quickshell/Widgets/CachingImage.qml | 23 +- 3 files changed, 1093 insertions(+), 590 deletions(-) create mode 100644 quickshell/Modules/Settings/PluginBadge.qml diff --git a/quickshell/Modules/Settings/PluginBadge.qml b/quickshell/Modules/Settings/PluginBadge.qml new file mode 100644 index 000000000..995ec0ef5 --- /dev/null +++ b/quickshell/Modules/Settings/PluginBadge.qml @@ -0,0 +1,41 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Rectangle { + id: root + + property string label: "" + property string iconName: "" + property color tone: Theme.primary + property bool onImage: false + + height: 20 + width: content.implicitWidth + Theme.spacingS * 2 + radius: height / 2 + color: onImage ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.92) : Theme.withAlpha(tone, 0.12) + border.color: Theme.withAlpha(tone, onImage ? 0.5 : 0.35) + border.width: 1 + + Row { + id: content + anchors.centerIn: parent + spacing: Theme.spacingXXS + + DankIcon { + name: root.iconName + size: 11 + color: root.tone + visible: root.iconName.length > 0 + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: root.label + font.pixelSize: Theme.fontSizeSmall - 2 + font.weight: Font.Medium + color: root.tone + anchors.verticalCenter: parent.verticalCenter + } + } +} diff --git a/quickshell/Modules/Settings/PluginBrowser.qml b/quickshell/Modules/Settings/PluginBrowser.qml index 3370892a8..eb4014abd 100644 --- a/quickshell/Modules/Settings/PluginBrowser.qml +++ b/quickshell/Modules/Settings/PluginBrowser.qml @@ -1,7 +1,7 @@ import QtQuick -import QtQuick.Controls import QtQuick.Layouts import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.Common import qs.Services @@ -24,9 +24,10 @@ FloatingWindow { property string categoryFilter: "all" property var categoryFilterOptions: [] property var availableLetters: [] - property string expandedPluginId: "" - property string enlargedPreviewPluginId: "" + property string detailPluginId: "" + readonly property string previewApiBase: "https://api.danklinux.com/previews/" + readonly property var detailPlugin: resolveDetailPlugin(detailPluginId, allPlugins) readonly property bool activeCategorySort: normalizedSortMode(SessionData.pluginBrowserSortMode) === "category" readonly property bool showCategoryFilters: activeCategorySort && categoryFilterOptions.length > 1 readonly property bool showLetterIndex: { @@ -98,16 +99,33 @@ FloatingWindow { return (plugin.status || []).indexOf("reviewed") !== -1; } - function statusColor(status) { + function statusTone(status) { switch (status) { case "broken": - return Theme.error; + return "error"; case "unmaintained": - return Theme.warning; + return "warning"; case "reviewed": - return Theme.info; + return "info"; default: + return "outline"; + } + } + + function badgeTone(tone) { + switch (tone) { + case "secondary": + return Theme.secondary; + case "warning": + return Theme.warning; + case "error": + return Theme.error; + case "info": + return Theme.info; + case "outline": return Theme.outline; + default: + return Theme.primary; } } @@ -126,23 +144,76 @@ FloatingWindow { } } - function relatedNames(plugin) { + function badgeModel(plugin) { + if (!plugin || (!plugin.id && !plugin.name)) + return []; + var badges = []; + if (plugin.featured) + badges.push({ + label: I18n.tr("featured"), + icon: "star", + tone: "secondary" + }); + if (plugin.firstParty) + badges.push({ + label: I18n.tr("official"), + icon: "verified", + tone: "primary" + }); + else + badges.push({ + label: I18n.tr("3rd party"), + icon: "", + tone: "warning" + }); + var status = plugin.status || []; + for (var i = 0; i < status.length; i++) + badges.push({ + label: statusLabel(status[i]), + icon: "", + tone: statusTone(status[i]) + }); + return badges; + } + + function previewUrl(plugin) { + if (!plugin) + return ""; + if (plugin.previewUrl) + return plugin.previewUrl; + if (plugin.id) + return previewApiBase + plugin.id; + return plugin.screenshot || ""; + } + + function heroUrl(plugin) { + if (!plugin) + return ""; + return plugin.screenshot || previewUrl(plugin); + } + + function relatedPlugins(plugin) { if (!plugin || !plugin.similar || plugin.similar.length === 0) return []; - var names = []; + var related = []; for (var i = 0; i < plugin.similar.length; i++) { var id = plugin.similar[i]; + var key = ""; var name = id; for (var j = 0; j < allPlugins.length; j++) { - if (allPlugins[j].id === id) { - name = allPlugins[j].name || id; - break; - } + if (allPlugins[j].id !== id) + continue; + key = detailKeyFor(allPlugins[j]); + name = allPlugins[j].name || id; + break; } - names.push(name); + related.push({ + key: key, + name: name + }); } - return names; + return related; } function comparePluginAuthor(a, b) { @@ -252,14 +323,13 @@ FloatingWindow { } function refreshListLayout() { - if (!pluginBrowserList) + if (!pluginGrid) return; - pluginBrowserList.savedY = 0; - pluginBrowserList.cancelFlick(); - pluginBrowserList.contentY = 0; + pluginGrid.cancelFlick(); + pluginGrid.contentY = 0; Qt.callLater(() => { - if (pluginBrowserList) - pluginBrowserList.forceLayout(); + if (pluginGrid) + pluginGrid.forceLayout(); }); } @@ -268,17 +338,13 @@ FloatingWindow { for (var i = 0; i < filteredPlugins.length; i++) { var key = sortKeyForPlugin(filteredPlugins[i], mode); if (key && key.charAt(0).toUpperCase() === letter) { - pluginBrowserList.positionViewAtIndex(i, ListView.Beginning); - pluginBrowserList.savedY = pluginBrowserList.contentY; + pluginGrid.positionViewAtIndex(i, GridView.Beginning); return; } } } function updateFilteredPlugins() { - expandedPluginId = ""; - enlargedPreviewPluginId = ""; - var baseFiltered = []; var query = searchQuery ? searchQuery.toLowerCase() : ""; @@ -364,49 +430,102 @@ FloatingWindow { refreshListLayout(); } - function pluginKey(plugin, fallbackIndex) { + function detailKeyFor(plugin) { if (!plugin) - return "plugin-" + fallbackIndex; - return plugin.id || plugin.name || ("plugin-" + fallbackIndex); + return ""; + return plugin.id || plugin.name || ""; } - function toggleExpandedPlugin(pluginId) { - if (expandedPluginId === pluginId) { - expandedPluginId = ""; - enlargedPreviewPluginId = ""; - } else { - expandedPluginId = pluginId; - enlargedPreviewPluginId = ""; + function resolveDetailPlugin(key, plugins) { + if (!key) + return null; + for (var i = 0; i < plugins.length; i++) { + if (detailKeyFor(plugins[i]) === key) + return plugins[i]; } - keyboardNavigationActive = false; - Qt.callLater(() => { - if (pluginBrowserList) - pluginBrowserList.forceLayout(); - }); + return null; } - function toggleEnlargedPreview(pluginId) { - enlargedPreviewPluginId = enlargedPreviewPluginId === pluginId ? "" : pluginId; - Qt.callLater(() => { - if (pluginBrowserList) - pluginBrowserList.forceLayout(); - }); + function openPluginDetail(plugin) { + var key = detailKeyFor(plugin); + if (!key) + return; + detailPluginId = key; + } + + function closePluginDetail() { + detailPluginId = ""; + } + + function formatUpdatedDate(iso) { + if (!iso) + return ""; + var date = new Date(iso); + if (isNaN(date.getTime())) + return ""; + return date.toLocaleDateString(Qt.locale(), Locale.ShortFormat); + } + + function detailMetaBadges(plugin) { + if (!plugin) + return []; + var items = []; + if (plugin.version) + items.push({ + label: "v" + plugin.version, + icon: "sell" + }); + if (plugin.category) + items.push({ + label: formatCategoryLabel((plugin.category || "").toLowerCase()), + icon: "category" + }); + var updated = formatUpdatedDate(plugin.updated_at); + if (updated) + items.push({ + label: updated, + icon: "history" + }); + return items; + } + + function ensureSelectedVisible() { + if (selectedIndex < 0 || !pluginGrid) + return; + pluginGrid.positionViewAtIndex(selectedIndex, GridView.Contain); } function selectNext() { - if (filteredPlugins.length === 0) + if (detailPluginId !== "" || filteredPlugins.length === 0) return; - keyboardNavigationActive = true; - selectedIndex = Math.min(selectedIndex + 1, filteredPlugins.length - 1); + if (!keyboardNavigationActive) { + keyboardNavigationActive = true; + selectedIndex = 0; + ensureSelectedVisible(); + return; + } + selectedIndex = Math.min(selectedIndex + pluginGrid.columns, filteredPlugins.length - 1); + ensureSelectedVisible(); } function selectPrevious() { - if (filteredPlugins.length === 0) + if (detailPluginId !== "" || filteredPlugins.length === 0 || !keyboardNavigationActive) return; - keyboardNavigationActive = true; - selectedIndex = Math.max(selectedIndex - 1, -1); - if (selectedIndex === -1) + var next = selectedIndex - pluginGrid.columns; + if (next < 0) { + selectedIndex = -1; keyboardNavigationActive = false; + return; + } + selectedIndex = next; + ensureSelectedVisible(); + } + + function selectStep(delta) { + if (detailPluginId !== "" || filteredPlugins.length === 0 || !keyboardNavigationActive) + return; + selectedIndex = Math.max(0, Math.min(selectedIndex + delta, filteredPlugins.length - 1)); + ensureSelectedVisible(); } function installPlugin(pluginName, enableAfterInstall) { @@ -476,9 +595,19 @@ FloatingWindow { objectName: "pluginBrowser" title: I18n.tr("Browse Plugins", "plugin browser window title") - minimumSize: Qt.size(450, 400) - implicitWidth: 600 - implicitHeight: 650 + minimumSize: Qt.size(520, 460) + implicitWidth: { + const maxWidth = screen ? screen.width - 120 : 1500; + if (parentModal && parentModal.width > 0) + return Math.round(Math.min(maxWidth, Math.max(640, parentModal.width * 0.8))); + return Math.min(maxWidth, 900); + } + implicitHeight: { + const maxHeight = screen ? screen.height - 80 : 960; + if (parentModal && parentModal.height > 0) + return Math.round(Math.min(maxHeight, Math.max(540, parentModal.height * 0.8))); + return Math.min(maxHeight, 760); + } color: Theme.surfaceContainer visible: false @@ -500,8 +629,7 @@ FloatingWindow { selectedIndex = -1; keyboardNavigationActive = false; isLoading = false; - expandedPluginId = ""; - enlargedPreviewPluginId = ""; + detailPluginId = ""; } Connections { @@ -546,6 +674,11 @@ FloatingWindow { Keys.onPressed: event => { switch (event.key) { case Qt.Key_Escape: + if (root.detailPluginId !== "") { + root.closePluginDetail(); + event.accepted = true; + return; + } root.hide(); event.accepted = true; return; @@ -557,6 +690,25 @@ FloatingWindow { root.selectPrevious(); event.accepted = true; return; + case Qt.Key_Left: + if (!root.keyboardNavigationActive) + return; + root.selectStep(-1); + event.accepted = true; + return; + case Qt.Key_Right: + if (!root.keyboardNavigationActive) + return; + root.selectStep(1); + event.accepted = true; + return; + case Qt.Key_Return: + case Qt.Key_Enter: + if (root.detailPluginId !== "" || !root.keyboardNavigationActive || root.selectedIndex < 0) + return; + root.openPluginDetail(root.filteredPlugins[root.selectedIndex]); + event.accepted = true; + return; } } @@ -570,7 +722,7 @@ FloatingWindow { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - height: Math.max(headerIcon.height, headerText.height, refreshButton.height, closeButton.height) + height: 44 MouseArea { anchors.fill: parent @@ -578,36 +730,67 @@ FloatingWindow { onDoubleClicked: windowControls.tryToggleMaximize() } - DankIcon { - id: headerIcon - name: "store" - size: Theme.iconSize - color: Theme.primary + Rectangle { + id: headerIconTile + width: 40 + height: 40 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.primary, 0.12) anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter + + DankIcon { + anchors.centerIn: parent + name: "store" + size: Theme.iconSize + color: Theme.primary + } } - StyledText { - id: headerText - text: I18n.tr("Browse Plugins", "plugin browser header") - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.Medium - color: Theme.surfaceText - anchors.left: headerIcon.right + Column { + anchors.left: headerIconTile.right anchors.leftMargin: Theme.spacingM + anchors.right: headerActions.left + anchors.rightMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + StyledText { + text: I18n.tr("Browse Plugins", "plugin browser header") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + maximumLineCount: 1 + width: parent.width + } + + StyledText { + text: { + const description = I18n.tr("Install plugins from the DMS plugin registry", "plugin browser description"); + if (root.isLoading || root.allPlugins.length === 0) + return description; + return description + " • " + root.filteredPlugins.length + "/" + root.allPlugins.length; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.outline + elide: Text.ElideRight + maximumLineCount: 1 + width: parent.width + } } Row { + id: headerActions anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingXS DankButton { - id: thirdPartyButton text: SessionData.showThirdPartyPlugins ? I18n.tr("Hide 3rd Party") : I18n.tr("Show 3rd Party") iconName: SessionData.showThirdPartyPlugins ? "visibility_off" : "visibility" height: 28 + anchors.verticalCenter: parent.verticalCenter onClicked: { if (SessionData.showThirdPartyPlugins) { SessionData.setShowThirdPartyPlugins(false); @@ -621,11 +804,11 @@ FloatingWindow { } DankActionButton { - id: refreshButton iconName: "refresh" iconSize: 18 iconColor: Theme.primary visible: !root.isLoading + anchors.verticalCenter: parent.verticalCenter onClicked: root.refreshPlugins() } @@ -634,36 +817,25 @@ FloatingWindow { iconName: root.maximized ? "fullscreen_exit" : "fullscreen" iconSize: Theme.iconSize - 2 iconColor: Theme.outline + anchors.verticalCenter: parent.verticalCenter onClicked: windowControls.tryToggleMaximize() } DankActionButton { - id: closeButton iconName: "close" iconSize: Theme.iconSize - 2 iconColor: Theme.outline + anchors.verticalCenter: parent.verticalCenter onClicked: root.hide() } } } - StyledText { - id: descriptionText - anchors.left: parent.left - anchors.right: parent.right - anchors.top: headerArea.bottom - anchors.topMargin: Theme.spacingM - text: I18n.tr("Install plugins from the DMS plugin registry", "plugin browser description") - font.pixelSize: Theme.fontSizeSmall - color: Theme.outline - wrapMode: Text.WordWrap - } - DankTextField { id: browserSearchField anchors.left: parent.left anchors.right: parent.right - anchors.top: descriptionText.bottom + anchors.top: headerArea.bottom anchors.topMargin: Theme.spacingM height: 48 cornerRadius: Theme.cornerRadius @@ -688,112 +860,102 @@ FloatingWindow { } } - Item { + Flow { id: sortControlsRow anchors.left: parent.left anchors.right: parent.right anchors.top: browserSearchField.bottom anchors.topMargin: Theme.spacingM - height: sortControlsLayout.implicitHeight + spacing: Theme.spacingS - RowLayout { - id: sortControlsLayout - anchors.left: parent.left - anchors.right: parent.right - spacing: Theme.spacingS + Repeater { + model: root.sortChipOptions - Repeater { - model: root.sortChipOptions + Rectangle { + id: sortChip + required property var modelData + + property bool selected: root.isSortChipSelected(modelData.id, modelData.toggle) + property bool hovered: chipMouseArea.containsMouse + property bool pressed: chipMouseArea.pressed + + width: chipContent.implicitWidth + Theme.spacingM * 2 + height: 32 + radius: height / 2 + color: selected ? Theme.primary : Theme.surfaceVariant + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } Rectangle { - id: sortChip - required property var modelData - required property int index - - Layout.fillWidth: true - Layout.preferredHeight: 32 - Layout.maximumHeight: 32 - property bool selected: root.isSortChipSelected(modelData.id, modelData.toggle) - property bool hovered: chipMouseArea.containsMouse - property bool pressed: chipMouseArea.pressed - - implicitWidth: chipContent.implicitWidth + Theme.spacingM * 2 - radius: height / 2 - color: selected ? Theme.primary : Theme.surfaceVariant + anchors.fill: parent + radius: parent.radius + color: { + if (sortChip.pressed) + return sortChip.selected ? Theme.primaryPressed : Theme.surfaceTextHover; + if (sortChip.hovered) + return sortChip.selected ? Theme.primaryHover : Theme.surfaceTextHover; + return "transparent"; + } Behavior on color { ColorAnimation { - duration: Theme.shortDuration + duration: Theme.shorterDuration easing.type: Theme.standardEasing } } + } - Rectangle { - anchors.fill: parent - radius: parent.radius - color: { - if (pressed) - return sortChip.selected ? Theme.primaryPressed : Theme.surfaceTextHover; - if (hovered) - return sortChip.selected ? Theme.primaryHover : Theme.surfaceTextHover; - return "transparent"; - } + DankRipple { + id: chipRipple + cornerRadius: sortChip.radius + rippleColor: sortChip.selected ? Theme.primaryText : Theme.surfaceVariantText + } - Behavior on color { - ColorAnimation { - duration: Theme.shorterDuration - easing.type: Theme.standardEasing - } - } + Row { + id: chipContent + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: sortChip.modelData.toggle ? "download_done" : "check" + size: 16 + anchors.verticalCenter: parent.verticalCenter + color: Theme.primaryText + visible: sortChip.selected } - DankRipple { - id: chipRipple - cornerRadius: sortChip.radius - rippleColor: sortChip.selected ? Theme.primaryText : Theme.surfaceVariantText + StyledText { + text: sortChip.modelData.label + font.pixelSize: Theme.fontSizeSmall + font.weight: sortChip.selected ? Font.Medium : Font.Normal + color: sortChip.selected ? Theme.primaryText : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter } + } - Row { - id: chipContent - anchors.centerIn: parent - spacing: Theme.spacingXS - - DankIcon { - name: modelData.toggle ? "download_done" : "check" - size: 16 - anchors.verticalCenter: parent.verticalCenter - color: Theme.primaryText - visible: sortChip.selected - } - - StyledText { - text: modelData.label - font.pixelSize: Theme.fontSizeSmall - font.weight: sortChip.selected ? Font.Medium : Font.Normal - color: sortChip.selected ? Theme.primaryText : Theme.surfaceVariantText - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - id: chipMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onPressed: mouse => chipRipple.trigger(mouse.x, mouse.y) - onClicked: { - if (modelData.toggle) { - if (modelData.id === "hideInstalled") - SessionData.setPluginBrowserHideInstalled(!SessionData.pluginBrowserHideInstalled); - else - SessionData.setPluginBrowserInstalledFirst(!SessionData.pluginBrowserInstalledFirst); - } else { - if (modelData.id !== "category") - root.categoryFilter = "all"; - SessionData.setPluginBrowserSortMode(modelData.id); - } - root.updateFilteredPlugins(); + MouseArea { + id: chipMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => chipRipple.trigger(mouse.x, mouse.y) + onClicked: { + if (sortChip.modelData.toggle) { + if (sortChip.modelData.id === "hideInstalled") + SessionData.setPluginBrowserHideInstalled(!SessionData.pluginBrowserHideInstalled); + else + SessionData.setPluginBrowserInstalledFirst(!SessionData.pluginBrowserInstalledFirst); + } else { + if (sortChip.modelData.id !== "category") + root.categoryFilter = "all"; + SessionData.setPluginBrowserSortMode(sortChip.modelData.id); } + root.updateFilteredPlugins(); } } } @@ -823,7 +985,6 @@ FloatingWindow { } DankDropdown { - id: categoryFilterDropdown Layout.fillWidth: true Layout.preferredHeight: 32 compactMode: true @@ -848,7 +1009,6 @@ FloatingWindow { anchors.top: categoryFiltersRow.bottom anchors.topMargin: Theme.spacingM anchors.bottom: parent.bottom - anchors.bottomMargin: Theme.spacingM Item { anchors.fill: parent @@ -860,296 +1020,218 @@ FloatingWindow { } } - DankListView { - id: pluginBrowserList + DankGridView { + id: pluginGrid + + property int columns: Math.max(1, Math.floor(width / 300)) + readonly property real cardSpacing: Theme.spacingM + readonly property int previewHeight: Math.round((cellWidth - cardSpacing - Theme.spacingS * 2) * 0.52) + readonly property int infoHeight: 100 anchors.fill: parent - anchors.leftMargin: Theme.spacingM - anchors.rightMargin: root.showLetterIndex ? Theme.spacingM + 18 : Theme.spacingM - anchors.topMargin: Theme.spacingS - anchors.bottomMargin: Theme.spacingS - spacing: Theme.spacingS + anchors.rightMargin: root.showLetterIndex ? 22 : 0 + cellWidth: Math.floor(width / columns) + cellHeight: previewHeight + infoHeight + Math.round(cardSpacing) + Theme.spacingS * 2 + Theme.spacingM model: root.filteredPlugins clip: true visible: !root.isLoading - add: null - remove: null - displaced: null - move: null + cacheBuffer: cellHeight * 2 - ScrollBar.vertical: DankScrollbar { - id: browserScrollbar - } + delegate: Item { + id: cardCell - delegate: Rectangle { - id: pluginDelegate + required property var modelData + required property int index - width: pluginBrowserList.width - height: pluginDelegateColumn.implicitHeight + Theme.spacingM * 2 - radius: Theme.cornerRadius - property bool isSelected: root.keyboardNavigationActive && index === root.selectedIndex property bool isInstalled: modelData.installed || false - property bool isFirstParty: modelData.firstParty || false - property bool isFeatured: modelData.featured || false property bool isCompatible: PluginService.checkPluginCompatibility(modelData.requires_dms) - property string pluginId: root.pluginKey(modelData, index) - property bool isExpanded: root.expandedPluginId === pluginId - property bool isPreviewEnlarged: root.enlargedPreviewPluginId === pluginId - property string screenshotUrl: modelData.screenshot || "" - color: isSelected ? Theme.primarySelected : rowMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceVariant, 0.45) : Theme.withAlpha(Theme.surfaceVariant, 0.3) - border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.outline, 0.2) - border.width: isSelected ? 2 : 1 + property bool isSelected: root.keyboardNavigationActive && index === root.selectedIndex - MouseArea { - id: rowMouseArea - z: 0 + width: pluginGrid.cellWidth + height: pluginGrid.cellHeight + + Rectangle { + id: card anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.toggleExpandedPlugin(pluginDelegate.pluginId) - } + anchors.margins: pluginGrid.cardSpacing / 2 + radius: Theme.cornerRadius + color: cardMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceVariant, 0.5) : Theme.withAlpha(Theme.surfaceVariant, 0.3) + border.color: cardCell.isSelected ? Theme.primary : Theme.withAlpha(Theme.outline, 0.15) + border.width: cardCell.isSelected ? 2 : 1 + scale: cardMouseArea.containsMouse ? 1.012 : 1 - Column { - id: pluginDelegateColumn - z: 1 - anchors.fill: parent - anchors.margins: Theme.spacingM - spacing: Theme.spacingXS + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } - Row { - width: parent.width - spacing: Theme.spacingM + Behavior on scale { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } - DankIcon { - name: modelData.icon || "extension" - size: Theme.iconSize - color: Theme.primary - anchors.verticalCenter: parent.verticalCenter + MouseArea { + id: cardMouseArea + z: 0 + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => cardRipple.trigger(mouse.x, mouse.y) + onClicked: root.openPluginDetail(cardCell.modelData) + } + + DankRipple { + id: cardRipple + cornerRadius: card.radius + rippleColor: Theme.surfaceVariantText + } + + Item { + id: previewArea + z: 1 + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingS + height: pluginGrid.previewHeight + + ClippingRectangle { + anchors.fill: parent + radius: Theme.cornerRadius - 2 + color: Theme.surfaceContainerHigh + + CachingImage { + id: cardPreview + anchors.fill: parent + imagePath: root.previewUrl(cardCell.modelData) + maxCacheSize: 640 + fillMode: Image.PreserveAspectCrop + animate: false + visible: status === Image.Ready + } + + DankIcon { + anchors.centerIn: parent + name: cardCell.modelData.icon || "extension" + size: Theme.iconSize + 12 + color: Theme.withAlpha(Theme.outline, 0.6) + visible: cardPreview.status !== Image.Ready + } + + DankSpinner { + anchors.centerIn: parent + running: cardPreview.status === Image.Loading + visible: running + } } - Column { - width: parent.width - Theme.iconSize - Theme.spacingM - installButton.width - Theme.spacingM + Row { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: Theme.spacingXS spacing: Theme.spacingXXS - Row { - spacing: Theme.spacingXS + Repeater { + model: root.badgeModel(cardCell.modelData) - StyledText { - text: modelData.name - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium - color: Theme.surfaceText - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter + PluginBadge { + required property var modelData + label: modelData.label + iconName: modelData.icon + tone: root.badgeTone(modelData.tone) + onImage: true } - - Rectangle { - height: 16 - width: featuredRow.implicitWidth + Theme.spacingXS * 2 - radius: 8 - color: Theme.withAlpha(Theme.secondary, 0.15) - border.color: Theme.withAlpha(Theme.secondary, 0.4) - border.width: 1 - visible: isFeatured - anchors.verticalCenter: parent.verticalCenter - - Row { - id: featuredRow - anchors.centerIn: parent - spacing: Theme.spacingXXS - - DankIcon { - name: "star" - size: 10 - color: Theme.secondary - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - text: I18n.tr("featured") - font.pixelSize: Theme.fontSizeSmall - 2 - color: Theme.secondary - font.weight: Font.Medium - anchors.verticalCenter: parent.verticalCenter - } - } - } - - Rectangle { - height: 16 - width: firstPartyText.implicitWidth + Theme.spacingXS * 2 - radius: 8 - color: Theme.withAlpha(Theme.primary, 0.15) - border.color: Theme.withAlpha(Theme.primary, 0.4) - border.width: 1 - visible: isFirstParty - anchors.verticalCenter: parent.verticalCenter - - StyledText { - id: firstPartyText - anchors.centerIn: parent - text: I18n.tr("official") - font.pixelSize: Theme.fontSizeSmall - 2 - color: Theme.primary - font.weight: Font.Medium - } - } - - Rectangle { - height: 16 - width: thirdPartyText.implicitWidth + Theme.spacingXS * 2 - radius: 8 - color: Theme.withAlpha(Theme.warning, 0.15) - border.color: Theme.withAlpha(Theme.warning, 0.4) - border.width: 1 - visible: !isFirstParty - anchors.verticalCenter: parent.verticalCenter - - StyledText { - id: thirdPartyText - anchors.centerIn: parent - text: I18n.tr("3rd party") - font.pixelSize: Theme.fontSizeSmall - 2 - color: Theme.warning - font.weight: Font.Medium - } - } - - Repeater { - model: modelData.status || [] - - Rectangle { - required property string modelData - height: 16 - width: statusText.implicitWidth + Theme.spacingXS * 2 - radius: 8 - color: Theme.withAlpha(root.statusColor(modelData), 0.15) - border.color: Theme.withAlpha(root.statusColor(modelData), 0.4) - border.width: 1 - anchors.verticalCenter: parent.verticalCenter - - StyledText { - id: statusText - anchors.centerIn: parent - text: root.statusLabel(parent.modelData) - font.pixelSize: Theme.fontSizeSmall - 2 - color: root.statusColor(parent.modelData) - font.weight: Font.Medium - } - } - } - - Rectangle { - height: 16 - width: upvoteRow.implicitWidth + Theme.spacingXS * 2 - radius: 8 - color: Theme.withAlpha(Theme.primary, 0.1) - border.color: Theme.withAlpha(Theme.primary, 0.3) - border.width: 1 - visible: !!modelData.issueUrl - anchors.verticalCenter: parent.verticalCenter - - Row { - id: upvoteRow - anchors.centerIn: parent - spacing: Theme.spacingXXS - - DankIcon { - name: "thumb_up" - size: 10 - color: Theme.primary - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - text: modelData.upvotes || 0 - font.pixelSize: Theme.fontSizeSmall - 2 - color: Theme.primary - font.weight: Font.Medium - anchors.verticalCenter: parent.verticalCenter - } - } - } - } - - StyledText { - text: { - const author = I18n.tr("by %1", "author attribution").arg(modelData.author || I18n.tr("Unknown", "unknown author")); - const source = modelData.repo ? ` • ${I18n.tr("source", "source code link")}` : ""; - const discuss = modelData.issueUrl ? ` • ${I18n.tr("discuss", "plugin discussion link")}` : ""; - return author + source + discuss; - } - font.pixelSize: Theme.fontSizeSmall - color: Theme.outline - linkColor: Theme.primary - textFormat: Text.RichText - elide: Text.ElideRight - width: parent.width - onLinkActivated: url => Qt.openUrlExternally(url) - - MouseArea { - anchors.fill: parent - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - acceptedButtons: Qt.NoButton - propagateComposedEvents: true - } - } - - StyledText { - visible: root.relatedNames(modelData).length > 0 - text: I18n.tr("Related: %1", "related plugins").arg(root.relatedNames(modelData).join(", ")) - font.pixelSize: Theme.fontSizeSmall - color: Theme.outline - elide: Text.ElideRight - width: parent.width } } - Rectangle { - id: installButton + PluginBadge { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Theme.spacingXS + iconName: "thumb_up" + label: cardCell.modelData.upvotes || 0 + tone: Theme.primary + onImage: true + visible: !!cardCell.modelData.issueUrl + } + } - property string buttonState: { - if (isInstalled) - return "installed"; - if (!isCompatible) - return "incompatible"; - return "available"; + Column { + z: 1 + anchors.top: previewArea.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Theme.spacingS + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingXXS + + Row { + width: parent.width + spacing: Theme.spacingS + + DankIcon { + id: cardIcon + name: cardCell.modelData.icon || "extension" + size: Theme.iconSize - 4 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter } - implicitWidth: Math.max(80, incompatRow.implicitWidth + Theme.spacingM * 2) - width: implicitWidth - height: 32 - radius: Theme.cornerRadius - anchors.verticalCenter: parent.verticalCenter - color: { - switch (buttonState) { - case "installed": - return Theme.surfaceVariant; - case "incompatible": - return Theme.withAlpha(Theme.warning, 0.15); - default: - return Theme.primary; + StyledText { + width: parent.width - cardIcon.width - installAction.width - Theme.spacingS * 2 + text: cardCell.modelData.name || "" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + maximumLineCount: 1 + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + id: installAction + + property string buttonState: { + if (cardCell.isInstalled) + return "installed"; + if (!cardCell.isCompatible) + return "incompatible"; + return "available"; } - } - opacity: buttonState === "available" && installMouseArea.containsMouse ? 0.9 : 1 - border.width: buttonState !== "available" ? 1 : 0 - border.color: buttonState === "incompatible" ? Theme.warning : Theme.outline - Behavior on opacity { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing + width: 28 + height: 28 + radius: 14 + anchors.verticalCenter: parent.verticalCenter + color: { + switch (buttonState) { + case "installed": + return Theme.surfaceVariant; + case "incompatible": + return Theme.withAlpha(Theme.warning, 0.15); + default: + return Theme.primary; + } } - } + opacity: buttonState === "available" && installMouseArea.containsMouse ? 0.85 : 1 - Row { - id: incompatRow - anchors.centerIn: parent - spacing: Theme.spacingXS + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } DankIcon { + anchors.centerIn: parent + size: 15 name: { - switch (installButton.buttonState) { + switch (installAction.buttonState) { case "installed": return "check"; case "incompatible": @@ -1158,9 +1240,8 @@ FloatingWindow { return "download"; } } - size: 14 color: { - switch (installButton.buttonState) { + switch (installAction.buttonState) { case "installed": return Theme.surfaceText; case "incompatible": @@ -1169,155 +1250,36 @@ FloatingWindow { return Theme.surface; } } - anchors.verticalCenter: parent.verticalCenter } - StyledText { - text: { - switch (installButton.buttonState) { - case "installed": - return I18n.tr("Installed", "installed status"); - case "incompatible": - return I18n.tr("Requires %1", "version requirement").arg(modelData.requires_dms); - default: - return I18n.tr("Install", "install action button"); - } - } - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - elide: Text.ElideNone - wrapMode: Text.NoWrap - color: { - switch (installButton.buttonState) { - case "installed": - return Theme.surfaceText; - case "incompatible": - return Theme.warning; - default: - return Theme.surface; - } - } - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - id: installMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: installButton.buttonState === "available" ? Qt.PointingHandCursor : Qt.ArrowCursor - enabled: installButton.buttonState === "available" - onClicked: { - const isDesktop = modelData.type === "desktop"; - root.installPlugin(modelData.name, isDesktop); + MouseArea { + id: installMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: installAction.buttonState === "available" ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: installAction.buttonState === "available" + onClicked: root.installPlugin(cardCell.modelData.name, cardCell.modelData.type === "desktop") } } } - } - StyledText { - text: modelData.description || "" - font.pixelSize: Theme.fontSizeSmall - color: Theme.outline - width: parent.width - wrapMode: Text.WordWrap - visible: (modelData.description || "").length > 0 - } - - Flow { - width: parent.width - spacing: Theme.spacingXS - visible: (modelData.capabilities || []).length > 0 - - Repeater { - model: modelData.capabilities || [] - - Rectangle { - height: 18 - width: capabilityText.implicitWidth + Theme.spacingXS * 2 - radius: 9 - color: Theme.withAlpha(Theme.primary, 0.1) - border.color: Theme.withAlpha(Theme.primary, 0.3) - border.width: 1 - - StyledText { - id: capabilityText - anchors.centerIn: parent - text: modelData - font.pixelSize: Theme.fontSizeSmall - 2 - color: Theme.primary - } - } - } - } - - Rectangle { - id: screenshotPreview - width: parent.width - height: pluginDelegate.isExpanded ? (pluginDelegate.isPreviewEnlarged ? Math.min(620, Math.max(320, width * 0.78)) : Math.min(260, Math.max(150, width * 0.42))) : 0 - visible: height > 0 - clip: true - radius: Theme.cornerRadius - color: Theme.surfaceContainerHigh - border.color: Theme.withAlpha(Theme.outline, 0.2) - border.width: 1 - - Behavior on height { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing - } + StyledText { + width: parent.width + text: I18n.tr("by %1", "author attribution").arg(cardCell.modelData.author || I18n.tr("Unknown", "unknown author")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.outline + elide: Text.ElideRight + maximumLineCount: 1 } - Loader { - id: screenshotImageLoader - anchors.fill: parent - anchors.margins: 1 - active: pluginDelegate.isExpanded && pluginDelegate.screenshotUrl.length > 0 - - sourceComponent: CachingImage { - imagePath: pluginDelegate.screenshotUrl - maxCacheSize: pluginDelegate.isPreviewEnlarged ? 1600 : 960 - fillMode: Image.PreserveAspectFit - visible: status !== Image.Error - } - } - - MouseArea { - anchors.fill: parent - enabled: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Ready - hoverEnabled: enabled - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: mouse => { - mouse.accepted = true; - root.toggleEnlargedPreview(pluginDelegate.pluginId); - } - } - - DankSpinner { - anchors.centerIn: parent - running: screenshotImageLoader.active && screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Loading - visible: running - } - - Column { - anchors.centerIn: parent - spacing: Theme.spacingXS - visible: pluginDelegate.isExpanded && (pluginDelegate.screenshotUrl.length === 0 || (screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error)) - - DankIcon { - anchors.horizontalCenter: parent.horizontalCenter - name: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error ? "broken_image" : "image_not_supported" - size: Theme.iconSize - color: Theme.outline - } - - StyledText { - anchors.horizontalCenter: parent.horizontalCenter - text: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error ? I18n.tr("Screenshot unavailable", "plugin browser screenshot error") : I18n.tr("No screenshot provided", "plugin browser no screenshot") - font.pixelSize: Theme.fontSizeSmall - color: Theme.outline - } + StyledText { + width: parent.width + text: cardCell.modelData.description || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + elide: Text.ElideRight + maximumLineCount: 2 } } } @@ -1327,9 +1289,8 @@ FloatingWindow { Column { id: letterIndex anchors.right: parent.right - anchors.top: pluginBrowserList.top - anchors.bottom: pluginBrowserList.bottom - anchors.rightMargin: Theme.spacingXS + anchors.top: pluginGrid.top + anchors.bottom: pluginGrid.bottom width: 16 visible: root.showLetterIndex && !root.isLoading spacing: 0 @@ -1344,7 +1305,7 @@ FloatingWindow { StyledText { anchors.centerIn: parent - text: modelData + text: parent.modelData font.pixelSize: 10 font.weight: Font.Medium color: letterMouseArea.containsMouse ? Theme.primary : Theme.outline @@ -1355,18 +1316,510 @@ FloatingWindow { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.scrollToLetter(modelData) + onClicked: root.scrollToLetter(parent.modelData) } } } } - StyledText { - anchors.centerIn: listArea - text: I18n.tr("No plugins found", "empty plugin list") - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceVariantText + Column { + anchors.centerIn: parent + spacing: Theme.spacingS visible: !root.isLoading && root.filteredPlugins.length === 0 + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "search_off" + size: Theme.iconSize + 16 + color: Theme.outline + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: I18n.tr("No plugins found", "empty plugin list") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + } + } + } + + Rectangle { + id: detailPane + + property var plugin: ({}) + property bool heroFallback: false + readonly property var livePlugin: root.detailPlugin + + onLivePluginChanged: { + if (!livePlugin) + return; + plugin = livePlugin; + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: headerArea.bottom + anchors.topMargin: Theme.spacingM + anchors.bottom: parent.bottom + z: 10 + color: Theme.surfaceContainer + opacity: root.detailPluginId !== "" ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Connections { + target: root + function onDetailPluginIdChanged() { + detailPane.heroFallback = false; + detailFlickable.contentY = 0; + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + } + + Item { + id: detailHeader + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 40 + + DankActionButton { + id: detailBackButton + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + iconName: "arrow_back" + iconSize: Theme.iconSize + iconColor: Theme.surfaceText + onClicked: root.closePluginDetail() + } + + DankIcon { + id: detailIcon + anchors.left: detailBackButton.right + anchors.leftMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + name: detailPane.plugin.icon || "extension" + size: Theme.iconSize + color: Theme.primary + } + + StyledText { + anchors.left: detailIcon.right + anchors.leftMargin: Theme.spacingS + anchors.right: detailInstallButton.left + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: detailPane.plugin.name || "" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + maximumLineCount: 1 + } + + Rectangle { + id: detailInstallButton + + property string buttonState: { + if (detailPane.plugin.installed) + return "installed"; + if (!PluginService.checkPluginCompatibility(detailPane.plugin.requires_dms)) + return "incompatible"; + return "available"; + } + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitWidth: Math.max(96, detailInstallRow.implicitWidth + Theme.spacingL * 2) + width: implicitWidth + height: 36 + radius: height / 2 + color: { + switch (buttonState) { + case "installed": + return Theme.surfaceVariant; + case "incompatible": + return Theme.withAlpha(Theme.warning, 0.15); + default: + return Theme.primary; + } + } + opacity: buttonState === "available" && detailInstallMouseArea.containsMouse ? 0.9 : 1 + border.width: buttonState !== "available" ? 1 : 0 + border.color: buttonState === "incompatible" ? Theme.warning : Theme.outline + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Row { + id: detailInstallRow + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: { + switch (detailInstallButton.buttonState) { + case "installed": + return "check"; + case "incompatible": + return "warning"; + default: + return "download"; + } + } + size: 16 + color: { + switch (detailInstallButton.buttonState) { + case "installed": + return Theme.surfaceText; + case "incompatible": + return Theme.warning; + default: + return Theme.surface; + } + } + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: { + switch (detailInstallButton.buttonState) { + case "installed": + return I18n.tr("Installed", "installed status"); + case "incompatible": + return I18n.tr("Requires %1", "version requirement").arg(detailPane.plugin.requires_dms || ""); + default: + return I18n.tr("Install", "install action button"); + } + } + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + wrapMode: Text.NoWrap + color: { + switch (detailInstallButton.buttonState) { + case "installed": + return Theme.surfaceText; + case "incompatible": + return Theme.warning; + default: + return Theme.surface; + } + } + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: detailInstallMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: detailInstallButton.buttonState === "available" ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: detailInstallButton.buttonState === "available" + onClicked: root.installPlugin(detailPane.plugin.name, detailPane.plugin.type === "desktop") + } + } + } + + DankFlickable { + id: detailFlickable + anchors.top: detailHeader.bottom + anchors.topMargin: Theme.spacingM + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + clip: true + contentHeight: detailColumn.height + Theme.spacingL + contentWidth: width + + Column { + id: detailColumn + width: Math.min(880, parent.width) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingL + + Rectangle { + width: parent.width + height: Math.round(width * 0.52) + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.color: Theme.withAlpha(Theme.outline, 0.2) + border.width: 1 + + ClippingRectangle { + anchors.fill: parent + anchors.margins: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + CachingImage { + id: heroImage + anchors.fill: parent + imagePath: detailPane.heroFallback ? root.previewUrl(detailPane.plugin) : root.heroUrl(detailPane.plugin) + maxCacheSize: 1600 + fillMode: Image.PreserveAspectFit + visible: status === Image.Ready + onStatusChanged: { + if (status !== Image.Error || detailPane.heroFallback) + return; + detailPane.heroFallback = true; + } + } + } + + DankSpinner { + anchors.centerIn: parent + running: heroImage.status === Image.Loading + visible: running + } + + Column { + anchors.centerIn: parent + spacing: Theme.spacingXS + visible: heroImage.imagePath.length === 0 || heroImage.status === Image.Error + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "image_not_supported" + size: Theme.iconSize + 8 + color: Theme.outline + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: heroImage.status === Image.Error ? I18n.tr("Screenshot unavailable", "plugin browser screenshot error") : I18n.tr("No screenshot provided", "plugin browser no screenshot") + font.pixelSize: Theme.fontSizeSmall + color: Theme.outline + } + } + } + + Flow { + width: parent.width + spacing: Theme.spacingXS + + Repeater { + model: root.badgeModel(detailPane.plugin) + + PluginBadge { + required property var modelData + label: modelData.label + iconName: modelData.icon + tone: root.badgeTone(modelData.tone) + } + } + + PluginBadge { + iconName: "thumb_up" + label: detailPane.plugin.upvotes || 0 + tone: Theme.primary + visible: !!detailPane.plugin.issueUrl + } + + Repeater { + model: root.detailMetaBadges(detailPane.plugin) + + PluginBadge { + required property var modelData + label: modelData.label + iconName: modelData.icon + tone: Theme.outline + } + } + } + + StyledText { + width: parent.width + text: { + const plugin = detailPane.plugin; + const author = I18n.tr("by %1", "author attribution").arg(plugin.author || I18n.tr("Unknown", "unknown author")); + const source = plugin.repo ? ` • ${I18n.tr("source", "source code link")}` : ""; + const discuss = plugin.issueUrl ? ` • ${I18n.tr("discuss", "plugin discussion link")}` : ""; + return author + source + discuss; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.outline + linkColor: Theme.primary + textFormat: Text.RichText + onLinkActivated: url => Qt.openUrlExternally(url) + + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + } + } + + StyledText { + width: parent.width + text: detailPane.plugin.description || "" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + wrapMode: Text.WordWrap + visible: (detailPane.plugin.description || "").length > 0 + } + + Column { + width: parent.width + spacing: Theme.spacingS + visible: (detailPane.plugin.capabilities || []).length > 0 + + StyledText { + text: I18n.tr("Capabilities", "plugin detail section") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + } + + Flow { + width: parent.width + spacing: Theme.spacingXS + + Repeater { + model: detailPane.plugin.capabilities || [] + + PluginBadge { + required property string modelData + label: modelData + tone: Theme.primary + } + } + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + visible: (detailPane.plugin.permissions || []).length > 0 + + DankIcon { + name: "security" + size: Theme.iconSize - 6 + color: Theme.surfaceVariantText + } + + Flow { + width: parent.width - Theme.iconSize + 6 - Theme.spacingS + spacing: Theme.spacingXS + + Repeater { + model: detailPane.plugin.permissions || [] + + PluginBadge { + required property string modelData + label: modelData + tone: Theme.secondary + } + } + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + visible: (detailPane.plugin.dependencies || []).length > 0 + + DankIcon { + name: "package_2" + size: Theme.iconSize - 6 + color: Theme.surfaceVariantText + } + + Flow { + width: parent.width - Theme.iconSize + 6 - Theme.spacingS + spacing: Theme.spacingXS + + Repeater { + model: detailPane.plugin.dependencies || [] + + PluginBadge { + required property string modelData + label: modelData + tone: Theme.outline + } + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + visible: root.relatedPlugins(detailPane.plugin).length > 0 + + StyledText { + text: I18n.tr("Related: %1", "related plugins").arg("").trim() + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + } + + Flow { + width: parent.width + spacing: Theme.spacingXS + + Repeater { + model: root.relatedPlugins(detailPane.plugin) + + Rectangle { + id: relatedChip + + required property var modelData + + height: 26 + width: relatedRow.implicitWidth + Theme.spacingM * 2 + radius: height / 2 + color: relatedMouseArea.containsMouse ? Theme.withAlpha(Theme.primary, 0.2) : Theme.withAlpha(Theme.primary, 0.1) + border.color: Theme.withAlpha(Theme.primary, 0.3) + border.width: 1 + opacity: modelData.key ? 1 : 0.6 + + Row { + id: relatedRow + anchors.centerIn: parent + spacing: Theme.spacingXXS + + DankIcon { + name: "extension" + size: 12 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: relatedChip.modelData.name + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: relatedMouseArea + anchors.fill: parent + enabled: relatedChip.modelData.key !== "" + hoverEnabled: enabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.detailPluginId = relatedChip.modelData.key + } + } + } + } + } + } } } } diff --git a/quickshell/Widgets/CachingImage.qml b/quickshell/Widgets/CachingImage.qml index 9ea80934c..0d37a8da3 100644 --- a/quickshell/Widgets/CachingImage.qml +++ b/quickshell/Widgets/CachingImage.qml @@ -94,27 +94,36 @@ Item { } } + // Derives everything from a local snapshot of imagePath: sibling property + // bindings (isRemoteUrl, encodedImagePath, ...) are still stale when + // onImagePathChanged runs, so reading them here routes remote URLs down + // the local-file branch on the first path change function resolveSource() { - if (!imagePath) { + const path = imagePath; + if (!path) { _fromCache = false; staticImg.source = ""; return; } - if (isAnimated) + const lower = path.toLowerCase(); + if (animate && (lower.endsWith(".gif") || lower.endsWith(".webp"))) return; - if (isRemoteUrl) { + if (path.startsWith("http://") || path.startsWith("https://")) { _fromCache = false; - staticImg.source = imagePath; + staticImg.source = path; return; } - if (!cachePath) { + const stripped = path.startsWith("file://") ? path.substring(7) : path; + const encoded = "file://" + stripped.split('/').map(s => encodeURIComponent(s)).join('/'); + const hash = djb2Hash(stripped); + if (!hash) { _fromCache = false; - staticImg.source = encodedImagePath; + staticImg.source = encoded; return; } // Cache-first; a miss errors and falls back to encodedImagePath _fromCache = true; - staticImg.source = cachePath; + staticImg.source = `${Paths.stringify(Paths.imagecache)}/${hash}@${maxCacheSize}x${maxCacheSize}.png`; } onImagePathChanged: resolveSource()