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, Escape clears the query, then closes on a second press, and 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)
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.entries) do
local score, start = score_of(entry, needle)
if not entry.no_display and 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 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_key = function(key)
if key.name == "Down" or key.name == "Tab" then move(1) end
if key.name == "Up" or key.name == "ISO_Left_Tab" 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,
items = matches,
key = function(entry) return entry.id end,
each = function(entry)
return row {
width = "fill",
padding = 8,
radius = 8,
-- Per row, so a move reruns this and builds no row.
background = computed({ matches, selected }, function(entries, current)
local picked = entries[current]
return picked and picked.id == entry.id and "#45475a" or "#00000000"
end),
on_click = function() launch(entry) end,
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",
output = "active",
anchor = { top = true, bottom = true, left = true, right = true },
width = "fill",
height = "fill",
exclusive_zone = "ignore",
visible = open,
reset_on_close = { query, selected, results_scroll }, -- however it closed, it reopens fresh
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.
rect { 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.entriesincludes desktop entries used for window icon lookup; the launcher omits entries withno_display = true. It follows installs and removals;launchtakes an entry’sidand runs it detached (applications).fuzzyscores one candidate; ranking, the tiebreak and the cap stay in Lua (fuzzy).computedjoins the capability with the query (derived signals).- Each row derives its own highlight from
matchesandselected. An arrow key reruns those 50 small functions and builds no row; folding the selection intoitemswould calleachfor all 50 on every keypress (when items rebuild). - The
textfieldowns the typed text and reports it throughon_change;on_keygets the arrow and Tab keys the field does not use (textfield, key handlers). scroll(name):reveal(index)keeps the selected row in view inside themax_heightlist (scroll, list).reset_on_closereturns the query, the selection and the scroll to their start whenever the launcher closes, including bymantle toggle(reset on close)."exclusive"hands the panel the keyboard when it maps, andautofocusgives it to the field (keyboard focus).- A full-size transparent
rectunder the card closes it on an outside click (close an overlay).
Variations
| Change | Edit |
|---|---|
| Pointer focus on Hyprland | "on_demand" 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 |