Skip to content
ForgeCastDocs

Build an interface

Notifications, dialogs, windows, Dashboard sections and settings panels, and every element you can put in them.

A plugin can show things in four places: a notification in the corner, a dialog over the app, a window of its own, and a box on the Dashboard (plus a settings panel on its card in the Plugins screen). The first two are one call. The last three are made of elements: you describe what to show with a few ui.* functions, and ForgeCast draws it with its own components.

You never write HTML or CSS, and a plugin can't add scripts. That is on purpose: what a plugin draws always looks like part of ForgeCast, in light and dark theme, and it can't do anything the app doesn't allow.

Notifications

ui.notify (needs ui.notify) shows a short message and returns at once. Give it a string, or a table:

ui.notify("Done")

ui.notify({
  title = "New member",
  message = chat.author.name,
  level = "success",     -- "info" (default), "success", "warning" or "error"
  duration = 8000,       -- milliseconds on screen (default 5000; 0 keeps it until dismissed)
  actions = {            -- up to three buttons
    { label = "Say thanks", on_click = function() print("clicked") end },
  },
})

A notification button's on_click works once.

Dialogs

ui.modal (needs ui.modal) opens a dialog over the app and calls you back when the person closes it:

ui.modal({
  title = "Reset the counter?",
  body = "This can't be undone.",             -- text, or a whole interface (see below)
  buttons = {
    { id = "cancel", label = "Cancel" },
    { id = "reset", label = "Reset", primary = true },   -- also: danger = true
  },
  on_result = function(result)
    -- result.button     the id of the button pressed (nil if it was closed another way)
    -- result.dismissed  true when it was closed without a button (Esc, a click outside)
    -- result.values     what the inputs inside the dialog held, by name
    if result.button == "reset" then count = 0 end
  end,
})

Other options: width ("sm", "md", "lg"), and dismissable = false to force a button. ui.modal returns an id; ui.close(id) closes it from code.

Three shortcuts cover the common questions:

ui.alert("Saved.")                                         -- a message and an OK button
ui.confirm("Delete it?", function(yes) if yes then delete() end end, { yes = "Delete", danger = true })
ui.prompt("Your name?", "Ann", function(text)              -- text is nil when cancelled
  if text then greet(text) end
end)

A dialog's body can be a whole interface, with inputs. Whatever was typed arrives in result.values:

ui.modal({
  title = "Add a word",
  body = ui.column({ ui.input({ name = "word", placeholder = "a word to watch", autofocus = true }) }),
  buttons = { { id = "cancel", label = "Cancel" }, { id = "add", label = "Add", primary = true } },
  on_result = function(r)
    if r.button == "add" then add_word(r.values.word) end
  end,
})

How an interface works

An interface is a tree of elements. Each element is a call like ui.column{ … }. Inside the braces, the properties are name = value, and the children are just listed:

ui.column({
  gap = 8,                                   -- a property
  ui.heading("Today"),                       -- a child
  ui.row({ gap = 6, ui.text("Messages:"), ui.badge({ text = "42", tone = "accent" }) }),   -- a child with children
})

To show it, give the tree to something that draws: window:render(tree), section:render(tree), a settings panel's :render(tree), or a dialog's body. Each call replaces what was there. There is no "update one label": you keep your data in Lua variables, and call your draw function again when it changes.

local count = 0
local section = dashboard.section({ id = "counter", title = "Counter" })

local function draw()
  section:render(ui.row({
    gap = 8,
    ui.heading(tostring(count)),
    ui.button("+1", function() count = count + 1; draw() end),   -- a handler: runs when it is clicked
  }))
end

draw()
  • Handlers. on_click, on_change and on_submit are functions. They get one argument, the event ev: ev.value is the new value of an input, switch, select or slider, and ev.name is the element's name.
  • Forms. Give inputs a name. Every handler in the same interface gets ev.values, a table with the current value of every named input. So a "Save" button doesn't need to know about the inputs; it reads ev.values.words.
  • Nothing to draw. false and nil in a list of children are skipped, so cond and ui.text("x") or false draws it only when cond is true.
  • Inputs keep what was typed when you redraw, as long as the value you give them is the same. Change the value, or give the element a different key, and it starts fresh. The usual way to empty a box after "Add" is to bump a key:
ui.input({ name = "username", key = input_key })   -- input_key = input_key + 1 before drawing: the box is empty again
  • Redraw when something changes, not on every tick. A busy chat can send dozens of lines a second. If you draw from a chat handler, collect changes and draw from a timer (timer.every(1000, draw)) instead.
  • Limits. One interface can hold up to 2,000 elements, 24 levels deep, about 400 KB of data in all; text up to 20,000 characters per piece. Go over and the call fails with a message that says which.

The elements

Every element is a function in ui. Properties not listed for an element are ignored. The reference has one table with every property.

Layout

ElementWhat it doesProperties
ui.column{…}Children top to bottom.gap, padding, align, justify, grow
ui.row{…}Children left to right.gap, padding, align, justify, wrap, grow
ui.card{…}A box, with an optional title.title, description, gap, grow
ui.scroll{…}A scrolling area.max_height, gap
ui.divider()A thin line.
ui.spacer()Empty space that grows: pushes what follows to the end of a row or column.
ui.list{…}Children, each in its own bordered row.empty (what to say when there are no children), gap

align is "start", "center", "end" or "stretch"; justify is "start", "center", "end" or "between". gap and padding are pixels.

ui.card({
  title = "Chat right now",
  description = "last five minutes",
  ui.row({ justify = "between", align = "center", ui.text("Messages"), ui.badge({ text = "128" }) }),
  ui.divider(),
  ui.row({ ui.text("Members"), ui.spacer(), ui.text({ text = "31", bold = true }) }),
})

Text and pictures

ElementWhat it doesProperties
ui.text("hi") or ui.text{…}A line or paragraph of text.text, size (xs sm md lg), muted, bold, mono, color
ui.heading("Title", {level = 1})A heading.text, level (1 to 3)
ui.badge{…}A small coloured label.text, tone (neutral success warning error info accent)
ui.code{…}A block of fixed-width text.text
ui.link("Docs", url)A link that opens in the browser. https only.label, url
ui.image{…}A picture from an https address.src, alt, width, height, rounded
ui.progress{…}A bar.value (0 to 1), label, tone

color on text is a name (success, warning, error, info, accent, muted) or a "#rrggbb" value.

Buttons and inputs

ElementWhat it doesProperties
ui.button("Add", fn) or ui.button{…}A button.label, on_click, variant (primary outline ghost danger), icon, disabled
ui.input{…}A text box.name, label, placeholder, value, on_change, on_submit (Enter), autofocus, disabled, grow
ui.textarea{…}A box for several lines.as input, plus rows
ui.checkbox{…}A tick box.name, label, checked, on_change, disabled
ui.switch{…}An on/off switch.as checkbox
ui.select{…}A drop-down.name, label, options, value, on_change, disabled
ui.slider{…}A slider.name, label, min, max, step, value, on_change

Button icons: plus, trash, check, x, refresh, play, copy, dice, external, bell, settings, star, user, send. options for a select are strings, or { value = "wild", label = "Wild!" }.

A small form that reads everything at once:

local box = dashboard.section({ id = "shoutout", title = "Shout-out" })

box:render(ui.column({
  gap = 8,
  ui.input({ name = "who", label = "Who", placeholder = "@someone" }),
  ui.select({ name = "style", label = "Style", value = "loud", options = { "quiet", "loud", { value = "wild", label = "Wild!" } } }),
  ui.slider({ name = "times", label = "How many times", min = 1, max = 5, value = 1 }),
  ui.checkbox({ name = "pin", label = "Keep it on screen" }),
  ui.button({
    label = "Send",
    variant = "primary",
    icon = "send",
    on_click = function(ev)
      local v = ev.values
      ui.notify({ title = "Shout-out to " .. (v.who or "nobody"), message = v.style .. " x" .. v.times .. (v.pin and ", pinned" or "") })
    end,
  }),
}))

Data

ElementWhat it doesProperties
ui.table{…}A table.columns, rows, empty
ui.chat{…}Chat lines drawn like the Dashboard's chat.messages, compact, max_height, empty

columns is a list of names ({ "Name", "Count" }) or { key = "n", label = "Name" }. rows is a list of rows, each a list of cells ({ "Ann", 3 }) or, when the columns have keys, a table keyed by column ({ n = "Ann", count = 3 }). A table shows up to 500 rows.

ui.table({
  columns = { "Chatter", "Messages" },
  rows = { { "@ann", 12 }, { "@bob", 7 } },
  empty = "Nobody has chatted yet.",
})

ui.chat({ messages = chats.get({ limit = 20 }), compact = true, max_height = 360 })

Where interfaces go

Windows

window.create (needs ui.window) opens a window of its own, which can sit next to OBS or on another monitor:

local w = window.create({
  key = "stats",             -- a stable name: asking again for an open key just brings it forward
  title = "Stream stats",
  width = 320, height = 240, -- default 420 x 520
  min_width = 240,
  resizable = true,
  always_on_top = true,
  content = ui.text("loading…"),        -- the first thing it shows (optional)
  on_close = function() print("the window was closed") end,
})

w:render(ui.column({ ui.heading("Viewers: 120") }))   -- draw again
w:set_title("Stream stats (live)")
w:focus()
w:close()

A plugin can have 6 windows open at once. key is made of lowercase letters, digits, hyphens and underscores (30 characters at most). When the person closes the window, on_close runs and the place is free again. A plugin window has no access to the network or the account: it only draws what the plugin describes.

Dashboard sections

dashboard.section (needs ui.dashboard) adds a box to the Dashboard:

local box = dashboard.section({ id = "my-box", title = "My box", content = ui.text("hello") })
box:render(ui.text("changed"))   -- the title stays
box:remove()                     -- take it off the Dashboard

Give it a stable id (lowercase letters, digits, hyphens and underscores, 30 characters at most) so it keeps its place after a reload. A plugin may add 10 sections.

The plugin's settings panel

plugin.settings (needs ui.dashboard) shows an interface on the plugin's own card in the Plugins screen, which is where people expect to find options:

local settings = plugin.settings({ title = "Options" })
settings:render(ui.column({
  ui.switch({ name = "sound", label = "Play a sound", checked = true }),
  ui.button({ label = "Save", on_click = function(ev) storage.set("sound", ev.values.sound) end }),
}))

The Keyword alerts example is a complete plugin built around one.

Design tips

  • Draw from one function. local function draw() … end, called once at the start and again after any change, keeps the screen and your data in step.
  • Name your inputs, and save with a button. It is clearer than reacting to every keystroke.
  • Say what's empty. ui.list and ui.table take empty = "…"; use it.
  • Keep text short. Windows and sections are small. Use ui.scroll{ max_height = … } around anything that can grow.
  • Use muted and size = "sm" for captions, and badge/progress tones to show state. They follow the app's theme.

On this page