Components
Components are reusable UI objects. They inherit from Charming::Component, which itself inherits from Charming::View, so they get assigns and rendering helpers.
Rendering Components
Render components from templates or views with render_component:
<%= render_component Charming::Components::List.new(
items: ["Alpha", "Beta", "Gamma"],
selected_index: 0,
theme: theme
) %>
Custom Components
Define a component by implementing render:
class CounterComponent < Charming::Component
def render
text "Count: #{count}", style: theme.info
end
end
Assigns passed to new become reader methods:
<%= render_component CounterComponent.new(count: home.count, theme: theme) %>
Built-In Components
| Component | Description |
|---|---|
TextInput | Editable text field. masked: true for passwords, history: [...] for REPL-style up/down recall, paste support. |
TextArea | Multiline editor. Plain Enter inserts a newline (enter_newline: false to opt out); paste support. |
Form | Huh-inspired form with input, textarea, select, confirm, and note fields. |
List | Selectable list with keyboard navigation and mouse support. |
MultiSelectList | List with [x] checkboxes — Space toggles, Enter submits the checked set, max_selections: caps it. |
Table | Unicode data table. height: adds a scrolling window with page up/down and window-relative clicks. |
Tree | Collapsible hierarchy — right/left expand/collapse, Enter selects leaves, mouse toggles branches. |
Viewport | Scrollable container for tall content, with wrapping and horizontal scroll. |
Modal | Overlay dialog with title, content, help text — max_body_height: makes the body scrollable. |
Toast | Auto-dismissing notification box with :info/:success/:warn/:error accents. |
StatusBar | One-row bar with left/center/right segments; pass hints: for key-hint pairs. |
TabBar | Horizontal tabs — left/right move, Enter selects, mouse clicks select. |
Breadcrumbs | Home › Projects › Current trail with the last item highlighted. |
Badge | Inline styled pill for statuses, counts, and versions. |
Autocomplete | Text input with a live-filtered suggestion dropdown. |
HelpOverlay | Keyboard cheat-sheet modal built from a controller’s key bindings. |
EmptyState | “Nothing here yet” placeholder with loading and error variants. |
CommandPaletteModal | Standard modal wrapper for command palette content. |
CommandPalette | Fuzzy-search command picker used by the framework (and reusable). |
Markdown | CommonMark/GFM renderer backed by Commonmarker with Rouge syntax highlighting. |
Spinner | Animated frame-cycling indicator. |
ActivityIndicator | Gradient activity bar with label and ellipsis animation. |
Progressbar | Text-based progress bar. |
ErrorScreen | The panel the runtime renders for unhandled exceptions (not usually built by hand). |
KeyboardHandler | Key-mapping mixin for custom components. |
FuzzyMatcher | fzf-style subsequence scorer used by the palette — FuzzyMatcher.filter(query, items) { |i| i.label }. |
A few quick examples:
# Toast — usually composited as a layout overlay
Charming::Components::Toast.new(message: "Saved!", kind: :success, theme: theme)
# StatusBar — bottom row with hints
Charming::Components::StatusBar.new(
width: screen.width,
left: " Entries",
hints: [["enter", "open"], ["n", "new"], ["q", "quit"]],
right: "8 entries ",
theme: theme
)
# Tree — node hashes, mutated in place to track expansion
Charming::Components::Tree.new(nodes: [
{label: "src", expanded: true, children: [{label: "main.rb"}]},
{label: "README.md"}
])
# Password input with shell-style history
Charming::Components::TextInput.new(masked: true)
Charming::Components::TextInput.new(history: ["last command", "older command"])
ActivityIndicator accepts max_width: and fallback_label: to keep labeled loading indicators stable in constrained layouts:
render_component Charming::Components::ActivityIndicator.new(
width: 24,
label: "Loading Top Stories",
max_width: content_width,
fallback_label: "Working"
)
Markdown
Render Markdown with Charming::Components::Markdown:
<%= render_component Charming::Components::Markdown.new(
content: readme,
width: 72,
theme: theme,
style: :dark
) %>
Markdown parsing is handled by Commonmarker with CommonMark/GFM support. Code block tokenization is handled by Rouge. Charming owns terminal rendering, wrapping, and Glamour-inspired Markdown styling.
The built-in Markdown styles are :dark, :light, and :notty. GFM tables, task lists, strikethrough, autolinks, links, images, definition lists, and footnotes render as terminal-friendly text:
- Definition lists (
Term/: definition) render bold terms with indented, wrapped descriptions. - Footnotes render
[name]references inline and labeled definition blocks with hanging indentation. - Task lists detect the checked state from the list marker only — prose mentioning
[x]doesn’t check a box. - Raw HTML blocks render as nothing by default (matching Glamour); a custom style config can set
html_block: {format: "..."}to show a placeholder.
Make links clickable in modern terminals with hyperlinks: true — links are wrapped in OSC 8 escapes and the `
<%= render_component Charming::Components::Markdown.new(
content: readme,
hyperlinks: true
) %>
Relative links and image targets can be resolved with base_url::
<%= render_component Charming::Components::Markdown.new(
content: readme,
width: 72,
style: :dark,
base_url: "https://example.com/docs/"
) %>
Use it with Viewport for scrollable documentation or help screens:
<%= render_component Charming::Components::Viewport.new(
content: Charming::Components::Markdown.new(content: readme, width: 72, theme: theme, style: :dark),
width: 72,
height: 20
) %>
Disable code syntax highlighting when plain code blocks are preferred:
<%= render_component Charming::Components::Markdown.new(
content: readme,
syntax_highlighting: false
) %>
Interaction
Interactive components should expose handle_key(event) and may expose handle_mouse(event).
Return conventions:
| Return value | Meaning |
|---|---|
:handled | The component consumed the event. |
[:selected, object] | The user selected an item. |
:cancelled | The user cancelled the interaction. |
nil | The component did not handle the event. |
Controllers dispatch events to focused components when no higher-priority handler consumes the event.
Component results map to controller hooks named after the focus slot: :cancelled → <slot>_cancelled, [:selected, value] → <slot>_selected(value), [:submitted, value] → <slot>_submitted(value).
Text Capture
Components that accept free-typed text override captures_text? to return true (TextInput, TextArea, Form, Autocomplete, CommandPalette, and HelpOverlay do). 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.
Paste
The runtime enables bracketed paste, so pasted text arrives as a single Charming::Events::PasteEvent instead of a storm of key events. TextInput and TextArea implement handle_paste(event) (inputs strip newlines; textareas keep them and normalize CRLF). Custom components can implement it too — the focused component receives the event via the controller’s dispatch_paste.
Forms
Build session-backed forms from controllers with form(:name). Form state is stored as primitive values under session[:forms], so input survives fresh controller instances.
class SignupController < Charming::Controller
focus_ring :signup_form
def show
render :show, form: signup_form
end
def signup_form_submitted(values)
# values => {name: "Ada", plan: "Pro", terms: true}
navigate_to "/"
end
def signup_form_cancelled
navigate_to "/"
end
private
def signup_form
form(:signup) do |f|
f.input :name, label: "Name", placeholder: "Ada Lovelace", required: true
f.textarea :bio, label: "Bio", height: 5, placeholder: "Tell us about yourself"
f.select :plan, label: "Plan", options: ["Free", "Pro", "Team"]
f.confirm :terms, label: "Accept terms?", required: true
f.note "Enter submits from the last field. Escape cancels."
end
end
end
<%= render_component form %>
Form fields:
| Field | Behavior |
|---|---|
input | Single-line text input. |
textarea | Multiline text input — Enter inserts a newline (twice for a blank line). |
select | Single-choice picker (options:, option_label: for display strings). |
confirm | Boolean yes/no field. |
note | Static, non-focusable text. |
Keyboard behavior:
| Key | Behavior |
|---|---|
Tab / Shift+Tab | Move between focusable fields (including out of a textarea). |
Enter | In a textarea: insert a newline. Elsewhere: next field, or submit from the last field. |
Ctrl+S | Submit the form from any field. |
Escape | Cancel the form. |
Up / Down | Change select choices. |
Space, y, n | Toggle or set confirm fields. |
This matches charm.sh’s huh: a textarea is a text editor, so it owns Enter — leave it with Tab and submit with Ctrl+S. (Shift+Enter/Ctrl+J/Ctrl+N also insert newlines, for muscle memory and for hosts that construct TextArea with enter_newline: false.)
Focused form results dispatch to controller hooks named after the focus slot: signup_form_submitted(values) and signup_form_cancelled.
Key Events
Use Charming.key_of(event) when component code needs the normalized key symbol:
def handle_key(event)
case Charming.key_of(event)
when :enter then [:selected, selected_item]
when :escape then :cancelled
else nil
end
end
Mouse Events
Mouse-capable components can implement handle_mouse(event):
def handle_mouse(event)
return nil unless event.click?
select_at(event.y)
:handled
end
Mouse events expose button, coordinates, modifier flags, and helpers such as click?, scroll?, and release?.