plugins: discover shelves, quick filters and typo-tolerant search in the browser (2/3) (#3752)

This commit is contained in:
lung595
2026-10-17 11:33:29 -04:00
committed by GitHub
parent c0c054552e
commit 2ad442fb37
8 changed files with 742 additions and 22 deletions
+151
View File
@@ -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);
}
+1 -1
View File
@@ -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();
+313 -19
View File
@@ -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
@@ -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 {
@@ -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
}
}
@@ -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 {
@@ -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
}
}
}
+65
View File
@@ -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);
});