tuition_list
# One dependency brings in both: `kino_tuition` depends on `tuition`, so this
# pulls the widget shown and the bridge showing it. A bare `github:` dep is
# resolved to the default branch once and then cached — a re-run reuses the
# locked commit, so to pick up newer commits install without the cache
# (`Mix.install(..., force: true)`, or clear ~/.cache/mix / the Livebook cache).
Mix.install([
{:kino_tuition, github: "ausimian/kino_tuition"}
])
What this is
A runnable showcase of :tuition_list — a scrollable, selectable column of
items. The cell at the bottom opens a real terminal in your browser (xterm.js)
with a real tuition session behind it, so the colour, the selection highlight
and the scrolling are the widget’s own, not a picture of them.
:tuition_widget_host is the pane that shows one widget on its own. You hand it
a spec — which widget, its config, its initial state, and how to fold input —
and :tuition_shell hosts it. A single-element pane list draws no nav bar, so
the list is all you see.
The items
Enough of them that the list has to scroll in the terminal below, so you can watch the view follow the selection.
items = for n <- 1..40, do: "item #{String.pad_leading("#{n}", 2, "0")}"
length(items)
40
The spec
Three parts worth reading separately.
config is the widget’s content and styling. A highlight_style is what
makes the selection visible at all — the default is unstyled, so a list without
one draws a selection you cannot see.
state holds the selection index and scroll offset. It lives here, in the
caller’s hands, because the renderer is immediate-mode and throws every frame
away — anything the widget tried to keep between frames would not survive. Its
presence in the spec is also what tells the host this is a stateful widget, to
be rendered through :tuition_list.render/4.
input folds a key into the model. The host does not guess this: a widget’s
key bindings are a property of the showcase, not of the widget, and the stateful
widgets do not share one input API. next/2 and prev/2 take the item count so
the list can clamp at the ends, and they are pure — navigation is a state
transition the caller applies, exactly like the render.
spec = %{
widget: :tuition_list,
config: %{
items: items,
highlight_style: %{fg: 0, bg: 6, bold: true},
highlight_symbol: "> "
},
state: :tuition_list.select(:tuition_list.new(), 0),
input: fn
{:key, :down, _mods}, %{state: s} = model ->
%{model | state: :tuition_list.next(s, length(items))}
{:key, {:char, ?j}, _mods}, %{state: s} = model ->
%{model | state: :tuition_list.next(s, length(items))}
{:key, :up, _mods}, %{state: s} = model ->
%{model | state: :tuition_list.prev(s, length(items))}
{:key, {:char, ?k}, _mods}, %{state: s} = model ->
%{model | state: :tuition_list.prev(s, length(items))}
{:key, :home, _mods}, %{state: s} = model ->
%{model | state: :tuition_list.select(s, 0)}
{:key, :end, _mods}, %{state: s} = model ->
%{model | state: :tuition_list.select(s, length(items) - 1)}
_event, model ->
model
end
}
:ok
:ok
Note what the fold does not do: it never touches the scroll offset. The
selection is all you move; render/4 reconciles the offset each frame to keep
that selection on screen, and returns the adjusted state for the host to keep.
Scrolling is a consequence, not something the showcase drives.
Run it
Up/Down (or j/k) move the selection, Home/End jump to the ends. Watch the
view start scrolling once the selection reaches the bottom row, and the >
prefix keep the unselected rows aligned.
q is not bound below — this spec’s fold ignores it, since the fold owns every
key the shell does not. Ctrl-C quits: :tuition_shell claims it globally
before a pane ever sees it, so a showcase always has a way out.
KinoTuition.new(
fn opts -> :tuition_shell.start([{:tuition_widget_host, "List", spec}], opts) end,
cols: 40,
rows: 14
)