Build your own component
A component is a plain object subclassing Charming::Component, which itself subclasses Charming::View. That gives it two things: assigns passed to new become reader methods, and a render method you implement with the view DSL — text, box, row, column, render_component.
One invariant shapes everything on this page: components are rebuilt on every event. They have no lifecycle, no mount/unmount, no long-lived instance. Any state that must survive between keystrokes lives in controller state or session and gets passed back in through the constructor on the next render.
Generate one
charming generate component <name>
writes app/components/<name>_component.rb:
# frozen_string_literal: true
module MyApp
class BadgeComponent < Charming::Component
def render
text "Badge"
end
end
end
A static renderable
The minimal component implements render and reads its assigns:
class CounterComponent < Charming::Component
def render
text "Count: #{count}", style: theme.info
end
end
Render it from a template or view with render_component, passing the durable state back in:
<%= render_component CounterComponent.new(count: home.count, theme: theme) %>
Handling keys
Interactive components implement handle_key(event). Use Charming.key_of(event) to get the normalized key symbol:
def handle_key(event)
case Charming.key_of(event)
when :up then move_up
when :down then move_down
when :enter then return [:selected, selected_item]
when :escape then return :cancelled
else return nil
end
:handled
end
The return value is a protocol the controller understands:
| Return value | Meaning | Controller hook invoked |
|---|---|---|
:handled | The component consumed the event. | none — the screen re-renders |
[:selected, object] | The user selected an item. | <slot>_selected(object) |
[:submitted, value] | The user submitted a value. | <slot>_submitted(value) |
:cancelled | The user cancelled the interaction. | <slot>_cancelled |
nil | The component did not handle the event. | none — dispatch continues |
Dispatch works through focus: when no higher-priority handler consumes a key, the controller builds the component for the focused slot (by calling the controller method named after the slot) and sends it handle_key. A hook such as search_selected(item) only fires if the controller defines it; otherwise the default render runs. Returning nil lets the event fall through to other handlers.
The KeyboardHandler mixin
For components that map keys straight to methods, skip the case statement and include Charming::Components::KeyboardHandler. Define a KEY_ACTIONS constant mapping key symbols to method names — here is Viewport’s, verbatim:
class Viewport < Component
include KeyboardHandler
KEY_ACTIONS = {
up: :scroll_up,
down: :scroll_down,
page_up: :page_up,
page_down: :page_down,
home: :scroll_home,
end: :scroll_end,
left: :scroll_left,
right: :scroll_right
}.freeze
end
handle_key resolves the key with Charming.key_of, looks it up in KEY_ACTIONS, sends the method, and returns :handled — or nil when no mapping exists, so unhandled keys fall through.
The mixin also supports keymap aliases via a @keymap instance variable (built-ins expose it as a keymap: assign). keymap: :vim applies VIM_KEYMAP — {up: :k, down: :j, left: :h, right: :l} — so j triggers the :down action alongside the arrow key. Pass a custom hash in the same shape, mapping KEY_ACTIONS keys to one alias key or an array of them:
Charming::Components::Viewport.new(content: doc, keymap: {page_down: :space})
Capturing text
Components users type prose into override captures_text? to return true (TextInput, TextArea, Form, Autocomplete, CommandPalette, and HelpOverlay do; the Charming::Component default is false). While such a component is focused:
- printable characters route to it before global and content key bindings — typing
qinto a field inserts a q instead of quitting tabreaches it before focus-ring traversal, so forms handle field navigation- ctrl/alt-modified shortcuts (
ctrl+p,ctrl+s) keep working
Two rules for your own components:
- Override
captures_text?if users type prose into it. - Focus-slot methods on controllers are invoked on key-dispatch paths where
before_actionhas not run — build the component fromsession/params, never from hook-populated instance variables.
Handling paste
The runtime enables bracketed paste, so pasted text arrives as a single Charming::Events::PasteEvent (a Data with one member, text) instead of a storm of key events. Implement handle_paste(event) and the focused component receives it via the controller’s dispatch_paste:
def handle_paste(event)
insert(event.text)
:handled
end
The built-ins show the two sensible policies: TextInput strips newlines and control characters (it is single-line), while TextArea keeps newlines and normalizes CRLF to LF. Form forwards paste to its focused field (text fields insert it, other field types ignore it) and Autocomplete inserts into its query and re-filters.
Handling the mouse
Mouse-capable components implement handle_mouse(event) and use the same return protocol as handle_key:
def handle_mouse(event)
return nil unless event.click?
select_at(event.y)
:handled
end
Mouse events expose button, coordinates, and modifier flags (ctrl, alt, shift, decoded from the terminal’s button-code bits), plus helpers:
| Helper | True for |
|---|---|
click? | A left/middle/right button press (not a release, drag, or hover). |
release? | The button coming back up. |
scroll? | Either wheel direction (button_name is :scroll_up/:scroll_down). |
drag? | Movement with a button held down. |
motion? | Any movement report — drag or hover. |
The controller hit-tests each mouse event against the named panes of the last layout, so by the time your component sees the event its x/y are pane-local — event.y is the row within your component, not the screen. A click on a focusable pane also moves focus to that slot.
Drag reporting is on by default. Buttonless hover motion is opt-in on the application class, since it makes the terminal report every mouse movement:
class Application < Charming::Application
mouse_motion :all # :drag is the default
end
Fuzzy filtering
Charming::Components::FuzzyMatcher is the fzf-style subsequence scorer behind List filtering and the CommandPalette. Use it in your own components:
FuzzyMatcher.score("opl", "Open palette") # => positive score
FuzzyMatcher.score("xyz", "Open palette") # => nil (not a subsequence)
FuzzyMatcher.filter(query, candidates) { |i| i.label }
score(query, candidate) returns a relevance score when every character of the query appears in order within the candidate (case-insensitive), nil otherwise. Contiguous runs and word-start matches score higher, so "pal" prefers the literal run in “Open palette” over scattered letters. filter returns matching candidates ordered best-score first (original order breaks ties); the block extracts the searchable label, defaulting to to_s.
Theming
Components read theme through a fallback chain: the theme: assign, then the controller’s theme, then UI::Theme.default. A theme is a set of tokens — text, title, muted, border, selected, info, warn — each returning a UI::Style ready to render:
def render
items.each_with_index.map { |item, index|
style = index == selected_index ? theme.selected : theme.text
style.render(item.to_s)
}.join("\n")
end
Use theme.selected for the highlighted row, theme.muted for hints and placeholders, theme.border for box borders. Stick to the tokens rather than hard-coded colors and your component works under every theme — see Themes.