39 KiB
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
- Concepts
- Installation & quick start
- Server lifecycle
- The Flow layer
- The TUI layer
- The Application layer
- Widgets
- Windows
- Pre-built window types (
BBS::Windows) - Dialogs
- Menubar, Statusbar, Focus
- Theming
- FrameBuffer & drawing primitives
- Markdown renderer
- Banner, ASCII art & ERB screens
- Persistence (
BBS::Store) - Telnet, sessions & input model
- 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:
- Creates a
BBS::Session(assigns a hexsession_id). - Performs Telnet negotiation (SGA, ECHO, NAWS — terminal size).
- Enables mouse reporting if
BBS.config.mouse. - 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:
- Render full frame, diff against the committed buffer, write the delta.
- Wait for a key (
Session#readkey(timeout: 1.0)). - On
:idle— firetick!(which calls@on_tickandwindow.tick(self)for every open window), then check the idle-disconnect timeout. - Otherwise build an
Eventand 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— lazyFocusManagerover the window's children.reset_focus— rebuild the focus manager (call after adding widgets).close— fireon_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 astatus_hintor a custom key handler.- F10 opens the first menu; ESC closes.
- Block
&blockruns in the Application's context (callopen_window,cols, etc.). Returning:haltends 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'shandle_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 (styledtext_bright,accent,label).**bold**,*italic*,_italic_.`inline code`and```code blocks (preserved verbatim, no wrap).[link text](url)(renderslink textinaccent).> 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_idandtimestampare filled automatically when present. - New rows append in O(1); updates rewrite the file under the mutex.
find/allreturn 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 1–26 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 suppliedtimeoutexpired.
- Navigation:
BBS::Telnet::MouseEvent— whenBBS.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. |