rubbs
A Ruby gem for building telnet BBS servers. Three interaction layers, from simplest to richest:
| Layer | What it is | When to use |
|---|---|---|
BBS::Flow |
Line-oriented declarative dialogue DSL (ask / say / menu / persist) | Login, registration, simple wizards |
BBS::TUI |
Page-router with frame-buffer delta rendering | Single-purpose full-screen UIs |
BBS::Application |
Full widget framework: Menubar, Window, Button, TextInput, ListBox, … | Multi-window Synchronet-style BBSes |
A single session can switch between layers — typically Flow for login then
Application for the main shell.
Installation
# Gemfile
gem 'bbs', git: 'https://git.teletype.hu/tools/rubbs.git'
Quick 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', style: :success
ask :username, prompt: 'Name'
app MAIN
say 'Goodbye!'
end
end
BBS.start
Connect with telnet localhost 2323.
Configuration
BBS.configure yields a BBS::Config:
| Option | Default | Description |
|---|---|---|
flow |
— | A BBS::Flow object (required) |
port |
ENV["BBS_PORT"] || 2323 |
TCP port |
mouse |
false |
Enable xterm SGR mouse reporting |
idle_seconds |
nil |
Disconnect after N idle seconds (nil = never) |
theme |
— | Default theme for Applications (overridable per app) |
screens_dir |
nil |
Directory for ERB screens used by BBS::Flow#screen |
on_session_end |
nil |
Lambda called when a session ends |
The Application layer
BBS::Application is a widget event loop with:
- a root container drawn behind windows;
- an optional
Menubarwith Alt-letter pulldown menus and F10 access; - an optional
Statusbarwith clickable hotkey hints; - a stack of modal
Windows (the top one receives input first); - xterm SGR mouse events delivered to widgets;
- a 1 Hz internal tick for chat polling, clocks, etc.
Built-in widgets (BBS::Widgets)
| Widget | Purpose |
|---|---|
Label |
Static text, optionally aligned, themed via style_key |
Button |
&Save-style hotkey, fires on_click(self); mouse-clickable |
TextInput |
Single-line input with cursor, scroll, on_submit/on_change |
TextArea |
Multi-line editor with arrow-key navigation |
Checkbox |
Toggle with on_change(checked, self) |
ListBox |
Scrolling list with mouse + keyboard, wheel scrolling |
ScrollView |
Read-only scrolling text view, with scrollbar |
ProgressBar, Spinner |
Progress feedback |
Panel, HBox, VBox |
Containers / layout |
Window |
Top-level modal container with title bar, close gadget, shadow |
AnsiArt |
CP437 / ANSI art renderer with SAUCE stripping (AnsiArt.from_file, AnsiArt.random(dir)) |
Built-in dialogs (BBS::Dialogs)
BBS::Dialogs.message(app, "Saved!", title: ' Done ')
BBS::Dialogs.confirm(app, "Delete?", on_yes: -> { ... })
BBS::Dialogs.input(app, prompt: 'Name?', on_submit: ->(value) { ... })
Pre-built window types (BBS::Windows)
High-level modal windows you can open imperatively with .open(app, …) or
declaratively via the window DSL (see below).
| Window | Purpose |
|---|---|
Info |
Centered dialog for messages, splash screens, key/value displays, optional scroll mode |
Form |
Labeled input rows + textarea + checkbox + status line for inline validation |
MasterDetail |
Two-pane browser: ListBox on the left, ScrollView of details on the right |
Stream |
Live history view + single-line input + idle-tick polling (chat, logs) |
ButtonBar |
Helper that lays out a row of buttons at the bottom of any window |
Example — opening directly:
BBS::Windows::Info.open(app,
title: ' Welcome ',
width: 70,
body: [
{ text: 'rubbs', style: :accent },
:spacer, :rule,
{ kv: [['Online', '5'], ['Ruby', RUBY_VERSION]] },
])
BBS::Windows::Form.open(app,
title: ' Profile ',
fields: [
{ key: :sig, label: 'Signature', value: '...' },
{ key: :bio, label: 'Bio', type: :textarea, rows: 6 },
{ key: :nsfw, label: 'Show NSFW', type: :checkbox, value: false },
],
on_submit: ->(values, form) {
return (form.error('Bio required.'); :keep) if values[:bio].empty?
save(values); :close
})
BBS::Windows::Stream.open(app,
title: ' Live Chat ',
initial_lines: chat.history,
placeholder: 'Type and press Enter…',
on_submit: ->(text, _app) { chat.broadcast(username, text) },
on_poll: ->(_app) { chat.drain(session_id) })
Window DSL — declarative windows
Register windows by name inside Application.define, then open them by symbol.
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 chat: ChatService.new, 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.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
window :games, type: :master_detail do |w|
w.title ' Game Catalog '
w.items { |ctx| ctx[:games].all }
w.item_label { |g, _ctx| g.title }
w.render_detail { |g, width, _ctx| GameCard.lines(g, width) }
w.action(label: '&New') { |_win, ctx| ctx[:app].open_window(:new_game) }
end
end
# Later, inside a menu handler:
app.open_window(:compose, board: current_board)
window :name, type: supports :info, :form, :master_detail, :stream.
Inside the block, scalar attrs (title, width, …) and block attrs (body,
items, on_submit, …) accept either a value or a { |ctx, …| … } block.
Use w.field for form fields, w.action for master-detail action buttons,
and w.button for info-window buttons.
Menubar DSL
menubar do
menu '&File' do
item '&Open', shortcut: 'Ctrl-O' do … end
separator
item 'E&xit' do :halt end
end
end
The block runs in the Application's context (so you can call open_window,
close_window, cols, rows, etc.). Return :halt from any handler to end
the session.
Theming
Built-in palettes: synchronet (default), nortoncommander, dosnavigator,
neumanntronics, mono. Apply one and/or override individual style keys with
the theme DSL block:
MAIN = BBS::Application.define do
theme :nortoncommander do
window_border "\e[1;36;44m"
accent "\e[1;33;44m"
end
end
Pass a BBS::Theme instance directly to use it as-is, or build one from
scratch with BBS::Theme.new(:my_theme, { window_border: "\e[1;36m", … }).
Per-window ticks
BBS::Application fires a ~1 Hz tick when idle and calls tick(app) on each
open window. BBS::Windows::Stream uses this for polling. Override tick in
your own Window subclasses to refresh clocks, drain queues, etc.
Markdown → ANSI
BBS::Markdown.to_lines(text, width:, theme:) returns an Array of ANSI-styled
strings suitable for feeding to BBS::Widgets::ScrollView or Info's scroll
mode. Supports headings, bold/italic/code spans, links, quotes, bullet lists,
code blocks, and horizontal rules.
The Flow layer
The DSL for line-based dialogue. Available steps:
| Step | Purpose |
|---|---|
screen :name |
Render an ERB screen from screens/ |
banner, big_banner |
Box-drawing / FIGlet ASCII art header |
say, text, section, line, body |
Text output |
ask :field, set :field, value, persist :field, store: |
Input + persistence |
pause, wait_enter, gate, confirm |
Control flow |
menu, exit_menu |
Numbered option menus |
fetch, pick |
Data fetch + list selection |
rows, table |
Formatted output |
call { |ctx, runner| … } |
Arbitrary code; return :halt to end |
tui DEFINITION |
Hand off to a BBS::TUI page router |
app DEFINITION |
Hand off to a BBS::Application widget app |
Input model
Session#readkey(timeout:) returns one of:
- a single-character
String(printable input) - a
Symbol::enter,:tab,:shift_tab,:backspace,:escape,:up,:down,:left,:right,:home,:end,:page_up,:page_down,:insert,:delete,:f1…:f12,:ctrl_a…:ctrl_z,:alt_a… (any printable character preceded by ESC),:interrupt,:idle - a
BBS::Telnet::MouseEventwithkind,button,x,y,mods— delivered whenBBS.config.mouse = true
Stores
BBS::Store is a thread-safe upsert-by-session_id CSV file with an
in-memory cache; appends are O(1), full rewrites only on update.
Architecture
| File | Responsibility |
|---|---|
bbs/server.rb |
TCP accept loop, one thread per client |
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 |
bbs/frame_buffer.rb |
Double-buffer, delta diff, drawing helpers |
bbs/application.rb |
Widget event loop, window stack, ticking, DSL builder |
bbs/widget.rb, bbs/container.rb |
Widget tree base |
bbs/widgets.rb |
Built-in widgets |
bbs/widgets/ansi_art.rb |
CP437/ANSI art widget |
bbs/window.rb, bbs/menu.rb, bbs/statusbar.rb, bbs/dialogs.rb |
Chrome |
bbs/window_spec.rb |
Declarative window DSL (WindowSpec) |
bbs/windows/info.rb |
Centered info / message / kv 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/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 |