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
- 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 = rowsortable.rows = rows. - App-lifetime state goes in state objects.
Controller#statestores 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
- Restart-lifetime state goes in the persisted session.
persist_sessionwrites the session as JSON on quit and restores it on boot. State objects persist the attributes they mark withpersist; 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:
- Enters the terminal alternate screen and enables mouse tracking, bracketed paste, and focus reporting (and detects the terminal background for adaptive colors)
- Resolves the root route
- Dispatches controller actions and events
- Renders responses through a renderer
- Reads key, mouse, paste, resize, timer, and task events
- 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.