Ruby Frontend Design (ratatui_ruby)

This document describes the architectural design and guiding principles of the Ruby layer in ratatui_ruby. It is intended for contributors, architects, and AI agents working on the codebase.

Guiding Design Principles

1. Three-Tier Namespace Architecture

The gem provides three distinct namespaces, each with a specific purpose:

Tier 1: Ratatui:: β€” Upstream Alignment

Pure upstream Ratatui types with 1:1 API correspondence. If it exists in Rust Ratatui, it has the same name and location here.

Rust Module Ruby Module Purpose
ratatui::layout Ratatui::Layout Rect, Constraint, Layout, Position, Size
ratatui::widgets Ratatui::Widgets All widgets (Table, List, Paragraph, Block, etc.)
ratatui::style Ratatui::Style Style, Color
ratatui::text Ratatui::Text Span, Line
ratatui::buffer Ratatui::Buffer Cell (for buffer inspection)
ratatui::backend Ratatui::Backend WindowSize
ratatui::Terminal Ratatui::Terminal Terminal lifecycle (draw, size, cursor)
ratatui::Frame Ratatui::Frame Frame object for render callbacks

Tier 2: Crossterm:: β€” Backend Alignment

Direct exposure of crossterm functionality. These are terminal I/O primitives that Ratatui builds upon.

Rust Module Ruby Module Purpose
crossterm::terminal Crossterm::Terminal supports_keyboard_enhancement?, window_size
crossterm::style Crossterm::Style available_color_count, force_color_output

Tier 3: RatatuiRuby:: β€” Ruby Convenience Layer

Ruby-specific conveniences, the main entry point, and the TUI DSL facade. This is where Ruby idioms live.

Why Three Tiers?

  1. Upstream Purity: Users who want exact Ratatui API parity use Ratatui:: and Crossterm:: namespaces.

  2. Ruby Idioms: Users who want convenience use RatatuiRuby.run, the TUI facade, and helper methods.

  3. Documentation Mapping: A contributor reading Ratatui’s Rust docs immediately knows where to find the Ruby equivalent.

  4. Predictability: As upstream adds types, their Ruby placement is deterministic.

[!NOTE] This three-tier architecture will be rolled out incrementally as 1.x releases. The Ratatui:: and Crossterm:: namespaces are additiveβ€”RatatuiRuby:: will continue to work by delegating to them. No breaking changes required.

2. Two-Layer Architecture

The Ruby frontend implements a β€œMullet Architecture”: structured namespaces in the library, flat ergonomic DSL for users.

Layer 1: Schema Classes (The Library)

Located in lib/ratatui_ruby/widgets/, lib/ratatui_ruby/layout/, etc.

These are the actual Data.define classes that the Rust backend expects. They have deep, explicit namespaces that match Ratatui:

RatatuiRuby::Widgets::Paragraph.new(text: "Hello")
RatatuiRuby::Layout::Constraint.length(20)
RatatuiRuby::Style::Style.new(fg: :red)

Layer 2: TUI Facade (The DSL)

Located in lib/ratatui_ruby/tui.rb and lib/ratatui_ruby/tui/*.rb mixins.

The TUI class provides shorthand factory methods that hide namespace verbosity:

RatatuiRuby.run do |tui|
  tui.paragraph(text: "Hello")
  tui.constraint_length(20)
  tui.style(fg: :red)
end

Why This Matters:

Users write application code using the TUI API and rarely touch deep namespaces. Contributors maintaining the library work with explicit, documentable, IDE-friendly classes. Both audiences are served without compromise.

3. Explicit Over Magic

The TUI facade uses explicit factory method definitions, not runtime metaprogramming.

What We Do:

# lib/ratatui_ruby/tui/widget_factories.rb
module RatatuiRuby
  class TUI
    module WidgetFactories
      def paragraph(**kwargs)
        Widgets::Paragraph.new(**kwargs)
      end
      
      def table(**kwargs)
        Widgets::Table.new(**kwargs)
      end
    end
  end
end

What We Don’t Do:

# NO: Dynamic method generation
RatatuiRuby.constants.each do |const|
  define_method(const.underscore) { |**kw| RatatuiRuby.const_get(const).new(**kw) }
end

Benefits of Explicit Definitions:

  1. IDE Support: Solargraph and Ruby LSP provide autocomplete because methods exist at parse time.

  2. RDoc: Each method can have its own documentation with examples.

  3. RBS Types: Each method has an explicit type signature.

  4. Debugging: Stack traces show real method names, not define_method closures.

  5. Decoupling: Internal class names can change without breaking the public TUI API.

4. Data-Driven UI (Immediate Mode)

All UI components are pure, immutable Data.define value objects. They describe desired appearance for a single frame, not live stateful objects.

Widgets Are Inputs:

# This is just data. It has no behavior, no side effects.
paragraph = RatatuiRuby::Widgets::Paragraph.new(
  text: "Hello",
  style: RatatuiRuby::Style::Style.new(fg: :red)
)

# Pass to renderer as input
frame.render_widget(paragraph, area)

Immediate Mode Loop:

Every frame, the application constructs a fresh view tree and passes it to draw. No widget state persists between frames. This is Ratatui’s core paradigm.

loop do
  tui.draw do |frame|
    # Fresh tree every frame
    frame.render_widget(tui.paragraph(text: "Time: #{Time.now}"), frame.area)
  end
  break if tui.poll_event.key? && tui.poll_event.code == "q"
end

5. Separation of Configuration and Status

Widgets (Configuration) and State (Status) are strictly separated.

Configuration (Input):

Widgets define what to render. They are created, rendered, and discarded.

list = tui.list(items: ["A", "B", "C", "D", "E"])

Status (Output):

State objects track runtime metrics computed by the Rust backend: scroll offsets, selection positions, etc. They persist across frames.

# Created once
@list_state = RatatuiRuby::ListState.new

# Used every frame
frame.render_stateful_widget(list, area, @list_state)

# Read back computed values
puts "Scroll offset: #{@list_state.offset}"

Precedence Rule:

When using render_stateful_widget, the State object is the source of truth. Widget properties like selected_index are ignored.

6. No Render Logic in Ruby

Ruby defines data structures. Rust renders them.

The classes in lib/ratatui_ruby/widgets/ contain no rendering code. They are pure structural definitions that the Rust extension walks and converts to Ratatui primitives.

Ruby’s Job: - Define Data.define classes with attributes - Validate inputs (types, ranges) - Provide convenience constructors

Rust’s Job: - Walk the Ruby object tree - Extract attributes via funcall - Construct Ratatui widgets - Render to the terminal buffer

This separation ensures rendering performance remains in Rust while Ruby handles the ergonomic API layer.


Directory Structure

lib/ratatui_ruby/
β”œβ”€β”€ tui.rb                    # TUI class, includes all mixins
β”œβ”€β”€ tui/                      # TUI facade mixins
β”‚   β”œβ”€β”€ core.rb               # draw, poll_event, get_cell_at
β”‚   β”œβ”€β”€ layout_factories.rb   # rect, constraint_*, layout_split
β”‚   β”œβ”€β”€ style_factories.rb    # style
β”‚   β”œβ”€β”€ widget_factories.rb   # paragraph, block, table, list, etc.
β”‚   β”œβ”€β”€ text_factories.rb     # span, line, text_width
β”‚   β”œβ”€β”€ state_factories.rb    # list_state, table_state, scrollbar_state
β”‚   β”œβ”€β”€ canvas_factories.rb   # shape_map, shape_line, etc.
β”‚   └── buffer_factories.rb   # cell (for buffer inspection)
β”œβ”€β”€ layout/                   # ratatui::layout
β”‚   β”œβ”€β”€ rect.rb
β”‚   β”œβ”€β”€ constraint.rb
β”‚   └── layout.rb
β”œβ”€β”€ widgets/                  # ratatui::widgets
β”‚   β”œβ”€β”€ paragraph.rb
β”‚   β”œβ”€β”€ block.rb
β”‚   β”œβ”€β”€ table.rb
β”‚   β”œβ”€β”€ list.rb
β”‚   β”œβ”€β”€ row.rb               # Table row wrapper
β”‚   β”œβ”€β”€ cell.rb              # Table cell wrapper (NOT buffer cell)
β”‚   └── ...
β”œβ”€β”€ style/                    # ratatui::style
β”‚   └── style.rb
β”œβ”€β”€ text/                     # ratatui::text
β”‚   β”œβ”€β”€ span.rb
β”‚   └── line.rb
β”œβ”€β”€ buffer/                   # ratatui::buffer
β”‚   └── cell.rb              # For get_cell_at inspection
└── schema/                   # Legacy location (being migrated)

Adding a New Widget

Step 1: Create the Schema Class

Define the Data class in the appropriate namespace directory:

# lib/ratatui_ruby/widgets/my_widget.rb
module RatatuiRuby
  module Widgets
    # A widget that displays foo with optional styling.
    #
    # [content] The text content to display.
    # [style] Optional styling for the content.
    # [block] Optional block border wrapper.
    class MyWidget < Data.define(:content, :style, :block)
      def initialize(content:, style: nil, block: nil)
        super
      end
    end
  end
end

Step 2: Add the RBS Type

# sig/ratatui_ruby/widgets/my_widget.rbs
module RatatuiRuby
  module Widgets
    class MyWidget < Data
      attr_reader content: String
      attr_reader style: Style::Style?
      attr_reader block: Block?

      def self.new: (content: String, ?style: Style::Style?, ?block: Block?) -> MyWidget
    end
  end
end

Step 3: Add the TUI Factory Method

# lib/ratatui_ruby/tui/widget_factories.rb
def my_widget(**kwargs)
  Widgets::MyWidget.new(**kwargs)
end

Step 4: Implement Rust Rendering

See rust_backend.md for the Rust implementation steps.

Step 5: Register in Requires

Add to lib/ratatui_ruby.rb:

require_relative "ratatui_ruby/widgets/my_widget"

TUI Mixin Architecture

The TUI class is composed of 8 focused mixins, each with a single responsibility:

Mixin Methods Purpose
Core draw, poll_event, get_cell_at, draw_cell Terminal I/O operations
LayoutFactories rect, constraint_*, layout, layout_split Layout construction
StyleFactories style Style construction
WidgetFactories paragraph, block, table, list, etc. Widget construction
TextFactories span, line, text_width Text construction
StateFactories list_state, table_state, scrollbar_state State object construction
CanvasFactories shape_map, shape_line, shape_circle, etc. Canvas shape construction
BufferFactories cell Buffer cell construction (for testing)

This modular structure keeps each file focused (~20-50 lines) and makes it easy to locate and modify factory methods.


Thread and Ractor Safety

Shareable (Frozen Data Objects)

These are deeply frozen and Ractor.shareable?:

Not Shareable (I/O Handles)

These have side effects and are intentionally not Ractor-safe:

# OK: Cache TUI during run loop
RatatuiRuby.run do |tui|
  @tui = tui
  loop { render; handle_input }
end

# NOT OK: Include in immutable Model
Model = Data.define(:tui, :count)  # Don't do this