mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-18 01:04:01 -04:00
plugins: discover shelves, quick filters and typo-tolerant search in the browser (2/3) (#3752)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user