diff --git a/quickshell/Common/PluginRanking.js b/quickshell/Common/PluginRanking.js index 965f6e4e9..8994a7e58 100644 --- a/quickshell/Common/PluginRanking.js +++ b/quickshell/Common/PluginRanking.js @@ -301,3 +301,154 @@ function pickOfTheDay(plugins, now) { const week = pickOfTheWeek(plugins, t); return _pick(plugins, t, "day:" + _dayNumber(t), 30, week ? week.id : ""); } + +// --- Search ----------------------------------------------------------------- + +function _fold(text) { + return String(text || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); +} + +// Lowercased first, so camelCase is not split: "AvengeMedia" stays "avengemedia". +function _unsplit(text) { + return _fold(String(text || "").toLowerCase()); +} + +function _words(text) { + return _fold(text).split(/[^a-z0-9]+/).filter(w => w.length > 0); +} + +// Matching costs grow with every word of the query, so a pasted wall of text must not freeze the shell. +const MAX_QUERY_LENGTH = 100; +const MAX_QUERY_WORDS = 8; + +function _queryWords(query) { + const text = String(query || ""); + const words = _words(text.slice(0, MAX_QUERY_LENGTH)); + // A word cut in half would match names it is not ("weather" cut to "w"); a lone word stays so it still finds nothing. + if (words.length > 1 && _words(text.charAt(MAX_QUERY_LENGTH)).length > 0) + words.pop(); + return words.slice(0, MAX_QUERY_WORDS); +} + +// The one-word form of a camelCase text, empty when it has no camelCase to undo. +function _joined(text) { + const unsplit = _unsplit(text); + return unsplit === _fold(text) ? "" : unsplit; +} + +// Edit distance, given up as soon as it is known to exceed max. +function _distance(a, b, max) { + if (Math.abs(a.length - b.length) > max) + return max + 1; + let prev = []; + let prev2 = []; + for (let j = 0; j <= b.length; j++) + prev.push(j); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + let best = i; + for (let j = 1; j <= b.length; j++) { + let cost = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) + cost = Math.min(cost, prev2[j - 2] + 1); + row.push(cost); + if (cost < best) + best = cost; + } + if (best > max) + return max + 1; + prev2 = prev; + prev = row; + } + return prev[b.length]; +} + +function _typos(word) { + return word.length < 4 ? 0 : word.length < 8 ? 1 : 2; +} + +// What a query is matched against: the name first, then everything a user +// may type to find a plugin (author, category, capabilities, dependencies). +function _searchText(plugin) { + const p = plugin || {}; + const name = [_fold(p.name), _fold(p.id), _joined(p.name), _joined(p.id)].join(" "); + const extra = [p.description, p.author, p.category].concat(_list(p.capabilities), _list(p.dependencies)).map(_fold).join(" ") + " " + _joined(p.author); + return { + name: name, + all: name + " " + extra, + words: (name + " " + _fold(p.category) + " " + _list(p.capabilities).map(_fold).join(" ")).split(/[^a-z0-9]+/).filter(w => w.length > 0) + }; +} + +function _nearWord(token, words, max) { + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (_distance(token, word, max) <= max) + return true; + if (word.length > token.length && _distance(token, word.slice(0, token.length), max) <= max) + return true; + } + return false; +} + +// A typo is forgiven only for a word no plugin contains as typed, so a correct +// query never picks up look-alikes ("dark" does not match every "dank"). +function searchPlan(plugins, query) { + const tokens = _queryWords(query); + const texts = _list(plugins).map(_searchText); + return tokens.map(token => ({ + token: token, + typos: texts.some(t => t.all.indexOf(token) >= 0) ? 0 : _typos(token) + })); +} + +// 0 when the plugin does not match, higher for a better match. +function searchRank(plugin, plan) { + const terms = _list(plan); + if (terms.length === 0) + return 1; + const text = _searchText(plugin); + let total = 0; + for (let i = 0; i < terms.length; i++) { + const term = terms[i]; + if (text.name.indexOf(term.token) >= 0) + total += 3; + else if (text.all.indexOf(term.token) >= 0) + total += 2; + else if (term.typos > 0 && _nearWord(term.token, text.words, term.typos)) + total += 1; + else + return 0; + } + return total; +} + +// The plugins whose name is nearest to a query that found nothing. +function closest(plugins, query, limit) { + const tokens = _queryWords(query); + if (tokens.length === 0) + return []; + const rows = []; + _list(plugins).forEach(p => { + const words = _words((p.name || "") + " " + (p.id || "")); + let total = 0; + for (let i = 0; i < tokens.length; i++) { + const max = Math.max(1, Math.floor(tokens[i].length / 2)); + let best = max + 1; + for (let j = 0; j < words.length; j++) { + best = Math.min(best, _distance(tokens[i], words[j], max)); + if (words[j].length > tokens[i].length) + best = Math.min(best, _distance(tokens[i], words[j].slice(0, tokens[i].length), max)); + } + if (best > max) + return; + total += best; + } + rows.push({ + plugin: p, + distance: total + }); + }); + rows.sort((a, b) => a.distance - b.distance || (b.plugin.upvotes || 0) - (a.plugin.upvotes || 0) || String(a.plugin.id).localeCompare(String(b.plugin.id))); + return rows.slice(0, limit || 4).map(r => r.plugin); +} diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index c40f77e2d..3d7e6e198 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -1539,7 +1539,7 @@ Singleton { function setPluginBrowserSortMode(mode) { if (mode === "type" || mode === "contributor") mode = "author"; - if (mode !== "default" && mode !== "name" && mode !== "author" && mode !== "category") + if (mode !== "default" && mode !== "recommended" && mode !== "name" && mode !== "author" && mode !== "category") mode = "default"; pluginBrowserSortMode = mode; saveSettings(); diff --git a/quickshell/Modules/Settings/PluginBrowser.qml b/quickshell/Modules/Settings/PluginBrowser.qml index dec241537..b1f3973b0 100644 --- a/quickshell/Modules/Settings/PluginBrowser.qml +++ b/quickshell/Modules/Settings/PluginBrowser.qml @@ -7,6 +7,7 @@ import qs.Services import qs.DCommon.Widgets import qs.Widgets import qs.Modules.Settings.Widgets +import "../../Common/PluginRanking.js" as PluginRanking RegistryBrowserWindow { id: root @@ -23,8 +24,22 @@ RegistryBrowserWindow { property string operationMessage: "" property bool operationFailed: false property bool operationPending: false + property var discoverPicks: [] + // Quick filters of this visit: usable (on the running compositor), fresh (new or updated), official. + property var quickFilters: ({}) + property var searchSuggestions: [] + property var discoverShelves: [] readonly property var detailPlugin: resolveDetailPlugin(detailPluginId, allPlugins) + property bool discoverCollapsed: false + readonly property bool discoverVisible: { + if (isLoading || loadError !== "" || searchQuery !== "" || typeFilter !== "") + return false; + if (discoverPicks.length === 0 && discoverShelves.length === 0) + return false; + var mode = normalizedSortMode(SessionData.pluginBrowserSortMode); + return mode === "default" || mode === "recommended"; + } readonly property bool activeCategorySort: normalizedSortMode(SessionData.pluginBrowserSortMode) === "category" readonly property bool showCategoryFilters: activeCategorySort && categoryFilterOptions.length > 1 readonly property bool showLetterIndex: { @@ -32,6 +47,25 @@ RegistryBrowserWindow { return (mode === "name" || mode === "author") && availableLetters.length > 1; } + readonly property var quickFilterOptions: [ + { + value: "usable", + label: I18n.tr("Works here", "plugin browser filter chip, plugins that run on the current compositor"), + icon: "desktop_windows" + }, + { + value: "fresh", + label: I18n.tr("New & updated", "plugin browser filter chip"), + icon: "fiber_new" + }, + { + value: "official", + label: I18n.tr("Official only", "plugin browser filter chip, first party plugins"), + icon: "verified" + } + ] + showLoadingSpinner: false + readonly property var sortChipOptions: [ { id: "hideInstalled", @@ -48,6 +82,11 @@ RegistryBrowserWindow { label: I18n.tr("Votes", "plugin browser sort option"), toggle: false }, + { + id: "recommended", + label: I18n.tr("For you", "plugin browser sort option, plugins ranked for this user"), + toggle: false + }, { id: "name", label: I18n.tr("Name", "plugin browser sort option"), @@ -68,11 +107,97 @@ RegistryBrowserWindow { function normalizedSortMode(mode) { if (mode === "type" || mode === "contributor") return "author"; - if (mode === "name" || mode === "author" || mode === "category") + if (mode === "name" || mode === "author" || mode === "category" || mode === "recommended") return mode; return "default"; } + function rankingCompositor() { + if (CompositorService.isNiri) + return "niri"; + if (CompositorService.isHyprland) + return "hyprland"; + if (CompositorService.isSway) + return "sway"; + if (CompositorService.isMango) + return "mangowc"; + if (CompositorService.isLabwc) + return "labwc"; + if (CompositorService.isMiracle) + return "miracle-wm"; + return ""; + } + + function recommendationReason(key) { + switch (key) { + case "similar": + return I18n.tr("Like your plugins", "plugin recommendation reason, similar to installed plugins"); + case "featured": + return I18n.tr("Featured plugin", "plugin recommendation reason"); + case "new": + return I18n.tr("New", "plugin recommendation reason, recently published"); + case "trending": + return I18n.tr("Trending", "plugin recommendation reason, gaining votes quickly"); + case "category": + return I18n.tr("Fits your setup", "plugin recommendation reason, same category as installed plugins"); + default: + return I18n.tr("Popular", "plugin recommendation reason, many votes"); + } + } + + function updateDiscover(pool) { + var now = Date.now(); + var taste = PluginRanking.buildTaste(allPlugins); + var options = { + now: now, + taste: taste, + compositor: rankingCompositor(), + limit: 6 + }; + + var picks = []; + var week = PluginRanking.pickOfTheWeek(pool, now); + if (week) + picks.push({ + label: I18n.tr("Pick of the week", "plugin browser highlighted plugin"), + plugin: week + }); + var day = PluginRanking.pickOfTheDay(pool, now); + if (day) + picks.push({ + label: I18n.tr("Pick of the day", "plugin browser highlighted plugin"), + plugin: day + }); + + options.exclude = picks.map(pick => pick.plugin.id); + + var shelves = []; + var recommended = PluginRanking.recommend(pool, options); + if (recommended.length > 0) + shelves.push({ + label: taste.count > 0 ? I18n.tr("For you", "plugin browser shelf, plugins ranked for this user") : I18n.tr("Popular", "plugin browser shelf"), + plugins: recommended, + captions: recommended.map(p => recommendationReason(PluginRanking.reason(p, taste, now))) + }); + var trending = PluginRanking.trendingNow(pool, options); + if (trending.length > 0) + shelves.push({ + label: I18n.tr("Trending", "plugin browser shelf, gaining votes quickly"), + plugins: trending, + captions: [] + }); + var arrivals = PluginRanking.newArrivals(pool, options); + if (arrivals.length > 0) + shelves.push({ + label: I18n.tr("New", "plugin browser shelf, recently published"), + plugins: arrivals, + captions: [] + }); + + discoverPicks = picks; + discoverShelves = shelves; + } + function isSortChipSelected(chipId, toggle) { if (toggle) { if (chipId === "hideInstalled") @@ -230,7 +355,8 @@ RegistryBrowserWindow { if (!pluginGrid) return; pluginGrid.cancelFlick(); - pluginGrid.contentY = 0; + discoverCollapsed = false; + pluginGrid.positionViewAtBeginning(); Qt.callLater(() => { if (pluginGrid) pluginGrid.forceLayout(); @@ -250,7 +376,8 @@ RegistryBrowserWindow { function updateFilteredPlugins() { var baseFiltered = []; - var query = searchQuery ? searchQuery.toLowerCase() : ""; + var visiblePlugins = SessionData.showThirdPartyPlugins ? allPlugins : allPlugins.filter(p => p.firstParty || false); + var plan = PluginRanking.searchPlan(visiblePlugins, searchQuery); for (var i = 0; i < allPlugins.length; i++) { var plugin = allPlugins[i]; @@ -264,19 +391,14 @@ RegistryBrowserWindow { continue; } - if (query.length === 0) { - baseFiltered.push(plugin); - continue; - } - - var name = plugin.name ? plugin.name.toLowerCase() : ""; - var description = plugin.description ? plugin.description.toLowerCase() : ""; - var author = plugin.author ? plugin.author.toLowerCase() : ""; - - if (name.indexOf(query) !== -1 || description.indexOf(query) !== -1 || author.indexOf(query) !== -1) + if (PluginRanking.searchRank(plugin, plan) > 0) baseFiltered.push(plugin); } + var compositor = rankingCompositor(); + var filterNow = Date.now(); + baseFiltered = baseFiltered.filter(p => PluginRanking.matchesFilters(p, quickFilters, compositor, filterNow)); + updateDiscover(baseFiltered); categoryFilterOptions = buildCategoryFilterOptions(baseFiltered); if (categoryFilter !== "all") { var filterStillValid = false; @@ -290,7 +412,7 @@ RegistryBrowserWindow { categoryFilter = "all"; } - var filtered = baseFiltered.slice(); + var filtered = baseFiltered; if (SessionData.pluginBrowserHideInstalled) filtered = filtered.filter(p => !(p.installed || false)); if (activeCategorySort && categoryFilter !== "all") { @@ -302,6 +424,15 @@ RegistryBrowserWindow { }); } + var sortMode = normalizedSortMode(SessionData.pluginBrowserSortMode); + var scores = {}; + if (sortMode === "recommended") { + var now = Date.now(); + var taste = PluginRanking.buildTaste(allPlugins); + for (var s = 0; s < filtered.length; s++) + scores[filtered[s].id] = PluginRanking.score(filtered[s], taste, now); + } + filtered.sort((a, b) => { if (SessionData.pluginBrowserInstalledFirst) { var instA = a.installed || false; @@ -309,7 +440,13 @@ RegistryBrowserWindow { if (instA !== instB) return instA ? -1 : 1; } - var sortMode = normalizedSortMode(SessionData.pluginBrowserSortMode); + if (sortMode === "recommended") { + var scoreA = scores[a.id] || 0; + var scoreB = scores[b.id] || 0; + if (scoreA !== scoreB) + return scoreB - scoreA; + return comparePluginName(a, b); + } if (sortMode === "name") return comparePluginName(a, b); if (sortMode === "author") @@ -328,6 +465,7 @@ RegistryBrowserWindow { }); filteredPlugins = filtered; + searchSuggestions = filtered.length === 0 && plan.length > 0 ? PluginRanking.closest(visiblePlugins, searchQuery, 4) : []; updateAvailableLetters(filtered); selectedIndex = -1; keyboardNavigationActive = false; @@ -546,6 +684,8 @@ RegistryBrowserWindow { function resetContent() { allPlugins = []; filteredPlugins = []; + discoverPicks = []; + discoverShelves = []; loadError = ""; detailPluginId = ""; } @@ -664,14 +804,19 @@ RegistryBrowserWindow { model: root.sortChipOptions.slice(0, 2).map(option => ({ label: option.label, value: option.id - })) + })).concat(root.quickFilterOptions) multiSelect: true - selectedValues: [SessionData.pluginBrowserHideInstalled ? "hideInstalled" : "", SessionData.pluginBrowserInstalledFirst ? "installed" : ""] + selectedValues: [SessionData.pluginBrowserHideInstalled ? "hideInstalled" : "", SessionData.pluginBrowserInstalledFirst ? "installed" : ""].concat(Object.keys(root.quickFilters).filter(key => root.quickFilters[key])) onSelectionToggled: (index, selected) => { - if (index === 0) + if (index === 0) { SessionData.setPluginBrowserHideInstalled(selected); - else + } else if (index === 1) { SessionData.setPluginBrowserInstalledFirst(selected); + } else { + var next = Object.assign({}, root.quickFilters); + next[model[index].value] = selected; + root.quickFilters = next; + } root.updateFilteredPlugins(); } } @@ -717,6 +862,39 @@ RegistryBrowserWindow { ] listContent: [ + Item { + id: discoverPane + + readonly property bool fits: discover.height + pluginGrid.cellHeight / 3 <= parent.height + readonly property bool shown: root.discoverVisible && fits && !root.discoverCollapsed + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.rightMargin: pluginGrid.anchors.rightMargin + height: shown ? discover.height + Theme.spacingM : 0 + clip: true + visible: height > 0 && pluginGrid.visible + + Behavior on height { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + PluginDiscover { + id: discover + x: pluginGrid.cardSpacing / 2 + width: pluginGrid.cellWidth * pluginGrid.columns - pluginGrid.cardSpacing + picks: root.discoverPicks + shelves: root.discoverShelves + columns: pluginGrid.columns + cardSpacing: pluginGrid.cardSpacing + onPluginOpened: plugin => root.openPluginDetail(plugin) + onInstallRequested: plugin => root.installPlugin(plugin.id, plugin.name, plugin.type === "desktop") + } + }, DGridView { id: pluginGrid @@ -726,6 +904,7 @@ RegistryBrowserWindow { readonly property int infoHeight: Theme.iconButtonSize + Theme.fontSizeSmall * 4 + Theme.spacingS anchors.fill: parent + anchors.topMargin: discoverPane.height anchors.rightMargin: root.showLetterIndex ? Theme.iconButtonSize : 0 cellWidth: Math.floor(width / columns) cellHeight: previewHeight + infoHeight + Math.round(cardSpacing) + Theme.spacingS * 2 + Theme.spacingM @@ -733,6 +912,15 @@ RegistryBrowserWindow { clip: true visible: !root.isLoading && root.loadError === "" cacheBuffer: cellHeight * 2 + onContentYChanged: { + if (!root.discoverCollapsed) { + if (contentY > cellHeight / 4) + root.discoverCollapsed = true; + return; + } + if (contentY <= 0 && contentHeight > height + cellHeight) + root.discoverCollapsed = false; + } delegate: Item { id: cardCell @@ -751,6 +939,7 @@ RegistryBrowserWindow { plugin: cardCell.modelData busy: !!PluginService.installingPlugins[cardCell.modelData.id] previewHeight: pluginGrid.previewHeight + compositor: root.rankingCompositor() installed: cardCell.modelData.installed || false selected: root.keyboardNavigationActive && cardCell.index === root.selectedIndex onClicked: root.openPluginDetail(cardCell.modelData) @@ -758,6 +947,84 @@ RegistryBrowserWindow { } } }, + Grid { + id: loadingGrid + anchors.fill: pluginGrid + columns: pluginGrid.columns + clip: true + visible: root.isLoading + + SequentialAnimation on opacity { + running: loadingGrid.visible && Theme.longDuration > 0 + loops: Animation.Infinite + onRunningChanged: { + if (!running) + loadingGrid.opacity = 1; + } + + NumberAnimation { + to: 0.45 + duration: Theme.longDuration + easing.type: Theme.standardEasing + } + NumberAnimation { + to: 1 + duration: Theme.longDuration + easing.type: Theme.standardEasing + } + } + + Repeater { + model: root.isLoading ? pluginGrid.columns * Math.max(1, Math.ceil(pluginGrid.height / pluginGrid.cellHeight)) : 0 + + Item { + width: pluginGrid.cellWidth + height: pluginGrid.cellHeight + + Rectangle { + anchors.fill: parent + anchors.margins: pluginGrid.cardSpacing / 2 + radius: Theme.cornerRadiusM + color: SettingsMetrics.rowColor + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + + Rectangle { + id: ghostPreview + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingS + height: pluginGrid.previewHeight + radius: Theme.cornerRadiusS + color: SettingsMetrics.controlColor + } + + Column { + anchors.top: ghostPreview.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Theme.spacingM + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingS + + Repeater { + model: [0.55, 0.3, 0.9] + + Rectangle { + required property real modelData + width: parent.width * modelData + height: Theme.fontSizeSmall + radius: height / 2 + color: SettingsMetrics.controlColor + } + } + } + } + } + } + }, DFlickable { id: letterIndex anchors.right: parent.right @@ -806,6 +1073,33 @@ RegistryBrowserWindow { font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceVariantText } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + visible: root.searchSuggestions.length > 0 + text: I18n.tr("Did you mean", "plugin search, introduces plugins with a close name") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXS + visible: root.searchSuggestions.length > 0 + + Repeater { + model: root.searchSuggestions + + DButton { + required property var modelData + text: modelData.name || modelData.id + iconName: modelData.icon || "extension" + backgroundColor: Theme.secondaryContainer + textColor: Theme.onSecondaryContainer + onClicked: root.openPluginDetail(modelData) + } + } + } }, Column { anchors.centerIn: parent diff --git a/quickshell/Modules/Settings/PluginCard.qml b/quickshell/Modules/Settings/PluginCard.qml index 891d22f28..18dcd0a44 100644 --- a/quickshell/Modules/Settings/PluginCard.qml +++ b/quickshell/Modules/Settings/PluginCard.qml @@ -4,6 +4,7 @@ import qs.Common import qs.Modules.Settings.Widgets import qs.Services import qs.DCommon.Widgets +import "../../Common/PluginRanking.js" as PluginRanking DCard { id: root @@ -13,14 +14,17 @@ DCard { property bool installed: false property bool selected: false property string fallbackIcon: "extension" + property string highlight: "" property string previewSource: PluginService.thumbUrl(plugin) property string previewFallback: PluginService.previewUrl(plugin) property var badges: PluginService.badgeModel(plugin) property bool allowUninstall: false property var palette: null + property string compositor: "" property real previewHeight: Math.round((width - Theme.spacingS * 2) * SettingsMetrics.choiceCardPreviewRatio) readonly property int infoHeight: Theme.iconButtonSize + Theme.fontSizeSmall * 4 + Theme.spacingS readonly property bool compatible: PluginService.checkPluginCompatibility(plugin.requires_dms) + readonly property var otherCompositors: PluginRanking.unsupportedOn(plugin, compositor) readonly property bool focusRingShown: selected && Theme.focusRingWidth > 0 signal installRequested @@ -60,6 +64,7 @@ DCard { fillMode: Image.PreserveAspectCrop animate: false visible: status === Image.Ready + opacity: root.otherCompositors.length > 0 ? 0.5 : 1 onStatusChanged: { if (status !== Image.Error || useFallback || !root.previewFallback || root.previewFallback === root.previewSource) return; @@ -124,6 +129,26 @@ DCard { tone: Theme.primary visible: !!root.plugin.issueUrl } + + PluginBadge { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Theme.spacingXS + iconName: "auto_awesome" + label: root.highlight + tone: Theme.primary + visible: root.highlight.length > 0 + } + + PluginBadge { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: Theme.spacingXS + iconName: root.installed ? "check_circle" : "desktop_windows" + label: root.installed ? I18n.tr("Installed", "adjective, plugin or theme is already installed, button state and filter chip") : I18n.tr("%1 only", "lowercase plugin badge, compositors the plugin is limited to").arg(root.otherCompositors.join(", ")) + tone: root.installed ? Theme.primary : Theme.warning + visible: root.installed || root.otherCompositors.length > 0 + } } Column { diff --git a/quickshell/Modules/Settings/PluginDiscover.qml b/quickshell/Modules/Settings/PluginDiscover.qml new file mode 100644 index 000000000..6ec6f1197 --- /dev/null +++ b/quickshell/Modules/Settings/PluginDiscover.qml @@ -0,0 +1,182 @@ +import QtQuick +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.DCommon.Widgets +import qs.Widgets +import qs.Modules.Settings.Widgets + +Column { + id: root + + property var picks: [] + property var shelves: [] + property int columns: 1 + property real cardSpacing: Theme.spacingM + property int shelfIndex: 0 + + readonly property var shelf: shelves[Math.min(shelfIndex, shelves.length - 1)] || null + readonly property real cardWidth: Math.floor((width - cardSpacing * (columns - 1)) / columns) + readonly property int pickColumns: Math.min(Math.max(1, picks.length), columns > 1 ? 2 : 1) + readonly property int pickHeight: Theme.fontSizeMedium + Theme.fontSizeSmall * 6 + Theme.spacingM * 2 + + signal pluginOpened(var plugin) + signal installRequested(var plugin) + + spacing: Theme.spacingM + + Grid { + width: parent.width + columns: root.pickColumns + spacing: root.cardSpacing + visible: root.picks.length > 0 + + Repeater { + model: root.picks + + DCard { + id: pickCard + + required property var modelData + readonly property var plugin: modelData.plugin + + width: Math.floor((root.width - root.cardSpacing * (root.pickColumns - 1)) / root.pickColumns) + height: root.pickHeight + radius: Theme.cornerRadiusM + color: SettingsMetrics.rowColor + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + pad: 0 + clickable: true + Accessible.name: modelData.label + ": " + (plugin.name || "") + onClicked: root.pluginOpened(plugin) + + ClippingRectangle { + id: pickPreview + z: 1 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Theme.spacingS + width: Math.round(height / SettingsMetrics.choiceCardPreviewRatio) + radius: Theme.cornerRadiusS + color: SettingsMetrics.controlColor + + CachingImage { + id: pickImage + anchors.fill: parent + imagePath: PluginService.thumbUrl(pickCard.plugin) + maxCacheSize: 640 + fillMode: Image.PreserveAspectCrop + animate: false + visible: status === Image.Ready + } + + DIcon { + anchors.centerIn: parent + name: pickCard.plugin.icon || "extension" + size: Theme.iconSizeLarge + color: Theme.onSurfaceVariant + visible: pickImage.status !== Image.Ready + } + } + + Column { + z: 1 + anchors.left: pickPreview.right + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingXXS + + Row { + spacing: Theme.spacingXXS + + DIcon { + name: "award_star" + size: Theme.iconSizeSmall + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: pickCard.modelData.label + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightMedium + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + } + + StyledText { + width: parent.width + text: pickCard.plugin.name || "" + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + color: Theme.surfaceText + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + width: parent.width + text: I18n.tr("by %1", "author attribution").arg(pickCard.plugin.author || I18n.tr("Unknown", "unknown author")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.onSurfaceVariant + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + width: parent.width + text: pickCard.plugin.description || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + elide: Text.ElideRight + maximumLineCount: 2 + } + } + } + } + } + + DFilterChips { + width: parent.width + visible: root.shelves.length > 0 + model: root.shelves.map(shelf => shelf.label) + currentIndex: Math.min(root.shelfIndex, Math.max(0, root.shelves.length - 1)) + onSelectionChanged: index => root.shelfIndex = index + } + + Row { + width: parent.width + spacing: root.cardSpacing + visible: !!root.shelf + + Repeater { + model: root.shelf ? root.shelf.plugins.slice(0, root.columns) : [] + + PluginCard { + required property var modelData + required property int index + + width: root.cardWidth + plugin: modelData + busy: !!PluginService.installingPlugins[modelData.id] + installed: modelData.installed || false + highlight: root.shelf.captions[index] || "" + onClicked: root.pluginOpened(modelData) + onInstallRequested: root.installRequested(modelData) + } + } + } + + StyledText { + text: I18n.tr("All plugins", "plugin browser section title above the full list") + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightMedium + color: Theme.surfaceVariantText + } +} diff --git a/quickshell/Modules/Settings/PluginsHubHeader.qml b/quickshell/Modules/Settings/PluginsHubHeader.qml index a2a1ea021..62b1a271f 100644 --- a/quickshell/Modules/Settings/PluginsHubHeader.qml +++ b/quickshell/Modules/Settings/PluginsHubHeader.qml @@ -499,6 +499,8 @@ Column { width: parent.width text: root.searchQuery placeholderText: I18n.tr("Search plugins...", "plugin search placeholder") + // matches MAX_QUERY_LENGTH in PluginRanking.js + maximumLength: 100 onTextEdited: root.searchQuery = text } Flow { diff --git a/quickshell/Modules/Settings/RegistryBrowserWindow.qml b/quickshell/Modules/Settings/RegistryBrowserWindow.qml index bd7ea1d60..eff9e3d53 100644 --- a/quickshell/Modules/Settings/RegistryBrowserWindow.qml +++ b/quickshell/Modules/Settings/RegistryBrowserWindow.qml @@ -14,6 +14,7 @@ DFloatingWindow { property int selectedIndex: -1 property bool keyboardNavigationActive: false property bool isLoading: false + property bool showLoadingSpinner: true property bool pendingInstallHandled: false property string headerTitle: title property string searchPlaceholder: "" @@ -243,11 +244,11 @@ DFloatingWindow { Item { anchors.fill: parent - visible: root.isLoading + visible: root.isLoading && root.showLoadingSpinner DSpinner { anchors.centerIn: parent - running: root.isLoading + running: root.isLoading && root.showLoadingSpinner } } } diff --git a/quickshell/tests/plugin-ranking.test.mjs b/quickshell/tests/plugin-ranking.test.mjs index 21eba2f3d..da475f035 100644 --- a/quickshell/tests/plugin-ranking.test.mjs +++ b/quickshell/tests/plugin-ranking.test.mjs @@ -128,3 +128,68 @@ test("lists that are not real arrays (QML sequences) are still read", () => { assert.equal(rank.isUsable(plugin("b", { status: seq(["broken"]) }), "niri"), false); assert.deepEqual(Array.from(rank.unsupportedOn(plugin("c", { compositors: seq(["hyprland"]) }), "niri")), ["hyprland"]); }); + +const found = (list, query) => ids(list.filter(p => rank.searchRank(p, rank.searchPlan(list, query)) > 0)); + +test("search reads name, author, category, capabilities and dependencies", () => { + const list = [plugin("clock", { name: "World Clock", author: "Ada" }), plugin("shot", { name: "Quick Capture", capabilities: ["launcher"], dependencies: ["grim"] }), plugin("music", { name: "Tunes", category: "media" })]; + assert.deepEqual(found(list, "clock"), ["clock"]); + assert.deepEqual(found(list, "ada"), ["clock"]); + assert.deepEqual(found(list, "launcher"), ["shot"]); + assert.deepEqual(found(list, "grim"), ["shot"]); + assert.deepEqual(found(list, "media"), ["music"]); + assert.deepEqual(found(list, "quick launcher"), ["shot"]); + assert.deepEqual(found(list, ""), ["clock", "shot", "music"]); +}); + +test("a typo is forgiven, accents and camelCase do not matter", () => { + const list = [plugin("wallpaperCarousel", { name: "Wallpaper Carousel" }), plugin("cafe", { name: "Café Timer" }), plugin("dockerManager", { name: "dockerManager" })]; + assert.deepEqual(found(list, "walpaper"), ["wallpaperCarousel"]); + assert.deepEqual(found(list, "wallpapre"), ["wallpaperCarousel"]); + assert.deepEqual(found(list, "carousle"), ["wallpaperCarousel"]); + assert.deepEqual(found(list, "cafe"), ["cafe"]); + assert.deepEqual(found(list, "manager"), ["dockerManager"]); + assert.deepEqual(found(list, "dokcer"), ["dockerManager"]); +}); + +test("a camelCase name, id or author typed as one word still matches", () => { + const list = [plugin("orbitBluetooth", { author: "AvengeMedia" }), plugin("clock", { name: "World Clock", author: "Ada" })]; + assert.deepEqual(found(list, "avengemedia"), ["orbitBluetooth"]); + assert.deepEqual(found(list, "orbitbluetooth"), ["orbitBluetooth"]); + assert.deepEqual(found(list, "orbitbluetoth"), ["orbitBluetooth"]); + assert.deepEqual(found(list, "avenge"), ["orbitBluetooth"]); + assert.deepEqual(found(list, "media"), ["orbitBluetooth"]); + assert.deepEqual(found(list, "bluetooth"), ["orbitBluetooth"]); +}); + +test("a name without camelCase is indexed once", () => { + const words = Array.from(rank._searchText(plugin("clock", { name: "World Clock" })).words); + assert.equal(words.filter(w => w === "world").length, 1); +}); + +test("a huge query is cut to its first eight whole words of at most 100 characters", () => { + const list = [plugin("orbitBluetooth", { name: "Orbit Bluetooth" }), plugin("weather", { name: "Weather" }), plugin("clock", { name: "World Clock" })]; + const tokens = query => Array.from(rank.searchPlan(list, query), t => t.token); + assert.equal(tokens("aA".repeat(5000)).length, 8); + assert.deepEqual(tokens("orbit " + "blutooth ".repeat(3000)), ["orbit", "blutooth", "blutooth", "blutooth", "blutooth", "blutooth", "blutooth", "blutooth"]); + assert.deepEqual(found(list, "orbit " + "blutooth ".repeat(3000)), ["orbitBluetooth"]); + assert.deepEqual(tokens("x".repeat(200)), ["x".repeat(100)]); + assert.deepEqual(tokens("clock" + " ".repeat(94) + "weather"), ["clock"]); + assert.deepEqual(ids(rank.closest(list, "weather ".repeat(8) + "qqqqqqqq qqqqqqqq", 4)), ["weather"]); +}); + +test("a correctly typed word never picks up look-alikes", () => { + const list = [plugin("dark", { name: "Dark Mode" }), plugin("dank", { name: "Dank Clock" }), plugin("lock", { name: "Lock" })]; + assert.deepEqual(found(list, "dark"), ["dark"]); + assert.deepEqual(found(list, "lock"), ["dank", "lock"]); + assert.deepEqual(found(list, "zzzzzz"), []); + assert.deepEqual(found(list, "do"), []); +}); + +test("closest proposes the nearest names when nothing matches", () => { + const list = [plugin("weather", { name: "Weather", upvotes: 2 }), plugin("feather", { name: "Feather", upvotes: 9 }), plugin("notes", { name: "Notes" })]; + assert.deepEqual(ids(rank.closest(list, "wether", 3)), ["weather", "feather"]); + assert.deepEqual(ids(rank.closest(list, "qqqqqqqq", 3)), []); + assert.deepEqual(ids(rank.closest(list, "", 3)), []); + assert.equal(rank.closest(list, "eather", 1).length, 1); +});