Imported from csvance/Luximm.jl (
skills/tachikoma-tui/SKILL.md). Install upstream withnpx skills add csvance/Luximm.jl --skill tachikoma-tui. Copyright stays with the author.
Tachikoma.jl App Scaffolding
A reference for building a TUI in Tachikoma.jl. Covers the protocol every app implements, the layout primitives, the small set of widgets you reach for first, and the sharp edges that cost time when you hit them blind.
For anything not covered here, the canonical references are the published docs at kahliburke.github.io/Tachikoma.jl/dev and the bundled demos in the Tachikoma.jl repo. Treat this skill as the runway, not the manual.
1. Minimum viable app
Every Tachikoma app does three things: define a mutable Model, implement update! to react to events, and implement view to draw each frame. app(m) runs the loop.
using Tachikoma
@tachikoma_app
@kwdef mutable struct Counter <: Model
quit::Bool = false
tick::Int = 0
n::Int = 0
end
should_quit(m::Counter) = m.quit
function update!(m::Counter, evt::KeyEvent)
evt.key == :char && evt.char == 'q' && (m.quit = true)
evt.key == :escape && (m.quit = true)
evt.key == :char && evt.char == '+' && (m.n += 1)
evt.key == :char && evt.char == '-' && (m.n -= 1)
end
function view(m::Counter, f::Frame)
m.tick += 1
inner = render(Block(title="Counter"), f.area, f.buffer)
set_string!(f.buffer, inner.x + 1, inner.y + 1,
"n = $(m.n) [+/-]change [q]uit", tstyle(:primary))
end
app(Counter())
Three things to notice:
- The struct must be
mutableand subtypeModel.update!mutates it in place. @tachikoma_appbrings the callback names (view,update!,should_quit,init!,cleanup!) into your namespace so your method definitions extend Tachikoma's, instead of shadowing them in a fresh namespace.app(m)enters the alt screen, raw mode, and mouse mode, then runs the 60fps loop. Ctrl+C exits regardless of whatupdate!does.
2. The protocol
All callbacks dispatch on your model type. Only view and update! (or should_quit flag) are typically needed; the rest exist for less common cases.
| Callback | Required | Purpose |
|---|---|---|
view(m, f::Frame) |
yes | Draw into f.buffer within f.area. Called each frame. |
update!(m, e::Event) |
yes | React to input. Dispatch on KeyEvent, MouseEvent, TaskEvent. |
should_quit(m) |
yes | Return true to exit the loop cleanly. |
init!(m, t::Terminal) |
no | One-shot setup; terminal size is known here. |
cleanup!(m) |
no | Teardown after the loop exits (terminal already restored). |
pre_render!(m) / post_render!(m) |
no | Hooks immediately before/after view. |
task_queue(m) |
no | Return a TaskQueue to enable spawn_task! / TaskEvent. |
recording_enabled(m) |
no | Return false to disable Ctrl+R recording for this app. |
See architecture for the full lifecycle.
3. Events
update! is dispatched on three concrete event types. Define one method per type you care about.
KeyEvent — fields key::Symbol, char::Char, action::KeyAction.
Common key values: :char, :escape, :enter, :backspace, :tab, :backtab, :up, :down, :left, :right, :home, :end_key, :pageup, :pagedown, :delete, :insert, :f1 through :f12, :ctrl, :ctrl_c. For printable characters, key == :char and char holds the value. Match.jl is idiomatic for non-trivial dispatch:
using Match
function update!(m::MyApp, e::KeyEvent)
@match (e.key, e.char) begin
(:char, 'q') || (:escape, _) => (m.quit = true)
(:char, 'r') || (:enter, _) => reset!(m)
_ => nothing
end
end
MouseEvent — fields x::Int, y::Int (1-based screen coords), button, action, plus shift/alt/ctrl flags. Buttons include mouse_left, mouse_middle, mouse_right, mouse_scroll_up, mouse_scroll_down. Actions are mouse_press, mouse_release, mouse_drag, mouse_move. Store widget rects on the model and use contains(rect, e.x, e.y) for hit testing.
TaskEvent — fields id::Symbol, value::Any. Delivered when background work spawned via spawn_task!/spawn_timer! completes. Requires a task_queue(m) method. See the async docs.
4. Layout
Layouts are constraint-based and explicit. You divide a Rect into child rects using a vector of constraints.
| Constraint | Meaning |
|---|---|
Fixed(n) |
Exactly n cells. |
Min(n) |
At least n cells; grows if room remains. |
Max(n) |
Up to n cells. |
Percent(p) |
p% of the parent rect (integer 0-100). |
Fill(w=1) |
Takes remaining space; w is the relative weight. |
Ratio(a, b) |
a/b of the parent rect. |
rows = split_layout(
Layout(Vertical, [Fixed(1), Fixed(5), Fill(), Fixed(1)]),
f.area)
length(rows) < 4 && return # terminal may be too small
cols = split_layout(Layout(Horizontal, [Percent(30), Fill()]), rows[3])
Layouts nest freely: split horizontally, then split each column vertically. Use Layout(Vertical, [...]; spacing=1) to leave a row between children.
Geometry helpers worth knowing (all exported): inner(r) shrinks by 1 on every side, center(parent, w, h) returns a Rect of size w×h centered in parent, shrink(r, n) applies a uniform margin, bottom(r) and right(r) give the last row/column, anchor(parent, w, h; h=:center, v=:center) for corner placement.
5. Rendering primitives
Two ways to put characters on screen, both writing to f.buffer:
set_char!(buf, x, y, '█', tstyle(:primary))
set_string!(buf, x, y, "hello", tstyle(:accent))
Coordinates are 1-based: set_char!(buf, 1, 1, ...) writes the top-left cell. f.area.x and f.area.y are also 1-based.
For anything more complex, call a widget's render:
inner = render(Block(title="Stats"), f.area, buf)
# `inner` is the rect inside the border; draw widgets into it
render(Gauge(0.42; filled_style=tstyle(:primary)), inner, buf)
render(widget, rect, buf) returns the inner rect the widget consumed. For container widgets like Block, that's the space inside the border; for content widgets, it's usually the input rect unchanged. Construct stateless widgets fresh each frame with current data. Persist stateful widgets (see Section 7) on the model.
6. Styling
Use tstyle(name; bold=false, dim=false, italic=false, underline=false) keyed by a theme slot:
tstyle(:primary, bold=true)
tstyle(:text_dim)
tstyle(:error)
Available slots include :primary, :accent, :success, :warning, :error, :border, :title, :text, :text_dim, :background. The active theme controls the actual colors, so an app written with tstyle automatically adapts to all 24 bundled themes and to the user's Ctrl+\ theme switch. Avoid hard-coded Color256/ColorRGB unless drawing pure-aesthetic content.
7. The widgets you'll reach for first
Tachikoma ships 30+ widgets. Five cover most app scaffolds; for the rest see the widgets reference.
Block(title=..., border_style=...) — bordered container. Returns the inner Rect.
inner = render(Block(title="Logs", border_style=tstyle(:border)), area, buf)
StatusBar(left=[Span(...)], right=[Span(...)]) — single-row strip with left/right-aligned spans. Render into a Fixed(1) row, typically rows[end] or pinned to bottom(f.area).
render(StatusBar(
left=[Span(" [r]oll ", tstyle(:accent))],
right=[Span("[q]uit ", tstyle(:text_dim))],
), Rect(f.area.x, bottom(f.area), f.area.width, 1), buf)
Gauge(ratio; filled_style, empty_style, label="") — horizontal progress bar; ratio is 0.0..1.0. Reconstruct each frame.
SelectableList(items; selected=1, focused=true) — keyboard-navigable list. Stateful: store on the model. Use handle_key!(list, evt) to delegate arrow keys; read value(list) for the current item.
Form([FormField("Name", TextInput(...); required=true), ...]; tick) — labeled fields, Tab/Shift-Tab navigation, submit button, validation. Stateful: store on the model. Sync m.form.tick = m.tick each frame so cursors blink. handle_key!(m.form, evt) drives navigation; valid(m.form) and value(m.form) read the result.
For a fuller catalog: MarkdownPane, DataTable, Chart, BarChart, Sparkline, Calendar, Modal, TreeView, Checkbox, RadioGroup, DropDown, Button, TabBar, ScrollPane, Paragraph, BigText, CodeEditor, Canvas, BlockCanvas, PixelImage, FloatingWindow, WindowManager, TerminalWidget, REPLWidget, Separator.
8. Default key bindings
app(m) installs these automatically. Pass default_bindings=false to disable when an app needs the Ctrl+letter range for its own use.
| Key | Action |
|---|---|
Ctrl+C |
Quit (always active, can't be disabled) |
Ctrl+G |
Toggle mouse mode |
Ctrl+\ |
Open theme selector |
Ctrl+A |
Toggle animations |
Ctrl+S |
Settings overlay |
Ctrl+? |
Help overlay |
Ctrl+R |
Start/stop .tach recording |
Ctrl+Y |
Copy pane to clipboard |
9. Sharp edges
These are the mistakes that cost the most round trips. Most are not visible until they bite.
- Struct must be
mutable.update!mutates the model in place. An immutable struct fails withsetfield! immutable structthe first time a key arrives. @tachikoma_app(or explicitimport Tachikoma: view, update!, should_quit) is required. Without it,function view(::MyModel, ...)defines a freshviewin your module instead of extending Tachikoma's, and the loop never sees it. Symptom: blank screen, no errors.- Reconstruct stateless widgets each frame, persist stateful ones.
Block,Gauge,Sparkline,BarChart,StatusBar,Paragraph,BigText,Modal,Separatorare stateless: build them fresh inview.TextInput,TextArea,CodeEditor,DataTable,Form,SelectableList,TreeView,DropDown,Checkbox,RadioGroup,Button,ScrollPane,REPLWidget,TerminalWidgetcarry internal state (cursor, scroll, selection, focus); rebuilding them each frame resets it. Store these on the model. - Coordinates are 1-based.
set_char!(buf, 1, 1, ...)is the top-left. So aref.area.xandf.area.y. Off-by-one bugs at the top edge usually trace to assuming 0-based. split_layoutreturns fewer rects than constraints if the area is too small. Always guard:length(rows) < N && returnbefore indexing. This happens routinely at app startup when the terminal hasn't reported a size yet, and when the user shrinks the window.- Stateful widgets carrying a
tickneed it synced before render. Pattern:m.form.tick = m.tick(and same for any list/button with cursor animations) at the top ofview. Without it, cursors and spinners freeze on the first value. printlnandprintfrom insideview/update!corrupt the screen. They write directly to the terminal while Tachikoma is composing the frame. Use@info/@debug/@warn(Tachikoma redirects logging through the recording channel) or wireon_stdout/on_stderrcallbacks viaapp(m; on_stdout=..., on_stderr=...).- Don't
Pkg.activateinsideinit!. Activate the project before launching the app. Activating duringinit!causes Pkg side effects to compete with the render loop for the terminal. - Wrap
app(m)in a function. A bareapp(m)at top level blocks the REPL with no way to re-enter cleanly after exit. Standard pattern:function myapp(); app(MyModel()); end, then callmyapp(). TerminalWidgetandREPLWidgetown a subprocess or PTY. Close them incleanup!(m)(callclose!(w)orpty_close!) or wrap yourapp(...)call intry/finally. Otherwise the child process leaks past app exit.Frame.areamay not equal terminal size. It's the area the framework gave thisviewcall; on resize events you'll see a different rect mid-frame. Always layout fromf.area, never from a cached terminal size.
10. Where to look next
Reference docs (published at kahliburke.github.io/Tachikoma.jl/dev):
| Page | Topic |
|---|---|
| getting-started | Build-your-first-app walkthrough |
| architecture | Full lifecycle, Frame/Buffer internals |
| layout | Constraint semantics, ResizableLayout |
| widgets | Every widget with constructor signatures |
| events | Event types and dispatch in depth |
| styling | Themes, palettes, color helpers |
| async | TaskQueue, spawn_task!, spawn_timer! |
| animation | Tweens, springs, organic effects |
| canvas | Braille, block, and pixel rendering |
| testing | TestBackend for headless widget tests |
| recording | .tach format, record_app, SVG/GIF export |
Seed templates in the Tachikoma.jl repo, under demos/TachikomaDemos/src/:
clock.jl— minimal animation loop withBigText,Calendar,StatusBar. The cleanest "shape of a view" reference.form_demo.jl—Formwith validators,FocusRing, submit detection.dashboard.jl— multi-widget layout, simulated data, nestedsplit_layout.life.jlandmouse_demo.jl—init!-based canvas setup, mouse hit testing withcontains.snake.jl— game loop withtick-driven simulation.
To run the demos locally, clone Tachikoma.jl, then from its root:
using Pkg
Pkg.activate("demos/TachikomaDemos")
Pkg.instantiate()
using TachikomaDemos
launcher()