termbuf-widgets.cr
termbuf-widgets
Widgets and a layout engine for termbuf.
A widget is both the thing that draws and the element the layout engine sizes, so there is no separate declaration tree to keep in step with the objects. Build a tree of widgets, hand its root to a Layout::Tree, and every widget comes back carrying a rectangle in buffer coordinates that it can draw into.
Requires Crystal 1.21 or later.
Installation
Add the dependency to shard.yml and run shards install:
dependencies:
termbuf-widgets:
github: plambert/termbuf-widgets.cr
That pulls in termbuf, which is the drawing side, and termbuf-input behind it.
Layout
Every widget asks to be sized on each axis with a Layout::Sizing, and there are four ways to ask:
fixedtakes exactly the cells it names.fittakes what the content needs, and no more.percenttakes that share, in hundredths, of what is left of the parent's content box: the box less the gaps between the children, less every sibling already settled at a size, which means the fixed ones and the fitting ones. Two panes at fifty each with a one-cell rule between them fill the box, rather than claiming the rule's cell twice over and overflowing by one.growdivides whatever is still over, by weight.
Each of the four is held inside its own min and max.
Widgets
Display
Widgets that show a number, a state or a time, and mostly take no input. Each says how wide it wants to be, how tall it turns out at that width, and draws into the box the layout gave it. None owns a clock: the three that move on their own arm a timer through App#after, which an application wires to its terminal, and the ones that move without one are advanced by whatever is driving the frames.
StatusBar— label and value pairs on one row, separators configurable, cut from the right with an ellipsis when the row is too narrowProgressBar— a fraction from zero to one drawn as a filled run and an empty one, with an optional percentage label and an optionalTermBuf::Blendso a gradient can colour the fill; in indeterminate mode a block slides across at a phase the caller advancesSingleValue— one number made prominent, with a caption under it, coloured by where it falls against a list of thresholds or by a blockFormattedNumber— a number with grouping separators, fixed decimals, an optional sign and an optional unitBytesDisplay— a byte count in IEC or SI units to a given precision, stepping up a unit rather than rounding to a whole baseRating— a value out of a maximum drawn in stars, read-only by default, editable with the arrow keys,Home,End, the digit keys and a clickSpinner— a frame of an animation that says work is going on, turned by a timer it arms throughApp#afterand arms again after every tick. Every frame is drawn in the same number of cells, so turning one costs no layoutClock— the time of day in aTime::Formatstring, armed for the next whole second (or whatever#resolutionsays) rather than for a flat interval, so the reading changes when the clock on the wall doesRelativeTime— "3 minutes ago" or "in 2 hours", with the unit chosen to keep the number small and a refresh that slows down as the time it describes ages: every second while it is new, once a day once it is a day oldDateDisplay— a date in aTime::Formatstring, with no timer, because a date does not changeIcon— one glyph, with a plainer spelling taken whenever the first would not come out at the width the icon reserved. An optional picture goes over it where the terminal draws picturesPicture— a picture over the cells it is given, asked for throughTermBuf::ImageStore#place, with alt text underneath for the terminals that draw none. NamedPicturebecauseTermBuf::Imageis what it holdsHyperlink— text carrying an OSC 8 link, which reveals the whole address on a second row (or after it) once the keyboard reaches it.Enterand a click sayHyperlink::Activated, andccopies the address throughApp#copyCopyButton— aButtonthat copies what a block answers, flashes a label to say it did, and is disabled with a label saying so where there is no clipboard
The last four want something the widget layer does not own — a clock, the clipboard, an image store — so an application hands them the whole App and they ask it. See Ticking and Copyable.
bar = TermBuf::Widgets::ProgressBar.new 0.4
bar.label = TermBuf::Widgets::ProgressBar::Placement::Centre
stars = TermBuf::Widgets::Rating.new 3.5
stars.editable = true
app.after = ->(span : Time::Span) { terminal.after span }
app.cancel = ->(nonce : UInt64) { terminal.cancel nonce; nil }
app.copy = ->(text : String) { terminal.clipboard.copy text }
spinner = TermBuf::Widgets::Spinner.new "working"
spinner.start app
link = TermBuf::Widgets::Hyperlink.new "the protocol", "https://example.com"
link.attach app
Data
Widgets that put a source of rows on the screen without building a widget per row. Each asks its source only for the rows that are showing, the way VirtualList does, so what it costs is the size of the window rather than the size of the data.
Table— rows in columns over aRowssource, with a header that stays put while the rows scroll. A column carries a header, aLayout::Sizing, an alignment, a block that answers what a row says in it and an optional block for what to draw that in. Cells are cut with an ellipsis and measured under the tree's width policy, so a row with an emoji in it still lines up. Columns wider than the table scroll sideways, and it is aScrollson both axes, so a scrollbar attaches to either. EmitsTable::SelectedandTable::ActivatedDataGrid— aTablewith a focused column as well as a row, moved withLeftandRight;Enteropens aFieldover the cell and a commit emitsDataGrid::Edited;#sort_byorders the rows through an index map, leaving the source untouched; the selection covers a row, a cell or any number of rows picked with the space barTree— nodes from aNodessource flattened into the rows of aVirtualList, which the tree holds as its one child.RightandLeftopen and close a node,Enteruses one, and children are asked for once, the first time a node is opened, so a source that loads a level at a time loads each level once. The expander glyphs fall back to ASCII on a terminal that would draw the triangles two cells wide
table = TermBuf::Widgets::Table.new TermBuf::Widgets::Rows.of(people)
table.add_column "name", ->(person : Person) { person.name }
table.add_column "age", ->(person : Person) { person.age.to_s },
TermBuf::Widgets::Layout::Sizing.fixed(3)
tree = TermBuf::Widgets::Tree.new TermBuf::Widgets::Nodes.from(roots,
children: ->(path : Path) { entries_of path },
label: ->(path : Path) { path.basename })
Navigation
Widgets that say where you are and let you go somewhere else. Three of them draw their own items rather than holding a widget each: giving up a label but not the item it belongs to is a decision about the whole row, and the layout engine apportions space between children without ever asking one to spell itself differently.
NavigationBar— a row (or column) of items, each with a label, an optional key hint and an optional message or proc to run when it is activated. The arrows along the bar move the highlight,Enterand a click activate, and it saysNavigationBar::Selected. A brand sits at the start and a trailing slot at the far end. As the row runs out of room it gives up the trailing slot, then the brand, then the labels for the key hints, then the hints for one mark per itemTabbedPanels— a tab strip over a panel showing one child at a time. The tabs that are not showing are hidden widgets, so the layout engine skips them: twenty tabs open cost the layout of one.Ctrl+PageUpandCtrl+PageDownmove between them from anywhere inside, and moving takes the keyboard into whatever is now showing; the strip takes the keyboard too, where the arrows move between tabs andEntersteps down into one. A closable tab gets a close glyph. SaysTabbedPanels::ChangedandTabbedPanels::ClosedBreadcrumbs— the path to here, joined by a separator glyph. A crumb given a URI is written as an OSC 8 hyperlink as well as being clickable, and a click saysBreadcrumbs::Selected. Crumbs are given up from the left with a leading ellipsis, and the last crumb — where you are — is the one kept wholePagination— previous and nextButtons around the page numbers, with a gap standing in for the runs that are not shown. The first page, the last page and#windowpages either side of the one showing always have a number of their own; a run of exactly one page is shown rather than hidden behind a gap that is wider than it.Left,Right,HomeandEndmove, and it saysPagination::ChangedDisclosure— a header that opens and closes what is under it, andDisclosureGroupto make a set of them an accordion.#expandedis a layout property because a closed section is out of the layout entirely: it costs one row whatever is in it.Enter,Spaceand a click turn one over and sayDisclosure::Toggled
bar = TermBuf::Widgets::NavigationBar.new brand: "termbuf"
bar.add "files", hint: "F1"
panels = TermBuf::Widgets::TabbedPanels.new
panels.add "source", editor
panels.add "output", log, closable: true
sections = TermBuf::Widgets::DisclosureGroup.new exclusive: true
sections.add("advanced").body.add checkbox
Input
The widgets a form is made of live under src/termbuf-widgets/widgets/input/. Each of them says what happened to it with a Message and knows nothing about what that means; the widget that contains it answers the message in Widget#handle.
require "termbuf-widgets"
include TermBuf::Widgets
form = Panel.new direction: :column, gap: 1
name = ValidatedField.new prompt: Field::Prompt.new("name: ")
name.validators << Validators.required
form.add name,
MaskedField.new(prompt: Field::Prompt.new("word: ")),
CheckboxGroup.new(%w[email sms post], max: 2),
ButtonGroup.new(%w[Save Cancel])
Form does the same job with the labels attached and the rules gathered in one place:
form = Form.new
form.add "name", ValidatedField.new, [Validators.required]
form.add "port", ValidatedField.new, [Validators.numeric]
form.add "colour", Combobox.new(Option.all(%w[amber azure beige]))
Options
SelectionList, Combobox and ListSelector are all over Option(T), a label and the value it stands for. What a list shows and what the application gets back are rarely the same thing, and the widget has no business turning one into the other:
zones = Option.all TimeZones.all # labelled by #to_s
ports = [Option.new("ssh", 22), Option.new("http", 80)]
What there is
Button— a label that saysButton::PressedwhenEnter,Spaceor a click reaches it. A disabled button is out of the tab order and answers nothing, but is still drawn: a control that vanishes when it cannot be used tells the user less than one that is visibly unavailable.ButtonGroup— a row or a column of buttons that the arrows along it move between. An exclusive group is a radio set and saysButtonGroup::Changedwhen the choice moves.Checkbox— a box that is ticked or not, toggled bySpaceor a click, sayingCheckbox::Changed. The marks are measured before they are used and an ASCII pair is taken wherever the pretty one does not come out at a cell each.CheckboxGroup— a column of boxes answering one question, withminandmaxbounds on how many may be ticked. A toggle that would break a bound is put back and the group saysCheckboxGroup::Refusedinstead ofChanged.TextArea— somewhere to type more than one line.Enterbreaks the line, up and down move by drawn row keeping the column they set out from, and#accept_keyshands the text over.Field— one line to type on, with history, completion and a prompt.ValidatedField— a field holding its line to a list of rules, drawing the first refusal under the line and sayingValidatedField::Invalidin place ofField::Accepted.MaskedField— a validated field drawing a mark for every character, with#valuefor the text as typed and#reveal?for showing it.Validators—required,length,matches,numeric,one_of, andallto compose them. A rule is aProc(String, String?), so an application's own rules are written where they belong rather than as subclasses.SelectionList— a window overOptions with a mark against the ones chosen, single or multiple, capped bymax_selections.Spacechooses,Enterhands the choice over, and a filterable list narrows to what is typed at it. Filtering runs through a map from the showing rows to the options behind them, so a choice survives being hidden.Combobox— a field with aSelectionListfloating under it, narrowed by what is typed.UpandDownmove through it without the keyboard leaving the field,Entertakes the highlighted option and saysCombobox::Chosen, andEscapeshuts it.allow_customdecides whether text that is nobody's label is accepted.KeywordList— a field with the keywords already typed sitting above it as chips, which wrap and grow the widget as they do.Enterand,add one, completing it to a known slug;Backspaceon an empty field selects the last chip and a second one removes it.ListSelector— two lists in aSplitwith a column of buttons between them.Space,Enterand a click send a row across;Tabmoves between the parts rather than between every button in them, andorderingadds a column that moves a chosen row up and down.Form— labelled fields in a column, tab order following declaration order.#submitasks every rule — the ones given per field, aValidatedField's own, and the form's#rulesabout all the values at once — and saysForm::SubmittedorForm::Invalidwith the keyboard on the first field that was refused.
Handing the text over
Enter cannot hand a TextArea over, because it is what puts the line break in. Ctrl+Enter is what everyone reaches for, and only a terminal speaking the kitty keyboard protocol can report it: Enter is the byte 0x0D and control does not change it, so anywhere else Ctrl+Enter arrives as a plain Enter. TextArea.default_accept_keys is therefore both Ctrl+Enter and Alt+Enter, and #accept_keys takes whatever an application would rather use.
Overlays
The widgets drawn over the screen rather than beside it live under src/termbuf-widgets/widgets/overlay/. Each of them is a float that is put up with #open and taken down with #close, and each takes the same three things with it: itself, a Catcher one z below that answers every point the overlay did not so a click cannot reach what is behind it, and an optional Backdrop one below that which dims what is there.
dialog = TermBuf::Widgets::Dialog.new "Unsaved changes",
body: TermBuf::Widgets::Label.new("Save before leaving?"),
actions: %w[Save Discard Cancel]
dialog.open app
A modal overlay pushes a focus scope with itself as the root, so tab moves inside it and a key nothing in it claims stops there rather than reaching the window behind. Closing pops the scope and gives the keyboard back to whatever had it. The overlay stays in the tree, hidden, so opening it again costs nothing and the message it emits on the way down still has a parent to reach.
Dialog— a centred box with a title, a body and aButtonGroupof actions. Modal with a backdrop by default.Escapecloses it with nothing chosen andEnterpresses the default action; either way it saysDialog::Closedcarrying the index of the action ornil.Dialog.confirmandDialog.alertare the two everyone writes anyway.Popover— a box hanging off another widget by a pair ofLayout::AttachPoints, which flips to the other side of its target rather than going off the screen. Not modal: the rest of the screen keeps the keyboard. A click anywhere else takes it down instead of pressing what it landed on.DropdownMenu— a popover holding aVirtualListof items, each with an optional key hint, an enabled flag and a submenu marker. Up and down move past anything disabled,Enterand a click choose, and it saysDropdownMenu::Selected. There is no menu bar: a row of buttons each opening one of these is what a menu bar is.Drawer— a panel against one edge of the screen, as long as that edge and#sizecells deep. Nothing animates; a program wanting it to slide moves#sizea cell at a time between frames. SaysDrawer::Closed.Toasts— a stack of short messages in a corner. The newest is always nearest the corner, so a bottom stack grows upward and a top one downward; past#max_visiblethe oldest is pushed off. A click takes one down, and so does its time running out.HelpOverlay— a dialog listingRouter#active_bindingsfor the chain the keyboard is in, grouped by the widget each binding came from and scrolling when there are more than fit.HelpOverlay.install appbinds it toF1and?by merging them intoApp#keymap, which leaves whatever was bound there.
Timers
Nothing in the widget layer opens a device, and a clock is a device. An application that wants a toast to time out hands App the terminal's:
app.after = ->(span : Time::Span) { terminal.after span }
app.cancel = ->(nonce : UInt64) { terminal.cancel nonce }
App#after(span) { ... } then arms one and runs the block when the TermBuf::Events::Timer arrives; App#cancel(nonce) withdraws it. Without those two procs nothing is armed and a toast stays up until it is clicked.
Development
shards install
crystal spec -v --error-trace
crystal tool format
ameba
Specs run with Layout::Tree.verify_invalidation on, which lays a clean tree out again and raises when a rectangle moved without anything having said so. Anything the layout reads goes through Widget.layout_property for that reason.
License
MIT. See LICENSE.
termbuf-widgets.cr
- 0
- 0
- 0
- 0
- 3
- about 6 hours ago
- September 5, 2026
Other
Sat, 05 Sep 2026 20:30:44 GMT