Controllers & Views
Controllers dispatch actions, bind input, navigate between routes, run background tasks, and render responses.
Controller Actions
Generated controllers inherit from the app’s ApplicationController:
module MyApp
class HomeController < ApplicationController
def show
render :show, home: home, palette: command_palette
end
private
def home
state(:home, HomeState)
end
end
end
The controller’s private home method returns the screen state object. render :show, home: home passes that object into the view as the home assign.
render :show resolves the conventional Ruby view class from the controller/action name, not from the state class:
app/views/home/show_view.rb
Rendering
Controller render forms:
| Form | Behavior |
|---|---|
render :show, **assigns | Renders AppName::Home::ShowView, falling back to app/views/home/show.tui.erb or .txt.erb. |
render_template "custom/page", **assigns | Renders an explicit ERB template path under app/views. |
render "literal text" | Renders a literal string. |
render view_object | Renders a class-based view or component object. |
Assigns passed to render become methods in the view. In home: home, the left home is the assign name and the right home calls the controller method:
render :show, home: home, palette: command_palette
One dispatch sets the response once. render, render_view, render_template, navigate, and quit all assign the response. A second call in the same dispatch raises Charming::DoubleRenderError. The message names the action, the response already set, and the one attempted. If you see this error, delete the first call — it never painted.
module MyApp
module Home
class ShowView < Charming::View
def render
text home.title, style: theme.title
end
end
end
end
Because the controller passed home: home, the view can call home.title. That title comes from the HomeState object returned by the controller’s home method.
Views also receive screen, controller, and theme assigns.
ERB Template Fallback
ERB templates remain available for large text rendering or simple fallback views.
If no Home::ShowView class exists, render :show searches:
app/views/home/show.tui.erb
app/views/home/show.txt.erb
.tui.erb is preferred before .txt.erb. render_template always renders an explicit template path.
Template Helpers
Views and templates share the same helper set:
| Helper | Purpose |
|---|---|
text(value, style: nil) | Render styled text. |
box(value, style: nil) | Style or border a block. |
row(*items, gap: 0) | Join blocks side by side. |
column(*items, gap: 0) | Stack blocks vertically. |
screen_layout { ... } | Build a full-screen declarative layout tree. |
render_component(component) | Render a component or partial object. |
render_partial(partial) | Alias for render_component. |
yield_content | Render wrapped screen content in a layout. |
focused?(slot) | Ask the controller whether a focus slot is active. |
Action Hooks
Controllers support Rails-style lifecycle hooks. Hooks are inherited by subclasses and accept only: / except: action filters:
class EntryController < ApplicationController
before_action :load_entry, only: [:show, :edit]
after_action :track_view
around_action :measure
rescue_from ActiveRecord::RecordNotFound, with: :entry_missing
def show
render :show, entry: @entry
end
private
def load_entry
@entry = Entry.find(params.fetch(:id))
end
def measure
started = Time.now
yield
logger.info("rendered in #{Time.now - started}s")
end
def entry_missing(_error)
render "Entry not found."
end
end
around_action methods must yield to invoke the action. rescue_from picks the handler whose rescued class is most specific for the raised exception (ties go to the last registered handler) — deliberately less surprising than Rails’ pure declaration-order rule. Unmatched exceptions propagate to the runtime, which renders the built-in error screen instead of crashing the terminal.
Hooks run around actions (dispatch(:show), timers, task handlers). They do not run on component key dispatch, so any method the focus system can call directly — focus slot component builders, component result handlers — must load its own data instead of relying on before_action instance variables.
Key Bindings
Bind key events to controller actions:
key "up", :increment
key "down", :decrement
key "q", :quit, scope: :global
Content-scoped bindings only run when content focus is active. Global bindings run from any focused pane.
Bindings compose modifiers with + — ctrl+s, alt+b, shift+up, ctrl+shift+left — and the input layer decodes them from the terminal’s escape sequences (xterm modifier parameters for arrows/home/end/F-keys, ESC-prefixed chords for alt+letter), so modified bindings work across modern terminals.
Dispatch priority
Key events flow through this chain, first match wins:
- Command palette (when open) consumes everything.
- Text capture — when the focused component accepts free-typed text (TextInput, TextArea, Form, Autocomplete…), printable characters go to it before any binding. Typing
qor?into a field inserts the character;ctrl/altshortcuts are unaffected. - Global key bindings.
- Overlay scopes — a pushed modal focus scope captures all remaining keys.
- Sidebar keys (when the sidebar is focused).
- Content key bindings.
- The focused component — text-capturing components also see
tabhere (so forms do field navigation); for all other components,tabcycles the controller’s focus ring first.
A practical consequence: printable global keys like q only fire when no text field is focused — your users can type the letter q.
Help Overlay
HelpOverlay.for_controller turns a controller’s key bindings into a ?-style cheat-sheet modal. Pair it with a pushed focus scope so the overlay captures the dismissing keypress:
key "?", :open_help, scope: :global
on_cancel :help_overlay, :close_help
def open_help
session[:help_open] = true
focus.push_scope([:help_overlay], origin: :modal)
render_default_action
end
def help_overlay
Charming::Components::HelpOverlay.for_controller(self.class, theme: theme)
end
def close_help
session.delete(:help_open)
focus.pop_scope
render_default_action
end
The layout renders the overlay while session[:help_open] is set. Any key dismisses it (the component returns :cancelled, which on_cancel routes to close_help). The old <slot>_cancelled naming convention still works but emits a one-time deprecation warning.
Command Palette
The palette lives outside the controller kernel. Include it in ApplicationController to use it (charming generate layout adds the include):
class ApplicationController < Charming::Controller
include Charming::Shell::Palette
end
The command class DSL comes with the palette module. Add commands with a method name or block:
command "Refresh", :refresh
command "Home" do
navigate :root
end
Generated apps bind ctrl+p globally to open_command_palette and include commands for theme switching and quit. The palette filters with fuzzy matching — type any subsequence of a command label (otp finds “Open theme palette”).
Navigation And Quit
Use controller helpers to change app flow:
navigate :settings
quit
Pass params at navigate time as Ruby values: navigate :city, id: city.id.
These produce Charming::Response objects that the runtime interprets.
Timers
Timers dispatch periodically while the current route is active:
timer :clock, every: 0.5, action: :tick
def tick
clock.now = Time.now
show
end
every: must be positive — a zero or negative interval raises ArgumentError at declaration time (it would busy-loop the runtime).
Timers run from boot by default. Declare timer ..., autostart: false to schedule one only when an action asks for it, and control it with the instance helpers start_timer(:name), stop_timer(:name), and timer_running?(:name) (all idempotent). animate name, fps:, action: is shorthand for a stopped timer ticking at a frame rate — see Animation for the physics primitives built on it.
Terminal Focus
When the terminal window gains or loses focus, the runtime dispatches an optional focus_changed action (it enables focus reporting mode automatically). Define the action to react — a common use is pausing a timer-driven clock or animation while the window is in the background so it isn’t burning CPU:
class DashboardController < Charming::Controller
timer :clock, every: 1, action: :tick
def focus_changed
dashboard.active = event.focused? # event is a Charming::Events::FocusEvent
show
end
def tick
return show unless dashboard.active
dashboard.now = Time.now
show
end
end
event.focused? is true on focus gain and false on blur. Controllers that don’t define focus_changed simply ignore the event. For a timer that should stop entirely while blurred (rather than tick and skip work), call stop_timer(:clock) on blur and start_timer(:clock) on focus gain.
Background Tasks
Register a task handler and dispatch work with run_task:
on_task :refresh_home, action: :refresh_loaded
def refresh
run_task(:refresh_home) { "Loaded" }
show
end
def refresh_loaded
home.status = event.error? ? event.error.message : event.value
show
end
Task results arrive as Charming::Events::TaskEvent with value, error, and error?.
The data-in/data-out rule
Task blocks run on an executor thread. They receive data in via with: and return data out as the block value. They touch nothing else — the on_task handler on the loop thread is the only place task results become state.
def search
run_task(:search, with: {query: query.value}) do |ctx|
Api.search(ctx[:query])
end
end
The with: hash is deep-frozen at submit time. Mutating it inside the task raises FrozenError. Calling render, navigate, quit, session, or focus from the task thread raises Charming::CrossThreadAccess in development and test, and logs a warning in production.
Progress streaming
Blocks that accept an argument receive a context whose report dispatches the matching on_task_progress handler with a TaskProgressEvent (current, total, message, and a fraction helper):
on_task :export, action: :export_finished
on_task_progress :export, action: :export_progressed
def start_export
run_task(:export, with: {entries: entries}) do |ctx|
ctx[:entries].each_with_index do |entry, index|
write(entry)
ctx.report(index + 1, of: ctx[:entries].length, message: entry.title)
end
ctx[:entries].length
end
show
end
def export_progressed
stats.export_current = event.current
show
end
Timeouts and cancellation
run_task(:fetch, timeout: 10) { slow_api_call }
cancel_task(:fetch)
Both deliver a TaskEvent whose error is a Charming::Tasks::Cancelled, so the on_task handler can distinguish cancellation from real failures with event.error.is_a?(Charming::Tasks::Cancelled).
Custom executors implement submit(name, timeout: nil, &block) (the keyword is only passed when a timeout is requested, so the simple submit(name, &block) signature keeps working), plus optional cancel(name) and shutdown(timeout:).
Class-Based Views
Generated apps use templates by default, but class-based views still work:
class HomeView < Charming::View
def render
text title, style: theme.title
end
end
render HomeView.new(title: "Home", theme: theme)