Build a Form
This tutorial builds a registration form with text input, checkboxes, radio buttons, a dropdown, validation, and a live preview panel.
What We'll Build
A two-panel app: a form on the left with Tab/Shift-Tab navigation, and a live preview on the right showing the current form values and validation state.
<!– tachi:begin form_app –>
Step 1: Define the Model
using Tachikoma
function build_form(tick)
Form([
FormField("Name", TextInput(; text="", focused=true, tick=tick,
validator=s -> length(s) < 2 ? "Min 2 chars" : nothing);
required=true),
FormField("Bio", TextArea(; text="", focused=false, tick=tick)),
FormField("Notify", Checkbox("Enable notifications"; focused=false)),
FormField("Role", RadioGroup(["Admin", "Editor", "Viewer"])),
FormField("Region", DropDown(["Tokyo", "Berlin", "NYC", "London",
"São Paulo", "Sydney"])),
];
submit_label="Submit",
block=Block(title="Registration", border_style=tstyle(:border),
title_style=tstyle(:title)),
tick=tick,
)
end
@kwdef mutable struct FormApp <: Model
quit::Bool = false
tick::Int = 0
form::Form = build_form(0)
submitted::Bool = false
flash::Int = 0
end
should_quit(m::FormApp) = m.quitKey points:
Each
FormFieldpairs a label with a widgetThe
validatoron TextInput returnsnothingfor valid input or an error messagerequired=truemeans the field must pass validation for the form to be validsubmit_labeladds a submit button to the form
Step 2: Handle Events
function update!(m::FormApp, evt::KeyEvent)
# Ctrl+R resets the form
if evt.key == :ctrl && evt.char == 'r'
m.form = build_form(m.tick)
m.submitted = false
m.flash = 0
return
end
# Check if submit button is focused and Enter is pressed
cur = current(m.form.focus)
is_submit = cur === m.form.submit_button &&
(evt.key == :enter || (evt.key == :char && evt.char == ' '))
# Let the form handle the key (Tab navigation, widget input)
handled = handle_key!(m.form, evt)
# If submit was pressed and form is valid, capture values
if is_submit && handled && valid(m.form)
m.submitted = true
m.flash = 90 # show "Submitted!" for ~3 seconds
end
# If form didn't consume the key, handle app-level keys
if !handled
evt.key == :escape && (m.quit = true)
end
endThe Form widget's FocusRing handles Tab/Shift-Tab navigation. Each widget gets key events when focused. Unhandled keys bubble up to your update!.
Step 3: Render the View
function view(m::FormApp, f::Frame)
m.tick += 1
m.form.tick = m.tick
m.flash > 0 && (m.flash -= 1)
buf = f.buffer
# Layout: header | body | footer
rows = split_layout(Layout(Vertical, [Fixed(1), Fill(), Fixed(1)]), f.area)
header, body, footer = rows[1], rows[2], rows[3]
# Header
set_string!(buf, header.x + 1, header.y, "Form Demo",
tstyle(:title, bold=true))
# Body: form (55%) + preview (45%)
cols = split_layout(Layout(Horizontal, [Percent(55), Fill()]), body)
form_area, preview_area = cols[1], cols[2]
# Render the form
render(m.form, form_area, buf)
# Render the preview panel
render_preview!(buf, preview_area, m)
# Footer
render(StatusBar(
left=[Span(" [Tab/S-Tab] navigate [Ctrl+R] reset ", tstyle(:text_dim))],
right=[Span("[Esc] quit ", tstyle(:text_dim))],
), footer, buf)
endStep 4: The Preview Panel
function render_preview!(buf::Buffer, area::Rect, m::FormApp)
block = Block(title="Preview", border_style=tstyle(:border),
title_style=tstyle(:title))
inner = render(block, area, buf)
inner.width < 4 && return
y = inner.y
# Validation indicator
if valid(m.form)
set_string!(buf, inner.x, y, "✓ Valid", tstyle(:success, bold=true))
else
set_string!(buf, inner.x, y, "✗ Invalid", tstyle(:error, bold=true))
end
y += 2
# Flash message
if m.flash > 0
set_string!(buf, inner.x, y, "Submitted!", tstyle(:accent, bold=true))
y += 2
end
# Display form values
vals = value(m.form) # Dict{String, Any}
for (label, val) in sort(collect(vals); by=first)
y > bottom(inner) && break
display = val isa Bool ? (val ? "yes" : "no") :
val isa String && isempty(val) ? "(empty)" : string(val)
set_string!(buf, inner.x, y, "$(label): ", tstyle(:text_dim))
set_string!(buf, inner.x + length(label) + 2, y, display,
tstyle(:text); max_x=right(inner))
y += 1
end
endStep 5: Run It
app(FormApp())
How It Works
Tab/Shift-Tab moves focus between fields; the focused widget gets keyboard events
value(form)returns aDictmapping field labels to their widget valuesvalid(form)checks all required fields against their validatorsThe preview reads
value(form)each frame for a live display