Terminal Object Design Proposals
Executive Summary
The current ratatui_ruby architecture abstracts Terminal functionality behind module-level methods (RatatuiRuby.draw, RatatuiRuby.poll_event) and the TUI facade class. This document proposes a design for introducing a proper Terminal object that aligns with upstream Ratatuiβs architecture while maintaining Ruby idioms, the established Mullet Architecture, and full backward compatibility.
Background
Current Architecture
Module-Level API (RatatuiRuby module): - init_terminal, restore_terminal, run - draw, poll_event, get_cell_at - get_viewport_area, get_terminal_size - insert_before (for inline viewports) - cursor_position, cursor_position=
Rust Implementation: - Global TERMINAL singleton wrapped in Mutex<Option<TerminalWrapper>> - TerminalWrapper enum supporting Crossterm and Test backends - Direct FFI methods exposed to Ruby
Upstream Ratatui Terminal struct provides: - new(backend), with_options(backend, options) β construction - draw(callback) β frame rendering - get_frame() β direct frame access - hide_cursor(), show_cursor(), get_cursor_position(), set_cursor_position() β cursor control - clear() β screen clearing - resize(area), autoresize() β size management - insert_before(height, draw_fn) β inline viewport insertion - backend(), backend_mut() β backend access - current_buffer_mut() β buffer inspection - flush(), swap_buffers() β low-level rendering control
Design Principles (from ruby_frontend.md)
-
Ratatui Alignment: Ruby namespace mirrors Rust module hierarchy
-
Two-Layer Architecture (Mullet):
-
Layer 1: Explicit schema classes (
RatatuiRuby::Widgets::*) -
Layer 2: Ergonomic DSL (
TUIfacade) -
Explicit Over Magic: No runtime metaprogramming
-
Data-Driven UI: Immediate mode, immutable data structures
-
Separation of Configuration and Status: Widgets (input) vs State (output)
-
No Render Logic in Ruby: Ruby defines data, Rust renders
Proposal 1: Terminal Class with Instance-Based API (Full Upstream Port)
Overview
Create a proper RatatuiRuby::Terminal class that mirrors the upstream Ratatui Terminal struct. Maintains full backward compatibility via singleton delegation pattern.
Design Philosophy: Rust-Side Object Construction
Proposal 1 design decision: FFI methods return fully-constructed Ruby objects, not raw primitives or hashes.
Using Magnus, Rust can instantiate Ruby classes directly: - _poll_event_instance(timeout) returns Event::Key, Event::Mouse, Event::None, etc. - _viewport_area_instance() returns Layout::Rect - _get_cell_at_instance(x, y) returns Buffer::Cell
This keeps Terminal methods as thin delegates to Rust, with all object construction logic centralized in the FFI layer. Ruby code becomes cleaner and less error-prone.
Current pattern (returns hash): <!β SPDX-SnippetBegin β> <!β SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 β>
def poll_event(timeout: 0.016) raw = _poll_event_instance(timeout) return Event::None.new.freeze if raw.nil? case raw[:type] when :key then Event::Key.new(code: raw[:code], ...) # ... 30 lines of parsing end end
Proposal 1 pattern (returns object): <!β SPDX-SnippetBegin β> <!β SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 β>
def poll_event(timeout: 0.016) _poll_event_instance(timeout) # Rust instantiates Event::Key, etc. end
Implementation
# lib/ratatui_ruby/terminal.rb module RatatuiRuby class Terminal # Construction def initialize(viewport: :fullscreen, height: nil) @viewport = resolve_viewport(viewport, height) _init_terminal_instance(@viewport.type.to_s, @viewport.height) end # Core rendering (thin delegate) def draw(&block) _draw_instance(&block) end # Event polling (Rust returns Event objects) def poll_event(timeout: 0.016) _poll_event_instance(timeout) end # Cursor control def hide_cursor _hide_cursor_instance end def show_cursor _show_cursor_instance end # Cursor position (Rust returns Layout::Position) def cursor_position _get_cursor_position_instance end def cursor_position=(position) if position.is_a?(Array) x, y = position else x, y = position.x, position.y end _set_cursor_position_instance(x, y) end # Viewport operations def insert_before(height, widget = nil, &block) content = widget || block&.call _insert_before_instance(height, content) end # Viewport queries (Rust returns Layout::Rect) def viewport_area _get_viewport_area_instance end def terminal_size _get_terminal_size_instance end # Screen management def clear _clear_instance end def resize(width, height) _resize_instance(width, height) end def autoresize _autoresize_instance end # Buffer inspection (Rust returns Buffer::Cell) def get_cell_at(x, y) _get_cell_at_instance(x, y) end # Low-level rendering control def flush _flush_instance end def swap_buffers _swap_buffers_instance end # Backend access (advanced) def backend _backend_instance end def backend_mut _backend_mut_instance end # Lifecycle def restore _restore_terminal_instance end private def resolve_viewport(viewport, height) case viewport when nil, :fullscreen then Terminal::Viewport.fullscreen when :inline then Terminal::Viewport.inline(height || 8) when Terminal::Viewport then viewport else raise ArgumentError, "Unknown viewport: #{viewport.inspect}" end end end # Module-level singleton for backward compatibility @default_terminal = nil class << self private def default_terminal @default_terminal ||= Terminal.new end end # Existing module methods delegate to singleton (BACKWARD COMPATIBLE) def self.draw(&block) default_terminal.draw(&block) end def self.poll_event(timeout: 0.016) default_terminal.poll_event(timeout:) end def self.get_cell_at(x, y) default_terminal.get_cell_at(x, y) end def self.get_viewport_area default_terminal.viewport_area end def self.get_terminal_size default_terminal.terminal_size end def self.insert_before(height, widget = nil, &block) default_terminal.insert_before(height, widget, &block) end # ... (all other module methods delegate similarly) end # TUI facade delegates to the Terminal instance passed to it class TUI def initialize(terminal) @terminal = terminal end # Expose the terminal instance attr_reader :terminal # Delegate core operations to terminal def draw(&block) @terminal.draw(&block) end def poll_event(timeout: 0.016) @terminal.poll_event(timeout:) end # ... (all existing TUI factory methods remain unchanged) end # run creates a Terminal and yields a TUI wrapping it def self.run(viewport: :fullscreen, height: nil, &block) terminal = Terminal.new(viewport:, height:) tui = TUI.new(terminal) yield tui ensure terminal.restore end
Usage: Three APIs (Deprecation Plan)
# API 1: Beginner-friendly TUI facade (PERMANENT) # This is the recommended API for most users RatatuiRuby.run do |tui| tui.draw { |frame| ... } event = tui.poll_event # Access the underlying Terminal instance puts tui.terminal.viewport_type # :inline or :fullscreen puts tui.terminal.width # terminal width tui.terminal.clear if some_condition end # API 2: Direct module methods (DEPRECATED, remove before v1.0.0) # This API should never have existed and will be removed RatatuiRuby.init_terminal RatatuiRuby.draw { |frame| ... } event = RatatuiRuby.poll_event RatatuiRuby.restore_terminal # API 3: Explicit Terminal instance (PERMANENT) # This is aligned with upstream Ratatui and provides explicit resource management terminal = RatatuiRuby::Terminal.new(viewport: :inline, height: 10) terminal.draw { |frame| ... } event = terminal.poll_event terminal.restore
Migration strategy: - API 1 (RatatuiRuby.run { |tui| }) remains the primary, beginner-friendly interface - API 3 (Terminal.new) is the advanced, upstream-aligned interface for explicit control - API 2 (module methods like RatatuiRuby.draw) exists only for backward compatibility during transition - Add deprecation warnings immediately after implementing Terminal - Remove completely before v1.0.0 release
Rust Changes
The Rust implementation needs significant refactoring to support instance-based terminals:
Current Architecture: <!β SPDX-SnippetBegin β> <!β SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 β>
pub static TERMINAL: Mutex<Option<TerminalWrapper>> = Mutex::new(None);
New Architecture: <!β SPDX-SnippetBegin β> <!β SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 β>
// Instance tracking
static TERMINAL_INSTANCES: Mutex<HashMap<u64, TerminalWrapper>> = Mutex::new(HashMap::new());
static NEXT_TERMINAL_ID: AtomicU64 = AtomicU64::new(0);
pub fn init_terminal_instance(viewport_type: String, height: Option<u16>) -> Result<u64, Error> {
let id = NEXT_TERMINAL_ID.fetch_add(1, Ordering::SeqCst);
let terminal = create_terminal(viewport_type, height)?;
let mut instances = TERMINAL_INSTANCES.lock().unwrap();
instances.insert(id, terminal);
Ok(id)
}
pub fn draw_instance(terminal_id: u64, callback: Value) -> Result<(), Error> {
let mut instances = TERMINAL_INSTANCES.lock().unwrap();
let terminal = instances.get_mut(&terminal_id)
.ok_or_else(|| Error::new(error_class, "Terminal instance not found"))?;
match terminal {
TerminalWrapper::Crossterm(term) => term.draw(|frame| { ... }),
TerminalWrapper::Test(term) => term.draw(|frame| { ... }),
}
}
Module-level methods maintain singleton: <!β SPDX-SnippetBegin β> <!β SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 β>
static DEFAULT_TERMINAL_ID: Mutex<Option<u64>> = Mutex::new(None);
pub fn init_terminal(viewport_type: String, height: Option<u16>) -> Result<(), Error> {
let id = init_terminal_instance(viewport_type, height)?;
*DEFAULT_TERMINAL_ID.lock().unwrap() = Some(id);
Ok(())
}
pub fn draw(callback: Value) -> Result<(), Error> {
let default_id = DEFAULT_TERMINAL_ID.lock().unwrap()
.ok_or_else(|| Error::new(error_class, "No default terminal initialized"))?;
draw_instance(default_id, callback)
}
Rust-side object instantiation (Magnus): <!β SPDX-SnippetBegin β> <!β SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 β>
pub fn poll_event_instance(terminal_id: u64, timeout: Option<f64>) -> Result<Value, Error> {
let ruby = magnus::Ruby::get().unwrap();
let module = ruby.define_module("RatatuiRuby")?;
let event_module = module.const_get::<_, RModule>("Event")?;
match crossterm::event::poll(timeout_duration)? {
true => match crossterm::event::read()? {
CrosstermEvent::Key(key_event) => {
let key_class = event_module.const_get::<_, RClass>("Key")?;
key_class.new_instance((
("code", extract_key_code(key_event.code)),
("modifiers", extract_modifiers(key_event.modifiers)),
("kind", extract_kind(key_event.kind)),
))?.freeze()
},
CrosstermEvent::Mouse(mouse_event) => {
let mouse_class = event_module.const_get::<_, RClass>("Mouse")?;
mouse_class.new_instance((
("kind", extract_mouse_kind(mouse_event.kind)),
("x", mouse_event.column),
("y", mouse_event.row),
("button", extract_mouse_button(mouse_event.kind)),
("modifiers", extract_modifiers(mouse_event.modifiers)),
))?.freeze()
},
// ... other event types
},
false => {
let none_class = event_module.const_get::<_, RClass>("None")?;
none_class.new_instance(())?.freeze()
}
}
}
// Similar pattern for Layout::Rect, Buffer::Cell, etc.
pub fn get_viewport_area_instance(terminal_id: u64) -> Result<Value, Error> {
let ruby = magnus::Ruby::get().unwrap();
let module = ruby.define_module("RatatuiRuby")?;
let layout_module = module.const_get::<_, RModule>("Layout")?;
let rect_class = layout_module.const_get::<_, RClass>("Rect")?;
let area = get_terminal_viewport_area(terminal_id)?;
rect_class.new_instance((
("x", area.x),
("y", area.y),
("width", area.width),
("height", area.height),
))
}
Pros
β
Full alignment with upstream Ratatui β Terminal is a first-class object
β
Explicit ownership model β terminal is a managed resource
β
Supports multiple terminals (future: testing, headless)
β
Clear lifecycle β new, draw, restore
β
Non-breaking β all existing APIs continue to work via singleton delegation
β
Three API tiers β beginner (TUI), intermediate (module), advanced (instance)
β
MVU-compatible β Command.tui(->(tui) { tui.terminal.clear }) provides escape hatch for terminal operations while maintaining Update purity
Cons
β Significant Rust refactoring β requires instance tracking, ID management, and both instance-based FFI methods (_draw_instance) and singleton-delegating methods (_draw). The global TERMINAL mutex must be replaced with an instance registry (HashMap<u64, TerminalWrapper>), adding complexity to every terminal operation.
Verification Plan
Unit Tests
# test/test_terminal.rb class TestTerminal < Minitest::Test def setup RatatuiRuby.init_test_terminal(80, 24, "fullscreen") end def teardown RatatuiRuby.restore_terminal end def test_terminal_instance_creation terminal = RatatuiRuby::Terminal.new assert_instance_of RatatuiRuby::Terminal, terminal end def test_terminal_size terminal = RatatuiRuby::Terminal.new size = terminal.terminal_size assert_equal 80, size.width assert_equal 24, size.height end def test_viewport_type_fullscreen terminal = RatatuiRuby::Terminal.new assert_equal :fullscreen, terminal.viewport_type end def test_tui_terminal_access RatatuiRuby.run do |tui| assert_instance_of RatatuiRuby::Terminal, tui.terminal end end def test_backward_compatible_module_methods # Module methods still work via singleton delegation RatatuiRuby.draw { |frame| frame.render_widget(...) } event = RatatuiRuby.poll_event assert_instance_of RatatuiRuby::Event::None, event end end # test/test_terminal_inline.rb class TestTerminalInline < Minitest::Test def setup RatatuiRuby.init_test_terminal(80, 24, "inline", 8) end def teardown RatatuiRuby.restore_terminal end def test_inline_viewport terminal = RatatuiRuby::Terminal.new(viewport: :inline, height: 8) assert_equal :inline, terminal.viewport_type end def test_insert_before_works_inline terminal = RatatuiRuby::Terminal.new(viewport: :inline, height: 8) terminal.insert_before(1, RatatuiRuby::Widgets::Paragraph.new(text: "test")) # Should not raise end end
Integration Tests
# examples/terminal_instance_demo.rb require "ratatui_ruby" # API 3: Direct Terminal instance terminal = RatatuiRuby::Terminal.new(viewport: :inline, height: 10) terminal.draw do |frame| info = <<~INFO Terminal Information: - Type: #{terminal.viewport_type} - Size: #{terminal.width}x#{terminal.height} Press 'q' to quit INFO paragraph = RatatuiRuby::Widgets::Paragraph.new(text: info) frame.render_widget(paragraph, frame.area) end # Insert log above viewport terminal.insert_before(1, RatatuiRuby::Widgets::Paragraph.new(text: "[LOG] Started")) loop do event = terminal.poll_event break if event.key? && event.code == "q" end terminal.restore
Manual Testing Checklist
-
[ ]
Terminal.newcreates new instance -
[ ]
terminal.widthandterminal.heightmatch expected dimensions -
[ ]
terminal.viewport_typereturns correct mode -
[ ]
tui.terminalprovides access from TUI facade -
[ ]
terminal.insert_beforeworks in inline mode -
[ ]
terminal.poll_eventreturns Event objects -
[ ]
terminal.cursor_positionreturns Position object -
[ ] Module methods (
RatatuiRuby.draw) still work (backward compat) -
[ ] Deprecation warnings appear for module methods
Future Enhancements
Command.tui Integration
Implement the MVU escape hatch:
module RatatuiRuby module Tea module Command def self.tui(callable) TuiCommand.new(callable) end class TuiCommand < Data.define(:callable) def execute(tui) callable.call(tui) nil end end end end end # Usage in Update def update(model, message) case message when clear_requested [model, Command.tui(->(tui) { tui.terminal.clear })] end end