Skip to content

UI

All of these are UiHost APIs: they need the terminal UI and raise in headless mode.

Overlays

An overlay is a floating or docked window. Create a buffer, attach it to a window, and mount the window in an overlay:

local buf = smelt.buf.new()
local win = smelt.win.new(buf, { surface = "inert" })

smelt.overlay.new({
  title     = { { text = " perf ", bold = true } },
  anchor    = "screen_at",
  corner    = "ne",
  width     = 44,
  height    = 14,
  modal     = false,
  draggable = true,
  layout    = smelt.ui.layout.leaf(win),
})

Anchor decides where the overlay lives:

Anchor Placement
dock_bottom (default) Docked above the statusline
dock_top, dock_left, dock_right Docked to that edge (statusline row stays reserved)
center Centered on screen
screen_at Absolute; pair with corner, row, col
win Attached to another window; pair with target, attach, row_offset, col_offset

Size: width and height are fixed; max_width and max_height shrink to fit up to a cap. Don't set both fixed and max on one axis. Values are a number of cells, a percentage string like "50%", or "fill".

Dialogs

For a modal with a markdown panel, an option list and a text input, smelt.dialog.open is the higher-level API and returns the user's choice. The bundled dialogs in runtime/lua/smelt/dialogs/ (confirm, permissions, resume, rewind) are the reference implementations.

Dialog height is either height = "N%" (fixed) or max_height = "N%" (shrink to fit, capped). With neither, the dialog fits its content. Setting both is an error.

Layouts and resizable splits

Compose windows with hbox, vbox and leaf nodes. hsplit(first, second, opts) puts two subtrees side by side and vsplit stacks them, each with a draggable divider.

local layout = smelt.ui.layout
local files = smelt.win.new(smelt.buf.new(), { surface = "list_inert" })
local changes = smelt.win.new(smelt.buf.new(), { surface = "readonly_text" })
local body = layout.hsplit(layout.leaf(files), layout.leaf(changes), {
  size = 34, min_first = 26, min_second = 32,
})
smelt.dialog.new({ layout = body, height = 20, focus = files })

The same body works in smelt.overlay.new({ layout = body }). size is the first pane's initial extent in cells, a percentage, or ratio:N/M (default "50%"). By default a user-resized split keeps its ratio when the terminal changes; pass resize = "cells" to keep a fixed width instead.

If you rebuild the layout from a composer function, create the split once with layout.split("horizontal", opts) and call split:layout(first, second) inside the composer, so the user's chosen size is kept. split:size() and split:set_size() save and restore it.

layout.frame(body, { border = "single", padding = 1, title = "details" }) adds chrome around any subtree.

win:resize("width", 4) and win:resize("height", -1) resize the nearest split (or the containing overlay), and win:equalize() balances splits. The built-in keys are Ctrl-W >, <, +, - and =.

smelt.dialog.new and smelt.dialog.open accept either a composed layout or the simpler panels / bottom_panels recipe, not both.

The main screen

smelt.ui.layout.set(fn) replaces the composer for the whole screen. The default one is require("smelt.layout").compose, so a plugin can wrap it rather than rebuild it, for example to put a sidebar beside everything:

local layout = smelt.ui.layout
local default = require("smelt.layout").compose
local side = smelt.win.new(smelt.buf.new(), { surface = "list_inert" })
layout.set(function(state)
  return layout.hbox({
    { default(state), width = "fill" },
    { layout.leaf(side, { border = "rounded", title = " notes " }), width = 30 },
  })
end)

The changes sidebar in the gallery is a complete example.

Large read-only views

For big read-only content, give a window a document instead of filling a buffer with every line. Only the visible rows are materialized, so a million-line diff stays responsive.

smelt.spawn(function()
  local snapshot, err = smelt.git.diff()
  if not snapshot then smelt.notify.error(err); return end
  local buf = smelt.buf.new()
  local win = smelt.win.new(buf, { surface = "readonly_text", vim_enabled = true })
  win:document(snapshot.diff:document())
  smelt.overlay.new({
    anchor = "center", width = "90%", height = "80%", modal = true,
    layout = smelt.ui.layout.leaf(win),
  })
  win:focus()
end)
  • smelt.document.text(text) builds a document from plain text; smelt.diff.parse(patch) from a unified patch. smelt.git.diff() loads and indexes off the UI thread.
  • A document can back several windows, each with its own scroll and cursor. win:document(nil) restores the original buffer.
  • Syntax highlighting arrives in the background; text and navigation work immediately.
  • smelt.git.index(diff, index, action) stages or unstages one file ("stage", "unstage" or "toggle") without touching the worktree, and returns a fresh snapshot.

The bundled smelt.plugins.diff plugin shows the full pattern with a file tree, folding and refresh.

Pickers

smelt.picker.open(opts) asks the user to choose one item. Items are strings or { label, description, ansi_color, search_terms } records. With placement = "prompt_docked" the prompt filters them using smelt.fuzzy.rank. It returns { index, item, action }, or nil when dismissed.

smelt.spawn(function()
  local choice = smelt.picker.open({
    items = { "first", "second", "third" },
    placement = "prompt_docked",
  })
  if choice then smelt.log.info("picked " .. choice.item) end
end)

Window events and marks

To decorate an existing buffer, subscribe to window events and draw marks in a namespace you own:

local prompt = smelt.prompt.win()
local ns     = smelt.ns("my_plugin")

prompt:on("text_changed", function()
  local buf = prompt:buf()
  if not buf then return end
  buf:clear_ns(ns):mark(ns, 1, 0, {
    end_col  = 999,
    hl_group = "DiagnosticHint",
    priority = 200,
  })
end)

Event names and mark options are type-checked in your editor.

Paint leaves and pointer events

For canvas-like overlays that don't need a buffer, register a paint function and handle pointer events directly. The leaf that received press keeps receiving drag and release even if the pointer leaves its rectangle.

local paint = smelt.paint.register(draw_fn, { name = "myplugin.paint" })
paint:on("press",   function(ev) smelt.notify.info("down @ "..ev.row..","..ev.col) end)
paint:on("drag",    function(ev) ... end)
paint:on("release", function(ev) ... end)

Statusline segments

Append segments to the statusline. The handler runs on each refresh and returns a list of segments.

local statusline = require("smelt.statusline")

statusline.add("clock", function()
  return { {
    text = os.date(" %H:%M "),
    style = { fg = { ansi = 245 } },
    priority = 2,
    align_right = true,
  } }
end)

statusline.remove(name) removes it. Built-in segments keep rendering alongside yours.