mr.zeroandClaude Opus 5 9c21133ce5
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline failed
Add a CI check
The repo was registered in Woodpecker but had no pipeline, so nothing
ever ran on it. There is no test suite here, so the check is what can
actually catch a regression in a library: that it still compiles and
loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:07:35 +02:00
TUI
2026-05-11 20:24:40 +02:00
2026-08-17 07:07:35 +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.
S
Description
No description provided
Readme
148 KiB
Languages
Ruby 100%