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

This commit is contained in:
lung595 authored and GitHub committed 2026-10-17 11:33:29 -04:00
1 parent c0c054552e
commit 2ad442fb37
8 files changed
+742 -22

No files matched your search

+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
}
}
}