popup
An xdg_popup on a shown panel, window or popup: a dropdown, context menu or tooltip. The
compositor places it against a rectangle in the parent, keeps it on screen and, with a
grab, dismisses it on an outside click, which a second panel cannot do. A hidden popup has
no Wayland object. Rules every role shares are in surfaces.
local menu_open = state("menu_open", false)
local menu_anchor = state("menu_anchor", { x = 0, y = 0, width = 1, height = 1 })
local bar = panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill", height = "Fill", background = "#1e1e2e",
children = {
button {
padding = 8,
on_click = function(rect)
menu_anchor:set(rect)
menu_open:set(not menu_open:get())
end,
children = { text { content = "Menu", foreground = "#cdd6f4" } },
},
},
},
}
local menu = popup {
id = "menu",
parent = "bar",
anchor_rect = menu_anchor,
anchor = "Bottom",
gravity = "Bottom",
offset = { y = 4 },
visible = menu_open,
on_dismiss = function() menu_open:set(false) end,
child = column {
width = 160, padding = 12, spacing = 10, radius = 10, background = "#1e1e2e",
border_width = 1, border_color = "#45475a",
children = {
text { content = "Settings", foreground = "#cdd6f4" },
text { content = "Log out", foreground = "#cdd6f4" },
},
},
}
return { bar, menu }
A dropdown under a bar button. on_click’s rect is the button in its surface’s coordinates,
which is what anchor_rect takes (pointer input). An outside click
dismisses it and on_dismiss clears the state.
Properties
Beyond the shared properties. Every field but id, parent
and on_dismiss takes a signal.
| Property | Type | Default | Behaviour |
|---|---|---|---|
id | string | Required | The surface’s identity across reloads, unique among surfaces. A panel’s or lock’s per-output instances are "{id}@{output}"; monitor = "Active" keeps the bare id |
parent | string | Required | The id of a shown panel, window or popup; hiding the parent closes this popup. On a per-output panel it opens on the clicked instance, else the first. A change applies at the next open; a lock cannot be a parent |
anchor_rect | Rect|Bound | Required | In the parent’s surface coordinates; width/height in (0, 8192], x/y default 0. Usually the rect on_click passes |
anchor | "Top"|"Bottom"|"Left"|"Right"|"TopLeft"|"TopRight"|"BottomLeft"|"BottomRight"|"Center"|Bound | "Center" | The point on anchor_rect the popup hangs from |
gravity | "Top"|"Bottom"|"Left"|"Right"|"TopLeft"|"TopRight"|"BottomLeft"|"BottomRight"|"Center"|Bound | "Center" | The direction it extends from that point: "Bottom" hangs it below, "BottomRight" below and to the right |
constraint_adjustment | ("SlideX"|"SlideY"|"FlipX"|"FlipY"|"ResizeX"|"ResizeY")[]|Bound | { "FlipY", "SlideX" } | How the compositor may keep it on screen; {} for none, order is ignored |
offset | { x?: number, y?: number }|Bound | { x = 0, y = 0 } | Pixel nudge after anchor and gravity; an absent axis is 0, negative moves up or left |
width | number|Bound | Content | Pixels in (0, 8192]; no "Fill" or %. Omitted sizes to the content, capped at the first output’s size and the root’s max_width/max_height; an open popup follows it through xdg_popup.reposition (xdg-shell v3+) |
height | number|Bound | Content | As width; each axis is independent |
grab | boolean|Bound | true | Takes an input grab so an outside click dismisses it (grab). false for a tooltip |
on_dismiss | fun() | None | The compositor closed it (click outside, denied grab, parent gone); not called when the config hides it. Set visible = false here, or it reopens on the next click |
visible | boolean|Bound | true | Opens and closes the popup; state and id survive |
child | Node|Bound | None | The one root node; a function child is refused |
Placement
While a popup is open, a change to its measured size or to any positioner field moves it through
xdg_popup.reposition. Below xdg_popup version 3 it keeps the size and place it opened at until
it closes, and logs a warning.
Grab
A grab needs a left, right or middle press or release on one of the shell’s surfaces in the same
turn, so open a grabbing popup from on_click. Without one the popup stays closed and a warning
is logged. A compositor that denies the grab dismisses the popup, and on_dismiss runs.
Dismissal
A compositor dismissal destroys the popup but leaves your visible signal true. The engine
latches it shut until the next pointer press or release on the shell, then opens it again. Clear
your state in on_dismiss, as the example at the top does.
Hiding or dismissing a popup also closes the popups open under it and latches them the same way,
so each one whose visible stays true reopens on the next press.
How do I…
| Task | Answer |
|---|---|
| Show a dropdown under a bar button | The example at the top |
| Open a submenu from a menu | Nested menus |
| Show a tooltip on hover | Tooltip |
| Anchor to a node without clicking or hovering it | Anchor to a node’s geometry |
| Open a context menu on right click | on_click = function(rect, which) if which == "right" then ... end end (pointer) |
| Fade it out before it closes | Keep visible true with delay while the child’s opacity animates (delay) |
| Open it from a keybind | grab = false, since a keybind is no pointer press; it then stays until the config hides it |
| Keep it on screen near an edge | constraint_adjustment = { "FlipX", "FlipY", "SlideX", "SlideY" } |
| Give it a fixed size | width and height in px |
| Open it from a window | parent = "<window id>" |
Nested menus
A popup can parent another popup. The submenu hangs off the right edge of the clicked row and flips left near the screen edge:
local sub_open = state("sub_open", false)
local sub_anchor = state("sub_anchor", { x = 0, y = 0, width = 1, height = 1 })
local power_row = button {
padding = 4,
on_click = function(rect)
sub_anchor:set(rect)
sub_open:set(true)
end,
children = { text { content = "Power ›" } },
}
local submenu = popup {
id = "power_menu",
parent = "menu",
anchor_rect = sub_anchor,
anchor = "TopRight",
gravity = "BottomRight",
constraint_adjustment = { "FlipX", "SlideY" },
visible = sub_open,
on_dismiss = function() sub_open:set(false) end,
background = "#1e1e2e", padding = 8,
child = column { spacing = 4, children = { text { content = "Suspend" }, text { content = "Reboot" } } },
}
power_row goes inside the menu popup. on_click’s rect is in the menu’s own coordinates, which
is what a child popup’s anchor_rect expects. Set sub_open to false wherever the menu closes
(its on_dismiss included), or the submenu reopens on the next click.
Tooltip
A hover opens no grab, so grab = false. hover_rect tracks the
hovered node and is 1×1 before the first hover, which keeps anchor_rect valid:
local clock = text { content = "12:30", padding = 8, hover = hover("clock") }
local tooltip = popup {
id = "clock_tooltip",
parent = "bar",
anchor_rect = hover_rect("clock"),
anchor = "Bottom",
gravity = "Bottom",
offset = { y = 4 },
grab = false,
visible = hover("clock"),
background = "#1e1e2e", radius = 6, padding = 6,
child = text { content = "Thursday, 24 September" },
}
clock goes inside the bar panel.
Anchor to a node’s geometry
Bind geometry. It reads zero before
the first layout, so map it to a 1×1 fallback. With no click to open it, it needs grab = false:
local battery = text { content = "87%", padding = 8, geometry = geometry("battery") }
local details = popup {
id = "battery_details",
parent = "bar",
anchor_rect = geometry("battery"):map(function(rect)
return rect.width > 0 and rect or { x = 0, y = 0, width = 1, height = 1 }
end),
anchor = "Bottom",
gravity = "Bottom",
grab = false,
visible = state("battery_open", false),
child = text { content = "2 h 10 min left" },
}
Gotchas
| Trap | Fix |
|---|---|
anchor_rect with a zero width or height is refused (a geometry before first layout, a hand-built rect) | Fall back to { x = 0, y = 0, width = 1, height = 1 } |
| A dropdown reopens on the next click after an outside click closed it | Set its visible state to false in on_dismiss |
A popup that is visible at startup, or opened from a keybind, never opens | grab = true needs a click; open it from on_click, or set grab = false |
| A popup whose parent is hidden does not open | Show the parent first; the popup opens on the next pass |
| A submenu reopens after its menu closed | Clear the submenu’s state wherever the menu closes, on_dismiss included |
width = "Fill" or "50%" is refused | px, or omit it to size to the content |
anchor_rect from a click in a popup is placed wrong on the bar | A rect is in its own surface’s coordinates; anchor a popup only to rects from its parent |
| An open tooltip does not grow with its text on an old compositor | xdg_popup below version 3 cannot reposition; it keeps its opening size until it closes |
See also: surfaces, panel, input, signals.
Source: popup spec, popup, instances.