Core Concepts

Charming is a Rails-inspired framework for terminal apps. Generated apps use routes, controllers, state objects, templates, layouts, components, themes, and a runtime that talks to a terminal backend.

Architecture

Generated apps follow this flow:

Application -> Router -> Controller -> Template/Layout -> Component -> UI
                         Runtime -> Renderer -> Terminal Backend

At runtime, the flow for a screen is:

Route -> Controller action -> Template -> Layout -> Renderer -> Terminal frame

Applications

An application owns the route table, session, themes, and task executor. Generated apps define the application class in lib/my_app/application.rb:

module MyApp
  class Application < Charming::Application
    root File.expand_path("../..", __dir__)

    Charming::UI::Theme.built_in_names.each do |theme_name|
      theme theme_name.to_sym, built_in: theme_name
    end

    default_theme :phosphor
  end
end

Routes are usually defined separately in config/routes.rb:

MyApp::Application.routes do
  root "home#show"
end

Controllers Are Persistent Per Screen

Charming creates one controller instance when a route is entered. The instance handles every action and event for that screen. Instance variables live for the screen’s lifetime:

def increment
  @count = (@count || 0) + 1
  render "Count: #{@count}"
end

Navigation away discards the instance. Returning to the screen builds a fresh one, so ivars reset. Two hooks bracket the lifetime. Use them to start and stop per-screen resources:

def screen_entered
  @player = Audio::Player.new
end

def screen_exited
  @player&.stop
end

The three state lifetimes

  1. Screen-lifetime state goes in ivars. Interactive components belong in declared slots: slot :query { Components::TextInput.new(...) }. They die with the screen. Data-bound components need both halves: declare the slot so its interaction state (selection, scroll) survives, and refresh its data on render — list.items = rows or table.rows = rows.
  2. App-lifetime state goes in state objects. Controller#state stores the object in the application session. It survives navigation:
def increment
  counter.count += 1
  render "Count: #{counter.count}"
end

private

def counter
  state(:counter, CounterState)
end
  1. Restart-lifetime state goes in the persisted session. persist_session writes the session as JSON on quit and restores it on boot. State objects persist the attributes they mark with persist; other JSON-safe session values pass through.

Background tasks

Task blocks run on an executor thread, not the loop thread. They receive data in via run_task’s with: and return data out as the block value. The on_task handler on the loop thread is the only place task results become state.

Views And Layouts

Generated controllers render views by symbol:

def show
  render :show, home: home, palette: command_palette
end

For HomeController, render :show resolves:

app/views/home/show_view.rb

Layouts wrap rendered views. Generated apps use a Ruby layout class:

class ApplicationController < Charming::Controller
  layout Layouts::ApplicationLayout
end

That resolves:

app/views/layouts/application_layout.rb

ERB templates remain available as a fallback. See Controllers & Views and Layouts for details.

Runtime

Most apps start through:

Charming.run(MyApp::Application.new)

The runtime:

  1. Enters the terminal alternate screen and enables mouse tracking, bracketed paste, and focus reporting (and detects the terminal background for adaptive colors)
  2. Resolves the root route
  3. Dispatches controller actions and events
  4. Renders responses through a renderer
  5. Reads key, mouse, paste, resize, timer, and task events
  6. Restores terminal state on quit or error

Key dispatch priority

Key events are matched in order: command palette (when open) → printable characters to a focused text-capturing component → global key bindings → overlay focus scopes (modals) → sidebar keys → content key bindings → the focused component / Tab traversal. The practical upshot: typing into a field always types, shortcuts work everywhere else. Details in Controllers & Views.

Errors

An unhandled exception from a controller action does not crash the terminal. The runtime logs the full backtrace to the application logger and renders a centered error panel showing the exception class, message, and top backtrace lines. Any key dismisses it and re-renders the current route; q quits. Handle expected errors yourself with rescue_from before they reach the runtime.

Quitting

q-style bindings produce a quit Response. The runtime also traps SIGINT (and SIGTERM/SIGHUP) and treats an unbound ctrl+c keypress as quit, so apps always have an escape hatch — bind "ctrl+c" yourself to take it over. On the way out the runtime drains background tasks (with a grace period), persists the session when configured, and restores the terminal. An at_exit fallback restores the terminal even if the process dies without reaching the normal teardown.

Suspend and resume

Ctrl+Z works like it does in any well-behaved TUI: the runtime returns the terminal to its normal state and stops the process; on fg it re-enters raw mode and the alternate screen and repaints the current route. No app code is needed.

For tests, instantiate Charming::Runtime directly with MemoryBackend. See Testing.


This site uses Just the Docs, a documentation theme for Jekyll.