App launcher
A centred search overlay for installed apps. Typing ranks desktop entries with fzf’s scorer, the
arrow keys move the selection, Enter or a click launches, and Escape or a click outside closes it.
Open it from a compositor keybind with mantle toggle launcher_open.
local MAX_RESULTS = 50
local open = state("launcher_open", false)
local query = state("launcher_query", "")
local selected = state("launcher_selected", 1)
local results_scroll = scroll("launcher_results")
local function close()
open:set(false)
query:set("")
selected:set(1)
end
-- Best score of the name, generic name and keywords; nil when none match.
local function score_of(entry, needle)
local best, best_start = fuzzy(entry.name, needle)
for _, field in ipairs({ entry.generic_name or "", table.concat(entry.keywords, " ") }) do
local score, start = fuzzy(field, needle)
if score and (best == nil or score > best) then
best, best_start = score, start
end
end
return best, best_start
end
local matches = computed({ mantle.applications, query }, function(applications, needle)
local scored = {}
for _, entry in ipairs(applications and applications.entries or {}) do
local score, start = score_of(entry, needle)
if score then
scored[#scored + 1] = { entry = entry, score = score, start = start }
end
end
table.sort(scored, function(left, right)
if left.score ~= right.score then return left.score > right.score end
if left.start ~= right.start then return left.start < right.start end
return left.entry.id < right.entry.id -- unique, so the order is stable
end)
local out = {}
for index = 1, math.min(#scored, MAX_RESULTS) do
out[index] = scored[index].entry
end
return out
end)
local rows = computed({ matches, selected }, function(entries, current)
local out = {}
for index, entry in ipairs(entries) do
out[index] = { entry = entry, selected = index == current }
end
return out
end)
local function launch(entry)
if entry then
mantle.applications:launch(entry.id)
end
close()
end
local function move(step)
local count = #matches:get()
if count == 0 then
return
end
selected:set((selected:get() - 1 + step) % count + 1) -- wraps at both ends
results_scroll:reveal(selected:get())
end
local search = rect {
width = "Fill",
padding = { left = 12, right = 12 },
radius = 10,
background = "#313244",
children = {
textfield {
id = "search",
width = "Fill",
height = 40,
font_size = 16,
foreground = "#cdd6f4",
placeholder = "Search apps",
autofocus = true,
on_change = function(text)
query:set(text)
selected:set(1)
results_scroll:reveal(1)
end,
on_navigate = function(key)
if key == "down" or key == "tab" then move(1) end
if key == "up" or key == "backtab" then move(-1) end
end,
on_submit = function() launch(matches:get()[selected:get()]) end,
on_cancel = function(cleared)
if not cleared then close() end -- first Escape clears, second closes
end,
},
},
}
local results = list {
width = "Fill",
max_height = 400,
spacing = 2,
scroll = results_scroll,
source = rows,
key = function(row_data) return row_data.entry.id end,
itemfn = function(row_data)
local entry = row_data.entry
return button {
width = "Fill",
padding = 8,
radius = 8,
background = row_data.selected and "#45475a" or "#00000000",
on_click = function() launch(entry) end,
children = {
row {
width = "Fill",
spacing = 12,
children = {
icon { name = entry.icon or "application-x-executable", size = 32, align_v = "Center" },
column {
width = "Fill",
align_v = "Center",
children = {
text { content = entry.name, width = "Fill", elide = "End", font_size = 14, foreground = "#cdd6f4" },
text {
content = entry.comment or entry.generic_name or "",
visible = (entry.comment or entry.generic_name) ~= nil,
width = "Fill",
elide = "End",
font_size = 11,
foreground = "#a6adc8",
},
},
},
},
},
},
}
end,
}
return {
panel {
id = "launcher",
layer = "Overlay",
monitor = "Active",
anchor = { top = true, bottom = true, left = true, right = true },
width = "Fill",
height = "Fill",
exclusive = "Ignore",
visible = open,
keyboard_interactivity = "Exclusive", -- hiding destroys the surface, so no binding needed
child = rect {
width = "Fill",
height = "Fill",
background = "#11111b80",
children = {
-- Catches clicks outside the card.
button { width = "Fill", height = "Fill", on_click = close },
column {
width = 560,
align_h = "Center",
margin = { top = 160 },
padding = 12,
spacing = 8,
radius = 16,
background = "#1e1e2e",
border_width = 1,
border_color = "#45475a",
children = {
search,
results,
text {
content = "No matches",
visible = matches:map(function(entries) return #entries == 0 end),
align_h = "Center",
padding = 12,
foreground = "#6c7086",
},
},
},
},
},
},
}
Bind a key to mantle toggle launcher_open, for example bind = SUPER, Space, exec, mantle toggle launcher_open on Hyprland or Mod+Space { spawn "mantle" "toggle" "launcher_open"; } on niri.
How it works
applications.entriesholds every visible desktop entry and follows installs and removals;launchtakes itsidand runs it detached (applications).fuzzyscores one candidate; ranking, the tiebreak and the cap stay in Lua (fuzzy).computedjoins the capability with the query, and a second one marks the selected row (derived signals).- The
textfieldowns the typed text and reports it throughon_change;on_navigategets the arrow and Tab keys (textfield, text fields). scroll(name):reveal(index)keeps the selected row in view inside themax_heightlist (scroll, list)."Exclusive"hands the panel the keyboard when it maps, andautofocusgives it to the field (keyboard focus).- A full-size transparent
buttonunder the card closes it on an outside click (close an overlay).
Variations
| Change | Edit |
|---|---|
| Pointer focus on Hyprland | "OnDemand" instead of "Exclusive", so other surfaces keep taking clicks (panel gotchas) |
| Fewer rows | MAX_RESULTS = 8 and drop max_height |
| Debounce typing | Rank against delay(query, 80) instead of query (debounce a search) |
| No dimmed backdrop | Remove the root rect’s background |