Files
rubbs/README.md
2026-05-13 22:51:17 +02:00

1127 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```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](#1-concepts)
2. [Installation & quick start](#2-installation--quick-start)
3. [Server lifecycle](#3-server-lifecycle)
4. [The Flow layer](#4-the-flow-layer)
5. [The TUI layer](#5-the-tui-layer)
6. [The Application layer](#6-the-application-layer)
7. [Widgets](#7-widgets)
8. [Windows](#8-windows)
9. [Pre-built window types (`BBS::Windows`)](#9-pre-built-window-types-bbswindows)
10. [Dialogs](#10-dialogs)
11. [Menubar, Statusbar, Focus](#11-menubar-statusbar-focus)
12. [Theming](#12-theming)
13. [FrameBuffer & drawing primitives](#13-framebuffer--drawing-primitives)
14. [Markdown renderer](#14-markdown-renderer)
15. [Banner, ASCII art & ERB screens](#15-banner-ascii-art--erb-screens)
16. [Persistence (`BBS::Store`)](#16-persistence-bbsstore)
17. [Telnet, sessions & input model](#17-telnet-sessions--input-model)
18. [Source layout](#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
```ruby
gem 'bbs', git: 'https://git.teletype.hu/tools/rubbs.git'
```
### Hello world — Flow only
```ruby
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
```ruby
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
```ruby
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.
```ruby
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 `Proc`s 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`
```ruby
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](#11-menubar-statusbar-focus)). |
| `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](#9-pre-built-window-types-bbswindows)). |
### 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:
```ruby
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.
```ruby
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`.
```ruby
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.
```ruby
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.
```ruby
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.
```ruby
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.
```ruby
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:
```ruby
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`.
```ruby
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 `{ |ctx| ... }` block. |
| `body` | info | Array of body items. |
| `scroll`, `close_button`, `close_label` | info, master_detail, stream | |
| `button(**opts) { |win, ctx| ... }` | info | Custom buttons list. |
| `field key, **opts` | form | Field descriptor. |
| `prefill { |ctx| {...} }` | form | Pre-populate fields. |
| `hint`, `submit_label`, `cancel_label` | form | |
| `on_submit { |values, form, ctx| ... }` | form, stream | |
| `on_cancel { |form, ctx| ... }` | form | |
| `items { |ctx| ... }`, `item_label { |item, ctx| ... }` | master_detail | |
| `render_detail { |item, width, ctx| ... }` | master_detail | |
| `bottom_meta { |item, ctx| ... }` | master_detail | |
| `list_width`, `empty_message` | master_detail | |
| `action(**opts) { |win, ctx| ... }` | master_detail | Extra button. |
| `initial_lines { |ctx| ... }`, `placeholder`, `buffer_size` | stream | |
| `on_poll { |app, ctx| ... }` | stream | |
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`:
```ruby
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](#9-pre-built-window-types-bbswindows).
---
## 11. Menubar, Statusbar, Focus
### `BBS::Menubar`
```ruby
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`
```ruby
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
```ruby
# 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:
```ruby
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`
```ruby
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`:
```ruby
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:
```ruby
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.
```ruby
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:
```ruby
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`:
```ruby
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. |