Combine the Omarchy menu and the launcher

Now that we can deep search, they don't need to be different
This commit is contained in:
David Heinemeier Hansson
2026-07-23 15:04:22 -07:00
parent 347997871e
commit 7a9947a732
23 changed files with 576 additions and 876 deletions
+258
View File
@@ -0,0 +1,258 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import "AppSearch.js" as AppSearch
// Shared desktop-application library: the sorted entry list with hidden-entry
// filtering, the icon fallback index, launch feedback, and entry removal.
// Injected as shell.appLibrary; the menu's Apps submenu is the consumer.
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property var configuredHiddenEntryIds: ({})
property var desktopHiddenEntryIds: ({})
// Maps an icon name to a file on disk (e.g. "omacut" -> ".../apps/omacut.svg").
// Used as a fallback for icons that Qt's themed lookup misses because they were
// installed after this process started (its icon cache never re-scans). Refreshed
// whenever the app list changes, so newly installed apps get their icon live.
property var iconIndex: ({})
property var pendingIconIndex: ({})
property int launchSerial: 0
property int launchToplevelCount: 0
property var launchActiveToplevel: null
property bool launchOsdOpen: false
property string launchOsdMessage: ""
// Emitted whenever the visible application set may have changed: desktop
// entries appeared or vanished, or the hidden-entry filters reloaded.
signal appsChanged()
function entryName(entry) {
return AppSearch.entryName(entry)
}
function entrySubtext(entry) {
return AppSearch.entrySubtext(entry)
}
function isHiddenEntry(entry) {
var id = String((entry && entry.id) || "")
return root.configuredHiddenEntryIds[id] === true || root.desktopHiddenEntryIds[id] === true
}
function sortedEntries(query) {
var values = DesktopEntries.applications.values || []
return AppSearch.sortedEntries(values, query, function(entry) { return root.isHiddenEntry(entry) })
}
function iconSource(icon) {
var value = String(icon || "")
if (value.length === 0) return Quickshell.iconPath("application-x-executable", true)
if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value
if (value.charAt(0) === "/") return Util.fileUrl(value)
// Prefer the context-limited app/device index. An unconstrained themed
// lookup can resolve an app name such as "zoom" to an action icon instead.
var found = root.iconIndex[value]
if (found) return Util.fileUrl(found)
var themed = Quickshell.iconPath(value, true)
if (themed.length > 0) return themed
return Quickshell.iconPath("application-x-executable", true)
}
// The shell may start before first-install packages have finished placing
// their icons; consumers call this when they open so icons appear live.
function refreshIcons() {
if (!iconIndexScan.running) iconIndexScan.running = true
}
function launch(desktopId, name) {
var id = String(desktopId || "")
if (!id) return
root.beginLaunchFeedback(name)
Util.execDetached("gtk-launch " + Util.shellQuote(id))
}
function remove(desktopId, name) {
var id = String(desktopId || "")
if (!id) return
Util.execDetached(Util.shellQuote(root.omarchyPath + "/bin/omarchy-remove-launcher-entry") + " " + Util.shellQuote(id) + " " + Util.shellQuote(String(name || id)))
}
function normalizeDesktopId(id) {
var value = String(id || "").trim()
if (value.slice(-8) === ".desktop") value = value.slice(0, -8)
return value
}
function loadConfiguredHides(rawText) {
var next = ({})
var lines = String(rawText || "").split(/\n/)
for (var i = 0; i < lines.length; i++) {
var id = root.normalizeDesktopId(lines[i])
if (id.length > 0) next[id] = true
}
root.configuredHiddenEntryIds = next
root.appsChanged()
}
function loadDesktopHiddenEntries(rawText) {
var next = ({})
var lines = String(rawText || "").split(/\n/)
for (var i = 0; i < lines.length; i++) {
var id = root.normalizeDesktopId(lines[i])
if (id.length > 0) next[id] = true
}
root.desktopHiddenEntryIds = next
root.appsChanged()
}
function iconIndexScanCommand() {
// List app/device icons across the XDG icon dirs and /usr/share/pixmaps as
// "<path>" lines. Some desktop entries, such as Print Settings, use device
// icons like "printer" instead of app icons. SVGs are emitted before PNGs
// so the parser, which keeps the first hit per name, prefers scalable icons.
return [
'dirs="$HOME/.icons $HOME/.local/share/icons";',
'IFS=":"; for d in ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do dirs="$dirs $d/icons"; done; unset IFS;',
'for ext in svg png; do',
' for base in $dirs; do',
' [[ -d $base ]] && find "$base" \\( -path "*/apps/*" -o -path "*/devices/*" \\) -name "*.$ext" 2>/dev/null;',
' done;',
' find /usr/share/pixmaps -maxdepth 1 -name "*.$ext" 2>/dev/null;',
'done'
].join(' ')
}
function indexIconLine(path) {
var value = String(path || "").trim()
if (value.length === 0) return
var slash = value.lastIndexOf("/")
var file = slash >= 0 ? value.slice(slash + 1) : value
var dot = file.lastIndexOf(".")
var name = dot > 0 ? file.slice(0, dot) : file
if (name.length > 0 && root.pendingIconIndex[name] === undefined)
root.pendingIconIndex[name] = value
}
function hiddenEntryScanCommand() {
var desktop = [Quickshell.env("XDG_CURRENT_DESKTOP"), Quickshell.env("XDG_SESSION_DESKTOP"), Quickshell.env("DESKTOP_SESSION")].filter(function(v) { return String(v || "").length > 0 }).join(":")
var script = root.omarchyPath + "/shell/services/hidden-entries.sh"
return Util.shellQuote(script) + " " + Util.shellQuote(desktop)
}
function toplevelCount() {
try { return ToplevelManager.toplevels.values.length } catch (e) { return 0 }
}
function beginLaunchFeedback(name) {
root.launchSerial++
root.launchToplevelCount = root.toplevelCount()
root.launchActiveToplevel = ToplevelManager.activeToplevel
root.launchOsdOpen = false
root.launchOsdMessage = "Launching " + String(name || "application") + "…"
launchDelay.restart()
launchTimeout.restart()
}
function closeLaunchFeedback(serial) {
if (serial !== root.launchSerial) return
launchDelay.stop()
launchTimeout.stop()
if (root.launchOsdOpen) {
Quickshell.execDetached(["omarchy-shell", "osd", "close"])
root.launchOsdOpen = false
}
}
function maybeFinishLaunchFeedback() {
if (!launchDelay.running && !launchTimeout.running && !root.launchOsdOpen) return
if (root.toplevelCount() <= root.launchToplevelCount && ToplevelManager.activeToplevel === root.launchActiveToplevel) return
root.closeLaunchFeedback(root.launchSerial)
}
QtObject {
id: hiddenEntryOutput
property string text: ""
}
Process {
id: hiddenEntryScan
command: ["bash", "-lc", root.hiddenEntryScanCommand()]
stdout: SplitParser { onRead: function(line) { hiddenEntryOutput.text += line + "\n" } }
onStarted: hiddenEntryOutput.text = ""
onExited: root.loadDesktopHiddenEntries(hiddenEntryOutput.text)
}
Process {
id: iconIndexScan
command: ["bash", "-lc", root.iconIndexScanCommand()]
stdout: SplitParser { onRead: function(line) { root.indexIconLine(line) } }
onStarted: root.pendingIconIndex = ({})
// Swapping the property re-evaluates every iconSource() binding, so
// newly found icons appear without rebuilding the list.
onExited: root.iconIndex = root.pendingIconIndex
}
// Coalesces bursts of app-list changes (a package install touches many
// entries) into a single rescan.
Timer {
id: iconIndexDebounce
interval: 750
onTriggered: if (!iconIndexScan.running) iconIndexScan.running = true
}
FileView {
path: root.omarchyPath + "/default/omarchy/launcher.hides"
watchChanges: true
printErrors: false
onLoaded: root.loadConfiguredHides(text())
onFileChanged: root.loadConfiguredHides(text())
onLoadFailed: root.loadConfiguredHides("")
}
Connections {
target: ToplevelManager.toplevels
function onValuesChanged() { root.maybeFinishLaunchFeedback() }
}
Connections {
target: ToplevelManager
function onActiveToplevelChanged() { root.maybeFinishLaunchFeedback() }
}
Timer {
id: launchDelay
interval: 2000
onTriggered: {
if (root.toplevelCount() > root.launchToplevelCount || ToplevelManager.activeToplevel !== root.launchActiveToplevel) return
root.launchOsdOpen = true
Quickshell.execDetached(["omarchy-shell", "osd", "show", JSON.stringify({ icon: "󱓞", message: root.launchOsdMessage, duration: 0 })])
}
}
Timer {
id: launchTimeout
interval: 15000
onTriggered: root.closeLaunchFeedback(root.launchSerial)
}
Connections {
target: DesktopEntries.applications
function onValuesChanged() {
hiddenEntryScan.running = true
iconIndexDebounce.restart()
root.appsChanged()
}
}
Component.onCompleted: {
hiddenEntryScan.running = true
iconIndexScan.running = true
}
}
+134
View File
@@ -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
}
}
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
desktop_names=${1:-}
declare -A seen_ids
desktop_matches() {
local list=$1
local desktop_name
local entry
IFS=":" read -ra desktop_parts <<< "$desktop_names"
IFS=";" read -ra entries <<< "$list"
for desktop_name in "${desktop_parts[@]}"; do
[[ -z $desktop_name ]] && continue
for entry in "${entries[@]}"; do
[[ -z $entry ]] && continue
[[ $entry == "$desktop_name" ]] && return 0
done
done
return 1
}
desktop_id_for_file() {
local dir=$1
local file=$2
local rel=${file#"$dir"/}
rel=${rel%.desktop}
printf '%s\n' "${rel//\//-}"
}
is_hidden_desktop_file() {
local file=$1
local in_desktop_entry=0
local hidden=false
local only_show_in=
local not_show_in=
local line key value
while IFS= read -r line || [[ -n $line ]]; do
line=${line%$'\r'}
if [[ $line =~ ^\[(.*)\]$ ]]; then
[[ ${BASH_REMATCH[1]} == "Desktop Entry" ]] && in_desktop_entry=1 || in_desktop_entry=0
continue
fi
(( in_desktop_entry )) || continue
[[ $line == *=* ]] || continue
key=${line%%=*}
value=${line#*=}
case $key in
Hidden|NoDisplay)
[[ $value == "true" ]] && hidden=true
;;
OnlyShowIn)
only_show_in=$value
;;
NotShowIn)
not_show_in=$value
;;
esac
done < "$file"
[[ $hidden == "true" ]] && return 0
[[ -n $only_show_in ]] && ! desktop_matches "$only_show_in" && return 0
[[ -n $not_show_in ]] && desktop_matches "$not_show_in" && return 0
return 1
}
scan_dir() {
local dir=$1
local file id
[[ -d $dir ]] || return
while IFS= read -r -d '' file; do
id=$(desktop_id_for_file "$dir" "$file")
[[ -n ${seen_ids[$id]+set} ]] && continue
seen_ids[$id]=1
if is_hidden_desktop_file "$file"; then
printf '%s\n' "$id"
fi
done < <(find "$dir" -type f -name '*.desktop' -print0 2>/dev/null | sort -z)
}
scan_dir "$HOME/.local/share/applications"
IFS=":" read -ra data_dirs <<< "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
for data_dir in "${data_dirs[@]}"; do
scan_dir "$data_dir/applications"
done
scan_dir "$HOME/.nix-profile/share/applications"