potty
A curses-based terminal UI framework for Ruby. Build full-screen TUIs from a tree of composable widgets, with view-stack navigation, a focus model, theming, and frame-based animation.
┌─────────────────────────────────┐
│ → Say hello │
│ Configure │
│ Quit │
└─────────────────────────────────┘
↑↓: Navigate HELLO ESC: Quit
Status: early release (
0.0.1). The API is young and evolving under real consumers. Expect additive change.
Installation
Requires the curses gem (a native extension) and a real terminal. Not yet
published to RubyGems — depend on it via git or a local path:
# Gemfile
gem 'potty', github: 'TwilightCoders/potty'
# or, for local development:
gem 'potty', path: '../potty'
require 'potty'
Quick start
A Potty::Application runs a stack of Potty::Views. A view builds a tree of
widgets in build_layout and reacts to input. Subclass View, hand the app a
root view, and call run:
require 'potty'
class HelloView < Potty::View
def build_layout
@flash = Potty::Widgets::FlashMessage.new(app)
@list = Potty::Widgets::List.new(app)
@list.items = [
Potty::Widgets::ActionItem.new('Say hello') { flash_success('Hello!') },
Potty::Widgets::ActionItem.new('Quit') { app.quit }
]
@status = Potty::Widgets::StatusBar.new(app)
@status.left_text = '↑↓: Navigate'
@status.center_text = 'HELLO'
@status.right_text = 'ESC: Quit'
@widgets = [@flash, @list, @status]
@list.focus
end
def handle_escape
app.quit
true
end
end
app = Potty::Application.new
app.run(HelloView.new(app))
Two bundled demos, one per rendering mode — run either in a real terminal:
bin/potty_demo # curses: full-screen
bin/potty_inline_demo # inline: stays in the terminal flow
potty_demo(curses) — a single self-demonstrating dashboard: one composed layout (it shows off the layout system by being it) whose form controls reconfigure the demo live — the Border radio restyles the very panels you're looking at, the Title field renames the header, the checkboxes show/hide the live animation.potty_inline_demo(inline/TTY) — styled output, a live in-place "deploy" (spinners resolving without a screen takeover), and interactive prompts (ask/confirm/choose) — all in your normal terminal flow.
See examples/test_view.rb for a smaller example.
Core concepts
Application
Potty::Application owns the curses lifecycle and the event loop.
run(root_view)— set up curses, push the root view, loop untilquit.push_view(view)/pop_view— navigate a stack of views (e.g. drilling into a submenu and back). ESC pops by default unless the view'shandle_escapeconsumes it.quit— stop the loop.suspend/resume— tear down and rebuild curses so you can shell out to an external process (an editor, a pager) and come back cleanly.tick_interval=— see Animation & ticking.
View
Subclass Potty::View and override:
build_layout— construct widgets into@widgetsand callfocuson the initial one. Called once at construction.handle_escape— returntrueto consume ESC (e.g.app.quitor a confirm),falseto let the app pop the view.- optionally
on_activate/on_deactivate— run when the view becomes (in)active on the stack; a good place to rebuild dynamic lists.
The view routes keys to the focused widget first, then cycles focus with
Tab / Shift+Tab across widgets whose can_focus? is true (recursing into
containers). flash_success, flash_error, and flash_info post messages to a
FlashMessage widget in the tree. Widgets are laid out top-to-bottom by
Layout, unless you nest them in containers.
Events
Every widget mixes in Potty::Events, so you can wire a UI together
declaratively instead of subclassing for one-off behavior. Widgets emit semantic
events; subscribe with on:
name.on(:change) { |text| greeting.text = "Hello, #{text}" }
notify.on(:change) { |on| features.visible = on }
save.on(:press) { app.pop_view }
Emitted events: :focus/:blur (any widget), :change (TextInput, Toggle,
RadioGroup, CheckboxGroup), :select/:activate (List), :press
(Button), :expire (Countdown), :complete (Animator, Spinner). on
returns self and supports multiple listeners. Keys are named in Potty::Keys
(ENTER, ESC, TAB, UP, …) — no magic integers in your handle_key.
Containers & composition
A View's @widgets is laid out as a vertical stack, but any entry can be a
container holding more widgets — so you get nesting, columns, and framed panels.
Render, tick, and focus traversal all recurse.
VBox/HBox— vertical stack / equal-width columns (spacing:).Panel— bordered, optionally titled container (title:,style:,color:) that insets its children.
Potty::Widgets::Panel.new(app, title: 'Settings').add(
Potty::Widgets::Label.new(app, text: 'Name'),
Potty::Widgets::TextInput.new(app),
Potty::Widgets::Button.new(app, label: 'Save')
)
Widgets
Every widget inherits Potty::Widgets::Base and implements as much of this
contract as it needs:
| Method | Purpose |
|---|---|
preferred_height(width) |
rows the widget wants (drives stack layout) |
layout(rect) / on_layout |
receive assigned position+size |
render(window) |
draw onto the curses window |
handle_key(ch) |
handle input; return true if consumed |
tick(now) |
per-frame update (time-driven widgets only) |
can_focus? / focus / blur |
focus participation |
show / hide |
visibility |
Widget catalog
List— scrollable list of heterogeneousListItems. Delegates unhandled keys to the selected item (howInputItemcaptures typing). Item types:ActionItem(callback on Enter),DisabledItem/SeparatorItem(skipped by selection),InputItem(inline editable row), andColoredFieldsItem(multi-color segments viarender_custom).Label— static, non-focusable single-line text.text:,color:,bold:.Button— focusable; Space/Enter emits:press.on_press:shortcut.TextInput— single-line editable field. Block cursor when focused, dim placeholder, horizontal scroll.text/text=,placeholder,max_length, emits:change(snapshot). ASCII input.Toggle— boolean[●]/[○]; Space/Enter flips.value/value=,label, emits:change.RadioGroup— N mutually exclusive{value, label}options; arrows move a cursor, Space/Enter commits.selected/selected=, emits:change.CheckboxGroup— multi-select sibling ofRadioGroup; Space/Enter toggles the cursor row.selected,selected?, emits:change(selected values).Spinner— single-line activity indicator: animated braille glyph + livelabel+ trailing state.complete!(:success/:failure/:cancelled)freezes the glyph and flips color (idempotent); emits:complete. Tick-driven.Countdown— passive display counting down N seconds, emits:expire. Tick-driven (see below).FlashMessage— transient success/error/warning/info banner with timeout.StatusBar— bottom bar withleft_text/center_text/right_text.ProgressBar— pure-string bar using Unicode eighth-blocks for sub-cell resolution;render(0.0..1.0)returns a string (usable on a curses window or plain stdout).
Layout
Potty::Layout is pure geometry over a Rect(x, y, width, height):
Layout.stack(container, widgets, spacing:)— vertical stack (the default a view uses), querying each widget'spreferred_height.Layout.split_horizontal(container, ratio:)— left/right split.Layout.fill(container)— full container.
Theme
Potty::Theme maps semantic names to curses color pairs: :normal,
:selected, :disabled, :success, :error, :warning, :info, :dim,
:header, :status.
theme[:error] # color-pair attr
theme.attr(:selected, bold: true) # attr with A_BOLD / A_UNDERLINE OR'd in
Animation & ticking
The event loop normally blocks on input. To drive animations and countdowns,
give the app a tick interval — the loop then wakes every N milliseconds, fans a
single shared Time.now out to every widget's tick(now), and repaints.
app = Potty::Application.new
app.tick_interval = 40 # ms; ≈25fps. nil (default) = blocking, input-only.
app.run(view)
Potty::Sprite is a named sequence of multiline-string frames; Potty::Animator
is a widget that plays sprites by elapsed-time / fps, with :loop and :once
modes (:once fires on_complete).
class LoaderView < Potty::View
def build_layout
@spinner = Potty::Animator.new(app, centered: true, color: :info)
@spinner << Potty::Sprites::Sample.spinner # first sprite auto-plays
@label = Potty::Widgets::Label.new(app, text: 'Loading…', color: :info)
@widgets = [@spinner, @label]
end
end
app = Potty::Application.new
app.tick_interval = 40
app.run(LoaderView.new(app))
add_sprite (<<) registers more sprites; play(:name) swaps and restarts.
Define your own Sprite.new(:name, frames: [...], fps:, mode:);
Potty::Sprites::Sample (spinner, plane) is a template to copy.
Development
bundle install
bundle exec rspec # full suite
bundle exec rspec spec/potty/animator_spec.rb:42 # a single example
ruby examples/test_view.rb # interactive demo (needs a real TTY)
Tests cover the pure-logic surface — input handling, frame timing (via an
injected clock), layout, rendering assertions through fake windows — so the
suite runs without init_screen or a real terminal.
License
MIT.