From 324747636873e7742aed8d418aa571550fdea508 Mon Sep 17 00:00:00 2001 From: bbedward Date: Fri, 25 Sep 2026 18:54:53 -0400 Subject: [PATCH] settings: rank search results by page level and whole-word matches --- quickshell/Services/SettingsSearchService.qml | 152 ++++++----- .../translations/extract_settings_index.py | 29 ++- .../translations/settings_search_index.json | 237 +----------------- 3 files changed, 124 insertions(+), 294 deletions(-) diff --git a/quickshell/Services/SettingsSearchService.qml b/quickshell/Services/SettingsSearchService.qml index 04fb01eb5..b0d5498c6 100644 --- a/quickshell/Services/SettingsSearchService.qml +++ b/quickshell/Services/SettingsSearchService.qml @@ -341,7 +341,8 @@ Singleton { runtimeType: t.runtimeType, runtimeId: t.runtimeId, page: t.page, - isTab: String(t.section).startsWith("_tab_"), + level: _entryLevel(t.section), + order: i, labelSearch: labelLower, categorySearch: categoryLower, descriptionSearch: descriptionLower, @@ -433,17 +434,81 @@ Singleton { return out; } - function _bestFieldScore(fields, queryLower, exactScore, prefixScore, includesScore) { + readonly property var labelScores: ({ + "exact": 10000, + "plural": 9500, + "prefix": 5000, + "wordStart": 3000, + "includes": 1000 + }) + readonly property var squashScores: ({ + "exact": 9000, + "plural": 8500, + "prefix": 4500, + "wordStart": 900, + "includes": 900 + }) + // V4 regex has no \p{} classes, so word breaks are whitespace and punctuation ranges. + readonly property var wordBreak: /[\s!-\/:-@\[-`{-~\u2000-\u206f\u3000-\u303f]/ + + function _entryLevel(section) { + const id = String(section); + if (id.startsWith("_hub_")) + return 2; + if (id.startsWith("_tab_")) + return 1; + return 0; + } + + function _singular(value) { + if (value.length <= 3 || !value.endsWith("s") || value.endsWith("ss")) + return value; + return value.slice(0, -1); + } + + function _startsWord(field, query) { + for (var at = field.indexOf(query, 1); at > 0; at = field.indexOf(query, at + 1)) { + if (wordBreak.test(field[at - 1])) + return true; + } + return false; + } + + function _labelScore(labels, query, scores) { var score = 0; - for (var i = 0; i < fields.length; i++) { - var field = fields[i]; - if (field === queryLower) { - score = Math.max(score, exactScore); - } else if (field.startsWith(queryLower)) { - score = Math.max(score, prefixScore); - } else if (field.includes(queryLower)) { - score = Math.max(score, includesScore); - } + for (var i = 0; i < labels.length; i++) { + var label = labels[i]; + if (label === query) + return scores.exact; + if (_singular(label) === _singular(query)) + score = Math.max(score, scores.plural); + else if (label.startsWith(query)) + score = Math.max(score, scores.prefix); + else if (_startsWord(label, query)) + score = Math.max(score, scores.wordStart); + else if (label.includes(query)) + score = Math.max(score, scores.includes); + } + return score; + } + + function _keywordScore(entry, query, querySquash) { + var score = 0; + for (const keyword of entry.keywords) { + if (keyword === query) + return 900; + if (keyword.startsWith(query)) + score = Math.max(score, 800); + else if (keyword.includes(query)) + score = Math.max(score, 400); + } + if (!querySquash) + return score; + for (const keyword of entry.keywordsSquash) { + if (keyword === querySquash) + return Math.max(score, 850); + if (keyword.startsWith(querySquash)) + score = Math.max(score, 750); } return score; } @@ -465,10 +530,10 @@ Singleton { } function _searchEntries(text, maxResults) { - if (!text) + var queryLower = (text || "").toLowerCase().trim(); + if (!queryLower) return []; - var queryLower = text.toLowerCase().trim(); var querySquash = _squash(queryLower); var queryWords = queryLower.split(/\s+/).filter(w => w.length > 0); var scored = []; @@ -480,46 +545,15 @@ Singleton { if (!checkCondition(entry)) continue; - var labelScore = _bestFieldScore(entry.labelSearch, queryLower, 10000, 5000, 1000); + var labelScore = _labelScore(entry.labelSearch, queryLower, labelScores); if (querySquash) - labelScore = Math.max(labelScore, _bestFieldScore(entry.labelSquash, querySquash, 9000, 4500, 900)); + labelScore = Math.max(labelScore, _labelScore(entry.labelSquash, querySquash, squashScores)); - var score = labelScore; - score = Math.max(score, _bestFieldScore(entry.categorySearch, queryLower, 500, 500, 500)); - score = Math.max(score, _bestFieldScore(entry.descriptionSearch, queryLower, 250, 250, 250)); - if (querySquash) { - score = Math.max(score, _bestFieldScore(entry.categorySquash, querySquash, 500, 500, 500)); - score = Math.max(score, _bestFieldScore(entry.descriptionSquash, querySquash, 250, 250, 250)); - } - - if (score === 0) { - var keywords = entry.keywords; - for (var k = 0; k < keywords.length; k++) { - var keyword = keywords[k]; - if (keyword === queryLower) { - score = 900; - break; - } - if (keyword.startsWith(queryLower)) { - score = Math.max(score, 800); - } else if (keyword.includes(queryLower) && score < 400) { - score = 400; - } - } - } - - if (score === 0 && querySquash) { - var keywordsSquash = entry.keywordsSquash; - for (var ks = 0; ks < keywordsSquash.length; ks++) { - if (keywordsSquash[ks] === querySquash) { - score = Math.max(score, 850); - break; - } - if (keywordsSquash[ks].startsWith(querySquash)) { - score = Math.max(score, 750); - } - } - } + var score = Math.max(labelScore, _keywordScore(entry, queryLower, querySquash)); + if (_fieldsContainWord(entry.categorySearch, queryLower) || (querySquash && _fieldsContainWord(entry.categorySquash, querySquash))) + score = Math.max(score, 500); + if (_fieldsContainWord(entry.descriptionSearch, queryLower) || (querySquash && _fieldsContainWord(entry.descriptionSquash, querySquash))) + score = Math.max(score, 250); if (score === 0 && queryWords.length > 1) { var allMatch = true; @@ -551,23 +585,27 @@ Singleton { scored.push({ item: entry, score: score, - labelScore: labelScore + labelTier: labelScore >= labelScores.wordStart ? 2 : labelScore > 0 ? 1 : 0 }); } } scored.sort((a, b) => { + if (b.labelTier !== a.labelTier) + return b.labelTier - a.labelTier; + if (a.labelTier > 0 && b.item.level !== a.item.level) + return b.item.level - a.item.level; if (b.score !== a.score) return b.score - a.score; + if (b.item.level !== a.item.level) + return b.item.level - a.item.level; const aRuntime = !!a.item.runtimeType; const bRuntime = !!b.item.runtimeType; if (aRuntime !== bRuntime) return aRuntime ? 1 : -1; - if (b.labelScore !== a.labelScore) - return b.labelScore - a.labelScore; - if (a.item.isTab !== b.item.isTab) - return a.item.isTab ? 1 : -1; - return a.item.label.length - b.item.label.length; + if (a.item.label.length !== b.item.label.length) + return a.item.label.length - b.item.label.length; + return a.item.order - b.item.order; }); return scored.slice(0, limit).map(s => s.item); } diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index 9cdc57b55..70b91e294 100644 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -235,24 +235,31 @@ STOPWORDS = { } +def alias_keywords(text): + aliases = set() + for term in sorted(ABBREVIATIONS, key=len, reverse=True): + pattern = rf"\b{re.escape(term)}(?:e?s)?\b" + if not re.search(pattern, text): + continue + aliases.update(ABBREVIATIONS[term]) + if " " in term or "-" in term: + text = re.sub(pattern, " ", text) + return aliases + + def enrich_keywords(label, description, category, existing_tags, parent_label=None): keywords = set(existing_tags) label_lower = label.lower() label_words = re.split(r"[\s\-_&/]+", label_lower) keywords.update(w for w in label_words if len(w) > 2) - - for term, aliases in ABBREVIATIONS.items(): - if term in label_lower: - keywords.update(aliases) + keywords.update(alias_keywords(label_lower)) if description: desc_lower = description.lower() desc_words = re.split(r"[\s\-_&/,.]+", desc_lower) keywords.update(w for w in desc_words if len(w) > 3 and w.isalpha()) - for term, aliases in ABBREVIATIONS.items(): - if term in desc_lower: - keywords.update(aliases) + keywords.update(alias_keywords(desc_lower)) for name in (category, parent_label): if not name: @@ -260,9 +267,7 @@ def enrich_keywords(label, description, category, existing_tags, parent_label=No keywords.update(CATEGORY_KEYWORDS.get(name, [])) name_lower = name.lower() keywords.update(w for w in re.split(r"[\s\-_&/]+", name_lower) if len(w) > 2) - for term, aliases in ABBREVIATIONS.items(): - if term in name_lower: - keywords.update(aliases) + keywords.update(alias_keywords(name_lower)) keywords = {k for k in keywords if k not in STOPWORDS and len(k) > 1} return sorted(keywords) @@ -597,6 +602,8 @@ def generate_hub_entries(hubs): "keywords": sorted(k for k in keywords if k not in STOPWORDS), "icon": hub["icon"], } + if hub["hint"]: + entry["description"] = hub["hint"] if hub["conditionKey"]: entry["conditionKey"] = hub["conditionKey"] entries.append(entry) @@ -614,7 +621,7 @@ def generate_tab_entries(leaves, settings_entries): entries = [] for leaf in leaves: base_label = leaf["label"] - if not base_label: + if not base_label or leaf["children"]: continue label = ( f"{leaf['parentLabel']}: {base_label}" diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 6872acb81..197e040b3 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -300,7 +300,8 @@ "taskbar", "topbar" ], - "icon": "toolbar" + "icon": "toolbar", + "description": "Layout, position, appearance" }, { "section": "barConfigurations", @@ -931,7 +932,8 @@ "visible", "widgets" ], - "icon": "dock_to_bottom" + "icon": "dock_to_bottom", + "description": "Visibility, position, pinned apps, trash" }, { "section": "dockConfiguration", @@ -2455,27 +2457,6 @@ ], "icon": "palette" }, - { - "section": "_tab_0", - "label": "Wallpaper & colors", - "tabIndex": 0, - "category": "Wallpaper & colors", - "keywords": [ - "background", - "bg", - "colors", - "colour", - "colours", - "desktop", - "hue", - "image", - "palette", - "picture", - "tint", - "wallpaper" - ], - "icon": "palette" - }, { "section": "clockDateFormat", "label": "Bar format", @@ -3908,9 +3889,7 @@ "panel", "screen", "screens", - "statusbar", - "taskbar", - "topbar" + "taskbar" ], "icon": "tune", "parentLabel": "Dock", @@ -5370,13 +5349,11 @@ "app menu", "applications", "background", - "dark mode", "darken", "drawer", "launcher", "menu", "modal", - "night", "overlay", "start", "start menu" @@ -5882,10 +5859,8 @@ "applications", "bar", "chips", - "day", "drawer", "launcher", - "light mode", "menu", "panel", "shortcut", @@ -7031,24 +7006,6 @@ "registries", "widgets" ], - "icon": "extension" - }, - { - "section": "_tab_12", - "label": "Plugins", - "tabIndex": 12, - "category": "Plugins", - "keywords": [ - "addon", - "addons", - "browse", - "extend", - "extensions", - "install", - "plugins", - "registries", - "widgets" - ], "icon": "extension", "description": "Browse, install, registries" }, @@ -7883,26 +7840,6 @@ "rules", "toast" ], - "icon": "notifications" - }, - { - "section": "_tab_17", - "label": "Notifications", - "tabIndex": 17, - "category": "Notifications", - "keywords": [ - "alert", - "alerts", - "disturb", - "dnd", - "messages", - "notif", - "notifications", - "notifs", - "popups", - "rules", - "toast" - ], "icon": "notifications", "description": "Popups, do not disturb, rules" }, @@ -7915,15 +7852,10 @@ "always", "displays", "indicator", - "monitor", - "monitors", "osd", - "output", - "outputs", "percentage", "popup", "screen", - "screens", "show" ] }, @@ -7936,14 +7868,10 @@ "audio", "displays", "indicator", - "monitor", - "monitors", "osd", "output", - "outputs", "popup", "screen", - "screens", "switch" ] }, @@ -7959,14 +7887,9 @@ "dim", "displays", "indicator", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", - "screen", - "screens" + "screen" ] }, { @@ -7979,14 +7902,9 @@ "displays", "indicator", "lock", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", - "screen", - "screens" + "screen" ] }, { @@ -8001,14 +7919,9 @@ "inactive", "indicator", "inhibitor", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "screen", - "screens", "screensaver", "timeout" ] @@ -8022,15 +7935,10 @@ "displays", "indicator", "media", - "monitor", - "monitors", "osd", - "output", - "outputs", "playback", "popup", - "screen", - "screens" + "screen" ] }, { @@ -8044,14 +7952,9 @@ "indicator", "loudness", "media", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "screen", - "screens", "sound", "speaker", "volume" @@ -8066,15 +7969,10 @@ "displays", "indicator", "microphone", - "monitor", - "monitors", "mute", "osd", - "output", - "outputs", "popup", - "screen", - "screens" + "screen" ] }, { @@ -8088,14 +7986,9 @@ "indicator", "loudness", "microphone", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "screen", - "screens", "sound", "speaker", "volume" @@ -8117,15 +8010,10 @@ "indicator", "lock", "loudness", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "position", "screen", - "screens", "sound", "speaker", "volume" @@ -8141,15 +8029,10 @@ "keywords": [ "displays", "indicator", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "position", - "screen", - "screens" + "screen" ] }, { @@ -8160,15 +8043,10 @@ "keywords": [ "displays", "indicator", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "position", - "screen", - "screens" + "screen" ] }, { @@ -8180,18 +8058,13 @@ "displays", "hibernate", "indicator", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "power", "profile", "reboot", "restart", "screen", - "screens", "shutdown", "sleep", "suspend" @@ -8205,14 +8078,9 @@ "keywords": [ "displays", "indicator", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "screen", - "screens", "show" ] }, @@ -8226,14 +8094,9 @@ "displays", "indicator", "loudness", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "screen", - "screens", "sound", "speaker", "volume" @@ -8248,14 +8111,9 @@ "desktop", "displays", "indicator", - "monitor", - "monitors", "osd", - "output", - "outputs", "popup", "screen", - "screens", "switch", "virtual", "workspace" @@ -9081,26 +8939,6 @@ "widget", "widgets" ], - "icon": "widgets" - }, - { - "section": "_tab_22", - "label": "Bar widgets", - "tabIndex": 22, - "category": "Bar widgets", - "keywords": [ - "bar", - "components", - "configure", - "modules", - "panel", - "remove", - "reorder", - "statusbar", - "taskbar", - "topbar", - "widgets" - ], "icon": "widgets", "description": "Add, remove, reorder and configure" }, @@ -10490,29 +10328,6 @@ "widget", "widgets" ], - "icon": "widgets" - }, - { - "section": "_tab_27", - "label": "Desktop widgets", - "tabIndex": 27, - "category": "Desktop widgets", - "keywords": [ - "addons", - "clocks", - "components", - "conky", - "desktop", - "desktop clock", - "extensions", - "modules", - "monitors", - "plugins", - "system", - "time", - "watch", - "widgets" - ], "icon": "widgets", "description": "Clocks, system monitors, plugins" }, @@ -13405,25 +13220,6 @@ "shadows", "style" ], - "icon": "layers" - }, - { - "section": "_tab_48", - "label": "Interface style", - "tabIndex": 48, - "category": "Interface style", - "keywords": [ - "blur", - "borders", - "corners", - "interface", - "opacity", - "radius", - "round", - "rounded", - "shadows", - "style" - ], "icon": "layers", "description": "Opacity, borders, blur, shadows, corners" }, @@ -15564,17 +15360,6 @@ ], "icon": "account_circle" }, - { - "section": "_tab_60", - "label": "Users & accounts", - "tabIndex": 60, - "category": "Users & accounts", - "keywords": [ - "accounts", - "users" - ], - "icon": "account_circle" - }, { "section": "createUserGreeter", "label": "Allow greeter login access",