Back Original

Show HN: RatatuiRuby wraps Rust Ratatui as a RubyGem – TUIs with the joy of Ruby

RatatuiRuby is a RubyGem built on Ratatui, a leading TUI library written in Rust. You get native performance with the joy of Ruby.

$ gem install ratatui_ruby --pre

SemVer Tag: v1.0.0-beta.2 RubyGems.org: 1.0.0.pre.beta.2

Inline Viewports

Standard TUIs erase themselves on exit. Your carefully formatted CLI output disappears. Users lose their scrollback.

Inline viewports solve this. They occupy a fixed number of lines, render rich UI, then leave the output in place when done.

Perfect for spinners, menus, progress indicators—any brief moment of richness.

class Spinner
  def main
    RatatuiRuby.run(viewport: :inline, height: 1) do |tui|
      until connected?
        status = tui.paragraph(text: "#{spin} Connecting...")
        tui.draw { |frame| frame.render_widget(status, frame.area) }
        return ending(tui, "Canceled!", :red) if tui.poll_event.ctrl_c?
      end
      ending(tui, "Connected!", :green)
    end
  end

  def ending(tui, message, color) = tui.draw do |frame|
    frame.render_widget(tui.paragraph(text: message, fg: color), frame.area)
  end

  def initialize = (@frame, @finish = 0, Time.now + 2)
  def connected? = Time.now >= @finish 
  def spin = SPINNER[(@frame += 1) % SPINNER.length]
  SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
end
Spinner.new.main; puts
See more: Inline menu example
require "ratatui_ruby"


class RadioMenu
  CHOICES = ["Production", "Staging", "Development"]         
  PREFIXES = { active: "", inactive: "" }                
  CONTROLS = "↑/↓: Select | Enter: Choose | Ctrl+C: Cancel"
  TITLES = ["Select Environment",                            
            { content: CONTROLS,                             
              position: :bottom,                             
              alignment: :right }]                           

  def call                                                   
    RatatuiRuby.run(viewport: :inline, height: 5) do |tui|   
      @tui = tui                                             
      show_menu until chosen?                                
    end                                                      
    RadioMenu::CHOICES[@choice]                              
  end
                                                             
  private                                                    

  def show_menu = @tui.draw do |frame|                       
    widget = @tui.paragraph(                                 
      text: menu_items,                                      
      block: @tui.block(borders: :all, titles: TITLES)       
    )                                                        
    frame.render_widget(widget, frame.area)                  
  end

  def chosen?                                                
    interaction = @tui.poll_event                            
    return choose if interaction.enter?                      
                                                             
    move_by(-1) if interaction.up?                           
    move_by(1) if interaction.down?                          
    quit! if interaction.ctrl_c?                             
    false                                                    
  end

  def choose                                                 
    prepare_next_line                                        
    @choice                                                  
  end                                                        
                                                             
  def prepare_next_line                                      
    area = @tui.viewport_area                                
    RatatuiRuby.cursor_position = [0, area.y + area.height]  
    puts                                                     
  end

  def quit!                                                  
    prepare_next_line                                        
    exit 0                                                   
  end

  def move_by(line_count)                                    
    @choice = (@choice + line_count) % CHOICES.size          
  end                                                        
                                                             
  def menu_items = CHOICES.map.with_index do |choice, i|     
    "#{prefix_for(i)} #{choice}"                             
  end                                                        
  def prefix_for(choice_index)                               
    return PREFIXES[:active] if choice_index == @choice      
    PREFIXES[:inactive]                                      
  end                                                        
                                                             
  def initialize = @choice = 0                               
end                                                          
                                                             
choice = RadioMenu.new.call                                  
puts "You chose #{choice}!"                                  
RatatuiRuby.run do |tui|
  loop do
    tui.draw do |frame|
      frame.render_widget(
        tui.paragraph(
          text: "Hello, RatatuiRuby!",
          alignment: :center,
          block: tui.block(
            title: "My App",
            titles: [{ content: "q: Quit", position: :bottom, alignment: :right }],
            borders: [:all],
            border_style: { fg: "cyan" }
          )
        ),
        frame.area
      )
    end

    case tui.poll_event
    in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
      break
    else
      nil
    end
  end
end

The Managed Loop

RatatuiRuby.run enters raw mode, switches to the alternate screen, and restores the terminal on exit.

Inside the block: call draw to render, and poll_event to read input.

Widgets included:

Need something else? Build custom widgets in Ruby!

require "ratatui_ruby/test_helper"

class TestColorPicker < Minitest::Test
  include RatatuiRuby::TestHelper 

  def test_swatch_widget 
    with_test_terminal(10, 3) do
      RatatuiRuby.draw do |frame|
        frame.render_widget(Swatch.new(:red), frame.area)
      end
      assert_cell_style 2, 1, char: "", bg: :red
    end
  end

  def test_input_hex 
    with_test_terminal do
      inject_keys "#", "f", "f", "0", "0", "0", "0"
      inject_keys :enter, :q
      ColorPicker.new.run
      assert_snapshots "after_hex_entry"
    end
  end
end

One Include, Everything Works

The module sets up a headless terminal, injects events, and asserts on rendered output. Everything runs in-process with no external dependencies.

What's inside:

Ruby deserves world-class terminal user interfaces. TUI developers deserve a world-class language.

RatatuiRuby wraps Rust's Ratatui via native extension. The Rust library handles rendering. Your Ruby code handles design.

Text UIs are seeing a renaissance with many new TUI libraries popping up. The Ratatui bindings have proven to be full featured and stable.

Mike Perham, creator of Sidekiq and Faktory

Explore the RatatuiRuby ecosystem →

Why Rust? Why Ruby?

Rust excels at low-level rendering. Ruby excels at expressing domain logic and UI. RatatuiRuby puts each language where it performs best.

Versus CharmRuby

CharmRuby wraps Charm's Go libraries. Both projects give Ruby developers TUI options.

CharmRuby RatatuiRuby
Integration Two runtimes, one process Native extension in Rust
Runtime Go + Ruby (competing) Ruby (Rust has no runtime)
Memory Two uncoordinated GCs One Garbage Collector
Style The Elm Architecture (TEA) TEA, OOP, or Imperative