Skip to content

Content

The Content system separates what you display from how it renders.

F-strings (f"...") produce plain strings. Content strings (c"...") produce structured ContentNode values that carry styling, layout, and semantic information. A ContentNode can be rendered to a terminal with ANSI codes, to HTML with <span> tags, or to plain text with no formatting at all — from the same source.

Three levels of usage:

  1. Inline — ad-hoc styling in c"..." literals
  2. Trait — implement Content on a type for reusable rendering
  3. Builder — compose tables, charts, and fragments with Content.* helpers

Content strings use the c prefix and support the same interpolation modes as f-strings:

FormInterpolationUse case
c"..."{expr}General purpose
c$"..."${expr}Templates with literal braces
c#"..."#{expr}Shell-like templates

Basic interpolation:

let name = "Alice"
print(c"Name: {name}")

Inside c"..." interpolation, append styling hints after a colon:

let score = 95.5
print(c"{score: fg(green), fixed(2)}") // green, two decimals
let msg = "ALERT: threshold exceeded"
print(c"Alert: {msg: fg(red), bold}") // red bold text

Styling hints are comma-separated. Available hints:

  • fg(color) — foreground color
  • bg(color) — background color
  • bold, italic, underline, dim — text decoration
  • fixed(n) — numeric precision (same as f-string format spec)

When the interpolated value implements Content, its render() method runs first, then inline styles are applied on top.

Implement Content to define how any type renders as structured output:

trait Content {
method render() -> ContentNode;
}

Example for a score type:

type Score = number
impl Content for Score {
method render() {
let sign = if self < 0 { "-" } else { "+" }
Content.text(f"{sign}{abs(self):fixed(1)}")
.fg(if self < 0 { Color.red } else { Color.green })
}
}

Now any Score value automatically gets colored output in c"..." strings and when passed to print(...):

let delta: Score = -12.5
print(c"Change: {delta}") // renders as red "-12.5"

Any user-defined type can implement Content to produce rich output, including charts:

type Sensor {
name: string,
readings: Array<number>,
}
impl Content for Sensor {
method render() {
Content.chart("line")
.add(self.name, self.readings.enumerate())
.title(f"Sensor: {self.name}")
}
}
// Now print() automatically renders as a chart:
let s = Sensor { name: "temp", readings: [20, 22, 21, 23] }
print(s) // → renders line chart in terminal

The Content trait dispatch checks for user-defined implementations before falling back to built-in rendering for standard types (strings, numbers, arrays, tables, etc.).

When you print a Vec<T> where T is a struct, Shape automatically renders it as a table. Each field becomes a column. If a field’s type implements Content, the trait controls that column’s styling.

type TestResult {
name: string,
status: string,
duration_ms: int,
delta: Score
}
let results = [
TestResult { name: "auth", status: "PASS", duration_ms: 120, delta: 3.2 },
TestResult { name: "db", status: "FAIL", duration_ms: 450, delta: -8.7 },
]
print(c"{results}")

Output (terminal):

╭──────┬────────┬─────────────┬────────╮
│ name │ status │ duration_ms │ delta │
├──────┼────────┼─────────────┼────────┤
│ auth │ PASS │ 120 │ +3.2 │ ← green
│ db │ FAIL │ 450 │ -8.7 │ ← red
╰──────┴────────┴─────────────┴────────╯

The delta column is colored because Score implements Content.

The Color namespace provides named colors and RGB:

Color.red
Color.green
Color.blue
Color.yellow
Color.magenta
Color.cyan
Color.white
Color.default
Color.rgb(r, g, b) // 0-255 per channel

Style methods chain on any ContentNode:

Content.text("Error")
.fg(Color.red)
.bg(Color.rgb(40, 0, 0))
.bold()
.underline()

Available methods:

MethodEffect
.fg(color)Set foreground color
.bg(color)Set background color
.bold()Bold weight
.italic()Italic style
.underline()Underline decoration
.dim()Dimmed / faint text

Build a table explicitly with Content.table():

let records = load_records()
Content.table(records)
.border(Border.rounded)
.max_rows(20)
StyleExample
Border.rounded╭──┬──╮ corners with lines
Border.sharp┌──┬──┐ corners with lines
Border.heavy┏━━┳━━┓ thick lines
Border.double╔══╦══╗ double lines
Border.minimalNo outer border, row separators only
Border.noneNo borders at all

Visual reference for Border.rounded (default):

╭──────┬───────╮
│ name │ value │
├──────┼───────┤
│ test │ 142.5 │
│ prod │ 378.2 │
╰──────┴───────╯

Border.sharp:

┌──────┬───────┐
│ name │ value │
├──────┼───────┤
│ test │ 142.5 │
│ prod │ 378.2 │
└──────┴───────┘

Border.heavy:

┏━━━━━━┳━━━━━━━┓
┃ name ┃ value ┃
┣━━━━━━╋━━━━━━━┫
┃ test ┃ 142.5 ┃
┃ prod ┃ 378.2 ┃
┗━━━━━━┻━━━━━━━┛

Border.double:

╔══════╦═══════╗
║ name ║ value ║
╠══════╬═══════╣
║ test ║ 142.5 ║
║ prod ║ 378.2 ║
╚══════╩═══════╝
MethodDescription
.border(style)Set border style
.max_rows(n)Truncate display after n rows

Column styling comes from field types. If a field type implements Content, that implementation controls the column’s colors and formatting.

Build charts with Content.chart():

Content.chart("line")
.add("Revenue", [[1, 100], [2, 200], [3, 350]])
.title("Quarterly Revenue")
.x_label("Quarter")
.y_label("USD")

The .add(label, data) method adds a named data channel. Each data point is an [x, y] pair. Multiple data sets can be added to the same chart:

Content.chart("line")
.add("Revenue", [[1, 100], [2, 200], [3, 350]])
.add("Costs", [[1, 80], [2, 90], [3, 120]])
.title("Revenue vs Costs")
TypeUse case
ChartType.lineTime series, trends
ChartType.barCategorical comparison
ChartType.scatterCorrelation plots
ChartType.areaCumulative / stacked values
ChartType.histogramDistribution of values
ChartType.candlestickFinancial OHLC data
ChartType.boxplotStatistical distributions
ChartType.heatmapMatrix / density data
ChartType.bubbleScatter with size dimension

You can pass a type as a string ("line") or use the ChartType namespace (ChartType.line):

Content.chart("candlestick") // string form
Content.chart(ChartType.line) // namespace form
MethodDescription
.add(label, data)Add a named data series ([[x, y], ...])
.title(text)Set chart title
.x_label(text)Label the x-axis
.y_label(text)Label the y-axis
.width(n)Width hint in columns
.height(n)Height hint in rows

Internally, chart data is stored as channels — named arrays of numeric values with roles like "x", "y", "open", "high", "low", "close". The .add() method creates "x" and "y" channels from [x, y] pairs.

Each chart type requires specific channels:

Chart TypeRequired Channels
Line, Area, Scatterx, y
Bary
Candlestickx, open, high, low, close
Box Plotx, min, q1, median, q3, max
Histogramvalues
Heatmapx, y, value
Bubblex, y, size

Multiple chart nodes are supported:

let fast = Content.chart("line")
.add("SMA 20", sma_20_data)
.title("Window 20")
let slow = Content.chart("line")
.add("SMA 50", sma_50_data)
.title("Window 50")
Content.fragment([fast, slow])

Charts render directly in the terminal using Unicode Braille patterns (U+2800..U+28FF) for line, scatter, and area charts, and block characters (U+2581..U+2588) for bar charts and histograms. This works in any terminal that supports Unicode — no image protocol required.

Revenue vs Costs
350 ┤ ⡠⠊
275 ┤ ⡠⠤⠤⠤⠤⠤⠤⠔⠉
200 ┤ ⡠⠤⠤⠤⠤⠤⠔⠉
125 ┤⠤⠤⠔⠉
50 ┤
└────────────────────────────

In terminals with Kitty image protocol support, charts can render as high-resolution inline images via the shape-viz GPU renderer.

Combine multiple content nodes into a single output with Content.fragment():

let summary = Content.text(f"Report as of {today()}")
.bold()
let table = Content.table(records)
.border(Border.rounded)
let chart = Content.chart(ChartType.line)
.title("Trend")
let dashboard = Content.fragment([summary, table, chart])
print(dashboard)

Fragments render each part sequentially — text, then table, then chart — making it straightforward to build dashboard-style output.

For adapter-specific rendering, implement ContentFor<Adapter>:

trait ContentFor<Adapter> {
fn render(self, caps: RendererCapabilities): ContentNode
}

The caps parameter describes what the target supports:

type RendererCapabilities {
color: bool,
unicode: bool,
width: int,
interactive: bool
}

Example — render a chart as SVG for HTML, but as ASCII art for terminal:

impl ContentFor<Html> for DataReport {
method render(caps) {
Content.fragment([
Content.text(f"<h2>{self.title}</h2>"),
Content.chart(ChartType.line)
.title("Trend")
])
}
}
impl ContentFor<Terminal> for DataReport {
method render(caps) {
Content.fragment([
Content.text(self.title).bold().underline(),
Content.chart(ChartType.line)
.title("Trend")
.width(caps.width)
])
}
}
  • Terminal — ANSI escape codes
  • Html — HTML tags and inline styles
  • Markdown — GitHub-flavored markdown
  • Json — Structured JSON output
  • Plain — No formatting
AdapterTextTableChartCode
TerminalANSI codesUnicode box-drawingBraille/block UnicodeIndented
HTML<span> with styles<table>ECharts (interactive)<pre>
MarkdownPlain textGFM pipe tablesOmittedFenced blocks
JSONString fieldObject arrayChartSpec JSONString
PlainNo formattingASCII bordersText descriptionIndented

When no ContentFor<Adapter> impl exists, the default Content trait implementation is used with the adapter’s default rendering rules.

ContentNode is a first-class variant of WireValue, the universal wire format. It is serialized as compact binary (MessagePack) alongside all other Shape values — no JSON intermediate. The structure looks like this conceptually:

WireValue::Content(ContentNode::Chart {
chart_type: "line",
channels: [
{ name: "x", label: "x", values: [1, 2, 3] },
{ name: "y", label: "Revenue", values: [100, 200, 350] },
],
title: "Quarterly Revenue",
})

Any consumer that receives a WireValue::Content can re-render the ContentNode for its own target format. The web playground deserializes the content node and converts ChartSpec into interactive ECharts visualizations.

  • Strings — f-string and c-string literal syntax
  • Traits — trait system overview