TuiTui

TuiTui is a small terminal UI toolkit for Ruby, with no external dependencies.

It uses a lightweight, TEA-inspired (MVU) architecture: the app object is the model, update(event) returns the next app, and view(size) renders a Canvas that the runtime paints.

Usage

An app is any object with two methods: view(size) returns a Canvas, and update(event) returns the next app (or :quit). It may also return [app, *commands] to request one-shot effects (see below). Runtime#run drives the loop.

require "tui_tui"

class Counter
  def initialize(count = 0) = @count = count

  def view(size)
    TuiTui::Canvas.blank(size).text(1, 1, "count: #{@count}   (+/- to change, q to quit)")
  end

  def update(event)
    case event
    in TuiTui::KeyEvent(key: "+" | "=") then Counter.new(@count + 1)
    in TuiTui::KeyEvent(key: "-" | "_") then Counter.new(@count - 1)
    in TuiTui::KeyEvent(key: "q" | TuiTui::KeyCode::CTRL_C) then :quit
    else self
    end
  end
end

TuiTui::Runtime.new(Counter.new).run

Events are Data.define values, so Ruby's pattern matching (case/in) deconstructs them directly in update.

The runtime never inspects the model, only update's return value, so both state styles work: the counter above returns a fresh model from each branch; todo.rb and file_browser.rb mutate their state and return self. Prefer immutability when you want undo or to compare models in tests; prefer mutation when the model holds lists, caches, or widget state.

See examples/ for larger apps, each runnable with ruby examples/<name>.rb.

Commands and subscriptions

Ongoing declarations are predicate methods the runtime polls each frame (wants_tick?, wants_mouse?); one-shot instructions are TuiTui::Command values returned from update, which the runtime executes before rendering.

[self, TuiTui::Command::Copy.new(path)] # write to the clipboard (OSC 52)

Command::Invalidate requests a full repaint on the next render. Returning [:quit, *commands] performs the commands and then quits.

Testing

TuiTui::TestRuntime is the runtime with the screen removed: it folds injected events exactly like Runtime and renders view headless. It is opt-in — require "tui_tui/test_runtime".

require "tui_tui/test_runtime"

runtime = TuiTui::TestRuntime.new(Counter.new, rows: 3, cols: 60)
runtime.key("+").key("+").key("-")
expect(runtime.screen).to include("count: 1")
expect(runtime.commands).to eq([])
expect(runtime.key("q").quit?).to be(true)

screen returns Canvas#to_text, a style-free string projection of the grid; canvas returns the Canvas itself for cell-level style assertions. type("…") injects a string one grapheme at a time, and resize / tick / mouse inject the other event kinds.

Non-functional requirements

TuiTui is built around a small set of non-functional requirements (NFRs).

N1: Minimal dependencies.

Depends only on io/console, which is a default gem.

N2: Testable without a terminal.

State transitions (update) and drawing (view) are pure functions. Only the driver (Screen) touches the terminal.

This makes apps and widgets unit-testable and snapshot-testable in headless environments.

N3: Terminal safety.

Raw mode, the alternate screen, and cursor visibility are always restored. This applies to normal exits, exceptions, and signals.

This prevents the terminal from being left in a broken state. The Screen.run block form guarantees this behavior through TerminalSession.

N4: No flicker.

Only the frame diff is written. Each frame is flushed with a single write.

N5: Full-width aware.

Columns never misalign. Display width is measured using a small built-in table based on East Asian Width.

Glyphs are clipped at region edges, not split across them.

N6: Performance.

Movement and redraw stay responsive, even with large content. Only changed rows are repainted, so cost scales with the change, not the screen size.

N7: Width-safe UI chrome.

Self-drawn chrome defaults to ASCII, color, and spacing, which have a guaranteed width of 1. Unicode box-drawing has an ambiguous width that can break layouts under CJK terminal settings, so it is only used when the terminal is confirmed to render it at width 1; otherwise the chrome falls back to ASCII.

Content text, such as Japanese data, is measured with Width. It is clipped or padded to fit the available space.

Configuration

Environment variables (all optional):

  • TUITUI_MOUSE — set to 0/off/false to disable mouse reporting (on by default).
  • TUITUI_BACKGROUNDlight or dark to pick the theme for your terminal background. Without it, COLORFGBG is read if present, otherwise dark is assumed (reliable auto-detection isn't possible on all terminals).
  • TUITUI_BOXascii / unicode / auto to force or auto-detect Unicode box-drawing chrome (default auto: used only when the terminal renders it at width 1, else ASCII).

Installation

bundle add tui_tui

If bundler is not being used to manage dependencies, install the gem by executing:

gem install tui_tui

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/takahashim/tui_tui.