termbuf
Version, currently 0.2.010 versions
github.com/plambert/termbuf.cr
A terminal screen held in memory, repainted with a diff. Decouples a terminal application's UI from the terminal itself: draw into a buffer, ask for a paint, and only the difference goes out, encoded against what that particular terminal turned out to be able to do.
Nothing has been indexed for 0.2.0 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.
Installation
# Add this to your shard.yml
dependencies:
termbuf:
github: plambert/termbuf.cr
version: ~> 0.2.0Then run:
shards installshard.yml
No shard.yml has been indexed for 0.2.0. You can read it on the repository.
Dependencies
Unknown: the shard.yml for this version has not been read yet.
README
This README is the one indexed from the repository at its latest ref, not from the tag for this version.
termbuf
An in-memory terminal screen for Crystal, repainted with a diff.
Draw into a buffer, ask for a paint, and what reaches the terminal is the difference between what it is showing and what the buffer holds — encoded against what that particular terminal turned out to be able to do. Drawing the same frame twice sends nothing.
- Double-buffered cell grid with damage tracking; scrolled regions are moved with
DECSTBMrather than redrawn where that is cheaper - Capability detection by asking the terminal directly, falling back to
TERMheuristics, withTERMBUF_CAPSas the last word - Colour capped rather than limited: a 24-bit colour degrades to the 256 palette, then to the sixteen, then to nothing, depending on what is there
- UAX #29 grapheme clusters and UAX #11 widths, from generated tables, with cluster widths measured from the terminal rather than assumed
- Keys, mouse reports, bracketed paste, resizes, timers, signals, and terminal replies delivered as events on one channel
- Cursors that wrap and scroll inside a region, with an
IOfor each - Widgets — an editable field, layout, focus, and keymaps — in a shard of their own, drawn through the same API
Requires Crystal 1.21 or later.
Related shards
- plambert/termbuf-input.cr — handle keyboard and mouse input, as well as typical signals
- plambert/termbuf-widgets.cr — layout, focus, keymaps, and widgets such as fields, lists, tables and overlays, drawn through termbuf
Installation
Add the dependency to shard.yml and run shards install:
dependencies:
termbuf:
github: plambert/termbuf.cr
That pulls in termbuf-input, which is the input side and termbuf's only dependency.
Getting started
A whole program: take the terminal over, draw a frame, paint it, read an event, give the terminal back.
require "termbuf"
TermBuf::Terminal.open do |terminal|
message = terminal.size.to_s
loop do
terminal.batch do |screen|
screen.clear
screen.write 2, 1, "termbuf #{TermBuf::VERSION}", TermBuf::Style::DEFAULT.bold
screen.write 2, 2, message
screen.write 2, 4, "press q to leave", TermBuf::Style::DEFAULT.faint
end
terminal.paint
case event = terminal.events.receive
when TermBuf::Events::Key
break if event.key.is? 'q'
message = "key #{event.key}"
when TermBuf::Events::Resize then message = "resized to #{event.size}"
when TermBuf::Events::Closed then break
end
end
end
Terminal.open probes the terminal, puts it in raw mode, switches to the alternate screen, and
starts a fibre that owns the buffer. The block form gives the terminal back however the body ends,
including on an exception or a signal; without a block, Terminal#close does it by hand.
The public API
| Type | What it is | Reach for |
|---|---|---|
Terminal | the device, taken over and given back | open close batch paint events |
Drawing | the drawing surface, mixed into four things | write fill clear scroll view |
Batcher View BufferSurface | the other three surfaces | Drawing's methods |
Buffer | the cells, with no device attached | write blit hit resize to_text |
Sink | one output of a buffer | paint commit invalidate detach |
Cell | one cell: cluster, style id, width | char text width continuation? |
Style Color Blend Gradient | what a cell looks like | bold fg bg underlined merge |
Cursor CursorIO | streamed output inside a region | print puts io style= move_to |
Region Rect | a scrolling rectangle, and a plain one | bounds= scrollback view_offset= |
Capability Capabilities | what the terminal can do | includes? with without |
Quirk | what it gets wrong | per_code_point_columns? |
Unicode WidthPolicy | measuring and fitting text | string_width truncate fit window |
ImageStore Image Placement | pictures over the cells | add place delete clear |
ColorStack | the terminal's own colours | saved background= []= |
Clipboard | OSC 52 | copy |
Events | everything that arrives on the channel | Key Mouse Paste Resize Timer |
Coordinates are zero based from the top left, everywhere, and always in cells.
Drawing
Text goes in one grapheme cluster per cell, and a cluster the terminal draws double width takes two.
terminal.write 0, 0, "text", style
terminal.write_char 4, 0, '!', style
terminal.fill TermBuf::Rect.new(0, 1, 20, 3), '.', style
terminal.scroll TermBuf::Rect.new(0, 4, terminal.size.columns, 10), 1
terminal.clear
Nothing here touches the buffer: each call builds a command and sends it to the owning fibre, which
is what makes ordering total and locking unnecessary. #batch collects a frame's worth and sends it
as one channel operation — use it for anything larger than a few writes.
terminal.batch do |screen|
screen.clear
rows.each_with_index { |row, index| screen.write 0, index, row }
end
To read the buffer, or to do something the drawing API does not cover, #sync runs a block on the
owning fibre and waits:
terminal.sync { |buffer| File.write path, buffer.to_text }
Cursors
Drawing addresses cells. A Cursor streams instead: it holds a position, a Style, and the
Region it wraps and scrolls inside, and works out which cell each grapheme cluster lands in.
cursor = terminal.cursor # the whole screen
log = terminal.cursor TermBuf::Rect.new(0, 10, 80, 8), scrollback: 500
log.style = TermBuf::Style::DEFAULT.faint
log.puts "started"
log.io.printf "%-12s %s\n", name, status
Cursor#io is an IO, so printf, Colorize, inspect, and anything else that writes to one can
be pointed at a pane. It is unbuffered by default; set sync = false to gather writes until a
newline or a #flush.
Text written to a cursor is scanned for escape sequences, so \e[1m sets the bold attribute on the
cells that follow rather than landing in them. Sequences that address the terminal rather than the
text — cursor movement, screen clearing — are dropped, since the buffer already has its own idea of
where the cursor is. An application that changes appearance by assigning to #style and never
writes a sequence of its own can skip the scan with cursor.raw = true.
Wrapping is deferred the way a terminal defers it: a character landing in the last column leaves the
cursor at the margin, and only the next character takes it to the row below. Turn autowrap off and
the cursor stops at the margin; turn scrolls off and the bottom row stops scrolling.
Clipped panels
Drawing#view gives back a rectangle of a surface addressed from its own top left and cut at its
own edges. A panel drawn over other content stays inside its border without every widget doing the
arithmetic:
terminal.batch do |screen|
draw_table screen
panel = screen.view TermBuf::Rect.new(10, 4, 30, 8)
panel.fill panel.bounds, ' ', TermBuf::Style::DEFAULT.reverse
panel.write 0, 0, "a line far longer than thirty cells" # cut at the border
end
A cluster crossing an edge is dropped whole rather than split, measured with the policy of the
surface the view came from. Views nest, so a border can hand what it surrounds a surface of exactly
the space left inside it. #passthrough and #scroll_region pass through untouched, since neither
is addressed in the view's cells.
A view can also carry a style that everything drawn through it merges onto, so a highlighted row is filled once and its columns name only what each one adds:
row = screen.view rect, TermBuf::Style::DEFAULT.bg(highlight)
row.clear # paints the highlight
row.write 0, 0, name, TermBuf::Style::DEFAULT.bold # bold, on the highlight
row.write 24, 0, rate, TermBuf::Style::DEFAULT.faint
A write that names a field of its own wins; one that leaves a field unset takes the view's. Nested
views layer the same way. Attributes are the exception and combine rather than replace, since flags
have no value meaning "leave the panel's alone" — a bold write inside a faint panel is both. The
merge is Style#merge, usable on its own.
That covers one background for a whole row. When the background varies under the text — a label
across a progress bar — the cells themselves have the answer, and a blend: asks them per cell:
screen.fill TermBuf::Rect.new(0, y, filled, 1), ' ', TermBuf::Style::DEFAULT.bg(bar)
screen.write x, y, "#{percent}%", TermBuf::Style::DEFAULT.bold,
blend: TermBuf::Style::KEEP_BACKGROUND
A blend is given the style already in the cell, the style being written, and the cell's position in
the buffer, and returns the style to place. Style::KEEP_BACKGROUND keeps the colour already there
and takes everything else from the write; Style::OVER merges the two the way a view does; and
Style.blend { |under, over| ... } wraps anything else. A Gradient is a blend built from two
colours and a rectangle:
ramp = TermBuf::Gradient.new from, to, panel.bounds, :vertical
screen.view(rect, blend: ramp.background).clear
A blend passed to a draw call is asked in the buffer's coordinates; one carried by a view is asked
in the view's, so a gradient built against View#bounds lands wherever the view does.
A cluster covering two cells takes the style its first half lands on. #write_char, #fill and
#clear take the same argument.
One caution: styles are interned and the table only grows, so a blend returning a colour computed per cell interns a style per cell. That is bounded by the screen for one frame; across an animation it is not, and such a blend should draw from a fixed palette instead.
A fill, scroll, or blit whose edge falls inside a wide character takes the whole character —
half of one cannot be drawn — and the half lying outside the rectangle keeps the style it had,
losing only its glyph. Writing a character over half of one is different: there the displaced half
is erased in the style being written, which is what a terminal does.
This is clipping, not layering: nothing says a view is on top of anything. Dismissing a panel means the next frame does not draw it, and the paint diff then sends the cells it covered and nothing else.
Off-screen buffers
A Buffer needs no terminal, and BufferSurface is a full drawing surface over one — so a shard
that wants to composite panels itself can draw each into a buffer of its own and blit them into
place:
panel = TermBuf::Buffer.new 30, 8
TermBuf::BufferSurface.new(panel).view(inner).write 0, 0, "drawn off screen"
terminal.batch { |screen| screen.blit panel, 10, 4 }
Styles and clusters are interned per buffer, so the ids a source cell carries mean nothing in the
destination; #blit translates them. Stored widths are copied rather than remeasured, so a panel
keeps the layout it was drawn with. A wide character with only one half inside the copied rectangle
arrives as a blank. The source is read when the command is serviced, so do not draw into a panel
again between blitting it and painting.
Panes and resizing
A Region an application makes covers a pane it chose, and the driver does not move it: only the
screen-wide region follows the window, because nothing tells the buffer whether a pane was meant to
be a bottom edge, a fixed sidebar, or a third of the width. So a region an application placed keeps
its rectangle until the application assigns a new one.
Register the layout once instead of repeating it at every Events::Resize:
status = terminal.cursor TermBuf::Rect.new(0, rows - 1, columns, 1)
log = terminal.cursor TermBuf::Rect.new(0, 0, columns, rows - 1), scrollback: 500
terminal.on_resize do |size|
status.region.bounds = TermBuf::Rect.new 0, size.rows - 1, size.columns, 1
log.region.bounds = TermBuf::Rect.new 0, 0, size.columns, size.rows - 1
end
Handlers run in the order registered, on the fibre that owns the buffer, after the grids have been
resized and before Events::Resize reaches the application — so whatever it draws in response
already sees panes in their new places. That fibre is the one servicing commands, so a handler must
not call back into #batch, #paint, or #sync. Anything it raises arrives as an
Events::Failure, and the remaining handlers still run. #forget_resize takes a handler back.
SIGWINCH is what normally starts this. An application driving a pty whose size it sets itself
calls Terminal#window_resized in the signal's place, and #resize_interval — 50 ms by default —
is how often a burst of them is acted on.
There is no layout engine here on purpose. Anchors, splits, and constraint solving belong a layer up; this shard gives that layer the one hook it needs.
The terminal's own cursor
Hidden by default, which is what a full-screen application wants. Point it at a cursor and every paint puts it back there once the cells have been drawn:
terminal.hardware_cursor = input_cursor # shows it, and follows it
terminal.hide_cursor
A frame that changes no cells is still sent when this has moved. What the terminal draws it as is
#cursor_shape= and #cursor_blink=, and the window title is #title=; #close gives all three
back.
Painting
#paint sends the diff and waits for it to reach the terminal. #paint! rewrites every cell,
for after a suspend or anything else that leaves the screen in a state the buffer cannot know
about. #paint_async does not wait.
A scheduler is available for applications that would rather not decide:
terminal.start_frame_scheduler fps: 30
It coalesces whatever was drawn between frames, and a paint with nothing to do costs nothing.
#last_paint_bytes and #total_paint_bytes report what frames are costing.
Events
Terminal#events is a Channel(Event). Everything the terminal has to say arrives on it in the
order it happened. Event is a module rather than a union, so this is a when and not an in:
case event = terminal.events.receive
when TermBuf::Events::Key then handle event.key
when TermBuf::Events::Mouse then click event.x, event.y, event.button
when TermBuf::Events::Paste then insert event.text
when TermBuf::Events::Pasting then show_notice event.bytes
when TermBuf::Events::Resize then redraw event.size
when TermBuf::Events::Response then handle_reply String.new(event.bytes)
when TermBuf::Events::Timer then tick event.nonce
when TermBuf::Events::Signal then handle_signal event.signal
when TermBuf::Events::Warning then log event.message
when TermBuf::Events::Failure then raise event.error
when TermBuf::Events::Closed then return
end
Warning carries anything detection wanted to report — these never go to stderr, since the screen
is taken over by then.
The input side — the reader, the decoder, keys, mouse reports, timers, signals, and every event
above except Resize — is the
termbuf-input shard, which termbuf depends on and
which depends on nothing outside the standard library. A program that only wants to read a keyboard
can use it without a screen buffer attached. TermBuf::Key is TermBuf::Input::Key,
TermBuf::Events::Key is TermBuf::Input::Events::Key, and so on: the short spellings are aliases
and stay. Resize is the one event that did not move, because it carries a ScreenSize.
Keys
Key is a value: which key, which modifiers, and for an ordinary character which character.
ctrl_c = TermBuf::Key.character('c', TermBuf::Modifiers::Ctrl)
case
when key.is?('q') then quit # q, nothing held
when key.is?(TermBuf::Key::Name::PageDown) then scroll 1 # whatever is held
when key == ctrl_c then interrupt # exactly Ctrl+C
when key.character? then insert key.char
end
key.to_s # => "Ctrl+C", "Alt+Up", "Shift+F5", "a", "Space"
Events::Key#bytes carries what the terminal actually sent, for the sequences the decoder could
not name — those arrive as Key::Name::Unknown rather than being dropped.
Modifiers are only as good as the terminal's encoding. Ctrl with a letter arrives as one control
byte, so Ctrl+I and Tab are the same key press and nothing downstream can separate them. The
decoder reports the name people press.
Paste
Pasted text arrives as Events::Paste rather than as a burst of key presses, so a paste does not
run every key binding over whatever was on the clipboard. Bracketed paste is enabled at startup when
the terminal has it, and Events::Paste never arrives when it does not.
While a paste is open, every byte the terminal sends is paste content by definition — including the one that would have quit the application. That makes a paste the terminal opens and never closes a lost session rather than a slow paste, so there are deadlines:
| Property | Default | Reset by | Meaning |
|---|---|---|---|
escape_timeout | 25 ms | — | A lone ESC is the escape key, not the start of an arrow |
paste_notice | 300 ms | the paste opening | A paste running this long is worth mentioning |
paste_progress | 100 ms | each notice sent | How often the byte count is worth resending |
paste_stall | 3 s | every byte of the paste | No more of it is coming |
paste_stall is reset per byte rather than measured from the opening marker, since the question is
whether the paste is slow or stopped and the only evidence either way is whether anything is still
arriving. A paste ended that way is still delivered, with Events::Paste#complete false.
Events::Pasting carries the byte count so an application can say something rather than look hung;
Events::Paste is the signal to take that notice down. Drawing it is the application's job, because
the buffer belongs to the application — see page 7 of examples/validate.cr for one.
Mouse
Nothing turns mouse reporting on uninvited: a terminal reporting the mouse is one that no longer
lets the person select and copy text with it, which is a trade only the application can weigh. The
application asks for it, and Terminal#close gives it back:
terminal.enable TermBuf::Tty::MOUSE_SGR # press, release, motion with a button held
terminal.enable TermBuf::Tty::MOUSE_SGR_ANY # the above, and motion with no button held
terminal.enable TermBuf::Tty::MOUSE_SGR_CLICKS # press and release only
MOUSE_SGR is mode 1002 in the SGR encoding — button-event tracking, which reports motion while a
button is held and is what a drag needs. MOUSE_SGR_ANY is mode 1003, any-event tracking, which
reports motion with no button held as well; that is what a hover needs, and it costs a report for
every cell the pointer travels over. MOUSE_SGR_CLICKS is mode 1000, the X10-compatible tracking
that reports the press and the release and nothing in between; it is kept to be measured rather
than to be used, since a widget that takes the pointer on press never learns where it went. The
terminal has one mouse tracking mode and not three, so asking for any of them replaces whichever
was asked for last rather than adding to it.
What a terminal actually does under 1000 and 1002 is worth checking rather than assuming: some
report motion with no button held under both. A motion report is therefore not evidence that a
button is down — read Events::Mouse#button. scripts/caps_check.cr measures it per terminal and
measurements/CAPS.md records the readings.
A report arrives as Events::Mouse, with its coordinates already converted to 0-based buffer
cells.
Hit testing
Terminal#hit says what is in a cell, which is what a mouse report wants next. The lead of a wide
cluster is what comes back, so a click on the right half of a CJK glyph names the glyph rather than
half of one:
terminal.hit event.x, event.y do |hit|
next unless hit
status.puts "#{hit.text} at #{hit.x}, #{hit.y}"
end
The read goes through #sync, so the block runs on the fibre that owns the buffer — keep it short,
since the next frame is waiting. Buffer#hit is the same thing without a terminal, and View#local
turns a buffer cell back into a panel's own coordinates.
Timers
#after asks for an Events::Timer and returns the nonce that will name it. The tick travels the
event channel, so it is ordered against everything said before it rather than racing it, and it
arrives no sooner than the span asked for:
nonce = terminal.after 250.milliseconds
terminal.cancel nonce # nothing is delivered, even after it has gone off
Signals
SIGTERM, SIGINT and SIGHUP give the terminal back and re-raise themselves; SIGWINCH becomes
an Events::Resize; SIGTSTP and SIGCONT hand the screen over and take it back. An application
that wants a first interrupt to ask rather than kill says so:
terminal.signals.mode Signal::INT, TermBuf::Input::Signals::Mode::WarnThenExit
and then draws something on the Events::Signal that arrives, calling Input::Signals#reset_count
if the person decides to stay.
Stages
Every event walks a chain of Input::Stage before the application sees it. A stage passes an event
on, replaces it, consumes it, or emits several. Two are installed at startup: :resize, which
consumes the SIGWINCH signal and answers it with a resize, and :signals, which passes everything
through and is there to be replaced.
alias Event = TermBuf::Event
terminal.stages.replace terminal.stages.map { |stage|
next stage unless stage.name == :signals
TermBuf::Input::Stage.new :signals, ->(event : Event, emit : Proc(Event, Nil)) do
emit.call event unless event.is_a? TermBuf::Events::Signal
end
}
#stages is an Input::Stages: #push adds a stage, #replace reorders or removes, and both are
safe from any fibre while events are flowing.
Styles and colour
Style is a value. The builders return copies:
alias Style = TermBuf::Style
alias Color = TermBuf::Color
Style::DEFAULT.bold.italic
.fg(Color.rgb(0x66CCFF))
.bg(Color.indexed(236))
.underlined(TermBuf::Underline::Curly, Color.indexed(1))
Colours are stored as given and reduced at encode time, so raising the capability mask and
repainting yields better colour with nothing lost along the way. A terminal without
ExtendedUnderline gets a plain underline; one without Italic gets none.
Capabilities
What the terminal can do is settled once at startup, each stage overriding the one before: nothing
at all, then TERM and friends, then the terminal's own answers to a batch of queries, then
TERMBUF_CAPS.
terminal.capabilities.includes? TermBuf::Capability::KittyGraphics
TERMBUF_CAPS is the escape hatch, and is deliberately not application specific. Names are
Capability members in snake case:
TERMBUF_CAPS=+truecolor,-kitty_graphics
TERMBUF_CAPS=none,+color16,+bold # start from nothing
TERMBUF_CAPS=all # start from everything
A name that is not recognised becomes an Events::Warning rather than an error.
Detection is pessimistic by design: a terminal nobody recognises gets plain text, because a screen
full of escape sequences is worse than no escape sequences. Pass probe: false to Terminal.open
to skip the queries.
Passthrough and terminal replies
Drawing#passthrough sends bytes to the terminal untouched, once the current frame is out. A reply
from the terminal and a keystroke are not distinguishable by looking at them — an arrow key sends
ESC [ A, and so could a terminal. What separates them is that the application asked for one.
Register the shape of the answer before sending the query:
pattern = terminal.expect_response "\e[?", "$y"
terminal.passthrough "\e[?2026$p"
# the reply arrives as Events::Response; everything else is a key
terminal.forget_response pattern
With nothing registered, every escape sequence arriving from the terminal is treated as input.
Unicode
TermBuf::Unicode.string_width "漢字" # => 4
TermBuf::Unicode.graphemes "🇺🇸!" # => ["🇺🇸", "!"]
Width tables are generated from the UCD by scripts/gen_unicode.cr and committed, so building the
shard needs no network access.
Fitting text to a column
Column layout is arithmetic on cells, so it has to run against the same measurement the buffer uses.
Four helpers do it, each taking an optional WidthPolicy and each walking whole grapheme clusters —
a double-width cluster that would half-cross the edge is dropped rather than split, so a result can
come back a cell short. fit pads that cell back; the others leave it.
TermBuf::Unicode.truncate "hello world", 5 # => "hello"
TermBuf::Unicode.ellipsize "hello world", 8 # => "hello w…" marker measured too
TermBuf::Unicode.fit "42", 6, :right # => " 42" exactly 6 cells
TermBuf::Unicode.fit "name", 10, :left, '.' # => "name......"
TermBuf::Unicode.window "a long filename", 4, 6 # => "ng fil" a marquee step
fit is the one a table row wants: every column comes back exactly the width it was given, whatever
is in it.
Measured widths
How many cells a cluster occupies is a property of the terminal, not of Unicode. UAX #29 says where a cluster ends; it says nothing about what a terminal does with four emoji joined by zero width joiners, and terminals disagree. Measured on one machine:
| cluster | ghostty | tmux | Terminal.app |
|---|---|---|---|
☺️ U+263A U+FE0F | 2 | 2 | 1 |
👨👩👧👦 four faces, ZWJ | 2 | 2 | 11 |
क्षि conjunct plus vowel sign | 2 | 2 | 3 |
நி Tamil na plus vowel sign | 2 | 1 | 2 |
A terminal advancing eleven columns for a cluster the buffer thinks is two has every later cell on
that row nine columns out of place. So the shard asks. After the alternate screen is entered and
before anything is drawn on it, a batch of discriminating samples goes out, each followed by
ESC [ 6 n, and the columns that come back are the terminal's own measurements. One round trip,
invisible.
terminal.widths # => WidthPolicy(ambiguous=1 -emoji_presentation … )
terminal.width_readings # what was asked and what came back
Buffer#policy is what writes use, and Terminal#cursor hands the same one to every cursor it
makes. A measurement no rule explains — Terminal.app's eleven — becomes an Events::Warning naming
the cluster rather than being modelled wrong.
What a terminal gets wrong
Capability says what a terminal can do. Quirk says what it gets wrong, and the two are kept
apart: a capability turned off means don't ask, where a quirk means ask and then cope with the
answer. Quirks are named for the behaviour, so mapping another terminal onto one is a row in a
table once somebody measures it.
Quirk::PerCodePointColumns is the one there is. Terminal.app counts a cluster's columns by adding
up its code points — 👨👩👧👦 is four emoji of two columns and three joiners of one, so it owns
eleven. That count is what CPR reports and what CUP addresses; forcing a character to column
three of such a row tears the cluster into 👨X👪. It draws the composed glyph anyway, two columns
wide, and slides the rest of the row left to sit flush against it. So a row holding one of these has
everything after it out of step with every other row, and nothing here can lay it out correctly.
What the shard does instead is notice and say so, once, the first time it draws a cluster the terminal will misplace:
termbuf: this terminal counts a grapheme cluster's columns by adding up its code points, so
"👨👩👧👦" takes 11 columns where it is drawn in 2. Everything after it on that row sits 9
columns to the right of where it was put, and the last 9 columns of the row cannot be reached.
The last part is measured, not inferred: on a 155 column window, a CUP to column 155 on such a row
lands on screen column 146, and asking for 164 clamps at the margin and lands there too.
That goes to stderr with the alternate screen handed back for as long as it takes, and to
Events::Warning as well. Terminal#warn_composed_drift = false keeps the event and leaves the
screen alone, for an application that renders its own warnings.
The check costs one predictable branch per run of text on any terminal without the quirk, and stops looking after the first one it finds. An application certain it will never draw such a cluster can switch it off outright:
TermBuf::Terminal.open detect_composed_drift: false
A terminal with this quirk also has its width probe answers ignored: it reports the columns it counts rather than the ones it paints, so taking those as rules would put text under the glyph.
TERMBUF_QUIRKS=-per_code_point_columns # same shape as TERMBUF_CAPS
TERMBUF_QUIRKS=none
TERMBUF_WIDTHS has the last word:
TERMBUF_WIDTHS=off # skip the measurement, keep the tables
TERMBUF_WIDTHS=+ambiguous_wide # a CJK terminal, said rather than measured
TERMBUF_WIDTHS=-joined_emoji,-emoji_presentation
The core layer
Buffer and Sink work without a device attached, which is how they are tested:
buffer = TermBuf::Buffer.new 80, 24
sink = TermBuf::Sink.new buffer, TermBuf::Capabilities::XTERM
buffer.write 0, 0, "hello", TermBuf::Style::DEFAULT.bold
sink.encoder.encode sink.paint # => "\e[?7l\e[1;1H\e[0;1mhello\e[?7h"
sink.commit
The buffer holds the cells. A Sink holds one output of them: the grid that terminal is believed
to be showing, the damage it has yet to paint, and the Painter and Encoder that turn the
difference into bytes. Attach a second sink and the same buffer drives a second display, painted
whenever that display asks and under whatever that terminal turned out to be able to do:
web = TermBuf::Sink.new buffer, TermBuf::Capabilities::MODERN
bytes = web.encoder.encode web.paint
web.commit
A sink attached to a buffer that already holds content starts knowing nothing about the screen, so
call Sink#invalidate before its first paint. Detach it with Sink#detach when the display goes.
Hyperlinks, images, and the terminal's colours
Four things a modern terminal will do that a cell grid cannot express, each behind the capability that says whether asking is safe.
Hyperlinks
docs = terminal.link "https://example.com", "docs" # the id groups two ranges as one link
terminal.write 0, 0, "example", TermBuf::Style::DEFAULT.linked(docs)
A link is not an SGR attribute — SGR 0 does not close one — so the encoder tracks it apart from
the rest of the style and emits OSC 8 when it changes from one run to the next. Without
Capability::Osc8Links it is stripped from the style entirely, so two runs differing only by a link
become one run: setting links costs nothing on a terminal that has none.
The terminal's own colours
terminal.colors.saved do
terminal.colors.background = TermBuf::Color.rgb(20, 20, 30)
terminal.colors[3] = TermBuf::Color.rgb(255, 128, 0)
end
All of this needs Capability::KittyColorStack, the OSC 4 and OSC 10 setters included. The stack is
what makes a change reversible, and without somewhere to put the old values there is no way to give
them back. Terminal#close pops whatever is still pushed, so an application that forgets, or that
stops on a signal, still gives the terminal back the colours it was found with.
Fewer terminals have it than claim to, and there is no query to settle it: ghostty parses
XTPUSHCOLORS and XTPOPCOLORS and does nothing with them, so it is denied the capability by name.
Check Terminal#colors.available? if it matters which way a terminal went.
The clipboard
terminal.clipboard.copy "the selected text"
terminal.clipboard.copy "a middle-click paste", :primary
OSC 52, behind Capability::Osc52Clipboard. The terminal is the only thing in the picture with a
connection to the window system, so a program on the far end of an ssh session sets the clipboard of
the machine the human is sitting at. Copies go out in order with the frames around them, the way a
colour change does.
Nothing comes back: the terminal answers nothing on a write, so a refusal and a success look the same from here. Nor is there anything to chunk into — OSC 52 carries one payload, and the limit on it is the terminal's rather than the protocol's — so a caller moving more than a few kilobytes should not expect it to arrive, and cannot find out that it did not.
Images
sparkline = TermBuf::Image.rgb(pixels, 64, 16)
id = terminal.images.add sparkline
terminal.images.place id, TermBuf::Rect.new(2, 4, 16, 2)
terminal.images.place id, TermBuf::Rect.new(2, 8, 16, 2), z: -1 # under the text
Images are not cells. They are drawn over the screen after each frame rather than into the buffer,
so an application that writes text where one sits gets both. z decides which of them is on top:
zero and above covers the text, negative sits beneath it so the glyphs stay readable and the picture
shows through where the cells are blank. Among placements, higher covers lower. Pixels travel once
however many placements follow; the transport — a temp file or base64 down the escape sequence — is
chosen by a probe at startup. Placements that no longer fit are dropped on a resize, everything is
sent again on a forced repaint, and the pictures come down when the terminal is given back.
Widgets
The editing widgets live in termbuf-widgets:
an input field with history and completion, the line buffer and keymapped editor underneath it,
borders, layout, and focus. They draw through the Drawing API in this shard and cost the same
diff as anything else on screen, so nothing about them needs to be here. Add that shard alongside
this one and require "termbuf-widgets".
Examples
crystal run examples/clock.cr # drawing, input, and resize in one small program
crystal run examples/validate.cr # fourteen pages checking a terminal against the shard
validate.cr is worth running in any terminal you intend to support: it reports what detection
concluded, writes every cell of the screen including the bottom-right corner, checks the terminal's
idea of grapheme widths against the tables, shows what a frame costs in bytes, decodes whatever you
type, reports focus changes and mouse tracking under each of the three modes, renames the window,
draws clipped panels and a label across a two-colour bar, and gives you a pane to type into with the
terminal's own cursor following along.
Development
crystal spec # includes the UAX #29 conformance suite
ameba # lint
crystal tool format --check
crystal docs # API documentation into ./docs
Specs run the encoder's output back through a model terminal in spec/support and compare the
result against the buffer, over several capability masks and a mixed alphabet of ASCII, CJK,
combining marks, emoji sequences, and Indic conjuncts. A change that breaks the round trip fails
whether or not anyone wrote a spec for it.
To regenerate the Unicode tables against a newer UCD:
crystal run scripts/gen_unicode.cr
Stability
The shard is not at 1.0 yet, and this is what 1.0 will freeze — written down ahead of the tag so
the shape can be argued with while changing it is still cheap. Every public type says the same
thing in its API documentation, as a Stability: line at the top of its doc comment.
Tier 1: stable
Changes only in a major release. Something here may gain a keyword argument that defaults; nothing here is renamed, removed, or given a new required argument without the major version going up.
- The terminal.
Terminaland its methods: taking the terminal over and giving it back (open,start,close,restore); drawing and painting (batch,paint,paint!,paint_async,sync); what the terminal turned out to be (capabilities,quirks,widths,size); the cursor (cursor,hardware_cursor=,hide_cursor); input (events,input,stages,signals,after,cancel,expect_response,forget_response, and the decoder timing properties); resizing (on_resize,forget_resize,window_resized,resize_interval); the extras (link,images,colors,clipboard,enable,disable); the window (title,title=,cursor_shape,cursor_shape=,cursor_blink?,cursor_blink=); hit testing (hit); the frame scheduler; the paint byte counters; and the two drawing switchesclear_overhang=andwarn_composed_drift=. - Drawing. The
Drawingmodule and everything that mixes it in —Terminal,Batcher,BufferSurface,View— so a method written against one surface keeps working against all of them. - The buffer.
Buffer,Sink,Cursor,CursorIO,Region,Rect. - Appearance.
Style,Blend,Gradient,Color,Attributes,Underline,Link,LinkId. - What a terminal is.
Capability,Capabilities,Quirk,ScreenSize,CursorShape, andTty::Modewith the mode constants beside it —BRACKETED_PASTE,FOCUS_EVENTS,MOUSE_SGR,MOUSE_SGR_ANY,MOUSE_SGR_CLICKS,KITTY_KEYBOARD. - Talking to the terminal itself.
ColorStack,Clipboard,ImageStore,Image,Placement. - Unicode.
Unicode.string_width,.each_grapheme,.graphemes,.truncate,.ellipsize,.fit,.window, andWidthPolicy. - Events. Everything in
TermBuf::Events,Events::Resizeincluded.
Tier 2: internal
May change in a minor release. These are how the shard is built rather than how it is used; they are documented because reading them explains the thing, not because an application should be holding one. Anything reached only through tier 1 is free to move.
- The paint pipeline.
Grid,Cell,Damage,Painter,Encoder,OpsandOp,ScrollHint,StyleTable,ClusterPool,LinkTable,SgrScanner. - Detection.
Prober,EnvironmentDetector,CapabilityResolver,CapabilityOverrides,QuirkOverrides,WidthProbe,SizeDetector. What they conclude is tier 1; how they conclude it is not. - The device and the driver. The
Ttyclass — itsModerecord and mode constants are tier 1, the class around them is not — along withMeter,CommandsandCommand.
Constructors take keywords
Terminal.new takes the device positionally and everything else by name; Terminal.open takes
the input IO positionally and everything else by name. Ten mostly-defaulted arguments in a row
is exactly the shape that reads wrong positionally, and a keyword is the one thing that can be
reordered later without breaking a caller.
TermBuf::Terminal.open probe: false do |terminal|
# ...
end
TermBuf::Terminal.new tty, capabilities: caps, size: TermBuf::ScreenSize.new(80, 24)
Capability is append-only
A flags enum numbers its members by position, so inserting or reordering one silently renumbers
every member after it. New capabilities go on the end and a retired one keeps its place, which
makes appending a minor-release change rather than a breaking one. The names, the numbering, and
the snake case spellings TERMBUF_CAPS accepts are the part that is frozen.
Two members are detected but in no preset: GraphemeClusters, which the probe asks about with
DECRQM, and Osc52Clipboard, which comes from a table of terminals that document the write. A
mask built from Capabilities::MODERN rather than from detection carries neither.
The environment variables are interface
TERMBUF_CAPS, TERMBUF_QUIRKS, and TERMBUF_WIDTHS are as stable as the API, and deliberately
not application specific: a person who knows their terminal better than the probe does should be
able to say so once, in their shell profile, for every program built on this shard. Their syntax
and their names are tier 1.
Input arrives through aliases
The input side is the termbuf-input shard, and its
types reach an application here as TermBuf::Key, TermBuf::Modifiers, TermBuf::Decoder,
TermBuf::Event, and the TermBuf::Events namespace. Those aliases are tier 1 in this shard: an
application spelling them the short way is insulated from where the definitions live, which is the
whole reason they exist. Events::Resize sits in the same namespace and is defined here, because
it carries a ScreenSize.
Status
The core, the driver, input, cursors, hyperlinks, images, the window title, the cursor's own shape, and the terminal's own colours are in and specified. See CHANGELOG.md for what has landed and PLAN.md for what is coming.
Contributing
- Fork it (https://github.com/plambert/termbuf.cr/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Paul M. Lambert — creator and maintainer
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This release
- Version
0.2.0- Tagged
- Sep 2, 2026
- Commit
626e056cf62e- Indexed
- not yet
Dependents
Repository
github.com/plambert/termbuf.cr
Metadata
- Created
- Aug 26, 2026
- Updated
- Sep 20, 2026
- Synced
- Sep 20, 2026
- Versions
- 10