Widgets
Tachikoma includes dozens of widgets covering text display, input controls, data visualization, navigation, and containers. All widgets follow the render protocol: render(widget, area::Rect, buf::Buffer).
Value Protocol
Many widgets support a unified value interface:
value(widget) # get the widget's current value
set_value!(widget, v) # set the widget's value
valid(widget) # check if the current value is valid (default: true)Text Display
All text rendering in Tachikoma is grapheme-aware. Unicode combining marks (e.g. ṅ, n̈), precomposed characters, and CJK wide characters are handled correctly across all widgets. Combining marks attach to their base character without consuming an extra cell, and wide characters occupy two cells with proper alignment.
Block
Bordered panel with optional title. The workhorse container widget:
block = Block(; title="Panel", border_style=tstyle(:border),
title_style=tstyle(:title, bold=true), box=BOX_ROUNDED)
inner = render(block, area, buf) # returns inner Rect after drawing borders
Box styles: BOX_ROUNDED, BOX_HEAVY, BOX_DOUBLE, BOX_PLAIN.
Paragraph
Styled text with wrapping and alignment:
para = Paragraph([
Span("Bold text ", tstyle(:text, bold=true)),
Span("and dim text", tstyle(:text_dim)),
]; wrap=word_wrap, alignment=align_center)
render(para, area, buf)
para = Paragraph([Span("Hello ", tstyle(:text)), Span("world", tstyle(:accent))])
paragraph_line_count(para, 40) # count wrapped lines for a given widthWrap modes: no_wrap, word_wrap, char_wrap. Alignment: align_left, align_center, align_right.
ANSI Escape Sequences
Strings containing ANSI escape sequences are automatically parsed into styled spans — no manual Span construction needed:
text = "\e[1mBold\e[0m \e[3;32mitalic green\e[0m \e[38;5;208m256-color\e[0m"
para = Paragraph(text; wrap=char_wrap)
render(para, area, buf)
Supported SGR codes: standard colors (30–37, 40–47), bright colors (90–97, 100–107), 256-color (38;5;n / 48;5;n), 24-bit RGB (38;2;r;g;b / 48;2;r;g;b), bold, dim, italic, underline, strikethrough, reverse video, and reset. Non-SGR escape sequences (cursor movement, window titles, etc.) are silently stripped.
Disable per-widget with ansi=false — escape sequences are stripped and text is shown unstyled:
para = Paragraph("\e[31mred\e[0m"; ansi=false)
# renders as plain "red" without colorTo inspect the literal escape codes (useful for debugging), use raw=true — the ESC byte is replaced with the visible ␛ symbol:
para = Paragraph("\e[31mred\e[0m"; raw=true)
# renders as "␛[31mred␛[0m"Use parse_ansi directly to convert ANSI strings into Span vectors for reuse:
spans = parse_ansi("\e[1;31mError:\e[0m something broke")
Paragraph(spans)Span
Inline styled text fragment, used inside Paragraph and StatusBar:
Span("text", tstyle(:primary, bold=true))
BigText
Large block-character text (5 rows tall):
bt = BigText("12:34"; style=tstyle(:primary, bold=true))
render(bt, area, buf)
intrinsic_size(BigText("12:34")) # (width, height) in terminal cellsStatusBar
Full-width bar with left and right aligned spans:
render(StatusBar(
left=[Span(" Status: OK ", tstyle(:success))],
right=[Span("[q] quit ", tstyle(:text_dim))],
), area, buf)
Separator
Visual divider line:
render(Separator(), area, buf)
Input Widgets
TextInput
Single-line text editor with optional validation:
input = TextInput(; text="initial", label="Name:", focused=true,
validator=s -> length(s) < 2 ? "Min 2 chars" : nothing)
handle_key!(input, evt) # returns true if consumed
text(input) # get current text
set_text!(input, "new") # set text
value(input) # same as text()
valid(input) # true if validator returns nothingThe validator function receives the current text and returns nothing (valid) or an error message string.
TextArea
Multi-line text editor:
area = TextArea(; text="", label="Bio:", focused=true)
handle_key!(area, evt)
handle_mouse!(area, evt, rect)
text(area)
set_text!(area, "multi\nline")CodeEditor
Syntax-highlighted code editor:
CodeEditor(; text="function greet(name)\n println(\"Hello, \$name!\")\nend",
focused=true, block=Block(title="editor.jl", border_style=tstyle(:border),
title_style=tstyle(:title)))
handle_key!(editor, evt)
editor_mode(editor) # current mode symbolSupports Julia syntax highlighting with token types: token_keyword, token_string, token_comment, token_number, token_plain.
Checkbox
Boolean toggle:
cb = Checkbox("Enable notifications"; focused=false)
handle_key!(cb, evt) # space toggles
value(cb) # true/false
set_value!(cb, true)RadioGroup
Mutually exclusive selection:
rg = RadioGroup(["Admin", "Editor", "Viewer"])
handle_key!(rg, evt) # up/down + space/enter to select
value(rg) # selected index (Int)
set_value!(rg, 2)DropDown
Select from a dropdown list:
dd = DropDown(["Tokyo", "Berlin", "NYC", "London"])
handle_key!(dd, evt) # enter opens, up/down navigates, enter selects
value(dd) # selected index (Int)Calendar
Date picker widget:
cal = Calendar(2026, 2; today=19)
render(cal, area, buf)
Selection & Navigation
SelectableList
Keyboard and mouse navigable list:
list = SelectableList(["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
selected=1, focused=true,
block=Block(title="Items"),
highlight_style=tstyle(:accent, bold=true),
marker=MARKER)
handle_key!(list, evt)
value(list) # selected index
set_value!(list, 2)
list_hit(list, x, y, area) # hit test → index or nothing
list_scroll(list, lines) # scroll by n linesWith styled items:
items = [ListItem("Item 1", tstyle(:text)),
ListItem("Item 2", tstyle(:warning))]
list = SelectableList(items; selected=1)TreeView / TreeNode
Hierarchical tree display:
root = TreeNode("Root", [
TreeNode("Child 1", [
TreeNode("Leaf A"),
TreeNode("Leaf B"),
]),
TreeNode("Child 2"),
])
tree = TreeView(root; block=Block(title="Tree"))
render(tree, area, buf)
handle_key!(tree, evt) # up/down navigate, enter expand/collapseTabBar
Tab switching:
tabs = TabBar(["Overview", "Details", "Settings"]; active=2)
render(tabs, area, buf)
handle_key!(tabs, evt) # left/right/tab to switch
handle_mouse!(tabs, evt) # click to switch (returns :changed or :none)
value(tabs) # selected tab index (1-based)
set_value!(tabs, 2) # set active tab programmaticallyTab appearance is controlled by a TabBarStyle{D} with one of three built-in decoration types:
# Default bracket style: [Active] Inactive
TabBar(["Tab 1", "Tab 2"])
# Box tabs with heavy borders (requires height ≥ 3)
TabBar(["Tab 1", "Tab 2"]; tab_style=TabBarStyle(decoration=BoxTabs(box=BOX_HEAVY)))
# Plain text tabs
TabBar(["Tab 1", "Tab 2"]; tab_style=TabBarStyle(decoration=PlainTabs()))TabBarStyle keyword arguments:
| Argument | Default | Description |
|---|---|---|
decoration | BracketTabs() | BracketTabs(), BoxTabs(; box=…), or PlainTabs() |
active | tstyle(:accent, bold=true) | Style for the active tab label |
inactive | tstyle(:text_dim) | Style for inactive tab labels |
separator | " │ " | String placed between tabs (not used for BoxTabs) |
overflow_char | '…' | Character shown when tabs overflow the available width |
tab_colors | Style[] | Per-tab color overrides (empty = use active/inactive) |
BoxTabs requires at least 3 rows of height. If given less, it falls back to BracketTabs.
When there are more tabs than fit in the available width, overflow indicators (…) appear automatically and the visible window scrolls to keep the active tab in view.
Store the TabBar in your model to preserve state across frames:
@kwdef mutable struct App <: Model
tabs::TabBar = TabBar(["Overview", "Details", "Settings"]; focused=true)
end
function update!(m::App, e::KeyEvent)
handle_key!(m.tabs, e)
end
function update!(m::App, e::MouseEvent)
handle_mouse!(m.tabs, e)
end
function view(m::App, f::Frame)
render(m.tabs, area, f.buffer)
# Use value(m.tabs) to decide which pane to show
endModal
Confirmation dialog:
modal = Modal(; title="Delete?", message="This cannot be undone.",
confirm_label="Delete", cancel_label="Cancel",
selected=:cancel)
render(modal, area, buf)
Data Visualization
Sparkline
Mini line chart from a data vector:
Sparkline(data; style=tstyle(:accent))
Gauge
Progress bar (0.0 to 1.0):
Gauge(progress;
filled_style=tstyle(:primary),
empty_style=tstyle(:text_dim, dim=true),
tick=tick)
BarChart
Bar chart with labeled entries:
entries = [BarEntry("CPU", 65.0), BarEntry("MEM", 42.0), BarEntry("DSK", 78.0)]
render(BarChart(entries; block=Block(title="Usage")), area, buf)
Chart
Line and scatter plots with multiple data series:
cpu_data = Float64[0.3 + 0.2 * sin(i * 0.3) for i in 1:30]
mem_data = Float64[0.5 + 0.1 * cos(i * 0.2) for i in 1:30]
series = [
DataSeries(cpu_data; label="CPU", style=tstyle(:primary)),
DataSeries(mem_data; label="Mem", style=tstyle(:secondary)),
]
render(Chart(series; block=Block(title="System")), area, buf)
Chart types: chart_line, chart_scatter.
Table
Simple row/column table:
headers = ["Name", "Status", "CPU"]
rows = [["nginx", "running", "12%"],
["postgres", "running", "8%"]]
render(Table(headers, rows;
block=Block(title="Processes"),
header_style=tstyle(:title, bold=true),
row_style=tstyle(:text),
alt_row_style=tstyle(:text_dim)), area, buf)
DataTable
Sortable, filterable data table with pagination:
dt = DataTable([
DataColumn("Name", ["Alice", "Bob", "Carol"]),
DataColumn("Score", [95, 82, 91]; align=col_right),
DataColumn("Grade", ["A", "B", "A"]; align=col_center),
]; selected=1)
render(dt, area, buf)
Sort directions: sort_none, sort_asc, sort_desc. Column alignment: col_left, col_right, col_center.
With the Tables.jl extension, DataTable accepts any Tables.jl source:
using Tables
dt = DataTable(my_dataframe)Containers & Control
Form / FormField
Multi-field form with focus navigation and validation:
form = Form([
FormField("Name", TextInput(; validator=s -> isempty(s) ? "Required" : nothing);
required=true),
FormField("Bio", TextArea()),
FormField("Notify", Checkbox("Enable notifications")),
FormField("Role", RadioGroup(["Admin", "Editor", "Viewer"])),
FormField("City", DropDown(["Tokyo", "Berlin", "NYC"])),
]; submit_label="Submit",
block=Block(title="Registration"))
render(form, area, buf)
handle_key!(form, evt) # Tab/Shift-Tab navigation, widget key handling
value(form) # Dict{String, Any} of field label → value
valid(form) # true if all required fields are validButton
Clickable button:
btn = Button("Submit"; focused=true)
render(btn, area, buf)
handle_key!(btn, evt) # enter/space triggers
handle_mouse!(btn, evt) # left-click triggers; returns true if hitButton appearance is controlled by a ButtonStyle{D} with one of three built-in decoration types:
# Default bracket button: [ Label ]
Button("Click me")
# Bordered button with rounded box (requires height ≥ 3)
Button("Submit"; button_style=ButtonStyle(decoration=BorderedButton()))
# Plain text button
Button("Cancel"; button_style=ButtonStyle(decoration=PlainButton()))ButtonStyle keyword arguments:
| Argument | Default | Description |
|---|---|---|
decoration | BracketButton() | BracketButton(), BorderedButton(; box=…), or PlainButton() |
normal | tstyle(:text) | Style when unfocused |
focused | tstyle(:accent, bold=true) | Style when focused |
BorderedButton accepts a box keyword (e.g. BOX_ROUNDED, BOX_HEAVY, BOX_DOUBLE). It requires at least 3 rows of height; if given less it falls back to BracketButton.
Buttons can play a flash animation when focused using the following parameters:
flash_frames::Int: Number of frames the animation lasts.flash_style::Function: A function with the signatureflash_style(btn::Button)::Stylethat returns the button style during the animation. The fieldbtn.flash_remainingcan be used to check how many frames remain.
ScrollPane
Scrollable container for content:
sp = ScrollPane(["Line 1", "Line 2", "Line 3"]; following=true)
push_line!(sp, "new line") # append content
render(sp, area, buf)
handle_mouse!(sp, evt, area) # scrollbar drag + scroll wheelScrollPane automatically parses ANSI escape sequences in String content, just like Paragraph. This works with both the non-wrap and word_wrap=true paths:
lines = ["\e[32m[OK]\e[0m Server started", "\e[31m[ERR]\e[0m Connection refused"]
sp = ScrollPane(lines; word_wrap=true)Disable with ansi=false:
sp = ScrollPane(lines; ansi=false)Scrollbar
Standalone scrollbar indicator:
sb = Scrollbar(100, 20, 0)
render(sb, area, buf)
WidgetScroll
Scrollable 2D viewport that wraps any widget. Renders the inner widget into a virtual buffer larger than the viewport, then displays the visible portion with optional scrollbars.
ws = WidgetScroll(my_widget;
virtual_width=200, virtual_height=120,
block=Block(title="Viewport"),
show_vertical_scrollbar=true,
show_horizontal_scrollbar=false)
render(ws, area, buf)Navigation:
handle_key!(ws, evt) # arrow keys, Page Up/Down, Home/End
handle_mouse!(ws, evt) # click-drag panning, scroll wheel
value(ws) # returns (offset_x, offset_y)The virtual buffer is cached and reused across frames to avoid per-frame allocation.
ProgressList / ProgressItem
Task status list with status icons:
items = [
ProgressItem("Build"; status=task_done),
ProgressItem("Test"; status=task_running),
ProgressItem("Deploy"; status=task_pending),
]
render(ProgressList(items; tick=tick), area, buf)
Task statuses: task_pending, task_running, task_done, task_error, task_skipped.
FocusRing
Tab/Shift-Tab navigation manager — cycles focus between panes or widgets (see Input & Events for the full example):
ring = FocusRing([widget1, widget2, widget3])
handle_key!(ring, evt)
current(ring)
next!(ring)
prev!(ring)
Container
Group widgets with automatic layout:
container = Container(
[widget1, widget2, widget3],
Layout(Vertical, [Fixed(3), Fill(), Fixed(1)]),
Block(title="Metrics")
)
MarkdownPane
Scrollable CommonMark viewer with styled headings, bold/italic, inline code, code blocks with syntax highlighting, lists, block quotes, and horizontal rules. Requires the markdown extension (enable_markdown() or using CommonMark).
enable_markdown()
pane = MarkdownPane("# Hello\n\n**Bold**, *italic*, `code`.\n\n- Item 1\n- Item 2";
block=Block(title="Docs"))
render(pane, area, buf)
Update content dynamically with set_markdown!:
set_markdown!(pane, "# Updated\n\nNew content here.")Supports keyboard scrolling (↑/↓/Page Up/Page Down) and mouse wheel. Automatically reflows text when the render width changes.