Zsolt Tasnadi 6b320318ca Render GFM tables and enable mouse-wheel scrolling
Markdown.to_lines now detects GFM tables (header + delimiter row) and
renders them as box-drawn, alignment-aware tables that shrink to fit the
pane width, instead of mangling the rows into a single paragraph.

ScrollView now handles mouse-wheel events, and Window routes wheel
events to the widget under the cursor, so a detail/content pane scrolls
even when keyboard focus is on an adjacent list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:20:39 +02:00
TUI
2026-05-11 20:24:40 +02:00
2026-05-12 22:15:07 +02:00
2026-05-13 22:51:17 +02:00

rubbs

A Ruby gem for building telnet BBS servers. From a one-screen line prompt to a full-blown Synchronet-style multi-window application — without leaving Ruby.

require 'bbs'

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    big_banner 'My BBS'
    ask :name, prompt: 'Your name'
    say 'Welcome!', style: :success
  end
end

BBS.start

Connect with telnet localhost 2323.


Table of contents

  1. Concepts
  2. Installation & quick start
  3. Server lifecycle
  4. The Flow layer
  5. The TUI layer
  6. The Application layer
  7. Widgets
  8. Windows
  9. Pre-built window types (BBS::Windows)
  10. Dialogs
  11. Menubar, Statusbar, Focus
  12. Theming
  13. FrameBuffer & drawing primitives
  14. Markdown renderer
  15. Banner, ASCII art & ERB screens
  16. Persistence (BBS::Store)
  17. Telnet, sessions & input model
  18. Source layout

1. Concepts

rubbs ships three interaction layers, from simplest to richest. Layers can be mixed in a single session — typically Flow for login, then Application for the main shell.

Layer What it is When to use
BBS::Flow Line-oriented declarative dialogue (ask / say / menu / persist) Login, registration, simple wizards
BBS::TUI Single-screen page router with frame-buffer delta rendering Single-purpose full-screen UIs
BBS::Application Widget framework: Menubar, Window, Button, TextInput, ListBox, … Multi-window Synchronet-style BBSes

The server boots a Telnet TCP listener, hands each connection a Session, which runs the configured Flow. Inside that flow you can hand off to a TUI or Application, then return to the flow when the user exits.

TCPServer → Session → FlowRunner ─┬→ TUIRunner
                                  ├→ Application#run
                                  └→ FlowRunner (sub-flow, e.g. confirm-block)

2. Installation & quick start

Gemfile

gem 'bbs', git: 'https://git.teletype.hu/tools/rubbs.git'

Hello world — Flow only

require 'bbs'

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    big_banner 'MY BBS', style: :success
    ask :name, prompt: 'Name'
    say 'Welcome!', style: :success
  end
end

BBS.start

Flow → Application

require 'bbs'

MAIN = BBS::Application.define do
  theme BBS::Theme.synchronet
  menubar do
    menu '&File' do
      item '&About' do BBS::Dialogs.message(self, 'Hello from rubbs!') end
      item 'E&xit'  do :halt end
    end
  end
  status_hint('F1', 'Help')  { BBS::Dialogs.message(self, 'No help yet.') }
  status_hint('F10', 'Menu') { menubar.open(0) }
end

BBS.configure do |c|
  c.mouse        = true
  c.idle_seconds = 600
  c.flow = BBS::Flow.define do
    big_banner 'MY BBS'
    ask :username, prompt: 'Name'
    app MAIN
    say 'Goodbye!'
  end
end

BBS.start

3. Server lifecycle

BBS.configure { |c| … } / BBS.config

BBS::Config is a value object accessed through these two singleton helpers. The Server reads it once at boot; per-session state lives on BBS::Session.

Field Default Description
flow The root BBS::Flow to run for each connection (required).
port ENV["BBS_PORT"] || 2323 TCP port for BBS::Server.
mouse false If true, sessions enable xterm SGR mouse reporting on negotiate.
idle_seconds nil Hard idle-timeout. nil disables it.
theme nil Default BBS::Theme for Applications.
screens_dir nil Directory for ERB templates used by Flow#screen.
on_session_end nil ->(session) { … } invoked from the server thread after each session ends, even on errors.

BBS.start(port: …)

Boots a BBS::Server (one thread per client). Each thread:

  1. Creates a BBS::Session (assigns a hex session_id).
  2. Performs Telnet negotiation (SGA, ECHO, NAWS — terminal size).
  3. Enables mouse reporting if BBS.config.mouse.
  4. Runs FlowRunner.new(session, session_id, BBS.config.flow).run.

BBS::Server

Lightweight wrapper around TCPServer. Catches accept-loop errors (Errno::EMFILE, IOError, …) and warns instead of crashing.


4. The Flow layer

BBS::Flow.define { … } builds an immutable, line-oriented dialogue. Each step is interpreted by BBS::FlowRunner with access to a shared @ctx hash keyed by Symbol field names.

Step reference

Step Signature Effect
screen screen :name, **vars Render screens_dir/<name>.erb (vars resolved against @ctx if Symbols).
banner banner text, style: :success Box-drawing banner.
big_banner big_banner text, style:, font: FIGlet (Artii) ASCII art header. font: defaults to 'slant'.
say say text, style: :muted Print one line, then blank line.
text text content = nil, style: / text(style:) { ... } Print text; block receives @ctx.
line line color: :muted Horizontal rule.
section section title, color: :confirm "┌─ Title ─────┐" header.
body body(width:, indent:) { ... } Word-wrapped paragraph body; block returns Markdown-ish text.
rows rows(empty:) { ... } Block returns rows (strings or arrays) printed one per line.
table table { ... } Block returns [[label, value], …]; printed as label/value pairs.
output output { ... } Raw write; block returns a String written verbatim.
set set :name, value Assign @ctx[name].
ask ask :name, prompt:, transform:, validate: Read line into @ctx[:name]. transform is :upcase/:downcase/Proc. validate is :email/:non_empty.
persist persist :a, :b, store: STORE Upsert listed fields into a BBS::Store, keyed by session_id.
pause pause msg, seconds: 1.0 Print message and sleep.
wait_enter wait_enter prompt: Block until ENTER.
gate gate(denied:) { |ctx| ... } If block returns falsy, print denied msg and halt.
confirm confirm msg, denied: Yes/No prompt; halt if "no".
confirm confirm(msg, denied:) { ... } Yes/No prompt; on "yes" run sub-flow.
menu menu(prompt, loop: false) { option … } Numbered options. Each option is a sub-flow; loop: true re-shows after each selection until exit_menu.
exit_menu exit_menu Return to the enclosing menu's caller.
fetch fetch :name, loading: 'Loading…' { |ctx| ... } Show spinner-style line, store block result into @ctx[:name].
pick pick from:, prompt:, empty:, item:, hint: Show numbered list of @ctx[from]; on selection, store as @ctx[:picked] and run sub-flow.
call call { |ctx, runner| ... } Arbitrary code; runner exposes write/readline. Return :halt to terminate.
tui tui DEFINITION Hand off to a BBS::TUI.
app app DEFINITION Hand off to a BBS::Application.

Available styles for style: arguments

:success :muted :error :info :prompt :confirm :cyan :magenta :blue :yellow :white :green :red

Validation

ask … validate: :email rejects with a built-in message and halts. :non_empty rejects empties. Pass a custom Proc for ad-hoc rules (must return truthy).

Sub-flows

menu, confirm { … } and pick { … } open nested Flow blocks evaluated on a fresh Flow instance and executed with the same @ctx.

Example — login + menu

BBS::Flow.define do
  screen :welcome, year: Time.now.year
  ask :user, prompt: 'Login', validate: :non_empty, transform: :downcase
  persist :user, store: USERS

  gate(denied: 'Banned user.') { |ctx| !banned?(ctx[:user]) }

  menu 'Choose:', loop: true do
    option 'Read messages' do
      fetch(:msgs) { |ctx| MsgStore.for(ctx[:user]) }
      pick from: :msgs, prompt: 'Pick #:', empty: 'No mail.',
           item: ->(m, i) { "#{i}. #{m.subject}" } do
        text { |ctx| ctx[:picked].body }
        wait_enter
      end
    end
    option 'Quit' do
      call { :halt }
    end
  end
end

5. The TUI layer

A single-screen page router with frame-buffer delta rendering. Use it when you want a custom dashboard or interactive screen without the overhead of the full widget framework.

DASH = BBS::TUI.define do
  start :main

  init do
    @selection = 0
  end

  helpers do
    def palette = %w[red yellow green]
  end

  chrome do
    bar y: 1, content: ' my-bbs ', style: :info
    bar y: term_rows, content: ' F10=quit ', style: :muted
  end

  page :main do
    enter do
      @items = fetch_items
    end

    nav :menu, size: -> { @items.size }

    render do
      list @items, x: 4, y: 3, selected: @selection
    end

    key(:enter) { @selected = @items[@selection]; go :detail }
    key(:f10)   { :halt }
  end

  page :detail do
    nav :scroll, content: -> { @body }, window: -> { term_rows - 4 }

    enter { @scroll = 0; @body = render_body(@selected) }
    render do
      text @body.lines[@scroll, term_rows - 4]&.join, x: 2, y: 2
    end
    key(:escape) { go :main }
  end
end

# Inside a Flow:
BBS::Flow.define { tui DASH }

BBS::TUI.define

DSL method Purpose
start :page Initial page (default :idle).
init { ... } Runs once before the first render, in the page context.
chrome { ... } Drawn on every render, before the active page.
helpers { ... } Module-eval'd; methods become callable inside pages.
page :name { ... } Define a page.

BBS::TUI::Page DSL

Method Purpose
enter { ... } Runs when entering the page (go :name).
render { ... } Re-evaluated every paint.
key(k) { ... } Bind a single key (Symbol or 1-char String).
printable { |ch| ... } Catch printable chars without explicit binding.
mouse { |m| ... } Mouse event handler.
any_key { |k| ... } Catch-all fallback (after printable).
nav :menu, size: Install up/down on @menu_selection (wraps).
nav :cycle Up/down on @item_selection over @items (wraps).
nav :list, window: Up/down/page-up/page-down/home/end on @item_selection + @scroll over @items.
nav :scroll, content:, window: Same keys for @scroll over a content list (no selection).

Block arguments to nav are Procs evaluated inside the page context, e.g. window: -> { term_rows - 6 }. Plain values are wrapped in a Proc.

Returning :halt from any binding ends the TUI.

BBS::TUIRunner::Context — drawing primitives

Inside chrome/enter/render/keybindings the self is a Context with:

Method Purpose
term_cols, term_rows Negotiated terminal size (NAWS).
clear Wipe next frame.
at(x, y) Position cursor.
text(content, x:, y:, style:) Write a string (ANSI passthrough).
bar(y:, content:, width:, style:) Full-width band of style with text.
box(x:, y:, width:, height:, title:, style:, background:) { |ix, iy, iw, ih| ... } Double-line frame; yields inner area.
float(title:, width:, height:, style:, background:) { ... } Centered box.
fill(x:, y:, width:, height:, background:) Solid block.
screen_fill(background:) Repaint whole screen background.
list(items, x:, y:, selected:, style:, highlight:) Bullet list with optional selection marker.
list_view(items, x:, y:, height:, scroll:, selected:, label:, hint:, hint_y:, empty:, style:, highlight:) Scrollable list with selected hint line.
wordwrap(text, width) Strip simple Markdown and wrap.
go(page) Switch page (calls its enter).
reload Re-enter the current page.

Both style: and background: accept either a FlowRunner::STYLES Symbol (:success, :prompt, …) or a raw ANSI sequence. Background colors map through :black :red :green :yellow :blue :cyan :white.


6. The Application layer

BBS::Application is the full TUI: a widget event loop with menubar, status bar, modal window stack, mouse, focus traversal, themes and per-window ticks.

Top-level build with Application.define

MAIN = BBS::Application.define do
  theme :synchronet do
    accent "\e[1;33;40m"
  end

  services chat: ChatService.new, boards: BoardService.new

  helpers do
    def announce(text)
      BBS::Dialogs.message(self, text, title: ' Notice ')
    end
  end

  init do
    @user = context[:username]
  end

  body do
    add BBS::Widgets::Label.new(text: 'Background label', style_key: :text)
  end

  menubar do
    menu '&File' do
      item '&Quit', shortcut: 'Alt-X' do :halt end
    end
  end

  status_hint('F10', 'Menu')  { menubar.open(0) }
  status_hint('F1',  'Help')  { announce('No help yet') }

  window :compose, type: :form do |w|
    w.title 'Compose'
    w.field :body, type: :textarea
    w.on_submit { |values, form, ctx| ctx[:boards].post(values[:body]); :close }
  end
end

Application.define DSL

Method Purpose
theme(symbol_or_theme) Set the base theme (Symbol calls BBS::Theme.<name>; or pass a Theme instance).
theme(:base) { ... } Inside the block, any one-arg method call (screen "\e[40m") overrides that style key. Use inherit :neumanntronics to switch base mid-block. style :key, "\e[…" is also available.
services(hash) Merge into the services hash exposed to window callbacks via ctx. Call multiple times to add more.
helpers { ... } Module-eval'd; methods become available on the live Application instance.
init { ... } Runs in the Application context after building, before body.
body { ... } Block evaluated against a BodyContext — call add(widget) to attach widgets to the root container.
menubar { menu '&X' do item '&Y' do … end end } Define the top menubar (see §11).
status_hint(key, label) { ... } Append one status bar hotkey hint.
window(:name, type: :info|:form|:master_detail|:stream) { |w| ... } Register a declarative window spec (see §9).

Application instance API

Method Purpose
cols, rows Terminal size from NAWS (defaults 80×24).
theme / services / context Live state.
add(widget) Append to the root container.
open_window(window) Push a Window onto the modal stack.
open_window(:name, **extras) Open a registered WindowSpec; extras flow into the spec's ctx.
close_window(window = top_window) Pop a window. Calls its on_close if any.
top_window The current modal target of input.
with_loading(text) { ... } Show a "Please wait" modal while the block runs in a thread; returns the block's value.
tick! Manually fire the periodic tick.
stop! Break out of the run loop.

Run loop

run does:

  1. Render full frame, diff against the committed buffer, write the delta.
  2. Wait for a key (Session#readkey(timeout: 1.0)).
  3. On :idle — fire tick! (which calls @on_tick and window.tick(self) for every open window), then check the idle-disconnect timeout.
  4. Otherwise build an Event and dispatch (top window → menubar → statusbar → root focus manager).

Return :halt from any handler to end the application (and, via Flow, the session).


7. Widgets

All widgets inherit from BBS::Widget (see bbs/widget.rb). They have:

bounds     # Rect(x, y, width, height) — 1-based screen coords
parent     # enclosing Container
id         # optional Symbol (Container#find(:my_id))
visible    # default true
focused    # set by FocusManager
enabled    # default true
theme      # propagated by Container#theme=

Subclasses override render(frame) and handle_event(event). Return values from handle_event: :halt ends the app, :handled stops propagation, nil continues.

Widgets::Label

text:, align: (:left/:center/:right), style_key: (theme key, default :text). Renders one line, clipped to width.

Widgets::Button

label: (e.g. '&Save' — ampersand marks hotkey), on_click: ->(btn) { … }. Alt+letter or Enter/Space fires. Mouse-clickable. enabled = false greys out.

Widgets::TextInput

Single-line input. value:, placeholder:, max_length: (default 256), password: (mask with *), on_submit: ->(value, self) { … }, on_change: ->(value, self) { … }. Cursor with arrows / Home / End, Backspace, Delete.

Widgets::TextArea

Multi-line. value:, max_length: (default 8000), on_change:. Arrows navigate across lines, Enter splits, Backspace joins, Home/End within line. value returns \n-joined string.

Widgets::Checkbox

label:, checked:, on_change: ->(checked, self) { … }. Toggle with Space/Enter. Renders [x] Label / [ ] Label.

Widgets::ListBox

Scrolling list. items: array, label: ->(item) { … } (defaults item.to_s), on_select: ->(item, self) { … } (selection change), on_activate: ->(item, self) { … } (Enter / dbl-click). Up/Down, PgUp/PgDn, Home/End, mouse click, mouse wheel scroll. selected_item exposes the current row.

Widgets::ScrollView

Read-only scrollable text. lines: is Array<String> (ANSI passthrough). Up/Down/PgUp/PgDn/Home/End scrolls. Built-in right-side scrollbar.

Widgets::ProgressBar

value:, max:, label:. Renders ██░░░ filled to ratio with optional centered label.

Widgets::Spinner

label: — braille spinner. Call tick to advance frame.

Containers

Container#add(child), #remove, #clear, #find(id), #walk { |w| … }, #focusable_descendants, #hit_test(x, y). Theme propagates to children.

Container Purpose
Widgets::HBox(gap: 1) Horizontal split. Equal widths, minus gap per child.
Widgets::VBox(gap: 0) Vertical split.
Widgets::Panel(title:, box_style: :single|:double) Bordered container; renders box first then children.

Widgets::AnsiArt

CP437 / ANSI-art viewer.

art = BBS::Widgets::AnsiArt.from_file('art/welcome.ans',
                                       align: :center, valign: :middle)
art.layout(1, 2, 80, 22)
panel.add(art)

# Or a random pick from a directory:
BBS::Widgets::AnsiArt.random('art/')
BBS::Widgets::AnsiArt.from_string("hello", align: :left)

Constructors: from_file(path, **opts), from_string(text, **opts), random(dir, **opts) (falls back to a built-in card if the dir is missing), default_lines (the fallback card).

Encoding:

  • .ans / .asc / .nfo → decoded as IBM437 → UTF-8.
  • .ansi / .txt → assumed UTF-8.
  • Anything else → UTF-8, falling back to IBM437.
  • Trailing SAUCE metadata (\x1A SAUCE…) is stripped.

Options: align: :left|:center|:right, valign: :top|:middle|:bottom, fill_parent: true (default — re-bounds to parent each render).


8. Windows

BBS::Window < Container — a top-level modal container with title bar, shadow, close gadget, and its own FocusManager.

win = BBS::Window.new(title: ' Inbox ', modal: true,
                      shadow: true, close_key: :escape,
                      on_close: ->(w) { puts 'closing' })
win.layout(10, 5, 60, 18)
win.add(some_widget)
win.reset_focus
app.open_window(win)
Field Default Description
title nil Centered title in the top border.
modal false (Informational; the stack already gives the top window priority.)
shadow true Draw the drop shadow.
on_close nil Called when the user closes; pass false to suppress the close gadget.
close_key :escape Key that triggers close.

Methods:

  • focus_manager — lazy FocusManager over the window's children.
  • reset_focus — rebuild the focus manager (call after adding widgets).
  • close — fire on_close, return :close_window (the application pops).
  • tick(app) — override in subclasses for per-second polling.

The close gadget [■] (top-left, columns x+2..x+4) is mouse-clickable. Mouse-clicks elsewhere inside the window focus the hit widget.


9. Pre-built window types (BBS::Windows)

Higher-level modals you can open imperatively with .open(app, …), or register declaratively with the window DSL.

BBS::Windows::Info

Centered info / splash / message dialog.

BBS::Windows::Info.open(app,
  title: ' Welcome ',
  width: 70, height: 20,
  body: [
    { text: 'rubbs v1.0', style: :accent, align: :center },
    :spacer,
    :rule,
    { kv: [['Online', '5'], ['Ruby', RUBY_VERSION]] },
    'Plain label line',
  ],
  buttons: [
    { label: '&Continue', primary: true },
    { label: '&Cancel',   cancel:  true, on_click: ->(w) {  } },
  ])

Body item types: String (label), :spacer/''/nil (blank row), :rule (horizontal line), { text:, style:, align: }, { kv: [[k,v],…], label_width: }, { widget:, height: }, or a raw BBS::Widget (each used as-is, non-scroll mode).

scroll: true — flatten the whole body into a single ScrollView.

padding: overrides any of { sides: 2, top: 1, bottom: 2 }.

Default buttons: a single centered [ &Close ] if buttons: is nil. Pass buttons: [] for none.

BBS::Windows::Form

Labeled multi-row form with inline validation.

BBS::Windows::Form.open(app,
  title: ' Profile ',
  hint:  'Edit your info:',
  fields: [
    { key: :name,    label: 'Name',     value: 'Joe', max_length: 80 },
    { key: :pw,      label: 'Password', value: '',    password: true },
    { key: :bio,     label: 'Bio',      type: :textarea, rows: 8 },
    { key: :public,  label: 'Public',   type: :checkbox, value: false },
  ],
  on_submit: ->(values, form) {
    return (form.error('Name is required.'); :keep) if values[:name].empty?
    User.save(values); :close
  },
  on_cancel: ->(form) {  })

Field types: :input (default, single-line TextInput), :textarea (multi-line, label ignored), :checkbox (label part of widget).

Form methods callable from on_submit:

  • form.values — current field values as a Hash.
  • form.error('msg') — paint the status line red.
  • form.info('msg') — paint the status line cyan-ish (input_label color).
  • form.clear_status — wipe the status line.

Returning :keep from on_submit leaves the window open; any other return closes it.

Buttons: a [ &Save ] / [ &Cancel ] pair by default. Override labels with submit_label: / cancel_label:.

BBS::Windows::MasterDetail

Two-pane browser: ListBox on the left, ScrollView on the right.

BBS::Windows::MasterDetail.open(app,
  title: ' Game Catalog ',
  items: services[:games].all,
  item_label:    ->(g)     { g.title },
  render_detail: ->(g, w)  { GameCard.lines(g, w) },
  bottom_meta:   ->(g)     { "#{g.players} players  #{g.year}" },
  list_width: 30,
  empty_message: '  (no games)',
  actions: [
    { label: '&New', on_click: ->(win) {  } },
  ],
  close_label: '&Close')

render_detail is called whenever the selection changes; it receives (item, inner_width) and returns an Array of ANSI-styled strings. Returning nil shows nothing.

A [ &Close ] button is appended unless close_button: false. Other actions: render to the left of close.

Exposes list_widget, detail_view, meta_label for live manipulation.

BBS::Windows::Stream

Scrolling history pane + single-line input + idle-tick polling. Built for chat / log views.

BBS::Windows::Stream.open(app,
  title: ' Live Chat ',
  initial_lines: chat.history.map { format_line(_1) },
  placeholder: 'Type and press Enter…',
  buffer_size: 500,
  on_submit: ->(text, app) { chat.send(text); :keep },
  on_poll:   ->(app)       { chat.drain(app.session_id) })

on_submit is called when the user hits Enter on a non-empty line. Returning :keep keeps the input as-is; otherwise the input is cleared.

on_poll is invoked from the 1 Hz idle tick. Return an Array of new lines to append (or nil/[]).

buffer_size: caps the in-memory history (older lines drop off). Exposes history (ScrollView) and input (TextInput).

BBS::Windows::ButtonBar

Layout helper used by the other window types. Pass it a window, app and an Array of button descriptors:

BBS::Windows::ButtonBar.attach(window, app, [
  { key: :ok,     label: '&OK',     primary: true,
    on_click: ->(win) {  } },
  { key: :cancel, label: '&Cancel', cancel:  true, align: :left },
  { key: :help,   label: '&Help',   align: :left, keep_open: true,
    on_click: ->(win) { show_help } },
])

Returns { widgets:, primary:, by_key: }. Default alignment is :center for a single button, :right otherwise. cancel: true is informational (ESC already closes via Window#close_key). keep_open: true keeps the window open after click; otherwise it closes unless on_click returns :keep.

Window DSL — window :name, type: :form do |w| … end

Inside Application.define, register a WindowSpec keyed by a name. Block callbacks receive a ctx hash combining the app's services, app context, and any extras passed to open_window.

MAIN = BBS::Application.define do
  services boards: BoardService.new

  window :compose, type: :form do |w|
    w.title  { |ctx| " Post to #{ctx[:board].title} " }
    w.width  70
    w.field  :body, type: :textarea, max_length: 1000
    w.prefill { |ctx| { body: ctx[:draft]&.body || '' } }
    w.on_submit do |values, form, ctx|
      case ctx[:boards].post(ctx[:board].id, ctx[:username], values[:body])
      when :empty then form.error('Message cannot be empty.'); :keep
      else :close
      end
    end
  end
end

# Later:
app.open_window(:compose, board: current_board)

DSL inside the block:

Attr / method Applies to Notes
title, width, height, padding all Scalar or `{
body info Array of body items.
scroll, close_button, close_label info, master_detail, stream
`button(**opts) { win, ctx ... }`
field key, **opts form Field descriptor.
`prefill { ctx {...} }`
hint, submit_label, cancel_label form
`on_submit { values, form, ctx ... }`
`on_cancel { form, ctx ... }`
`items { ctx ... }, item_label {
`render_detail { item, width, ctx ... }`
`bottom_meta { item, ctx ... }`
list_width, empty_message master_detail
`action(**opts) { win, ctx ... }`
`initial_lines { ctx ... }, placeholder, buffer_size`
`on_poll { app, ctx ... }`

Block attrs that return blocks at the DSL level (title { |ctx| … }) are re-evaluated each time the window opens, so they reflect the current ctx.

Open with app.open_window(:name, key: value, …). Extras flow into ctx.


10. Dialogs

Quick imperative helpers in BBS::Dialogs:

BBS::Dialogs.message(app, 'Saved!', title: ' Done ', width: 40)

BBS::Dialogs.confirm(app, 'Delete this message?',
  on_yes: -> { Msg.delete(id) },
  on_no:  -> {  })

BBS::Dialogs.input(app, prompt: 'New name:', value: '',
  on_submit: ->(value) { rename(value) })

BBS::Dialogs.centred_window(app, width:, height:, title:) is the underlying helper to build a centered, modal Window.

For richer modal experiences prefer the BBS::Windows::* window types described in §9.


11. Menubar, Statusbar, Focus

BBS::Menubar

menubar do
  menu '&File' do
    item '&Open',    shortcut: 'Ctrl-O' do  end
    item '&Save',    shortcut: 'Ctrl-S', enabled: -> { current_doc } do  end
    separator
    item 'E&xit' do :halt end
  end
  menu '&Help' do
    item '&About' do BBS::Dialogs.message(self, 'rubbs') end
  end
end
  • Ampersand in label marks the hotkey letter (Alt+letter from anywhere, plain letter while the menu is open).
  • shortcut: is informational — it's drawn on the right of the menu item. Bind the actual shortcut via a status_hint or a custom key handler.
  • F10 opens the first menu; ESC closes.
  • Block &block runs in the Application's context (call open_window, cols, etc.). Returning :halt ends the session.

When the menubar is open, all events are routed to it first — its keys cannot leak through to underlying windows.

BBS::Statusbar

status_hint('F10', 'Menu')   { menubar.open(0) }
status_hint('F1',  'Help')   { announce 'No help yet' }
status_hint('Alt-X', 'Quit') { :halt }

Renders left-aligned hints. Optional statusbar.right_text = '...' for a right-aligned indicator (set after build via app.statusbar.right_text=). Mouse-click on a hint fires its handler. Key matching is forgiving: F10:f10, Alt-X:alt_x, Esc:escape, Tab:tab, anything else uses case-insensitive name match.

BBS::FocusManager

One per Window (and the application's root container). Walks visible, enabled, focusable descendants for Tab / Shift-Tab cycling.

Key behavior:

  • handle_focus_key(event) — consumes Tab / Shift-Tab.
  • focus(widget) — explicit focus (used when a window opens to put cursor on the first input).
  • dispatch(event) — forwards to the focused widget's handle_event.

12. Theming

A BBS::Theme is just a frozen Hash of role-name → ANSI SGR string.

Built-in presets:

Preset Vibe
BBS::Theme.synchronet (default) Yellow on black, blue windows — Synchronet-style.
BBS::Theme.nortoncommander Blue screen, cyan title bar — Norton Commander.
BBS::Theme.dosnavigator Light grey background, white panels — DOS Navigator.
BBS::Theme.neumanntronics Muted 256-color palette, dark cyan accents.
BBS::Theme.mono Black & white, inverse-video selections — terminals without color.

Style keys

screen, text, text_dim, text_bright, label, accent, success, warning, error, window_bg, window_border, window_title, window_title_focused, window_text, window_shadow, menubar, menubar_hotkey, menubar_selected, menubar_sel_hot, menu_bg, menu_text, menu_hotkey, menu_selected, menu_disabled, menu_border, statusbar, statusbar_key, button, button_focused, button_hotkey, button_disabled, input, input_focused, input_label, listbox, listbox_selected, listbox_focused, listbox_header, scrollbar, scrollbar_thumb, check_on, check_off, reset.

Customising

# 1. Use a preset
theme BBS::Theme.synchronet

# 2. Override per-key with the DSL block
theme :nortoncommander do
  inherit :neumanntronics            # optional: switch base mid-block
  window_border "\e[1;36;44m"
  accent        "\e[1;33;44m"
  style :screen, "\e[40m"            # explicit form
end

# 3. Build from scratch
theme BBS::Theme.new(:mine, {
  screen: "\e[40m", text: "\e[0;37;40m", # …
})

# 4. Merge in code
custom = BBS::Theme.synchronet.merge(accent: "\e[1;36;40m")

Theme#style(key) / Theme#[](key) look up a SGR. Widgets call style(:role) which resolves through the widget's theme or its parent's.


13. FrameBuffer & drawing primitives

BBS::FrameBuffer is the offscreen grid used by TUI and Application. Cells hold (char, sgr) pairs; diff computes the minimal ANSI byte stream to transform one frame into another (only changed cells get redrawn).

Direct API:

fb = BBS::FrameBuffer.new(cols, rows)
fb.move(col, row)                              # 1-based
fb.write('Hello', sgr: "\e[1;33m")
fb.write_ansi("\e[1;33mbright\e[0m  normal")   # respects embedded SGR
fb.fill(x:, y:, width:, height:, sgr:, char: ' ')
fb.hline(x:, y:, length:, char: '─', sgr:)
fb.vline(x:, y:, length:, char: '│', sgr:)
fb.box(x:, y:, width:, height:, sgr:, fill_sgr:,
       style: :single|:double, title:, title_sgr:, title_indent:)
fb.shadow(x:, y:, width:, height:, sgr:)
delta = fb.diff(prev_fb)                       # ANSI byte stream

Class helpers for ANSI-aware string handling:

  • BBS::FrameBuffer.visible_width(text) — length ignoring SGR escapes.
  • BBS::FrameBuffer.clip_ansi(text, max_width) — truncate keeping styles intact.
  • BBS::FrameBuffer.wrap_ansi(text, width) — word-wrap with SGR state carried across wrapped lines.

14. Markdown renderer

BBS::Markdown.to_lines(text, width:, theme:) → Array of ANSI strings, one per terminal line. Feed it into a BBS::Widgets::ScrollView or an Info window with scroll: true.

Supports:

  • ####### headings (styled text_bright, accent, label).
  • **bold**, *italic*, _italic_.
  • `inline code` and ``` code blocks (preserved verbatim, no wrap).
  • [link text](url) (renders link text in accent).
  • > quoted text (rendered with a gutter).
  • - / * / + bullet lists, with hanging indents on wrap.
  • 1. numbered lists.
  • --- / *** / ___ horizontal rules.
  • Backslash escapes for \* \_ \ [ ( { # …`.

Each inline span resets to theme[:text] so trailing text keeps the correct background — critical when the surrounding window has a tinted background.


15. Banner, ASCII art & ERB screens

BBS::Banner

BBS::Banner.render('Hello!',     color: "\e[1;32m", padding: 2)
BBS::Banner.render_big('ZINE',   color: "\e[1;36m", font: 'slant')

The plain variant draws a box-drawing banner; the big variant uses the artii gem (FIGlet). Any Artii font name is accepted (big, slant, small, etc.). These are used by Flow's banner / big_banner steps.

BBS::Renderer — ERB screens

For Flow#screen :name, **vars:

BBS.configure { |c| c.screens_dir = 'screens' }

# screens/welcome.erb
<%= clr %><%= banner 'Welcome' %>
   <%= green %>Hello <%= @username %>!<%= reset %>

The ERB context exposes color helpers (green, yellow, cyan, white, gray, red, dim_green, clr, reset) plus banner(text, padding:) and big_banner(text, font:). Instance variables are populated from vars (resolved against @ctx if the value is a Symbol).

BBS::Color

Tiny helper for ad-hoc colorisation outside the theme system:

include BBS::Color   # or BBS::Color.c(...)
c(:cyan, 'hello')    # => "\e[0;36mhello\e[0m"

Available colors: :reset :gray :yellow :white :blue :cyan :green :magenta :red.


16. Persistence (BBS::Store)

A thread-safe, upsert-by-session_id CSV file with an in-memory cache. Used by Flow#persist but useful standalone.

USERS = BBS::Store.new(
  path:    'data/users.csv',
  headers: %w[session_id timestamp username email]
)

USERS.upsert(session_id: sid, username: 'joe', email: 'j@e.com')
USERS.find(sid)        # => { 'session_id' => …, 'username' => 'joe', … } or nil
USERS.all              # => [ {…}, {…}, … ]
USERS.delete(sid)
  • Headers session_id and timestamp are filled automatically when present.
  • New rows append in O(1); updates rewrite the file under the mutex.
  • find / all return Hashes.

17. Telnet, sessions & input model

BBS::Session

Wraps a TCP socket with Telnet negotiation and the input parser:

session.session_id       # 16-hex SecureRandom id
session.term_cols        # NAWS column count (default 80)
session.term_rows        # NAWS row count    (default 24)
session.readline         # cooked line input (FlowRunner uses this)
session.readkey(timeout: 1.0)
session.write(bytes)
session.enable_mouse / disable_mouse

Telnet options negotiated on connect: SGA (suppress go-ahead), ECHO (server-side echo), NAWS (terminal size). Mouse uses xterm 1000/1006 SGR mode.

Session#readkey return values

  • Single-character String — printable input ('a', ' ', …).
  • Symbol — one of:
    • Navigation: :enter, :tab, :shift_tab, :backspace, :escape, :up, :down, :left, :right, :home, :end, :page_up, :page_down, :insert, :delete.
    • Function keys: :f1:f12.
    • Control codes: :ctrl_a:ctrl_z (raw 126 minus a few that map to other things — Enter, Tab, Backspace, Esc, Ctrl-C).
    • Alt-modified printables: :alt_a:alt_z, :alt_0, … (any printable preceded by ESC).
    • :interrupt — Ctrl-C.
    • :idle — the supplied timeout expired.
  • BBS::Telnet::MouseEvent — when BBS.config.mouse = true. Fields:
    • kind: :press, :release, :move, :wheel.
    • button: :left, :middle, :right, :up, :down (wheel), :none.
    • x, y: terminal-relative, 1-based.
    • mods: array of :shift, :ctrl, :meta.

BBS::Event

Application/Window/Widget handle_event receives a normalised BBS::Event:

event.kind       # :key, :mouse, :idle, :resize, :tick
event.key?       # kind == :key
event.mouse?     # kind == :mouse
event.key        # the Symbol/String key
event.mouse      # the Telnet::MouseEvent
event.x, event.y # mouse position
event.alt?       # key.to_s.start_with?('alt_')
event.alt_char   # 'x' from :alt_x
event.char?      # key is a 1-char String

Return :halt to end the application, :handled to stop propagation, nil/false to keep dispatching.


18. Source layout

File Responsibility
lib/bbs.rb Public surface: BBS.configure, BBS.config, BBS.start.
bbs/server.rb TCP accept loop, one thread per client, on-session-end hook.
bbs/session.rb Telnet negotiation, mouse enable/disable, idle timer.
bbs/telnet.rb Telnet protocol, full key parsing, xterm SGR mouse.
bbs/config.rb BBS::Config value object.
bbs/flow.rb, bbs/flow_runner.rb Flow DSL + interpreter.
bbs/tui.rb, bbs/tui_runner.rb Page-router with frame-buffer rendering and the drawing-primitive context.
bbs/frame_buffer.rb Double-buffer, delta diff, drawing helpers, ANSI string utilities.
bbs/application.rb Widget event loop, window stack, ticking, builder DSL.
bbs/widget.rb, bbs/container.rb Widget tree base.
bbs/widgets.rb Built-in widgets (Label, Button, TextInput, TextArea, Checkbox, ListBox, ScrollView, ProgressBar, Spinner, HBox, VBox, Panel).
bbs/widgets/ansi_art.rb CP437 / ANSI art widget with SAUCE stripping.
bbs/window.rb, bbs/window_spec.rb Window + declarative window DSL.
bbs/windows/info.rb Centered info / message / kv / scroll dialog.
bbs/windows/form.rb Multi-field form with status line.
bbs/windows/master_detail.rb Two-pane list / detail browser.
bbs/windows/stream.rb Live log / chat window with input + polling.
bbs/windows/button_bar.rb Bottom-row button layout helper.
bbs/menu.rb, bbs/statusbar.rb, bbs/dialogs.rb Chrome + ad-hoc dialogs.
bbs/focus_manager.rb Tab/Shift-Tab focus traversal.
bbs/theme.rb Style palette (synchronet, nortoncommander, dosnavigator, neumanntronics, mono).
bbs/markdown.rb Markdown → ANSI line array renderer.
bbs/event.rb Unified key + mouse event objects.
bbs/store.rb Thread-safe cached CSV upsert.
bbs/banner.rb, bbs/renderer.rb FIGlet + ERB.
bbs/color.rb Simple ANSI colour helper.
Description
No description provided
Readme 268 KiB
Languages
Ruby 100%