new framework
This commit is contained in:
269
README.md
269
README.md
@@ -1,6 +1,16 @@
|
||||
# rubbs
|
||||
|
||||
A Ruby gem for building telnet BBS servers with a declarative conversation flow DSL.
|
||||
A Ruby gem for building telnet BBS servers. Three interaction layers, from
|
||||
simplest to richest:
|
||||
|
||||
| Layer | What it is | When to use |
|
||||
|---|---|---|
|
||||
| `BBS::Flow` | Line-oriented declarative dialogue DSL (ask / say / menu / persist) | Login, registration, simple wizards |
|
||||
| `BBS::TUI` | Page-router with frame-buffer delta rendering | Single-purpose full-screen UIs |
|
||||
| `BBS::Application` | Full widget framework: Menubar, Window, Button, TextInput, ListBox, … | Multi-window Synchronet-style BBSes |
|
||||
|
||||
A single session can switch between layers — typically `Flow` for login then
|
||||
`Application` for the main shell.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -9,192 +19,165 @@ A Ruby gem for building telnet BBS servers with a declarative conversation flow
|
||||
gem 'bbs', git: 'https://git.teletype.hu/tools/rubbs.git'
|
||||
```
|
||||
|
||||
## Quick start
|
||||
## Quick start — Flow + Application
|
||||
|
||||
```ruby
|
||||
require 'bbs'
|
||||
|
||||
sessions = BBS::Store.new(path: 'data/sessions.csv', headers: %w[session_id timestamp name])
|
||||
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.screens_dir = 'screens'
|
||||
c.stores = [sessions]
|
||||
c.flow = BBS::Flow.define do
|
||||
screen :welcome
|
||||
ask :name, prompt: "What is your name?"
|
||||
say "Hello, %{name}!", style: :success
|
||||
persist :name, store: sessions
|
||||
c.mouse = true
|
||||
c.idle_seconds = 600
|
||||
c.flow = BBS::Flow.define do
|
||||
big_banner 'MY BBS', style: :success
|
||||
ask :username, prompt: 'Name'
|
||||
app MAIN
|
||||
say 'Goodbye!'
|
||||
end
|
||||
end
|
||||
|
||||
BBS::Server.start
|
||||
BBS.start
|
||||
```
|
||||
|
||||
Connect with `telnet localhost 2323`. The port defaults to `2323` and can be overridden with the `BBS_PORT` environment variable.
|
||||
Connect with `telnet localhost 2323`.
|
||||
|
||||
## Configuration
|
||||
|
||||
`BBS.configure` yields a `BBS::Config` object:
|
||||
`BBS.configure` yields a `BBS::Config`:
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `screens_dir` | `"screens"` | Directory containing ERB screen files |
|
||||
| `stores` | `[]` | List of `BBS::Store` instances |
|
||||
| `flow` | — | A `BBS::Flow` object (required) |
|
||||
| `port` | `ENV["BBS_PORT"] \|\| 2323` | TCP port to listen on |
|
||||
| `port` | `ENV["BBS_PORT"] \|\| 2323` | TCP port |
|
||||
| `mouse` | `false` | Enable xterm SGR mouse reporting |
|
||||
| `idle_seconds` | `nil` | Disconnect after N idle seconds (nil = never) |
|
||||
| `theme` | — | Default theme for Applications (overridable per app) |
|
||||
| `screens_dir` | `nil` | Directory for ERB screens used by `BBS::Flow#screen` |
|
||||
| `on_session_end` | `nil` | Lambda called when a session ends |
|
||||
|
||||
## Flow DSL
|
||||
## The Application layer
|
||||
|
||||
The entire dialogue is defined as a declarative `BBS::Flow.define` block.
|
||||
`BBS::Application` is a widget event loop with:
|
||||
|
||||
### Primitives
|
||||
- a root container drawn behind windows;
|
||||
- an optional `Menubar` with Alt-letter pulldown menus and F10 access;
|
||||
- an optional `Statusbar` with clickable hotkey hints;
|
||||
- a stack of modal `Window`s (the top one receives input first);
|
||||
- xterm SGR mouse events delivered to widgets;
|
||||
- a 1 Hz internal tick for chat polling, clocks, etc.
|
||||
|
||||
#### Output
|
||||
### Built-in widgets (`BBS::Widgets`)
|
||||
|
||||
| Primitive | Description |
|
||||
| Widget | Purpose |
|
||||
|---|---|
|
||||
| `screen :name, **vars` | Render an ERB screen from `screens/`; vars override/extend the context |
|
||||
| `banner "text", style:` | Print a box-drawing ASCII banner |
|
||||
| `big_banner "text", style:, font:` | Print a large FIGlet ASCII-art banner (default font: `slant`) |
|
||||
| `say "text", style:` | Print a single styled line |
|
||||
| `text "content", style:` | Print a line; accepts a block `{ \|ctx\| string }` for dynamic content |
|
||||
| `section "title", color:` | Print a decorated section header with box-drawing |
|
||||
| `line color:` | Print a horizontal rule |
|
||||
| `rows(empty: "…") { \|ctx\| array }` | Print a list of lines; shows `empty` when the array is empty |
|
||||
| `table { \|ctx\| hash }` | Print a two-column key/value table |
|
||||
| `body(width:, indent:) { \|ctx\| string }` | Word-wrap and print a block of text (strips basic Markdown) |
|
||||
| `output { \|ctx\| string }` | Write a raw string returned by the block verbatim |
|
||||
| `Label` | Static text, optionally aligned, themed via `style_key` |
|
||||
| `Button` | `&Save`-style hotkey, fires `on_click(self)`; mouse-clickable |
|
||||
| `TextInput` | Single-line input with cursor, scroll, `on_submit`/`on_change` |
|
||||
| `TextArea` | Multi-line editor with arrow-key navigation |
|
||||
| `Checkbox` | Toggle with `on_change(checked, self)` |
|
||||
| `ListBox` | Scrolling list with mouse + keyboard, wheel scrolling |
|
||||
| `ScrollView` | Read-only scrolling text view, with scrollbar |
|
||||
| `ProgressBar`, `Spinner` | Progress feedback |
|
||||
| `Panel`, `HBox`, `VBox` | Containers / layout |
|
||||
| `Window` | Top-level modal container with title bar, close gadget, shadow |
|
||||
|
||||
#### Input & control
|
||||
|
||||
| Primitive | Description |
|
||||
|---|---|
|
||||
| `ask :field, prompt:, transform:, validate:` | Read input and store it in the session context |
|
||||
| `set :field, value` | Set a context variable directly from code |
|
||||
| `persist :field, …, store:` | Write named context fields to a `BBS::Store` |
|
||||
| `pause "message", seconds:` | Display a message and sleep |
|
||||
| `wait_enter prompt:` | Print a prompt and block until the user presses Enter |
|
||||
| `gate denied: "…" { \|ctx\| bool }` | Halt the flow unless the block returns true |
|
||||
| `confirm "message", denied: "…"` | Yes/no gate — halts on no |
|
||||
| `confirm "message", denied: "…" { }` | Yes/no branch — runs the block on yes, skips on no |
|
||||
| `call { \|ctx\| }` | Run an arbitrary block; return `:halt` to end the session |
|
||||
| `menu "prompt", loop: bool { }` | Numbered option menu; loops back after each selection when `loop: true` |
|
||||
| `exit_menu` | Break out of the enclosing `menu` loop and continue the parent flow |
|
||||
|
||||
#### Data fetching
|
||||
|
||||
| Primitive | Description |
|
||||
|---|---|
|
||||
| `fetch :key, loading: "…" { \|ctx\| value }` | Show a loading message, call the block, store the result in `ctx[:key]`, erase the loading line |
|
||||
| `pick from:, prompt:, empty:, item:, hint: { sub-flow }` | Show a numbered list from `ctx[from]`; on selection stores the item in `ctx[:picked]` and runs the sub-flow |
|
||||
|
||||
### `ask` options
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `prompt:` | The string printed before the cursor |
|
||||
| `transform:` | A proc applied to the raw input before storing (e.g. `transform: :upcase.to_proc`) |
|
||||
| `validate:` | A proc that returns `true` if the value is acceptable; the question is repeated on failure |
|
||||
|
||||
### Banner styles
|
||||
|
||||
`:success`, `:info`, `:error`, `:warning`, `:muted` — each maps to an ANSI colour.
|
||||
|
||||
### `fetch` + `pick` example
|
||||
### Built-in dialogs (`BBS::Dialogs`)
|
||||
|
||||
```ruby
|
||||
option 'Blog Posts' do
|
||||
section 'Blog Posts', color: :blue
|
||||
fetch :pages, loading: 'Loading...' do |ctx|
|
||||
WIKI.list('blog')
|
||||
end
|
||||
pick from: :pages,
|
||||
empty: 'No results.',
|
||||
prompt: 'Enter number to read (blank to go back)',
|
||||
item: ->(p, i) { "#{i}. #{p.title}" },
|
||||
hint: ->(p, i) { p.description.to_s[0...65] } do
|
||||
fetch :body, loading: 'Loading page...' do |ctx|
|
||||
WIKI.content(ctx[:picked].id)
|
||||
end
|
||||
body { |ctx| ctx[:body] }
|
||||
wait_enter
|
||||
BBS::Dialogs.message(app, "Saved!", title: ' Done ')
|
||||
BBS::Dialogs.confirm(app, "Delete?", on_yes: -> { ... })
|
||||
BBS::Dialogs.input(app, prompt: 'Name?', on_submit: ->(value) { ... })
|
||||
```
|
||||
|
||||
### Menubar DSL
|
||||
|
||||
```ruby
|
||||
menubar do
|
||||
menu '&File' do
|
||||
item '&Open', shortcut: 'Ctrl-O' do … end
|
||||
separator
|
||||
item 'E&xit' do :halt end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Menu example
|
||||
The block runs in the Application's context (so you can call `open_window`,
|
||||
`close_window`, `cols`, `rows`, etc.). Return `:halt` from any handler to end
|
||||
the session.
|
||||
|
||||
```ruby
|
||||
menu ">", loop: true do
|
||||
option "Leave feedback" do
|
||||
ask :feedback, prompt: "Feedback"
|
||||
persist :feedback, store: sessions
|
||||
say "Recorded. Thank you.", style: :success
|
||||
end
|
||||
### Theming
|
||||
|
||||
option "Exit" do
|
||||
exit_menu
|
||||
end
|
||||
end
|
||||
`BBS::Theme.synchronet` (default) is a yellow/cyan Synchronet-inspired palette.
|
||||
`BBS::Theme.mono` provides a colour-free fallback. Create your own with
|
||||
`BBS::Theme.new(:my_theme, { window_border: "\e[1;36m", … })`.
|
||||
|
||||
say "Goodbye!", style: :muted
|
||||
```
|
||||
## The Flow layer
|
||||
|
||||
## Screens
|
||||
The DSL for line-based dialogue. Available steps:
|
||||
|
||||
ERB files in `screens/` have access to ANSI colour helpers and a `banner` helper. Context variables set by `ask`, `set`, or passed explicitly to `screen` are available as `@field`.
|
||||
| Step | Purpose |
|
||||
|---|---|
|
||||
| `screen :name` | Render an ERB screen from `screens/` |
|
||||
| `banner`, `big_banner` | Box-drawing / FIGlet ASCII art header |
|
||||
| `say`, `text`, `section`, `line`, `body` | Text output |
|
||||
| `ask :field`, `set :field, value`, `persist :field, store:` | Input + persistence |
|
||||
| `pause`, `wait_enter`, `gate`, `confirm` | Control flow |
|
||||
| `menu`, `exit_menu` | Numbered option menus |
|
||||
| `fetch`, `pick` | Data fetch + list selection |
|
||||
| `rows`, `table` | Formatted output |
|
||||
| `call { \|ctx, runner\| … }` | Arbitrary code; return `:halt` to end |
|
||||
| `tui DEFINITION` | Hand off to a `BBS::TUI` page router |
|
||||
| `app DEFINITION` | Hand off to a `BBS::Application` widget app |
|
||||
|
||||
```erb
|
||||
<%= banner("MISSION COMPLETE") %>
|
||||
## Input model
|
||||
|
||||
<%= white %>Congratulations!<%= reset %> You are <%= yellow %><%= @name %><%= reset %>.
|
||||
```
|
||||
`Session#readkey(timeout:)` returns one of:
|
||||
|
||||
### ANSI helpers
|
||||
|
||||
`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `reset`, `bold`, `dim`
|
||||
- a single-character `String` (printable input)
|
||||
- a `Symbol`: `:enter`, `:tab`, `:shift_tab`, `:backspace`, `:escape`,
|
||||
`:up`, `:down`, `:left`, `:right`, `:home`, `:end`,
|
||||
`:page_up`, `:page_down`, `:insert`, `:delete`,
|
||||
`:f1` … `:f12`, `:ctrl_a` … `:ctrl_z`,
|
||||
`:alt_a` … (any printable character preceded by ESC),
|
||||
`:interrupt`, `:idle`
|
||||
- a `BBS::Telnet::MouseEvent` with `kind`, `button`, `x`, `y`, `mods` —
|
||||
delivered when `BBS.config.mouse = true`
|
||||
|
||||
## Stores
|
||||
|
||||
Each `persist` step writes to a `BBS::Store` (a thread-safe, append-and-upsert CSV file). `session_id` and `timestamp` are always written automatically.
|
||||
|
||||
```ruby
|
||||
identities = BBS::Store.new(path: 'data/identities.csv', headers: %w[session_id timestamp name code])
|
||||
signups = BBS::Store.new(path: 'data/signups.csv', headers: %w[session_id timestamp email])
|
||||
|
||||
persist :name, :code, store: identities
|
||||
persist :email, store: signups
|
||||
```
|
||||
|
||||
Rows are keyed by `session_id`: the first `persist` for a session creates the row; subsequent ones update it in place.
|
||||
|
||||
## Colors
|
||||
|
||||
`BBS::Color` is a `module_function` module you can `include` to get the `c` helper anywhere — top-level, inside lambdas, and inside `instance_eval` blocks.
|
||||
|
||||
```ruby
|
||||
include BBS::Color
|
||||
|
||||
c(:green, "[▶ Play]") # => "\e[1;32m[▶ Play]\e[0m"
|
||||
c(:gray, "some text")
|
||||
c(:yellow, msg.timestamp)
|
||||
```
|
||||
|
||||
Available color symbols: `:reset`, `:gray`, `:yellow`, `:white`, `:blue`, `:cyan`, `:green`, `:magenta`, `:red`.
|
||||
|
||||
Raises `KeyError` on unknown symbols.
|
||||
`BBS::Store` is a thread-safe upsert-by-`session_id` CSV file with an
|
||||
in-memory cache; appends are O(1), full rewrites only on update.
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `bbs/server.rb` | TCP accept loop, spawns one thread per client |
|
||||
| `bbs/session.rb` | Negotiates telnet options, runs the flow |
|
||||
| `bbs/telnet.rb` | Telnet protocol (IAC handling, echo control, readline) |
|
||||
| `bbs/flow.rb` | Flow DSL builder |
|
||||
| `bbs/flow_runner.rb` | Executes a Flow step by step against a session context |
|
||||
| `bbs/color.rb` | `BBS::Color` module with ANSI color map and `c()` helper |
|
||||
| `bbs/config.rb` | Configuration object |
|
||||
| `bbs/banner.rb` | Box-drawing and FIGlet banner renderer |
|
||||
| `bbs/renderer.rb` | ERB screen loader with ANSI colour helpers |
|
||||
| `bbs/store.rb` | Thread-safe CSV persistence |
|
||||
| `bbs/server.rb` | TCP accept loop, one thread per client |
|
||||
| `bbs/session.rb` | Telnet negotiation, mouse enable/disable, idle timer |
|
||||
| `bbs/telnet.rb` | Telnet protocol, full key parsing, xterm SGR mouse |
|
||||
| `bbs/flow.rb`, `bbs/flow_runner.rb` | Flow DSL + interpreter |
|
||||
| `bbs/tui.rb`, `bbs/tui_runner.rb` | Page-router with frame-buffer rendering |
|
||||
| `bbs/frame_buffer.rb` | Double-buffer, delta diff, drawing helpers |
|
||||
| `bbs/application.rb` | Widget event loop, window stack, ticking |
|
||||
| `bbs/widget.rb`, `bbs/container.rb` | Widget tree base |
|
||||
| `bbs/widgets.rb` | Built-in widgets |
|
||||
| `bbs/window.rb`, `bbs/menu.rb`, `bbs/statusbar.rb`, `bbs/dialogs.rb` | Chrome |
|
||||
| `bbs/focus_manager.rb` | Tab/Shift-Tab focus traversal |
|
||||
| `bbs/theme.rb` | Style palette (synchronet / mono) |
|
||||
| `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 |
|
||||
|
||||
Reference in New Issue
Block a user