Repository navigation
modal
Centered modal popups with percent-based sizing, button configuration, hold-to-confirm, and a styled variant.
The module is exposed as wu.Modal.
local wu = GetMod("WindowUtils")
local modal = wu.Modal
-- Simple confirmation
modal.confirm("my_confirm", {
title = "Delete Preset",
body = "Are you sure?",
onConfirm = function()
deletePreset()
end,
})
-- Alert
modal.alert("my_alert", {
title = "Error",
body = "Failed to save settings.",
})
-- Info
modal.info("my_info", {
title = "About",
body = "My Mod v1.0 by Author",
})Modals are centered on screen, auto-sized to content, and close when any button is clicked. Call modal.draw() once per frame (WindowUtils does this automatically).
Call any modal function (e.g. modal.confirm(...)) to open a popup. The call is typically made inside a button click handler or event callback during your onDraw:
registerForEvent("onDraw", function()
if not wu or not overlayOpen then return end
if ImGui.Begin("My Mod") then
-- Button that opens a modal when clicked
if wu.Controls.Button(" Delete ", "danger") then
wu.Modal.confirm("delete_confirm", {
title = "Delete Item",
body = "This cannot be undone.",
onConfirm = function()
deleteItem()
end,
})
end
end
ImGui.End()
end)You do not need to call modal.draw() yourself. WindowUtils calls it automatically each frame after your UI renders. The modal appears centered on screen and blocks interaction with content behind it until dismissed.
To close a modal programmatically (e.g. from a timer or external event):
modal.close("delete_confirm")To check if a modal is currently open:
if modal.isOpen("delete_confirm") then
-- modal is visible
endAll four convenience functions write into the opts table you pass them and then hand it to open(). confirm, alert, and info overwrite opts.buttons, so a buttons array passed to them is discarded. styled only fills in a default Close button when opts.buttons is absent. Do not reuse the same opts table across calls if you care about its contents afterwards.
Two-button dialog: Confirm + Cancel.
modal.confirm("delete_all", {
title = "Delete All Data",
body = "This cannot be undone.",
onConfirm = function() wipeData() end,
})Single OK button.
modal.alert("save_error", {
title = "Save Failed",
body = "Check file permissions.",
})Single Close button.
modal.info("about", {
title = "About My Mod",
body = "Version 2.1 - Thanks for using this mod!",
})Centered title (no title bar), body wrapped in a panel background. Accepts custom buttons.
modal.styled("status", {
title = "System Status",
body = "All systems operational.",
widthPercent = 35,
})Full control over modal configuration. All convenience functions call this internally.
modal.open("my_modal", {
title = "Custom Modal",
body = "Some text above the content.",
content = function(innerWidth)
ImGui.Text("Arbitrary ImGui here")
end,
buttons = {
{ label = "Save", style = "active", onClick = function() save() end },
{ label = "Cancel", style = "inactive" },
},
})Close a modal programmatically. No-op for an id that is not open.
It calls ImGui.CloseCurrentPopup() as part of closing, which targets whatever popup is current at that moment. Calling it from a modal button callback is the intended path. Calling it while some other popup is open (a combo dropdown, for example) can close that popup instead.
Returns true if the modal is currently open. This also covers the frame between open() and the popup actually being pushed.
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | id | Window title (or centered heading in styled mode) |
| body | string | nil | Text rendered above content callback |
| content | function | nil | Callback function(innerWidth) for custom ImGui |
| buttons | table[] | varies | Button definitions (see below) |
| widthPercent | number | 33 | Modal width as % of screen width |
| heightPercent | number | nil | Fixed height as % of screen height (nil = auto) |
| maxHeightPercent | number | 50 | Maximum height as % of screen height |
| paddingPercent | number | nil | Inner padding as % of screen size (applied only when greater than 0) |
| styled | boolean | false | Centered title + panel background mode |
| panelBg | table | {0.65, 0.7, 1.0, 0.0225} | Panel background RGBA (styled mode only) |
Those eleven keys are everything open() reads. Extra keys are ignored, apart from the ones the convenience wrappers consume before calling open() (onConfirm, holdToConfirm, holdDuration).
buttons defaults to a single { label = "OK", style = "active" } entry when omitted. Passing an empty table renders no button row at all.
| Option | Type | Default | Description |
|---|---|---|---|
| onConfirm | function | - | Callback when confirmed |
| holdToConfirm | boolean | false | Require hold instead of click |
| holdDuration | number | 1.5 | Hold duration in seconds |
Modal copies each entry into its own def table before handing it to ButtonRow, so only the fields below are forwarded. Other ButtonRow fields (width, height, color, hoverIcon, hoverColor, elementId, progressFrom, progressStyle, truncatedTooltip) are dropped by the modal layer.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | - | Button text |
| icon | string | nil | IconGlyphs key (icon-only when no label, label + icon makes a DynamicButton) |
| style | string | "inactive" | Any Styles.PushButton name (see below) |
| onClick | function | nil | Click callback |
| onHold | function | nil | Hold callback (makes it a HoldButton) |
| holdDuration | number | 2.0 | Hold duration in seconds |
| progressDisplay | string | "overlay" | Hold progress rendering: "overlay", "replace", or "external" |
| warningMessage | string | nil | Warning text shown by the hold button while held |
| closesModal | boolean | true | Set false to keep modal open after click/hold |
| weight | number | nil | Relative width in ButtonRow |
| disabled | boolean|string | false | Truthy greys out and suppresses clicks; "hard" also wraps in BeginDisabled |
| tooltip | string|table | nil | Hover tooltip, passed to Tooltips.Show
|
| id | string | <modalId>_btn_<i> |
Hold button identity, override if you need a stable id |
Valid style values are the names Styles.PushButton accepts: "active", "inactive", "danger", "warning", "update", "disabled", "success", "statusbar", "label", "labelOutlined", "transparent", "frameless".
Close wrapping happens at open time, not per frame:
- With
onClick, the callback runs first and then the modal closes (unlessclosesModal = false). - With
onHoldand noonClick, the same applies to the hold completion. - With neither, the button still gets a click handler that closes the modal (unless
closesModal = false, which leaves it inert).
Auto-height, 33% width, grows to fit content:
modal.info("dynamic", { title = "Dynamic", body = "Grows to fit." })modal.open("fixed", {
title = "Fixed Size",
body = "50% wide, 35% tall",
widthPercent = 50,
heightPercent = 35,
buttons = { { label = "Close", style = "inactive" } },
})modal.info("padded", {
title = "Padded",
body = "Extra breathing room around content.",
widthPercent = 40,
paddingPercent = 3,
})modal.confirm("danger_action", {
title = "Reset All Settings",
body = "Hold to confirm reset.",
holdToConfirm = true,
holdDuration = 2.0,
onConfirm = function() resetAll() end,
})modal.open("custom_hold", {
title = "Delete Preset",
body = "Hold the delete button to confirm.",
buttons = {
{ label = " Hold to Delete ", style = "danger",
onHold = function() deletePreset() end,
holdDuration = 1.5, progressDisplay = "overlay" },
{ label = "Cancel", style = "inactive" },
},
})The styled variant hides the ImGui title bar and renders a centered title with body/content inside a panel background.
modal.styled("about", {
title = "About My Mod",
body = "Version 2.0\nBuilt with WindowUtils.",
widthPercent = 35,
})modal.styled("status", {
title = "System Status",
widthPercent = 35,
content = function(innerWidth)
controls.ProgressBar(0.9, innerWidth, 0, "90%", "success")
end,
buttons = {
{ label = "Refresh", style = "active", closesModal = false,
onClick = function() refresh() end },
{ label = "Close", style = "inactive" },
},
})modal.confirm("styled_delete", {
title = "Delete Everything",
body = "This action is irreversible.",
styled = true,
widthPercent = 35,
holdToConfirm = true,
holdDuration = 2.0,
onConfirm = function() deleteAll() end,
})modal.styled("custom_bg", {
title = "Custom Colors",
body = "Green-tinted panel background.",
widthPercent = 35,
panelBg = { 0.2, 0.8, 0.5, 0.06 },
})A built-in preset for displaying version history with a sidebar version list and scrollable changes panel.
modal.changelog("my_changelog", {
title = "Changelog",
versions = {
{ version = "2.0.0", date = "2026-04-01", changes = {
"Added new feature X",
"Fixed bug Y",
"Improved performance of Z",
}},
{ version = "1.0.0", date = "2026-01-15", changes = {
"Initial release",
}},
},
})Versions are displayed in array order (put newest first). Each version entry has:
| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Version label shown in sidebar and as the panel header |
| date | string | no | Date shown next to version header |
| changes | string[] | no | Array of change descriptions (rendered as bullet points, omitted renders none) |
changelog builds its own content callback and calls open() with a fixed set of options. It honours only these:
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | "Changelog" | Modal title |
| versions | table[] | {} | Array of version entries |
| widthPercent | number | 30 | Modal width |
| heightPercent | number | 75 | Modal height (fixed) |
| maxHeightPercent | number | 80 | Max height cap |
| buttons | table[] | Close button | Custom buttons, rendered inside the content column |
Options that open() supports but changelog ignores: body, content (replaced by the generated changelog layout), paddingPercent, styled, and panelBg. Passing them has no effect.
The button row is drawn at the bottom of the changelog content rather than through the normal modal button row, because changelog passes an empty buttons array to open(). Button entries use the same fields as the Button Definition table above, and closing still works through the same close wrapping.
An empty or missing versions array renders muted "No changelog entries." text. Each version's selected index is remembered per modal id for the session, and an out-of-range selection falls back to the first entry.
local file = io.open("data/changelog.json", "r")
if file then
local versions = json.decode(file:read("*a"))
file:close()
modal.changelog("my_changelog", {
title = "Changelog",
versions = versions,
})
endJSON format:
[
{
"version": "2.0.0",
"date": "2026-04-01",
"changes": ["Added feature X", "Fixed bug Y"]
}
]