Performance Tuning
Measure the interaction users report as slow, change one thing, and measure the same interaction again. A large static screen is not automatically expensive: a clean Basalt root skips drawing.
Render Pipeline
One event-loop turn:
- routes the event and resumes matching schedules;
- application code changes state or properties;
- affected roots become dirty;
- dirty containers resolve layout;
- each dirty root renders its visible tree into a retained buffer;
- only terminal rows whose final text or colors changed are blitted.
A tree redraw is Lua work. A terminal flush is IO. Basalt may redraw a tree and write only one changed row.
Measure Real Updates
os.clock() is suitable for comparisons. Measure a batch because one update may be below the clock resolution:
local function measureUpdates(iterations, mutate)
basalt.update() -- settle initial layout
local started = os.clock()
for i = 1, iterations do
mutate(i)
basalt.update()
end
return (os.clock() - started) / iterations
end
local average = measureUpdates(50, function(i)
counter.text = "Update " .. i
end)
print(("average: %.5f s"):format(average))Use the same computer, terminal size, data, and interaction. Warm up first, avoid logging inside the loop, and compare several batches. The debug overlay can display a result but is not a profiler.
Choose the Narrowest Invalidation
Ordinary properties invalidate automatically:
status.foreground = colors.green -- paint-only
status.text = "Ready" -- may affect measurement
panel.width = 30 -- layoutFor custom data:
| Method | Use |
|---|---|
markRenderDirty() | Pixels change, geometry cannot |
markLayoutDirty() | Size, position, children, or measurement changes |
markDirty() | Same full layout path as markLayoutDirty() |
Canvas paint usually needs:
cells[y][x] = colors.orange
canvas:markRenderDirty()Batch Work
Basalt draws after the current event and matching schedules finish. Several property changes in one handler therefore produce one draw:
saveButton:onClick(function()
title.text = "Saved"
title.foreground = colors.green
form.disabled = true
end)Do not call basalt.update() after each assignment.
Signal subscriptions run immediately on every distinct set(). Compute one final value and write it once. For a mutable Signal table, edit the batch and call touch() once.
Keep Reactive Work Small
Computed values are lazy but not memoized. Their function runs whenever the property is resolved:
local title = basalt.computed(function()
return "Selected: " .. selected:get()
end)Short formatting is appropriate. Move file IO, peripheral calls, large sorting, and filtering to the event that changes their inputs, then store the result.
For high-frequency Canvas data, subscribe and request render-only invalidation:
local level = basalt.state(0)
local unsubscribe = level:subscribe(function()
canvas:markRenderDirty()
end)Keep Trees and Layout Focused
- Use List, Table, and Tree instead of one child per data record.
- Use Canvas instead of one element per visual cell.
- Hidden pages skip rendering and hit testing.
- Destroy temporary subtrees that will never return.
- Avoid changing geometry every tick when a paint-only representation works.
- Keep
onLayoutcallbacks free from IO and heavy model work.
Containers cache layout, z sorting, and visible children. Off-screen children still exist and may be inspected when layout or content bounds are invalidated.
Schedule at a Useful Cadence
basalt.schedule(function()
while true do
sleep(0.5)
local nextLevel = readLevel()
if nextLevel ~= level:get() then level:set(nextLevel) end
end
end)Avoid sleep(0) polling. Match the interval to how quickly the source and user can meaningfully observe change. Cancel obsolete animations.
Remember Terminal Rows
The retained buffer compares complete rows. Changing one cell sends that row; changes across ten rows may send ten blits.
Keep rapidly changing status on few rows, draw dense surfaces row-by-row, and avoid full-screen palette animation. Custom RGB is mapped to the terminal's limited palette on dirty flushes.
Runnable Invalidation Lab
The lab performs a paint-only change, a width change, and 100 model changes followed by one render invalidation. Expected deltas are L+0 D+1, L+1 D+1, and L+0 D+1.
local basalt = require("basalt")
local frame = basalt.getMainFrame()
frame.background = colors.black
local layouts, draws, value = 0, 0, 0
local shade = colors.blue
local results = {}
local stage = frame:addFrame({
x = 2,
y = 3,
width = 30,
height = 12,
background = colors.gray,
})
stage:onLayout(function()
layouts = layouts + 1
end)
local canvas = stage:addCanvas({
x = 2,
y = 2,
width = 26,
height = 8,
})
canvas.draw = function(self, buffer)
draws = draws + 1
buffer:fill(1, 1, self.width, self.height, " ", colors.white, shade)
buffer:blit(2, 2, "Canvas draw: " .. draws, colors.white, shade)
buffer:blit(2, 4, "Layout: " .. layouts, colors.white, shade)
buffer:blit(2, 6, "Model: " .. value, colors.white, shade)
end
local function sample(name, action)
local beforeLayout, beforeDraw = layouts, draws
action()
basalt.update()
results[#results + 1] = {
name = name,
layout = layouts - beforeLayout,
draw = draws - beforeDraw,
}
end
basalt.update()
sample("Paint only", function()
shade = colors.purple
canvas:markRenderDirty()
end)
sample("Width", function()
canvas.width = 25
end)
sample("100 changes", function()
for _ = 1, 100 do value = value + 1 end
canvas:markRenderDirty()
end)
frame:addLabel({
x = 34,
y = 3,
width = 16,
text = "Measured deltas",
foreground = colors.yellow,
})
for index, result in ipairs(results) do
frame:addLabel({
x = 34,
y = 4 + index * 3,
width = 17,
height = 2,
text = result.name
.. "\nL+" .. result.layout
.. " D+" .. result.draw,
})
end
frame:addButton({
x = 36,
y = 16,
width = 12,
height = 2,
text = "Exit",
background = colors.red,
}):onClick(function()
basalt.stop()
end)
basalt.run()The displayed deltas are captured before the final result screen draw.
Optimization Order
- Remove unnecessary polling, logging, and repeated state writes.
- Move heavy work out of Computed, layout, and draw callbacks.
- Batch changes.
- Use render-only invalidation for custom paint.
- Replace large tiny-element trees with collection controls or Canvas.
- Reduce changing geometry and affected terminal rows.
- Measure again.