Skip to content
CyanideX edited this page Aug 5, 2026 · 1 revision

Modal

Centered modal popups with percent-based sizing, button configuration, hold-to-confirm, and a styled variant.

The module is exposed as wu.Modal.

Quick Start

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).

How It Works

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
end

Convenience Functions

All 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.

confirm(id, opts)

Two-button dialog: Confirm + Cancel.

modal.confirm("delete_all", {
    title = "Delete All Data",
    body = "This cannot be undone.",
    onConfirm = function() wipeData() end,
})

alert(id, opts)

Single OK button.

modal.alert("save_error", {
    title = "Save Failed",
    body = "Check file permissions.",
})

info(id, opts)

Single Close button.

modal.info("about", {
    title = "About My Mod",
    body = "Version 2.1 - Thanks for using this mod!",
})

styled(id, opts)

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,
})

Core Function

open(id, opts)

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(id)

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.

isOpen(id)

Returns true if the modal is currently open. This also covers the frame between open() and the popup actually being pushed.

Options Reference

Modal Options

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.

Confirm-Specific Options

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

Button Definition

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 (unless closesModal = false).
  • With onHold and no onClick, 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).

Sizing Examples

Dynamic (default)

Auto-height, 33% width, grows to fit content:

modal.info("dynamic", { title = "Dynamic", body = "Grows to fit." })

Fixed Dimensions

modal.open("fixed", {
    title = "Fixed Size",
    body = "50% wide, 35% tall",
    widthPercent = 50,
    heightPercent = 35,
    buttons = { { label = "Close", style = "inactive" } },
})

With Padding

modal.info("padded", {
    title = "Padded",
    body = "Extra breathing room around content.",
    widthPercent = 40,
    paddingPercent = 3,
})

Hold-to-Confirm

Via Convenience Function

modal.confirm("danger_action", {
    title = "Reset All Settings",
    body = "Hold to confirm reset.",
    holdToConfirm = true,
    holdDuration = 2.0,
    onConfirm = function() resetAll() end,
})

Via Custom Buttons

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" },
    },
})

Styled Modals

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,
})

Styled with Content Callback

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" },
    },
})

Styled with Hold-to-Confirm

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,
})

Custom Panel Background

modal.styled("custom_bg", {
    title = "Custom Colors",
    body = "Green-tinted panel background.",
    widthPercent = 35,
    panelBg = { 0.2, 0.8, 0.5, 0.06 },
})

Changelog Preset

A built-in preset for displaying version history with a sidebar version list and scrollable changes panel.

Basic Usage

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 Options

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.

Loading from JSON

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,
    })
end

JSON format:

[
    {
        "version": "2.0.0",
        "date": "2026-04-01",
        "changes": ["Added feature X", "Fixed bug Y"]
    }
]

Clone this wiki locally