EN /HU | Login

Drawing and theming

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. Widgets that use all this are on 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), 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). 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