---
title: 'Drawing and theming'
url: 'https://wiki.teletypegames.org/engines/rubbs/drawing'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/drawing.md'
lang: en
date: '2026-10-27'
description: 'How rubbs puts colour and characters on the screen: BBS::Theme (every preset and every role key), the BBS::Ansi palette, the BBS::FrameBuffer that the Application draws into and diffs, BBS::Renderer for ERB screens in a flow, BBS::Banner, and BBS::Markdown, which turns Markdown into ANSI lines. Wi…'
taxonomy:
  tag:
    - reference
---

# Drawing and theming

> How [rubbs](https://wiki.teletypegames.org/engines/rubbs) puts colour and characters on the screen: `BBS::Theme` (every preset and every role key), the `BBS::Ansi` palette, the `BBS::FrameBuffer` that the Application draws into and diffs, `BBS::Renderer` for ERB screens in a flow, `BBS::Banner`, and `BBS::Markdown`, which turns Markdown into ANSI lines. Widgets that use all this are on [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets). Everything here is checked against rubbs 1.0.0.

# Themes

## How a theme works

A `BBS::Theme` (lib/bbs/theme.rb) is a name and a Hash that maps **role keys** to ANSI SGR strings. Widgets never hard-code colours; they ask for a role (`:button_focused`, `:window_title`, `:listbox_selected`…) and draw with whatever SGR the theme gives. Switching the theme recolours the whole application.

```ruby
BBS::Theme.new(name, styles)
```

| Member | What it does |
|---|---|
| `Theme.new(name, styles)` | `name` is any value (the presets use a Symbol), `styles` a Hash of role key to SGR String. A key outside `Theme::ROLES` raises `ArgumentError`. |
| `Theme.fetch(name)` | The preset of that name (Symbol or String) from `Theme::PRESETS`; a `BBS::Theme` is returned as it is. An unknown name raises `ArgumentError`. |
| `Theme::ROLES` | Every role key (the keys of the `synchronet` preset); `Theme.roles` returns the same. |
| `name` | The name. |
| `styles` | The Hash, frozen. |
| `style(key)` / `[key]` | The SGR for a role, or `nil` when the theme does not have it. |
| `merge(overrides)` | A new theme with the same name and the given keys replaced or added. The original is untouched; a key outside `Theme::ROLES` raises `ArgumentError`. |

A theme is frozen: change it with `merge`, which gives a new one. A role the Hash leaves out is not an error: the cell gets a `nil` style, and `FrameBuffer#diff` writes it after a plain `\e[0m` reset, so it shows in the terminal's default colours.

```ruby
require 'bbs'

t = BBS::Theme.synchronet
p t[:accent]                          # => "\e[1;33;40m"
p t.style(:no_such_role)              # => nil
cyan = t.merge(accent: "\e[1;36;40m")
p [cyan.name, cyan[:accent]]          # => [:synchronet, "\e[1;36;40m"]
p t.frozen?                           # => true
t.merge(check_on: "\e[1;32m")         # ArgumentError: unknown theme roles: [:check_on]
```

## Presets

The presets are data: one Hash each in `BBS::Theme::PRESETS` (lib/bbs/theme/presets.rb), and `BBS::Theme.fetch(:name)` builds one; the five class methods below are shorthands for it. The application uses `synchronet` when none is given (or `BBS.config.theme`, when set).

| Preset | Looks like |
|---|---|
| `BBS::Theme.synchronet` | Black screen, grey text. Blue windows with a bright white frame and yellow titles; the active window's title black on light grey. Menubar and statusbar black on light grey with red hotkeys, the selected menu white on green. Buttons black on grey, focused white on green; inputs white on blue, focused black on grey. |
| `BBS::Theme.nortoncommander` | Norton Commander: blue screen and blue windows with cyan frames, titles black on cyan (active: white on cyan). Menubar and statusbar black on cyan, drop-down menus black on light grey. Buttons white on green, focused white on cyan; inputs black on cyan. |
| `BBS::Theme.dosnavigator` | DOS Navigator: light grey screen and windows with black text, titles on cyan, blue accents. Buttons black on grey, focused white on green; list selection black on cyan. |
| `BBS::Theme.neumanntronics` | A muted 256-colour palette (`38;5;N` / `48;5;N`): near-black background, teal text, light teal highlights, amber accents and hotkeys, dark teal bars. Needs a 256-colour terminal. The window shadow is the background colour, so windows have no visible shadow. |
| `BBS::Theme.mono` | No colour at all, only bold, dim, underline and reverse video, for terminals without colour. `screen` and `window_bg` are empty strings: the terminal's own background. |

## Role keys

`Theme::ROLES`: every key the presets define (all five define all of them), what draws with it in rubbs 1.0.0, and its `synchronet` value. Keys marked "not used by the gem" are there for your own widgets and screens. 1.0 dropped the roles nothing drew with: `check_on`, `check_off`, `listbox_header`, `warning` and `reset`.

| Key | What it colours | `synchronet` |
|---|---|---|
| `screen` | The whole screen, filled before anything else is drawn: the desktop background. | `\e[40m` |
| `text` | Default for `Label`; `Checkbox`; the base of `ScrollView` and every Markdown line. | `\e[0;37;40m` |
| `text_dim` | `TextInput` placeholder; Markdown italics and the quote gutter. | `\e[0;2;37;40m` |
| `text_bright` | Markdown level-1 headings and bold. | `\e[1;37;40m` |
| `label` | Markdown level-3+ headings, inline code, code blocks, rules and table borders; separator lines in `Info` windows. | `\e[0;36;40m` |
| `accent` | Markdown level-2 headings, links, list bullets and numbers. | `\e[1;33;40m` |
| `success` | Not used by the gem. | `\e[1;32;40m` |
| `error` | The status line of a `Form` window (validation errors). | `\e[1;31;40m` |
| `window_bg` | The inside of a window. | `\e[44m` |
| `window_border` | The frame of a window. | `\e[1;37;44m` |
| `window_title` | The title of an inactive window; the close `[■]` and zoom gadgets, the window number, the resize corner. | `\e[1;33;44m` |
| `window_title_focused` | The title of the active window; the whole frame while a window is being moved or sized. | `\e[1;30;47m` |
| `window_text` | Text in dialogs and `Info` windows. | `\e[0;37;44m` |
| `window_shadow` | The drop shadow of windows and drop-down menus. | `\e[40m` |
| `menubar` | The menubar row. | `\e[1;30;47m` |
| `menubar_hotkey` | Hotkey letters in the menubar. | `\e[1;31;47m` |
| `menubar_selected` | The open or highlighted menu title. | `\e[1;37;42m` |
| `menubar_sel_hot` | The hotkey letter of that title. | `\e[1;33;42m` |
| `menu_bg` | The fill of a drop-down menu. | `\e[1;37;44m` |
| `menu_text` | Its items. | `\e[0;37;44m` |
| `menu_hotkey` | Hotkey letters of the items. | `\e[1;33;44m` |
| `menu_selected` | The highlighted item. | `\e[1;37;46m` |
| `menu_disabled` | Disabled items. | `\e[0;2;37;44m` |
| `menu_border` | The frame of the drop-down and its separator lines. | `\e[1;37;44m` |
| `statusbar` | The statusbar row. | `\e[1;30;47m` |
| `statusbar_key` | The key names in the statusbar hints. | `\e[1;31;47m` |
| `button` | A `Button`. | `\e[0;30;47m` |
| `button_focused` | A focused `Button`; also a focused `Checkbox`. | `\e[1;37;42m` |
| `button_hotkey` | The hotkey letter of a `Button`. | `\e[1;31;47m` |
| `button_disabled` | A disabled `Button`. | `\e[0;2;30;47m` |
| `input` | `TextInput` and `TextArea`. | `\e[1;37;44m` |
| `input_focused` | The same, focused. | `\e[1;30;47m` |
| `input_label` | Field labels in `Form`, `Info` and `MasterDetail` windows, and the hint of a `Form`. | `\e[0;36;40m` |
| `listbox` | `ListBox` rows. | `\e[0;37;44m` |
| `listbox_selected` | The selected row of an unfocused `ListBox`. | `\e[1;37;46m` |
| `listbox_focused` | The selected row of a focused `ListBox`. | `\e[1;30;47m` |
| `scrollbar` | The `ScrollView` scrollbar track. | `\e[0;36;44m` |
| `scrollbar_thumb` | Its thumb. | `\e[1;37;46m` |

Each SGR value in the presets starts with the background as well as the foreground (`37;40`, `37;44`), and most start with `0;`, so a style never inherits bold or dim from the cell drawn before it. Do the same in your own values.

## Choosing a theme for an application

In the `BBS::Application.define` block, `theme` takes a theme instance or a preset name, or either of them plus a block of overrides:

```ruby
require 'bbs'

APP = BBS::Application.define do
  theme BBS::Theme.nortoncommander
end
```

| Form | Result |
|---|---|
| `theme BBS::Theme.synchronet` | That theme. |
| `theme :synchronet` | The preset of that name (`Theme.fetch`; an unknown name raises `ArgumentError`). |
| `theme some_theme.merge(accent: "\e[1;36;40m")` | Any `BBS::Theme` instance. |
| `theme(:nortoncommander) { ... }` | The preset of that name, with the block's overrides. |
| `theme(BBS::Theme.mono) { ... }` | That instance, with the block's overrides. |
| `theme { ... }` | `synchronet`, with the block's overrides. |
| no `theme` call | `synchronet`. |

In 0.17.0 the bare Symbol form (`theme :synchronet`) stored the Symbol itself and crashed the first redraw with a `TypeError`, and `APP.theme` without arguments reset the stored theme; since 0.17.1 the Symbol takes the preset, and `theme` without arguments on the builder returns the theme set so far (`nil` when there was no `theme` call, which means `synchronet`).

Inside the application, `theme` returns the `BBS::Theme` in use, which is what you pass to `BBS::Markdown.to_lines` or use for a coloured string of your own:

```ruby
helpers do
  def warn_line(text) = "#{theme[:error]}#{text}#{theme[:text]}"
end
```

## Overriding keys

Inside a `theme` block every call with one argument sets that key; `style(key, value)` is the explicit form, and `inherit` switches the base the overrides are applied to:

```ruby
require 'bbs'

APP = BBS::Application.define do
  theme :nortoncommander do
    inherit :neumanntronics              # optional: take another preset as the base
    window_border "\e[1;36;44m"
    accent        "\e[1;33;44m"
    style :screen, "\e[40m"
  end
end
```

| Call | Effect |
|---|---|
| `KEY value` | Sets `KEY`, a role of `Theme::ROLES`. A misspelled key raises `NoMethodError`. |
| `style(key, value)` | The same, explicitly. |
| `inherit(base)` | Use another base: a preset name (Symbol) or a `BBS::Theme`. Overrides set before and after it are kept. |

Without the DSL, `merge` does the same:

```ruby
require 'bbs'

custom = BBS::Theme.synchronet.merge(
  accent:         "\e[1;36;40m",
  button_focused: "\e[1;37;44m"
)
```

## A theme of your own

Build a `BBS::Theme` from a full Hash. The simplest way to get every key is to start from a preset's Hash in `Theme::PRESETS`:

```ruby
require 'bbs'

AMBER = BBS::Theme.new(:amber, BBS::Theme::PRESETS[:mono].merge(
  screen:           "\e[40m",
  text:             "\e[0;33;40m",
  text_dim:         "\e[0;2;33;40m",
  text_bright:      "\e[1;33;40m",
  label:            "\e[0;33;40m",
  accent:           "\e[1;33;40m",
  window_bg:        "\e[40m",
  window_border:    "\e[0;33;40m",
  window_title:     "\e[1;33;40m",
  window_title_focused: "\e[0;30;43m",
  window_text:      "\e[0;33;40m",
  button:           "\e[0;30;43m",
  button_focused:   "\e[1;37;43m",
  input:            "\e[0;33;40m",
  input_focused:    "\e[0;30;43m",
  listbox:          "\e[0;33;40m",
  listbox_selected: "\e[0;30;43m",
  listbox_focused:  "\e[1;37;43m"
))

APP = BBS::Application.define do
  theme AMBER
end
```

# The palette: BBS::Ansi

Every colour outside a theme comes from one palette, `BBS::Ansi` (lib/bbs/ansi.rb): a flow's styles, a line-by-line board's `say`, a FakeOS, a log line, a quick door. `BBS::Color` (`Color.c(:yellow, text)`) is gone since 1.0; write the constant and a reset instead.

| Constant | SGR | Constant | SGR |
|---|---|---|---|
| `GREY` (plain text) | `\e[0;37m` | `BRIGHT_YELLOW` | `\e[1;33m` |
| `DIM_GREY` | `\e[2;37m` | `BLUE` | `\e[0;34m` |
| `DARK_GREY` | `\e[1;30m` | `BRIGHT_BLUE` | `\e[1;34m` |
| `WHITE` | `\e[1;37m` | `MAGENTA` | `\e[0;35m` |
| `BRIGHT_RED` | `\e[1;31m` | `CYAN` | `\e[0;36m` |
| `GREEN` | `\e[0;32m` | `BRIGHT_CYAN` | `\e[1;36m` |
| `BRIGHT_GREEN` | `\e[1;32m` | `REVERSE` / `REVERSE_OFF` | `\e[7m` / `\e[27m` |
| `YELLOW` | `\e[0;33m` | `RESET` | `\e[0m` |

```ruby
require 'bbs'

p "#{BBS::Ansi::CYAN}hello#{BBS::Ansi::RESET}"    # => "\e[0;36mhello\e[0m"
```

`BBS::Petscii::Colors` are the C64 names of the same sequences (`WH YE CY LG GR DG LB BL GN RD PU OR`, `RV RO` for reverse on and off; see [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore)), and a component takes a Hash of roles in these colours: `BBS::Selector::COLORS`, the Flow's `FlowRunner::STYLES`, a FakeOS's `colors`. On a C64 graphics canvas the same colours turn into C64 codes: `BBS::Petscii::Canvas.color(YE) == Canvas::YELLOW`.

# BBS::FrameBuffer

`BBS::FrameBuffer` (lib/bbs/frame\_buffer.rb) is an off-screen grid of cells, each a character and an SGR string. The Application draws a whole frame into one, compares it with the previous frame, and sends only the difference. Widgets get one in `render(frame)`.

## Double buffering and the delta diff

Every redraw of the Application (the private `render_screen`) does this:

1. Make an empty `next` frame of the terminal's size. Keep the `committed` frame (what the terminal shows now); on the first draw or after a resize, start from an empty one and clear the screen.
2. Fill `next` with the theme's `screen` style, draw the desktop, the windows bottom to top, the menubar and the statusbar.
3. `delta = next.diff(committed)`; if it is not empty, write it wrapped in cursor hide/show (`\e[?25l` … `\e[?25h`) and make `next` the committed frame.

So after a key press only the cells that changed travel down the line: moving a list selection costs two rows, a ticking clock a few characters. The same works by hand:

```ruby
require 'bbs'

committed = BBS::FrameBuffer.new(20, 2)
nxt = BBS::FrameBuffer.new(20, 2)
nxt.move(1, 1)
nxt.write('Time: 12:00', sgr: "\e[1;33m")
p nxt.diff(committed)     # => "\e[1;1H\e[0m\e[1;33mTime: 12:00\e[0m"
committed = nxt

nxt = BBS::FrameBuffer.new(20, 2)
nxt.move(1, 1)
nxt.write('Time: 12:01', sgr: "\e[1;33m")
p nxt.diff(committed)     # => "\e[1;11H\e[0m\e[1;33m1\e[0m"   only the changed digit
p nxt.diff(nxt)           # => ""
```

## Constants

| Constant | Value |
|---|---|
| `Cell` | `Struct.new(:char, :sgr)`. |
| `BLANK` | A frozen `Cell` with a space and no style: what a new or cleared frame is filled with. |
| `BOX_CHARS` | The `:single` (`┌┐└┘─│`) and `:double` (`╔╗╚╝═║`) line sets used by `box`. |

## Class methods for ANSI strings

These work on strings that contain SGR escapes, counting only the visible characters. An escape sequence is what `BBS::Ansi::CSI_RE` matches (`/\e\[[\d;?]*[A-Za-z~]/`: SGR, cursor moves, `\e[2~`-style keys, and since 1.0 private-mode sequences such as `\e[?25l`); it replaces the `FrameBuffer::ANSI_ESC_RE` of 0.x.

| Method | What it does |
|---|---|
| `FrameBuffer.text(value)` | `value.to_s`, with invalid bytes replaced by `?` so a regexp can read it. Every other method here goes through it, so raw telnet bytes cannot crash a render. |
| `FrameBuffer.plain(text)` | The text without escape sequences. |
| `FrameBuffer.visible_width(text)` | The length without escape sequences. |
| `FrameBuffer.clip_ansi(text, max_width)` | Cuts the text to `max_width` visible characters, keeping the escapes before the cut. `''` for a width of 0 or less. |
| `FrameBuffer.wrap_ansi(text, width)` | The one word-wrap of the gem (dialogs, windows and the Markdown renderer use it too). Word-wraps into an Array of lines no wider than `width` (trailing spaces trimmed). The active SGR at a wrap point is repeated at the start of the next line, so colour carries over. A word longer than the width is not broken; it gets a line of its own and overflows. `['']` for a width of 0 or less. |

```ruby
require 'bbs'

fb = BBS::FrameBuffer
p fb.plain("\e[?25l\e[1;33mbright\e[0m")           # => "bright"
p fb.visible_width("\e[1;33mbright\e[0m normal")    # => 13
p fb.clip_ansi("\e[1;33mbright\e[0m normal", 4)     # => "\e[1;33mbrig"
p fb.wrap_ansi("\e[1;32mthe quick brown fox jumps\e[0m over", 10)
# => ["\e[1;32mthe quick", "\e[1;32mbrown fox", "\e[1;32mjumps\e[0m over"]
p fb.wrap_ansi('anextremelylongword here', 8)       # => ["anextremelylongword", "here"]
p fb.text("ok\xFFok")                               # => "ok?ok"
```

## Drawing

All coordinates are 1-based columns and rows, like the terminal's. Anything outside the frame is clipped, at any edge, so you can draw partly off-screen safely.

| Method | What it does |
|---|---|
| `FrameBuffer.new(cols, rows)` | An empty frame. `cols` and `rows` are readable. |
| `clear` | Blanks every cell and moves the cursor to the top left. |
| `move(col, row)` | Sets the write position. |
| `write(text, sgr: nil)` | Writes the characters of `text` from the position, all in one style, and advances. Escapes are not interpreted, they would be written as characters: use `write_ansi` for styled strings. |
| `write_ansi(text, base_sgr: nil)` | Writes a string with embedded escapes. See below. |
| `fill(x:, y:, width:, height:, sgr: nil, char: ' ')` | Fills a rectangle with `char`. |
| `hline(x:, y:, length:, char: '─', sgr: nil)` | A horizontal line. |
| `vline(x:, y:, length:, char: '│', sgr: nil)` | A vertical line. |
| `box(x:, y:, width:, height:, sgr: nil, fill_sgr: nil, style: :single, title: nil, title_sgr: nil, title_indent: 0)` | A frame in `sgr`; the inside filled with spaces in `fill_sgr` when given (else left alone); `style` is `:single` or `:double`; `title` drawn in the top edge in `title_sgr` (or `sgr`), shifted right by `title_indent` columns and clipped to fit. Nothing is drawn below 2×2. |
| `shadow(x:, y:, width:, height:, sgr: nil)` | A drop shadow for the given rectangle: two columns at the right, one row below, offset by one row and two columns. `sgr` defaults to `\e[40m`. |
| `diff(from)` | The ANSI bytes that turn the frame `from` into this one (see above). |

`write_ansi` interprets the escapes the way a terminal would, inside the frame:

- An SGR sequence (`\e[...m`) adds to the current style; a reset (`\e[0m` or `\e[m`) returns to `base_sgr`, not to "no style". This is how a coloured word inside a blue window keeps the blue background after it.
- `\e[NC` (cursor forward) writes N spaces in the current style, as ANSI art uses it.
- Every other CSI sequence is dropped, cursor moves (`\e[ROW;COLH`) included, so text inside a widget cannot jump out of it; the `cursor:` option of 0.x is gone. Control characters are dropped too (including `\n` and `\r`): one call writes one line.

```ruby
require 'bbs'

fb = BBS::FrameBuffer.new(20, 4)
fb.move(1, 1)
fb.write('Hello', sgr: "\e[1;33m")
fb.move(18, 1)
fb.write('clipped')                                        # only "cli" fits
fb.move(1, 2)
fb.write_ansi("a\e[1mb\e[0mc\e[3Cd", base_sgr: "\e[44m")   # styles: 44 / 44+bold / 44, three spaces, d
fb.move(1, 3)
fb.write_ansi("x\e[4;10Hy")                                # H ignored: "xy"
```

The resulting frame:

```text
Hello            cli
abc   d
xy

```

A framed panel on a patterned background, with its shadow:

```ruby
require 'bbs'

fb = BBS::FrameBuffer.new(32, 9)
fb.fill(x: 1, y: 1, width: 32, height: 9, sgr: "\e[44m", char: '░')
fb.box(x: 3, y: 2, width: 24, height: 6, sgr: "\e[1;37;44m", fill_sgr: "\e[44m",
       style: :double, title: 'Inbox', title_sgr: "\e[1;33;44m", title_indent: 4)
fb.shadow(x: 3, y: 2, width: 24, height: 6)
fb.hline(x: 4, y: 4, length: 22)
fb.vline(x: 15, y: 5, length: 2)
fb.move(5, 3)
fb.write('3 new messages', sgr: "\e[0;37;44m")
```

```text
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░╔═════ Inbox═══════════╗░░░░░░
░░║ 3 new messages       ║  ░░░░
░░║──────────────────────║  ░░░░
░░║           │          ║  ░░░░
░░║           │          ║  ░░░░
░░╚══════════════════════╝  ░░░░
░░░░                        ░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
```

## Character width

The frame buffer counts one cell per Ruby character. That is right for ASCII, Latin letters with precomposed accents, box drawing, block and braille characters, which is everything the gem itself draws. It is wrong for:

- wide characters (CJK, most emoji): stored in one cell but shown in two columns by the terminal, so the rest of the line shifts right by one per character;
- combining marks (`e` plus U+0301): counted as two cells but shown in one.

`visible_width`, `clip_ansi`, `wrap_ansi` and Markdown tables count the same way. Normalise text to NFC (`text.unicode_normalize(:nfc)`) and keep wide characters out of anything that has to line up.

# BBS::Renderer: ERB screens

`BBS::Renderer` (lib/bbs/renderer.rb) renders an ERB template into a string for a sequential, flow-style screen. It is what the Flow `screen` step uses (see [Flow](https://wiki.teletypegames.org/engines/rubbs/flow)). It has nothing to do with the frame buffer: the double buffering is done by `FrameBuffer#diff`, described above.

```ruby
BBS::Renderer.render(name, vars = {}, dir: BBS.config.screens_dir)
```

| Argument | Meaning |
|---|---|
| `name` | The template name: the file `NAME.erb` in `dir`. |
| `vars` | A Hash; each pair becomes an instance variable in the template (`username:` becomes `@username`). |
| `dir:` | The template directory. Raises `RuntimeError` ("No screens\_dir: …") when neither this nor `BBS.config.screens_dir` is set. |

ERB runs with `trim_mode: '-'` (so `-%>` swallows the newline), and every `\n` in the result is turned into `\r\n` for the telnet line. The template can call these helpers:

| Helper | Output |
|---|---|
| `clr` | `\e[2J\e[H`: clear the screen, cursor home. |
| `reset` | `\e[0m` |
| `green`, `yellow`, `cyan`, `white`, `red` | The bright colour (`\e[1;32m`, `\e[1;33m`, `\e[1;36m`, `\e[1;37m`, `\e[1;31m`). |
| `gray`, `dim_green` | `\e[0;37m`, `\e[0;32m` |
| `banner(text, padding: 2)` | A green double-line box around the text. |
| `big_banner(text, font: 'slant')` | FIGlet lettering (the `artii` gem) in green, indented by two spaces. |

`screens/welcome.erb`:

```erb
<%= clr %><%= banner 'Welcome' %>
   <%= green %>Hello <%= @username %>!<%= reset %>
<% if @new_mail > 0 -%>
   <%= yellow %>You have <%= @new_mail %> new message(s).<%= reset %>
<% end -%>
```

```ruby
require 'bbs'

out = BBS::Renderer.render('welcome', { username: 'tasi', new_mail: 2 }, dir: 'screens')
```

Written to the terminal (the box and greeting bright green, the mail line bright yellow):

```text
╔═══════════╗
║  Welcome  ║
╚═══════════╝
   Hello tasi!
   You have 2 new message(s).
```

## BBS::Banner

The Flow `banner` and `big_banner` steps use `BBS::Banner` (lib/bbs/banner.rb), which returns ready-to-send strings with `\r\n` line ends and a two-space indent:

| Method | Output |
|---|---|
| `Banner.render(text, color: "\e[1;32m", padding: 2)` | A double-line box around the text, with `padding` spaces on each side. |
| `Banner.render_big(text, color: "\e[1;32m", font: 'slant')` | FIGlet lettering via `artii`; any Artii font name (`slant`, `big`, `small`…). |

```ruby
require 'bbs'

print BBS::Banner.render_big('BBS', font: 'small').gsub("\r\n", "\n")
```

```text
    ___ ___ ___
   | _ ) _ ) __|
   | _ \ _ \__ \
   |___/___/___/
```

# BBS::Markdown

`BBS::Markdown` (lib/bbs/markdown.rb) turns Markdown text into an Array of ANSI-coloured lines that fit a given width. Feed the result to a `BBS::Widgets::ScrollView`, an `Info` window with scrolling, or write the lines out yourself.

```ruby
BBS::Markdown.to_lines(text, width:, theme:)
```

| Argument | Meaning |
|---|---|
| `text` | The Markdown. Invalid bytes become `?`. |
| `width:` | The width to wrap to. When the lines go into a `ScrollView`, pass one less than its width to leave room for the scrollbar. |
| `theme:` | A `BBS::Theme` (or anything that answers `[key]`); the colours come from its roles. |

Inside an application, the usual call is:

```ruby
lines = BBS::Markdown.to_lines(text, width: width - 1, theme: ctx[:app].theme)
```

Each styled span ends with the theme's `text` SGR, so the text after it gets its background back: this matters on a tinted window background. Plain paragraph lines do not start with an SGR of their own; the `ScrollView` draws them with `text` as the base style. When you write the lines straight to a session, put `theme[:text]` in front of each.

## What it understands

| Markdown | Renders as | Role |
|---|---|---|
| `# Heading` | The heading text, no `#` (trailing `#`s dropped too), wrapped. | `text_bright` |
| `## Heading` | Same. | `accent` |
| `###` to `######` | Same, all levels alike. | `label` |
| Paragraph | Consecutive lines joined with single spaces, whitespace collapsed, word-wrapped. | `text` |
| Blank line | Ends the paragraph and gives one empty line. |  |
| `**bold**` | The text. | `text_bright` |
| `*italic*`, `_italic_` | The text. Not inside words: `snake_case_name` and `a*b*c` stay as they are. | `text_dim` |
| ``code`` \| The text. \| `label` |
| `[text](url)` | Only the text; the URL is dropped. | `accent` |
| `> quote` | Each line with a `│` gutter, wrapped to the width minus 2. | gutter `text_dim` |
| `- item`, `* item`, `+ item` | `• item`, wrapped with a hanging indent. Leading spaces of the marker are kept, which gives nested lists. | bullet `accent` |
| `1. item` | `1. item`, the number as written, hanging indent. | number `accent` |
| Indented line under a list item | Continues the item's text. |  |
| `---`, `***`, `___` (three or more) | A `─` rule across the full width. | `label` |
| Code fence (three backticks, any info string) | The lines verbatim, clipped (not wrapped) to the width; embedded ANSI passes through. | `label` |
| GFM table (header row, `---` delimiter row, body rows) | A box-drawn table; header cells bold; `:--`, `:-:`, `--:` alignment; missing cells filled; columns shrunk from the widest when the table is too wide, cells then clipped. | borders `label`, header `text_bright` |
| `\*`, `\_`, `\``, `\#`, `\[`… | The character without the backslash (see the limits). |  |

A sample, rendered at width 36 with `synchronet`:

```ruby
require 'bbs'

md = <<~MD
  # Welcome
  ## Rules
  ### Small print
  Be **nice**, *kind* and _brief_. See [the wiki](https://wiki.example) or run `help`.
  This line joins the paragraph above and wraps at the width.

  > Quoted text gets a gutter
  > on every line.

  - first item
  - second item that is long enough to wrap onto a second line
    continued here
    - nested
  1. one
  2. two

  ---

  | Name | Calls | Note |
  |:-----|------:|:----:|
  | ra   | 12    | ok   |
  | **zed** | 3 |  |
MD
lines = BBS::Markdown.to_lines(md, width: 36, theme: BBS::Theme.synchronet)
puts lines.map { |l| BBS::FrameBuffer.plain(l) }
```

Without the colours (in the terminal: `Welcome` bright white, `Rules` yellow, `Small print` cyan, `nice` bright white, `kind` and `brief` dim, `the wiki` yellow, `help` cyan, the bullets and numbers yellow, the rule and the table frame cyan):

```text
Welcome
Rules
Small print
Be nice, kind and brief. See the
wiki or run help. This line joins
the paragraph above and wraps at the
width.

│ Quoted text gets a gutter
│ on every line.

• first item
• second item that is long enough to
  wrap onto a second line continued
  here
  • nested
1. one
2. two

────────────────────────────────────

┌──────┬───────┬──────┐
│ Name │ Calls │ Note │
├──────┼───────┼──────┤
│ ra   │    12 │  ok  │
│ zed  │     3 │      │
└──────┴───────┴──────┘
```

A table that does not fit is shrunk, then its cells are clipped (width 30):

```text
┌──────────┬─────────────────┐
│ Door     │ Description     │
├──────────┼─────────────────┤
│ SERMO    │ An AI character │
│ Impostor │ Social deductio │
└──────────┴─────────────────┘
```

## Limits

Checked against the source; rely on none of these working:

- **A backslash does not stop emphasis.** Escapes are applied after the emphasis rules, so `\*not italic\*` still becomes italic and the backslashes stay: the result is a backslash, a dim `not italic`, and a backslash. The same goes for `\_x\_`. An escape does work where no emphasis rule matches, e.g. `2\*3\*4` gives `2*3*4` and `\# no heading` gives `# no heading`.
- Escapes are removed inside code spans too: ``a\*b`` shows `a*b`.
- No `__bold__`, `~~strike~~`, HTML or entities (shown as written), setext headings (`===` under a line), indented code blocks, hard line breaks (two trailing spaces), or task lists (`- [ ]` keeps the brackets).
- Images: `![alt](src)` shows `!` and the alt text in the link colour.
- Nested quotes: `> > x` gives one gutter and a literal `> x`.
- Level 3 to 6 headings look the same.
- Code blocks are clipped, not wrapped; a word longer than the width is not broken.
- Wide characters count as one column (see Character width above).

# See also

- [rubbs](https://wiki.teletypegames.org/engines/rubbs) — overview
- [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets) — what draws with these roles
- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — the `theme` DSL in context, the run loop
- [Art and graphics screens](https://wiki.teletypegames.org/engines/rubbs/art) — drawings, the C64 graphics canvas
- [Flow](https://wiki.teletypegames.org/engines/rubbs/flow) — `screen`, `banner` and `big_banner` steps

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [RuBBS](https://wiki.teletypegames.org/engines/rubbs.md)
- Previous: [Doors](https://wiki.teletypegames.org/engines/rubbs/doors.md)
- Next: [Flow](https://wiki.teletypegames.org/engines/rubbs/flow.md)
