Project

potty

0.0
The project is in a healthy, maintained state
Provides views, widgets, layout, theming, and frame-based animation for building curses TUI applications.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 3.12

Runtime

~> 1.4
 Project Readme

potty

CI Gem Version

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 until quit.
  • 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's handle_escape consumes 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 @widgets and call focus on the initial one. Called once at construction.
  • handle_escape — return true to consume ESC (e.g. app.quit or a confirm), false to 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 heterogeneous ListItems. Delegates unhandled keys to the selected item (how InputItem captures typing). Item types: ActionItem (callback on Enter), DisabledItem / SeparatorItem (skipped by selection), InputItem (inline editable row), and ColoredFieldsItem (multi-color segments via render_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 of RadioGroup; Space/Enter toggles the cursor row. selected, selected?, emits :change (selected values).
  • Spinner — single-line activity indicator: animated braille glyph + live label + 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 with left_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's preferred_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.