class RatatuiRuby::Tea::Runtime
Runs the Model-View-Update event loop.
Applications need a render loop. You poll events, update state, redraw. Every frame. The boilerplate is tedious and error-prone.
This class handles the loop. You provide the model, view, and update. It handles the rest.
Use it to build applications with predictable state.
Example
RatatuiRuby::Tea.run( model: { count: 0 }.freeze, view: ->(model, tui) { tui.paragraph(text: model[:count].to_s) }, update: ->(message, model) { message.q? ? [model, Command.exit] : [model, nil] } )
Public Class Methods
Source
# File lib/ratatui_ruby/tea/runtime.rb, line 227 def self.dispatch(command, queue, active_commands = {}) case command when Command::Cancel entry = active_commands[command.handle] if entry && entry[:thread].alive? entry[:token].cancel! grace = command.handle.tea_cancellation_grace_period if grace.finite? deadline = Time.now + grace sleep 0.02 while entry[:thread].alive? && Time.now < deadline entry[:thread].kill if entry[:thread].alive? else entry[:thread].join end end active_commands.delete(command.handle) nil else # Custom command (responds to tea_command?) if command.respond_to?(:tea_command?) && command.tea_command? token = Command::CancellationToken.new outlet = Command::Outlet.new(queue) thread = Thread.new do command.call(outlet, token) rescue => e queue << Command::Error.new(command:, exception: e) end active_commands[command] = { thread:, token: } thread end end end
Dispatches a command asynchronously. :nodoc:
Spawns a background thread and pushes results to the message queue. See Command.system for message formats.
Source
# File lib/ratatui_ruby/tea/runtime.rb, line 179 def self.normalize_update_result(result, previous_model) # Case 0: Nil result - preserve previous model return [previous_model, nil] if result.nil? # Case 1: Already a [model, command] tuple if result.is_a?(Array) && (result.size == 2) model = result[0] command = result[1] # Verify the second element is a valid command (nil, built-in, or custom) if command.nil? || command.class.name&.start_with?("RatatuiRuby::Tea::Command::") || (command.respond_to?(:tea_command?) && command.tea_command?) return [model, command] end end # Case 2: Result is a Command - use previous model if result.class.name&.start_with?("RatatuiRuby::Tea::Command::") return [previous_model, result] end if result.respond_to?(:tea_command?) && result.tea_command? return [previous_model, result] end # Case 3: Result is the new model [result, nil] end
Extracts [model, command] from update result.
Uses is_a? checks for type narrowing. The result parameter is untyped because the method performs runtime type detection.
@param result [Array, Command::execution, Object] The update result @param previous_model [Model] Fallback model if result is a command @return [Array(Object, Command::execution?)] The [model, command] tuple
Source
# File lib/ratatui_ruby/tea/runtime.rb, line 45 def self.run(model:, view:, update:, init: nil) validate_ractor_shareable!(model, "model") # Execute init command synchronously if provided if init init_message = init.call result = update.call(init_message, model) model, _command = normalize_update_result(result, model) validate_ractor_shareable!(model, "model") end queue = Queue.new pending_threads = [] #: Array[Thread] active_commands = {} #: Hash[Command::_Command, active_entry] catch(:quit) do RatatuiRuby.run do |tui| loop do tui.draw do |frame| widget = view.call(model, tui) validate_view_result!(widget) frame.render_widget(widget, frame.area) end # 1. Handle user input (blocks up to 16ms) message = tui.poll_event # If provided, handle the event unless message.is_a?(RatatuiRuby::Event::None) result = update.call(message, model) model, command = normalize_update_result(result, model) validate_ractor_shareable!(model, "model") throw :quit if command.is_a?(Command::Exit) thread = dispatch(command, queue, active_commands) if command pending_threads << thread if thread end # 2. Check for synthetic events (Sync) # This comes AFTER poll_event so Sync waits for commands dispatched # by the preceding event (e.g., inject_key("a"); inject_sync) if RatatuiRuby::SyntheticEvents.pending? synthetic = RatatuiRuby::SyntheticEvents.pop if synthetic&.sync? # Wait for all pending threads to complete pending_threads.each(&:join) pending_threads.clear # Yield to ensure any final queue writes are visible Thread.pass # Process all pending queue items until queue.empty? begin background_message = queue.pop(true) result = update.call(background_message, model) model, command = normalize_update_result(result, model) validate_ractor_shareable!(model, "model") throw :quit if command.is_a?(Command::Exit) thread = dispatch(command, queue, active_commands) if command pending_threads << thread if thread rescue ThreadError break end end end end # 3. Check for background outcomes (non-blocking) until queue.empty? begin background_message = queue.pop(true) result = update.call(background_message, model) model, command = normalize_update_result(result, model) validate_ractor_shareable!(model, "model") throw :quit if command.is_a?(Command::Exit) thread = dispatch(command, queue, active_commands) if command pending_threads << thread if thread rescue ThreadError break end end end end end # Shutdown: signal all, wait grace periods, then kill active_commands.each do |handle, entry| entry[:token].cancel! grace = handle.tea_cancellation_grace_period if grace.finite? deadline = Time.now + grace sleep 0.02 while entry[:thread].alive? && Time.now < deadline entry[:thread].kill if entry[:thread].alive? else entry[:thread].join end end # Process any final messages from completed commands until queue.empty? begin background_message = queue.pop(true) result = update.call(background_message, model) model, = normalize_update_result(result, model) rescue ThreadError break end end model end
Starts the MVU event loop.
Runs until the update function returns a Command.exit command.
- model
-
Initial application state (immutable).
- view
-
Callable receiving
(model, tui), returns a widget. - update
-
Callable receiving
(message, model), returns[new_model, command]or justnew_model. - init
-
Optional callable to run at startup. Returns a message for update.
Source
# File lib/ratatui_ruby/tea/runtime.rb, line 164 def self.validate_view_result!(widget) return unless widget.nil? raise RatatuiRuby::Error::Invariant, "View returned nil. Return a widget, or use TUI#clear for an empty screen." end
Validates the view returned a widget.
Views return widget trees. Returning nil is a bug—you forgot to return something. For an intentionally empty screen, use TUI#clear.