From 162e6ce006fbd98d5b62082a38d58717054069f2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 25 May 2026 12:57:58 +0200 Subject: [PATCH] Fix launcher filtering and add tests for it --- shell/plugins/launcher/Launcher.qml | 64 ++--------- shell/plugins/launcher/LauncherSearch.js | 134 +++++++++++++++++++++++ test/launcher-search-test.sh | 94 ++++++++++++++++ 3 files changed, 235 insertions(+), 57 deletions(-) create mode 100644 shell/plugins/launcher/LauncherSearch.js create mode 100644 test/launcher-search-test.sh diff --git a/shell/plugins/launcher/Launcher.qml b/shell/plugins/launcher/Launcher.qml index d5460417..e978ba03 100644 --- a/shell/plugins/launcher/Launcher.qml +++ b/shell/plugins/launcher/Launcher.qml @@ -4,6 +4,7 @@ import Quickshell.Wayland import Quickshell.Widgets import QtQuick import qs.Commons +import "LauncherSearch.js" as LauncherSearch Item { id: root @@ -88,15 +89,15 @@ Item { } function entryName(entry) { - return String((entry && entry.name) || (entry && entry.id) || "") + return LauncherSearch.entryName(entry) } function entrySubtext(entry) { - return String((entry && entry.genericName) || "") + return LauncherSearch.entrySubtext(entry) } function entrySortKey(entry) { - return root.entryName(entry).toLowerCase() + return LauncherSearch.entrySortKey(entry) } function toplevelCount() { @@ -104,10 +105,7 @@ Item { } function entrySearchText(entry) { - if (!entry) return "" - var keywords = "" - try { keywords = (entry.keywords || []).join(" ") } catch (e) { keywords = "" } - return [entry.name, entry.genericName, entry.comment, keywords, entry.id].join(" ").toLowerCase() + return LauncherSearch.entrySearchText(entry) } function isHiddenEntry(entry) { @@ -150,60 +148,12 @@ Item { } function fuzzyScore(entry, query) { - var q = String(query || "").trim().toLowerCase() - if (!q) return 0 - - var name = root.entryName(entry).toLowerCase() - var id = String((entry && entry.id) || "").toLowerCase() - var haystack = root.entrySearchText(entry) - var directName = name.indexOf(q) - var directId = id.indexOf(q) - if (directName === 0) return 10000 - name.length - if (directId === 0) return 9500 - id.length - if (directName > 0) return 8000 - directName * 10 - name.length - if (directId > 0) return 7600 - directId * 10 - id.length - - var hayIndex = haystack.indexOf(q) - if (hayIndex >= 0) return 6000 - hayIndex - - var pos = 0 - var first = -1 - var last = -1 - for (var i = 0; i < haystack.length && pos < q.length; i++) { - if (haystack.charAt(i) !== q.charAt(pos)) continue - if (first < 0) first = i - last = i - pos++ - } - if (pos !== q.length) return -1 - return 3000 - (last - first) - haystack.length * 0.01 + return LauncherSearch.fuzzyScore(entry, query) } function sortedEntries(query) { var values = DesktopEntries.applications.values || [] - var q = String(query || "").trim() - var rows = [] - - for (var i = 0; i < values.length; i++) { - var entry = values[i] - if (!entry || entry.noDisplay || root.isHiddenEntry(entry)) continue - var name = root.entryName(entry) - if (!name) continue - var score = root.fuzzyScore(entry, q) - if (score < 0) continue - rows.push({ entry: entry, score: score, key: root.entrySortKey(entry), name: name.toLowerCase() }) - } - - rows.sort(function(a, b) { - if (q && a.score !== b.score) return b.score - a.score - if (a.key < b.key) return -1 - if (a.key > b.key) return 1 - if (a.name < b.name) return -1 - if (a.name > b.name) return 1 - return 0 - }) - - return rows + return LauncherSearch.sortedEntries(values, query, function(entry) { return root.isHiddenEntry(entry) }) } function rebuildDisplay() { diff --git a/shell/plugins/launcher/LauncherSearch.js b/shell/plugins/launcher/LauncherSearch.js new file mode 100644 index 00000000..f9e53cde --- /dev/null +++ b/shell/plugins/launcher/LauncherSearch.js @@ -0,0 +1,134 @@ +function entryName(entry) { + return String((entry && entry.name) || (entry && entry.id) || "") +} + +function entrySubtext(entry) { + return String((entry && entry.genericName) || "") +} + +function entrySortKey(entry) { + return entryName(entry).toLowerCase() +} + +function keywordText(entry) { + try { + if (entry && entry.keywords && typeof entry.keywords.join === "function") return entry.keywords.join(" ") + } catch (e) { + } + return "" +} + +function entrySearchText(entry) { + if (!entry) return "" + return [entry.name, entry.genericName, entry.comment, keywordText(entry), entry.id].join(" ").toLowerCase() +} + +function wordText(value) { + return String(value || "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[._:/\\-]+/g, " ") + .toLowerCase() +} + +function words(value) { + var values = wordText(value).split(/[^a-z0-9]+/) + var result = [] + for (var i = 0; i < values.length; i++) { + if (values[i]) result.push(values[i]) + } + return result +} + +function entryAcronym(entry) { + var values = words([entry && entry.name, entry && entry.genericName, keywordText(entry), entry && entry.id].join(" ")) + var result = "" + for (var i = 0; i < values.length; i++) result += values[i].charAt(0) + return result +} + +function termMatches(entry, term) { + if (!term) return true + + var name = entryName(entry).toLowerCase() + var id = String((entry && entry.id) || "").toLowerCase() + var haystack = entrySearchText(entry) + + if (name.indexOf(term) >= 0) return true + if (id.indexOf(term) >= 0) return true + if (haystack.indexOf(term) >= 0) return true + + return term.length <= 5 && entryAcronym(entry).indexOf(term) >= 0 +} + +function allTermsMatch(entry, query) { + var terms = String(query || "").toLowerCase().trim().split(/\s+/) + for (var i = 0; i < terms.length; i++) { + if (terms[i] && !termMatches(entry, terms[i])) return false + } + return true +} + +function fuzzyScore(entry, query) { + var q = String(query || "").trim().toLowerCase() + if (!q) return 0 + if (!allTermsMatch(entry, q)) return -1 + + var name = entryName(entry).toLowerCase() + var id = String((entry && entry.id) || "").toLowerCase() + var haystack = entrySearchText(entry) + var directName = name.indexOf(q) + var directId = id.indexOf(q) + if (directName === 0) return 10000 - name.length + if (directId === 0) return 9500 - id.length + if (directName > 0) return 8000 - directName * 10 - name.length + if (directId > 0) return 7600 - directId * 10 - id.length + + var hayIndex = haystack.indexOf(q) + if (hayIndex >= 0) return 6000 - hayIndex + + var acronym = entryAcronym(entry) + var acronymIndex = acronym.indexOf(q) + if (acronymIndex === 0) return 5000 - acronym.length + if (acronymIndex > 0) return 4600 - acronymIndex * 10 - acronym.length + + return 4000 - name.length +} + +function sortedEntries(values, query, hiddenCallback) { + var q = String(query || "").trim() + var rows = [] + + for (var i = 0; i < values.length; i++) { + var entry = values[i] + if (!entry || entry.noDisplay) continue + if (hiddenCallback && hiddenCallback(entry)) continue + var name = entryName(entry) + if (!name) continue + var score = fuzzyScore(entry, q) + if (score < 0) continue + rows.push({ entry: entry, score: score, key: entrySortKey(entry), name: name.toLowerCase() }) + } + + rows.sort(function(a, b) { + if (q && a.score !== b.score) return b.score - a.score + if (a.key < b.key) return -1 + if (a.key > b.key) return 1 + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 + }) + + return rows +} + +if (typeof module !== "undefined") { + module.exports = { + entryName: entryName, + entrySubtext: entrySubtext, + entrySortKey: entrySortKey, + entrySearchText: entrySearchText, + entryAcronym: entryAcronym, + fuzzyScore: fuzzyScore, + sortedEntries: sortedEntries + } +} diff --git a/test/launcher-search-test.sh b/test/launcher-search-test.sh new file mode 100644 index 00000000..2080e9eb --- /dev/null +++ b/test/launcher-search-test.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +export ROOT + +node <<'JS' +const search = require(`${process.env.ROOT}/shell/plugins/launcher/LauncherSearch.js`) + +function fail(description, detail) { + if (detail) console.error(detail) + console.error(`not ok - ${description}`) + process.exit(1) +} + +function pass(description) { + console.log(`ok - ${description}`) +} + +function assert(condition, description, detail) { + if (!condition) fail(description, detail) + pass(description) +} + +const entries = [ + { + name: 'Google Contacts', + genericName: 'Address Book', + comment: 'Manage contacts', + keywords: ['contacts', 'address book', 'people'], + id: 'google-contacts.desktop' + }, + { + name: 'Calculator', + genericName: 'Calculator', + comment: 'Perform arithmetic, scientific or financial calculations', + keywords: ['calculation', 'arithmetic', 'scientific', 'financial'], + id: 'org.gnome.Calculator.desktop' + }, + { + name: 'OBS Studio', + genericName: 'Streaming/Recording Software', + comment: 'Free and Open Source Streaming/Recording Software', + keywords: ['streaming', 'recording', 'capture'], + id: 'com.obsproject.Studio.desktop' + }, + { + name: 'Aether', + genericName: '', + comment: 'Minimal internet radio player', + keywords: ['audio', 'music', 'radio'], + id: 'io.github.taqi.aether.desktop' + }, + { + name: 'Xournal++', + genericName: 'Notetaking', + comment: 'Take handwritten notes', + keywords: ['notes', 'pdf', 'annotation'], + id: 'com.github.xournalpp.xournalpp.desktop' + }, + { + name: 'RustDesk', + genericName: 'Remote Desktop', + comment: 'Remote desktop control', + keywords: ['remote', 'desktop', 'control'], + id: 'com.rustdesk.RustDesk.desktop' + } +] + +const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry)) +assert( + contactMatches.length === 1 && contactMatches[0] === 'Google Contacts', + 'contact search only returns direct contact matches', + `matches: ${contactMatches.join(', ')}` +) + +assert( + search.fuzzyScore(entries[1], 'contact') < 0, + 'calculator does not match contact as a loose subsequence' +) + +const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry)) +assert( + acronymMatches[0] === 'Google Contacts', + 'short acronym matching still works' +) + +const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry)) +assert( + directMatches[0] === 'OBS Studio', + 'direct app-name matching still works' +) +JS