Styling
Charming styles terminal text with Charming::UI::Style, a Lip Gloss–inspired immutable builder. Every method returns a new style, so styles are safe to share and chain. render(value) applies the accumulated style to a string.
Most app code reaches styles through theme tokens (theme.title, theme.muted, …) — those tokens are Style objects, so everything here applies to them too.
The builder
style = Charming::UI.style # a blank style
styled = style.foreground(:cyan).bold.border(:rounded).padding(1, 2).width(40)
puts styled.render("Hello")
Because each call returns a new instance, a base style can be branched safely:
base = Charming::UI.style.foreground("#7dcfff")
heading = base.bold.underline
muted = base.faint
Text attributes
Each attribute toggles one ANSI text effect:
| Method | Effect |
|---|---|
bold | Increased intensity. |
faint | Dimmed intensity. |
italic | Italicized (terminal-dependent). |
underline | Underlined. |
reverse | Swap foreground and background. |
strikethrough | Struck-through. |
Charming::UI.style.bold.underline.render("Title")
Colors
foreground (alias fg) and background (alias bg) accept four forms:
- a named color symbol —
:black,:red,:green,:yellow,:blue,:magenta,:cyan,:white, and their:bright_*variants; - a 256-color index integer,
0–255; - a hex string,
"#rrggbb"or shorthand"#rgb"; - an adaptive color that picks a variant per terminal background (below).
Charming::UI.style.foreground(:bright_green).render("ok")
Charming::UI.style.fg(81).bg(236).render(" indexed ")
Charming::UI.style.foreground("#ff8800").render("hex")
Adaptive light/dark colors
Charming::UI.adaptive(light:, dark:) builds a color that resolves at render time against the terminal’s background, so one style reads well on both dark and light terminals:
ink = Charming::UI.adaptive(light: "#1a1a2e", dark: "#e6e6fa")
Charming::UI.style.foreground(ink).render("readable anywhere")
The runtime detects the background at startup by querying the terminal (OSC 11), falling back to the COLORFGBG convention, and finally assuming dark — the common terminal default. Override or reset detection with:
Charming::UI::Background.assume = :light # or :dark; nil re-enables detection
Charming::UI::Background.dark? # => true/false
The Markdown component’s :auto style uses the same detection to pick its dark or light style config.
Color capability and downconversion
Hex and indexed colors downconvert automatically to whatever the terminal supports, so a truecolor theme degrades gracefully. The active capability lives in Charming::UI::ColorSupport:
| Level | Meaning |
|---|---|
:truecolor | 24-bit color. |
:color256 | xterm 256-color cube. |
:color16 | 16 basic ANSI colors. |
:none | No color (escapes are dropped). |
The level is auto-detected from the environment — NO_COLOR forces :none, COLORTERM=truecolor/24bit forces :truecolor, otherwise TERM is inspected (*-256color → :color256, dumb/empty → :none). Override or query it:
Charming::UI::ColorSupport.level = :color256 # force a level (nil re-enables detection)
Charming::UI::ColorSupport.at_least?(:color256) # => true/false capability check
Specs pin the level for stable captures (spec_helper.rb sets Charming::UI::ColorSupport.level = :truecolor).
Borders
border(style = :normal, sides: nil, foreground: nil, background: nil) draws a box. The named styles:
| Style | Looks like |
|---|---|
:normal / :ascii | ASCII +, -, | |
:square | ┌─┐ │ └─┘ |
:rounded | ╭─╮ │ ╰─╯ |
:thick | ┏━┓ ┃ ┗━┛ |
:double | ╔═╗ ║ ╚═╝ |
:block | ███ █ ███ |
:hidden | Spaces — keeps the box footprint without drawing anything. |
Charming::UI.style.border(:double).padding(0, 1).render("Boxed")
sides:restricts the border to specific edges, e.g.sides: [:top, :bottom].foreground:colors the border independently of the content:border(:rounded, foreground: "#7dcfff")— or per side with a hash:border(:rounded, foreground: {top: :red, bottom: :blue})(corners take the top/bottom row’s color).background:colors the border cells independently of the box background.- A custom
Charming::UI::Borderinstance works anywhere a style name does:
dotted = Charming::UI::Border.new(corners: %w[. . . .], edges: %w[. :])
Charming::UI.style.border(dotted).render("custom")
In the layout DSL, panes take the same border names: pane(:content, border: :rounded).
Box model
Charming::UI.style.width(40).height(3).align(:center).padding(1, 2).margin(1).render(text)
padding(*values)andmargin(*values)follow CSS shorthand: 1 value → all sides, 2 →[vertical, horizontal], 4 →[top, right, bottom, left]. Padding sits inside the border and takes the style’s colors; margin sits outside the border and stays unstyled. Per-side setters exist for both:padding_left(2),margin_top(1), etc.width(n)/height(n)fix the rendered size in display columns / rows, padding content out to fit.max_width(n)/max_height(n)cap the size instead — content narrower or shorter than the cap is left alone.align(:left | :center | :right)aligns content within the width;align_vertical(:top | :middle | :bottom)positions it within a fixed height.
Overflow: clip, wrap, or truncate
Content wider than a fixed width is clipped by default. Two other fit modes:
Charming::UI.style.width(20).wrap.render(paragraph) # word-wrap at the width
Charming::UI.style.width(20).truncate.render(long_line) # cut with a trailing …
Charming::UI.style.width(20).truncate(ellipsis: "...").render(long_line)
All three preserve ANSI styling at the cut and never split a double-width glyph. Charming::UI::Truncate.tail(text, width) exposes the ellipsis cut directly.
The render pipeline applies everything in order: wrap to width → align (horizontal and vertical) → expand to height → padding → border → ANSI escapes → margin.
Composing blocks
Charming::UI provides ANSI-aware helpers for assembling rendered strings (they respect display width, so wide glyphs and escape codes stay aligned):
Charming::UI.join_horizontal(left, right, gap: 2) # side by side
Charming::UI.join_vertical(header, body, gap: 1) # stacked
Charming::UI.center(block, width: 80, height: 24) # center in a box
Charming::UI.place(block, width: 80, height: 24, top: :center, left: :center)
Charming::UI.overlay(base, modal, top: :center, left: :center) # draw over a base
Joins take a cross-axis align: for blocks of unequal size — symbolic or a fraction between 0.0 and 1.0, matching Lip Gloss positions:
Charming::UI.join_horizontal(tall, short, align: :center) # :top | :center | :bottom
Charming::UI.join_vertical(wide, narrow, align: :right) # :left | :center | :right
Charming::UI.join_horizontal(tall, short, align: 0.75) # fractional position
join_vertical pads narrower blocks to the widest, so stacked output is always rectangular. place and overlay accept fractional top:/left: positions too: place(block, width: 80, height: 24, top: 0.25, left: :center). The view helpers row(...) and column(...) pass align: through.
For multi-step composition, Charming::UI::Canvas is a fixed-size character grid you can build up and overlay onto:
canvas = Charming::UI::Canvas.new(80, 24) # blank grid
canvas = Charming::UI::Canvas.parse(rendered) # …or from existing output
canvas.overlay(modal) # draw modal centered on top
canvas.to_s
Gradients
Charming::UI::Gradient interpolates between two hex colors:
Charming::UI::Gradient.blend("#ff0000", "#0000ff", 0.5) # => "#800080"
Charming::UI::Gradient.steps("#ff0000", "#0000ff", 5) # evenly spaced ramp
Charming::UI::Gradient.colorize("charming", from: "#ff0000", to: "#0000ff")
colorize sweeps the foreground color across the text one grapheme at a time (multi-codepoint emoji stay intact). Progressbar and ActivityIndicator use the same module for their gradient fills.
A note on display width
All of the above measure display width, not character count, via Charming::UI::Width.measure — it strips ANSI escapes and counts wide glyphs (CJK and emoji) as two columns, so borders and alignment stay correct even over emoji content. You rarely call it directly, but it is why a styled, emoji-laden row still lines up.
For the compact method-by-method reference, see UI & Themes in the API Reference.