Notification popups
Notification cards stacked in the top-right corner, newest first. Each card shows the app, the summary, the formatted body and the sender’s action buttons; clicking it runs the default action, the × dismisses it, and hovering the stack pauses every countdown.
local MAX_CARDS = 4
local stack_hover = hover("notification_stack")
-- Cards to show: not expired, not silenced by do-not-disturb, newest first.
local cards = mantle.notifications:map(function(notifications)
local out = {}
for _, item in ipairs(notifications.feed) do
local quiet = notifications.dnd and item.urgency ~= "critical"
if not item.expired and not quiet and #out < MAX_CARDS then
out[#out + 1] = item
end
end
return out
end)
-- A text span is a text run as it is; links also get a colour. Image spans are skipped.
local function body_runs(spans)
local runs = {}
for _, span in ipairs(spans) do
if span.kind == "text" then
-- A copy: the span belongs to the pushed snapshot every other reader shares.
local run = {}
for field, value in pairs(span) do run[field] = value end
if run.href then
run.underline, run.color = true, "#89b4fa"
end
runs[#runs + 1] = run
end
end
return runs
end
local function artwork(item)
if item.image_path then
return rect {
width = 40,
height = 40,
radius = 8,
clip = "rounded",
children = { image { source = item.image_path, fit = "cover", width = "fill", height = "fill" } },
}
end
return icon { name = item.app_icon or "dialog-information-symbolic", size = 32 }
end
local function action_buttons(item)
local buttons = {}
for index, action in ipairs(item.actions) do
buttons[index] = rect {
width = "fill",
padding = 6,
radius = 6,
background = "#313244",
on_click = function() mantle.notifications:invoke_action(item.id, action.key) end,
children = {
text { content = action.label, align_h = "center", elide = "end", foreground = "#cdd6f4" },
},
}
end
return row { width = "fill", spacing = 6, visible = #buttons > 0, children = buttons }
end
local function card(item)
local critical = item.urgency == "critical"
local runs = body_runs(item.body)
return row {
width = "fill",
padding = 12,
radius = 12,
background = "#1e1e2ef2",
border_width = 1,
border_color = critical and "#f38ba8" or "#45475a",
opacity = 1, -- `from` needs the property set
scale = 1,
animate = {
opacity = { duration = 150, from = 0 },
-- Grows in place: a slide would leave the surface and be clipped at its edge.
scale = { duration = 200, easing = "out_cubic", from = 0.95 },
move = { duration = 200, easing = "out_cubic" }, -- older cards slide down for a new one
exit = { duration = 150, opacity = 0 },
},
on_click = function()
if item.has_default_action then
mantle.notifications:invoke_action(item.id, "default")
else
mantle.notifications:dismiss(item.id)
end
end,
spacing = 10,
children = {
artwork(item),
column {
width = "fill",
spacing = 4,
children = {
row {
width = "fill",
spacing = 6,
children = {
text { content = item.app_name, width = "fill", elide = "end", font_size = 11, foreground = "#a6adc8" },
rect {
padding = { left = 4, right = 4 },
radius = 4,
on_click = function() mantle.notifications:dismiss(item.id) end,
children = { text { content = "×", font_size = 14, foreground = "#a6adc8" } },
},
},
},
text {
content = { { text = item.summary, bold = true } },
width = "fill",
elide = "end",
font_size = 13,
foreground = "#cdd6f4",
},
text {
content = runs,
visible = #runs > 0,
width = "fill",
wrap = "word",
max_lines = 4,
elide = "end",
foreground = "#bac2de",
on_link = function(href) mantle.applications:open_url(href) end,
},
action_buttons(item),
},
},
},
}
end
return {
panel {
id = "notifications",
layer = "overlay",
anchor = { top = true, right = true },
margin = { top = 8, right = 8 },
width = 380,
visible = cards:map(function(shown) return #shown > 0 end),
child = column {
width = "fill",
hover = stack_hover,
-- Pause every countdown while the pointer is over the stack.
on_hover = function(inside) mantle.notifications:hold_expiry(inside and 300 or 0) end,
children = {
list {
width = "fill",
spacing = 8,
items = cards,
key = function(item) return tostring(item.id) end,
each = card,
},
},
},
},
}
How it works
feedis the newest 20, expired ones included; the map keeps the live ones and caps them (notifications).dndonly mutes sounds, so hiding popups during it is the config’s filter; critical ones still show.keybyidkeeps each card’s node when a newer one arrives above it, so only the new one animates in whileanimate.moveslides the older ones down; a dismissed card fades out throughanimate.exit, except the last one, which leaves with its hidden panel (identity, move, exit).- A body’s text spans pass to
textas runs unchanged;on_linkhands a clickedhreftoopen_url(text runs, applications). - The × is a clickable
rectinside the clickable card: the innermost one with a handler takes the click (pointer). on_hoveron the stack callshold_expiry, so a card cannot expire while being read; the hold lapses after 300 s of continuous hover (hover).- The panel is anchored to two edges, so it measures its content and grows with the stack (corner stack).
Variations
| Change | Edit |
|---|---|
| Play sounds | Once at top level: mantle.notifications:set_sound("normal", "/usr/share/sounds/freedesktop/stereo/message.oga") |
| Bottom-right corner | anchor = { bottom = true, right = true }, margin = { bottom = 8, right = 8 } |
| Hide everything under do-not-disturb | local quiet = notifications.dnd |
| Relative time | Add text { content = mantle.clock:map(function(clock) return math.floor((clock.time - item.timestamp) / 60) .. " min ago" end, "") } |
| Mute one app | mantle.notifications:set_app_muted("discord", true) |