The Problem
Users need to launch applications quickly without lifting their hands from the keyboard or navigating through menus. A launcher combines search with application discovery.
The Naive Approach
Use a TextInput and hardcoded app list:
TextInput {
onAccepted: Qt.process([text])
}This expects users to type exact executable names. No search, no discovery, no desktop file integration.
Think of a launcher as a search engine for your computer. You type what you want to do ("write a document", "browse the web") and it shows matching applications. The best match is pre-selected — just press Enter to launch.
The Idea
Parse .desktop files from XDG data directories into a searchable list. Display results in a ListView with fuzzy matching. Highlight the best match and launch on Enter.
Let's Build It
// Launcher.qml
PopupWindow {
id: launcher
width: 500; height: 400
visible: false
dismissOnClickOutside: true
property string searchText: ""
property var apps: []
property var filtered: []
function loadApps() {
Qt.process(["bash", "-c", "ls /usr/share/applications/*.desktop ~/.local/share/applications/*.desktop 2>/dev/null"])
.onStdout.connect(function(data) {
apps = data.trim().split("\n").filter(function(p) { return p }).map(function(path) {
return path.split("/").pop().replace(".desktop", "")
}).sort()
})
}
function filterApps() {
if (!searchText) { filtered = apps.slice(0, 20); return }
var q = searchText.toLowerCase()
filtered = apps.filter(function(a) {
return a.toLowerCase().indexOf(q) >= 0
}).slice(0, 20)
}
Component.onCompleted: loadApps()
Rectangle {
anchors.fill: parent; radius: 8; color: "#1e1e2e"
Column {
anchors { fill: parent; margins: 12 }; spacing: 8
Rectangle {
width: parent.width; height: 40; radius: 6; color: "#313244"
TextInput {
id: input
anchors { fill: parent; leftMargin: 12; rightMargin: 12 }
font.pixelSize: 15; color: "#cdd6f4"
placeholderText: "Search applications..."
focus: true
onTextChanged: { launcher.searchText = text; launcher.filterApps() }
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Down) listView.incrementCurrentIndex()
else if (event.key === Qt.Key_Up) listView.decrementCurrentIndex()
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (listView.currentIndex >= 0 && listView.currentIndex < filtered.length) {
Qt.execDetached({ command: filtered[listView.currentIndex] })
launcher.visible = false
}
}
else if (event.key === Qt.Key_Escape) launcher.visible = false
}
}
}
ListView {
id: listView
width: parent.width; height: parent.height - 60
model: filtered
spacing: 2; clip: true
delegate: Rectangle {
required property string modelData
required property int index
width: parent.width; height: 36; radius: 4
color: index === listView.currentIndex ? "#45475a" : "transparent"
Row {
anchors { fill: parent; leftMargin: 12; rightMargin: 12 }; spacing: 8
anchors.verticalCenter: parent.verticalCenter
Text {
text: modelData.charAt(0).toUpperCase()
font.pixelSize: 14; color: "#89b4fa"
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: modelData
font.pixelSize: 13; color: "#cdd6f4"
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
onClicked: {
Qt.execDetached({ command: modelData })
launcher.visible = false
}
}
}
ScrollBar.vertical: ScrollBar { active: true }
}
}
}
}The launcher loads .desktop filenames at startup, filters by substring match as the user types, and displays results in a ListView. Arrow keys navigate, Enter launches, Escape dismisses.
Let's Improve It
Add fuzzy matching with scoring and result caching:
function fuzzyScore(query, target) {
var q = query.toLowerCase(), t = target.toLowerCase()
if (t.indexOf(q) >= 0) return 100 - (t.indexOf(q) * 5) // prefix matches score highest
// Simple character-by-character matching
var score = 0, qi = 0
for (var ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) { score += 10; qi++ }
}
return qi === q.length ? score : 0
}Parsing full .desktop files. Exec= lines contain field codes (%f, %u, %F, %U) that must be stripped. Use a proper .desktop parser or rely on the executable name from the filename.
No debouncing. Filtering 1000+ apps on every keystroke causes visible lag. Debounce at 100ms, or move filtering to a worker.
Forgetting TryExec. Some .desktop files have TryExec — don't show apps whose TryExec binary isn't installed.
Under the Hood
XDG .desktop files are INI-format files located in /usr/share/applications/ and ~/.local/share/applications/. Each file has a Name (human-readable), Exec (command to run), Icon (themed icon name), and Categories (for grouping). The launcher parses these to build its search index.
Exercises
- Parse XDG .desktop files for app search
- Use fuzzy matching with scoring for better result ordering
- Keyboard navigation: arrows, Enter, Escape
- Debounce search and cache results for performance