# 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/.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.`; 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` (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 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 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. |