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
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 hooks — 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.
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
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 help_overlay_cancelled
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 calls the <slot>_cancelled hook).
Command Palette
Add commands with a method name or block:
command "Refresh", :refresh
command "Home" do
navigate_to "/"
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_to "/settings"
quit
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).
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?.
Progress streaming
Blocks that accept an argument receive a progress reporter. Each 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) do |progress|
entries.each_with_index do |entry, index|
write(entry)
progress.report(index + 1, of: entries.length, message: entry.title)
end
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)