RubyTUI
RubyTUI is a terminal user interface library written in pure Ruby with no runtime dependencies. It provides immediate-mode rendering with double buffering, constraint-based layouts, built-in widgets, keyboard and mouse input, an optional Model-View-Update application framework, and inline images through the Kitty graphics protocol.
It is suited both to full-screen interactive applications and to command-line tools that only need styled, one-shot output such as tables, boxes and progress bars.
Installation
gem install rubytui
Or add it to your Gemfile:
gem "rubytui"
Quick Start
require "rubytui"
RubyTUI.run do |terminal, reader|
loop do
terminal.draw do |frame|
style = RubyTUI::Style.new.fg(RubyTUI::Color::GREEN).bold
frame.buffer.set_string(0, 0, "Hello, RubyTUI! Press q to quit.", style)
end
event = reader.poll(timeout_ms: 50)
break if event.is_a?(RubyTUI::KeyEvent) && event.char == "q"
end
end
RubyTUI.run puts the terminal into raw mode, hides the cursor and switches to the alternate screen, then yields a RubyTUI::Terminal and a RubyTUI::Input::Reader. The terminal is restored when the block returns or raises.
Core Concepts
Rendering model
RubyTUI redraws the whole interface on every frame. Terminal#draw yields a RubyTUI::Frame; you render widgets into it, and when the block returns the terminal compares the new frame with the previous one and writes only the cells that changed.
frame.areais aRubyTUI::Rectcovering the whole screen.frame.render_widget(widget, rect)renders a stateless widget.frame.render_stateful_widget(widget, rect, state)renders a stateful widget.frame.bufferis the underlyingRubyTUI::Buffer, a grid of cells (a character plus a style). Write to it directly withset_string(x, y, text, style)andset_style(rect, style).frame.set_cursor_position(x, y)shows the terminal cursor at a position after the frame is drawn; otherwise the cursor stays hidden.
The terminal handles SIGWINCH (replacing any existing handler for it): after a resize, the next draw uses the new size and clears the screen. terminal.force_redraw! makes the next draw repaint everything.
RubyTUI.run accepts mouse: (default false), alternate_screen: (default true; pass false to keep the output in the scrollback after exit), input: and output: (default $stdin and $stdout; for example, pass input: File.open("/dev/tty") when standard input is redirected). For manual control use RubyTUI.init, which returns [terminal, reader], and RubyTUI.restore(terminal), passing the same alternate_screen: value to both.
Widgets
A widget is any object that responds to render(area, buf). A stateful widget responds to render(area, buf, state), where the state object belongs to your application and persists across frames (for example the selected row of a list). Most widgets are cheap to construct and are typically built inside draw from your current data; AsyncImage is the exception and must be kept across frames.
Styles and colors
RubyTUI::Style is immutable; each builder method returns a new style.
base = RubyTUI::Style.new.fg(RubyTUI::Color::WHITE).bg(RubyTUI::Color.indexed(236))
warning = base.patch(RubyTUI::Style.new.fg(RubyTUI::Color.rgb(255, 170, 0)).bold)
warning.bold? # => true
warning.italic? # => false
- Colors: the 16 named constants (
RubyTUI::Color::RED,RubyTUI::Color::BRIGHT_BLUE, ...;GRAYand theLIGHT_*names are aliases for the bright variants),RubyTUI::Color::RESETfor the terminal default,RubyTUI::Color.indexed(0..255)andRubyTUI::Color.rgb(r, g, b). - Modifiers:
bold,dim,italic,underlined,reversed,strikethrough, each with a query method (bold?, ...).add_modifierandremove_modifiertakeRubyTUI::Modifierconstants. a.patch(b)layersbovera: colors set inbwin, and modifiers are combined.
Styled text is built from RubyTUI::Span (text plus style), RubyTUI::Line (a sequence of spans) and RubyTUI::Text (a sequence of lines; a string is split on newlines):
line = RubyTUI::Line.new([
RubyTUI::Span.new("Status: ", RubyTUI::Style.new.bold),
RubyTUI::Span.new("OK", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN))
])
paragraph = RubyTUI::Widgets::Paragraph.new(text: line)
Colors are sent to the terminal exactly as specified. To support terminals with fewer colors, use RubyTUI::ColorMode to detect the capability and convert colors yourself:
mode = RubyTUI::ColorMode.detect # => :truecolor, :color256 or :basic16
accent = RubyTUI::ColorMode.downgrade(RubyTUI::Color.rgb(255, 128, 0), mode)
detect inspects COLORTERM, TERM and TERM_PROGRAM and caches the result; RubyTUI::ColorMode.force(mode) overrides it. downgrade(color, mode) uses the detected mode when mode is omitted. For :color256 it maps RGB colors to the nearest 256-color index; for :basic16 it maps RGB and indexed colors to a named color; other colors are returned unchanged.
Layout
RubyTUI::Layout::Layout splits a Rect into rows or columns according to a list of constraints and returns one Rect per constraint.
area = RubyTUI::Rect.new(0, 0, 80, 24)
header, body, = RubyTUI::Layout::Layout.vertical([
RubyTUI::Constraint.length(3),
RubyTUI::Constraint.fill(1),
RubyTUI::Constraint.length(1)
]).split(area)
, main = RubyTUI::Layout::Layout.horizontal(
[RubyTUI::Constraint.percentage(25), RubyTUI::Constraint.fill(1)],
spacing: 1
).split(body)
| Constraint | Size |
|---|---|
length(n) |
n cells |
percentage(n) |
n percent of the available space |
ratio(num, den) |
num / den of the available space |
fill(weight = 1) |
A share of the remaining space, proportional to weight |
min(n) |
At least n cells, plus a weight-1 share of the remaining space |
max(n) |
A weight-1 share of the remaining space, at most n cells |
length, percentage and ratio are allocated first, in order; the remaining space is then divided among fill, min and max. spacing: inserts a gap between segments. When the constraints leave space unused, flex: decides where it goes: :start and :stretch (the default) leave it at the end, and :end, :center, :space_between and :space_around position the segments accordingly. The same values are available as constants in RubyTUI::Layout::Flex.
RubyTUI::Rect accepts positional (Rect.new(x, y, width, height), Rect[x, y, width, height]) or keyword (Rect.new(x:, y:, width:, height:)) arguments, and provides right, bottom, empty?, inner(margin) and inner_rect(top:, right:, bottom:, left:). Layouts nest by splitting the rects returned by another layout.
Widgets
All built-in widgets live in RubyTUI::Widgets. Most accept a block: argument (a Block) that draws a border and title around them.
| Widget | Kind | Description |
|---|---|---|
Block |
stateless | Borders on all or selected sides, title aligned left, center or right, padding, background style. block.inner(area) returns the area inside the borders and padding. |
Paragraph |
stateless | Multi-line styled text with word, character or no wrapping, aligned left, center or right. |
List + ListState |
stateful | Scrollable, selectable list with a highlight symbol and style; scrolls to keep the selection visible. |
Table + TableState |
stateful | Rows with an optional header and separator, row selection, and fixed or equally divided column widths. |
Gauge |
stateless | Horizontal progress bar with eighth-of-a-cell precision and a centered label (the percentage by default). |
Sparkline |
stateless | Compact bar chart, one column per value, showing the most recent values that fit. |
Scrollbar |
stateless | Vertical or horizontal scroll indicator with a proportional thumb and optional arrows. |
Tabs |
stateless | Horizontal tab bar with active and inactive styles and a configurable divider. |
Canvas |
stateless | Points, lines and text labels in data coordinates, drawn with Braille characters (2x4 dots per cell). |
Chart + Dataset |
stateless | Line chart of one or more datasets with optional labeled axes and fixed or data-derived bounds. Dataset names are not displayed. |
Popup |
stateless | Overlay that clears its area and draws a block and an optional shadow; sized in cells or as a percentage of the parent, centered or placed at a fixed position. popup.inner(area) returns its content area. |
InputField + InputState |
stateful | Single-line text input with a cursor, horizontal scrolling and a placeholder. |
Image |
stateless | PNG or raw RGBA image drawn with the Kitty graphics protocol. See Images. |
AsyncImage |
stateless | Loads image data on a background thread and draws a placeholder until it is ready. |
Block borders are chosen with borders: (:all, the default, :none, a single side such as :top, or an array of sides) and drawn with border_set:: RubyTUI::Symbols::ROUNDED (the default), LIGHT, DOUBLE, THICK or PLAIN (ASCII +, -, |).
Canvas keeps every shape added to it, so create a new canvas for each frame and give it both x_bounds: and y_bounds::
canvas = RubyTUI::Widgets::Canvas.new(x_bounds: [0, 10], y_bounds: [0, 10])
canvas.line(0, 0, 10, 10).point(5, 2).label(1, 9, "peak")
The following example renders several widgets once to standard output:
require "rubytui"
RubyTUI.render_once(width: 60, height: 12) do |frame|
tabs_area, middle, bottom = RubyTUI::Layout::Layout.vertical([
RubyTUI::Constraint.length(1),
RubyTUI::Constraint.fill(1),
RubyTUI::Constraint.length(3)
]).split(frame.area)
left, right = RubyTUI::Layout::Layout.horizontal(
[RubyTUI::Constraint.percentage(50), RubyTUI::Constraint.fill(1)],
spacing: 1
).split(middle)
tabs = RubyTUI::Widgets::Tabs.new(titles: ["Overview", "Logs", "Help"], selected: 0)
frame.(tabs, tabs_area)
table = RubyTUI::Widgets::Table.new(
header: ["Host", "Status"],
rows: [["web-1", "up"], ["db-1", "down"]],
block: RubyTUI::Widgets::Block.new(title: " Hosts ")
)
frame.(table, left, RubyTUI::Widgets::TableState.new)
sparkline = RubyTUI::Widgets::Sparkline.new(
data: [3, 5, 2, 8, 6, 9, 4],
block: RubyTUI::Widgets::Block.new(title: " Load ")
)
frame.(sparkline, right)
gauge = RubyTUI::Widgets::Gauge.new(ratio: 0.62, block: RubyTUI::Widgets::Block.new(title: " Disk "))
frame.(gauge, bottom)
end
Input
reader.poll(timeout_ms: 100) waits up to the given time and returns a RubyTUI::KeyEvent, a RubyTUI::MouseEvent, or nil when no recognized input arrived. reader.read blocks until an event arrives.
Keyboard
A KeyEvent has a code, a char and modifier predicates (ctrl?, alt?, shift?).
codeis one of the symbols inRubyTUI::Input::Key:CHAR,ENTER,ESCAPE,BACKSPACE,DELETE,TAB,BACKTAB(Shift+Tab),UP,DOWN,LEFT,RIGHT,HOME,END_KEY,PAGE_UP,PAGE_DOWN,INSERTandF1toF12.- For
CHARevents,charholds the character. Ctrl+letter arrives as the lowercase letter withctrl?true, except Ctrl+H, Ctrl+I and Ctrl+M, which the terminal sends as Backspace, Tab and Enter. Alt+key arrives withalt?true. - Arrow keys, Home and End also report Shift, Alt and Ctrl modifiers.
- A lone Escape key press is reported after a 50 ms wait that separates it from escape sequences.
- In raw mode, Ctrl+C does not send
SIGINT; it arrives as a key event, so handle it if your application should quit on it.
require "rubytui"
items = ["Apples", "Bananas", "Cherries"]
state = RubyTUI::Widgets::ListState.new
RubyTUI.run do |terminal, reader|
loop do
terminal.draw do |frame|
body, = RubyTUI::Layout::Layout.vertical([
RubyTUI::Constraint.fill(1),
RubyTUI::Constraint.length(1)
]).split(frame.area)
list = RubyTUI::Widgets::List.new(
items: items,
block: RubyTUI::Widgets::Block.new(title: " Fruit ")
)
frame.(list, body, state)
frame.buffer.set_string(.x, .y, "Up/Down to move, q or Ctrl+C to quit")
end
event = reader.poll(timeout_ms: 50)
next unless event.is_a?(RubyTUI::KeyEvent)
case event.code
when RubyTUI::Input::Key::UP then state.select_previous(items.length)
when RubyTUI::Input::Key::DOWN then state.select_next(items.length)
when RubyTUI::Input::Key::CHAR
break if event.char == "q" || (event.ctrl? && event.char == "c")
end
end
end
Text input
InputState#handle_key(event) applies a key event to the input and returns true if it consumed the event. Printable characters are inserted; Backspace, Delete, Left, Right, Home and End edit and move the cursor; Ctrl+A and Ctrl+E move to the start and end; Ctrl+K and Ctrl+U delete to the end and to the start; Ctrl+W deletes the previous word. Other keys, such as Enter, Tab and Escape, are left to your application.
state = RubyTUI::Widgets::InputState.new
field = RubyTUI::Widgets::InputField.new(
placeholder: "Search...",
block: RubyTUI::Widgets::Block.new(title: " Query ")
)
# Inside terminal.draw:
frame.(field, area, state)
# In the event loop:
if event.is_a?(RubyTUI::KeyEvent) && event.code == RubyTUI::Input::Key::ENTER
search(state.text)
elsif event
state.handle_key(event)
end
Mouse
RubyTUI.run(mouse: true) enables reporting of button presses, releases, drags and the scroll wheel. A MouseEvent has:
button::left,:middle,:right,:scroll_up,:scroll_downor:none(constantsRubyTUI::MouseEvent::LEFTand so on)action::press,:releaseor:drag(scroll-wheel events are reported as:press)x,y: zero-based cell coordinatesctrl?,alt?,shift?
require "rubytui"
RubyTUI.run(mouse: true) do |terminal, reader|
= "Click or scroll; press q to quit"
loop do
terminal.draw { |frame| frame.buffer.set_string(0, 0, ) }
case (event = reader.poll(timeout_ms: 50))
when RubyTUI::MouseEvent
= "#{event.action} #{event.} at #{event.x},#{event.y}"
when RubyTUI::KeyEvent
break if event.char == "q"
end
end
end
App Framework (Model-View-Update)
For structured applications, include RubyTUI::App and implement the hooks below. Every hook has a default, so implement only the ones you need.
| Hook | Purpose |
|---|---|
init |
Returns the initial model (any object; a Hash by default). |
handle_event(event) |
Converts an input event into a message, or returns nil to ignore it. |
handle_tick(model) |
Called after every poll, whether or not an event arrived; returns a message or nil. Use it for animations and periodic work. |
update(model, msg) |
Returns the next model. Call throw(:quit) or throw(:quit, value) to leave the loop. |
view(model, frame) |
Draws the model into the frame. |
on_start(terminal, reader) |
Called once after init, before the loop starts. |
on_stop(value) |
Called once after the loop ends and before the terminal is restored, with the value given to throw(:quit, value) (or nil). |
require "rubytui"
class Counter
include RubyTUI::App
def init
{ count: 0 }
end
def handle_event(event)
return unless event.is_a?(RubyTUI::KeyEvent)
case event.char
when "+" then :increment
when "-" then :decrement
when "q" then :quit
end
end
def update(model, msg)
case msg
when :increment then model.merge(count: model[:count] + 1)
when :decrement then model.merge(count: model[:count] - 1)
when :quit then throw(:quit, model)
else model
end
end
def view(model, frame)
frame.buffer.set_string(0, 0, "Count: #{model[:count]} (+/- to change, q to quit)")
end
end
Counter.run!
Counter.run! is shorthand for Counter.new.run!. run! accepts mouse: (default false), tick_ms: (the poll timeout per loop iteration, default 50), input: and output:, and sets up and restores the terminal like RubyTUI.run. Each iteration draws view, polls for an event, passes it through handle_event and update, then calls handle_tick.
One-Shot Output for CLI Tools
These functions render once and write ANSI-styled text to $stdout (or to output:), with no raw mode, alternate screen or event loop. The output stays in the terminal scrollback. When width: is omitted, the width of the output terminal is used (80 columns if it is not a terminal).
render_once
RubyTUI.render_once yields a Frame for any widgets and layouts, prints the result followed by a newline, and returns the frame. Pass height: explicitly; it defaults to the terminal height.
require "rubytui"
RubyTUI.render_once(width: 40, height: 3) do |frame|
block = RubyTUI::Widgets::Block.new(title: " Report ")
frame.(block, frame.area)
frame.buffer.set_string(2, 1, "All checks passed", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN))
end
Print helpers
require "rubytui"
RubyTUI.print_table(
header: ["Name", "Price", "Change"],
rows: [["AAPL", "150.00", "+2.3%"], ["GOOG", "2800", "-1.1%"]],
title: " Stocks ",
border: :rounded,
width: 40
)
RubyTUI.print_box(
title: " Summary ",
content: "Total: $1,234\nProfit: +5.2%",
border_style: RubyTUI::Style.new.fg(RubyTUI::Color::YELLOW),
width: 30
)
RubyTUI.print_gauge(ratio: 0.75, title: " Progress ", width: 40)
print_table(header:, rows:, title:, border:, width:, ...)also acceptswidths:,column_spacing:,header_style:,style:andborder_style:. The border is drawn only whentitle:is given.print_box(title:, content:, border:, width:, ...)takes a string (split on newlines) or an array of lines, and also acceptsalignment:,text_style:,style:andborder_style:.print_gauge(ratio:, label:, title:, width:, ...)also acceptsgauge_style:,border:andborder_style:. The border is drawn only whentitle:is given.
border: is one of :rounded (the default), :light, :double, :thick or :plain, or a RubyTUI::Symbols border set.
Printing a buffer
require "rubytui"
buf = RubyTUI::Buffer.new(40, 1)
buf.set_string(0, 0, "Hello!", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN).bold)
puts buf.to_ansi
Buffer.new(width, height) creates a buffer at the origin; Buffer.new(rect) uses an existing Rect. buf.to_ansi returns the content as a string with ANSI styles and one line per row; buf.print(io = $stdout) writes it.
Images
RubyTUI::Widgets::Image displays images in terminals that implement the Kitty graphics protocol, such as kitty, Ghostty and WezTerm. The specification lists other terminals that implement it.
Formats
- PNG is sent to the terminal as is; its dimensions are read from the file header.
- Raw 8-bit RGBA pixel data requires
format: :rgbatogether withpixel_width:andpixel_height:. - JPEG, WebP, GIF and BMP data is detected and raises
RubyTUI::UnsupportedImageFormat. Convert such images to PNG first, for example with ImageMagick:system("magick", "input.jpg", "output.png").
# From a PNG file
image = RubyTUI::Widgets::Image.new(path: "photo.png")
frame.(image, area)
# With a border and title
image = RubyTUI::Widgets::Image.new(
path: "photo.png",
block: RubyTUI::Widgets::Block.new(title: " Photo ")
)
# From raw RGBA pixels
image = RubyTUI::Widgets::Image.new(data: rgba_bytes, pixel_width: 800, pixel_height: 600, format: :rgba)
Sizing
fit: controls how the image is placed in its area:
:stretch(the default) scales the image to fill the area without preserving its aspect ratio.:containuses the largest centered part of the area that preserves the aspect ratio.:covercomputes a placement that would cover the area and limits it to the area. The image is not cropped, so the result is the same as:stretch; to show part of an image, usesrc_rect:.
Aspect-ratio calculations need the size of a terminal cell in pixels. Pass it as cell_size: [width, height]; the default is [9, 18]. terminal.cell_pixel_size asks the terminal for the real value, waits up to 100 ms for the reply, falls back to [9, 18], and caches the result. Call it once, before your event loop starts reading input.
cell_size = terminal.cell_pixel_size # e.g. [9, 18]
image = RubyTUI::Widgets::Image.new(path: "photo.png", fit: :contain, cell_size: cell_size)
# Or compute the placement yourself
dims = RubyTUI::Widgets::Image.png_dimensions(File.binread("photo.png")) # => [width, height] or nil
rect = RubyTUI::Widgets::Image.fit_area(area, dims[0], dims[1], cell_size: cell_size, mode: :contain)
Cropping and scrolling
src_rect: selects a rectangle of the source image, in pixels, to display. Changing its offset between frames scrolls through a large image:
image = RubyTUI::Widgets::Image.new(
path: "tall_image.png",
src_rect: RubyTUI::Rect.new(0, scroll_offset_px, image_width_px, visible_height_px)
)
Lifecycle
Images are tracked by position and content. When an image disappears, moves or changes between frames, the old image is deleted from the terminal and the following frame is repainted in full. terminal.clear_all_images! removes every image immediately.
RubyTUI::Widgets::Image.detect_format(data) returns :png, :jpeg, :webp, :gif or :bmp from the file signature, or :rgba when the data is not recognized.
AsyncImage
RubyTUI::Widgets::AsyncImage runs a loader on a background thread and renders the image once the loader returns PNG data. Create it once and keep it across frames.
image = RubyTUI::Widgets::AsyncImage.new(
loader: -> { File.binread("large.png") },
fit: :contain,
on_error: ->(error) { warn error. }
)
# Inside terminal.draw:
frame.(image, area)
image.loading? # => true while the loader runs
image.ready? # => true once the image can be drawn
image.error? # => true if the loader raised or returned unsupported data; see image.error
- While loading,
Loading...is drawn centered in the area; after a failure,error_placeholder:(default[Error]) is drawn. placeholder:accepts a widget that includesRubyTUI::Widget(all built-in widgets do), which is drawn instead until the image is ready, or a string, which is shown before loading starts whenauto_start: falseis given. Withauto_start: false, callstart_loadingto begin;cancelstops the loader thread.on_ready:is called on the loader thread when the loader has returned (not on a cache hit);on_error:receives the exception raised by the loader.cache:(a Hash shared between instances) andkey:reuse loaded data: when the cache already holds the key, the widget is ready immediately and the loader is not called.block:,style:andcell_size:work as forImage.
Custom Widgets
Any object with a render(area, buf) method can be rendered. Including RubyTUI::Widget is optional and documents the protocol.
require "rubytui"
class Badge
include RubyTUI::Widget
def initialize(label)
@label = label
end
def render(area, buf)
text = RubyTUI::Unicode.truncate_to_width(" #{@label} ", area.width)
style = RubyTUI::Style.new.fg(RubyTUI::Color::BLACK).bg(RubyTUI::Color::CYAN)
buf.set_string(area.x, area.y, text, style)
end
end
RubyTUI.render_once(width: 20, height: 1) do |frame|
frame.(Badge.new("beta"), frame.area)
end
Stateful widgets implement render(area, buf, state), may include RubyTUI::StatefulWidget, and are rendered with frame.render_stateful_widget.
Unicode Width
Text is measured and stored by grapheme cluster, the unit a terminal displays as one character:
- CJK characters and emoji occupy two columns, including symbols with emoji presentation by default (such as ✅) and text symbols followed by the emoji variation selector U+FE0F (such as ⚠️).
- Combining marks, such as Latin diacritics and Devanagari vowel signs, stay in the same cell as their base character.
Buffer#set_stringstores each cluster in one cell and reserves the following cell for two-column clusters.
RubyTUI::Unicode exposes the same calculations:
RubyTUI::Unicode.string_width("Hi🔥") # => 4
RubyTUI::Unicode.truncate_to_width("中文字", 5) # => "中文"
RubyTUI::Unicode.cluster_width("⚠️") # => 2
RubyTUI::Unicode.char_width("a") # => 1
Testing
RubyTUI::TestBackend is an in-memory backend for testing rendering without a terminal.
require "minitest/autorun"
require "rubytui"
class TitleTest < Minitest::Test
def test_block_title
backend = RubyTUI::TestBackend.new(width: 20, height: 3)
terminal = RubyTUI::Terminal.new(backend: backend)
terminal.draw do |frame|
frame.(RubyTUI::Widgets::Block.new(title: " Hi "), frame.area)
end
assert_equal "Hi", backend.text_at(3, 0, 2)
assert_equal "╰──────────────────╯", backend.row_text(2)
assert_equal "╭", backend.cell_at(0, 0).symbol
end
end
cell_at(x, y)returns theRubyTUI::Cell(symbolandstyle) at a position.text_at(x, y, length)returns the characters oflengthcells starting at a position.row_text(y)returns a whole row without trailing whitespace.draw_count,total_changesanddraw_historyrecord what eachdrawsent;cursor_visible,cursor_xandcursor_yrecord the cursor;image_historyrecords image placements;reset!clears the grid and history.
Widgets can also be tested without a terminal by rendering them into a Buffer and reading cells with buf[x, y].
Requirements
- Ruby 3.2 or later
- A terminal that supports ANSI escape sequences
- For images, a terminal that implements the Kitty graphics protocol
License
Released under the MIT License. The full text is in the LICENSE file included with the gem.