BBS::Applicationis the desktop layer of rubbs: a full-screen, mouse-aware event loop with a menubar, a statusbar, a background panel and a stack of switchable windows, Turbo Vision style. This page covers theApplication.defineDSL method by method, the instance API, the run loop and the app-to-door loop, the context hash, events, focus, the menubar and the statusbar. The windows themselves are on Windows, the widgets on Widgets.
An application is a root container with layers drawn over it. Every frame is drawn in this order, from the back to the front:
| Layer | Class | What it is |
|---|---|---|
| screen | — | the whole terminal filled with the theme's :screen style |
| root panel | BBS::Container (app.root) |
the desktop: widgets added with add (ANSI art, labels, a button) |
| window stack | BBS::Window (app.windows) |
the windows, the last one is the active one |
| menubar | BBS::Menubar (app.menubar) |
row 1, Alt-letter pulldown menus |
| statusbar | BBS::Statusbar (app.statusbar) |
the last row, key hints and a right-aligned text |
The menubar and the statusbar exist only if the definition declares them (menubar and at least one status_hint). Without them the root panel and the windows get the whole screen.
The rendering is double-buffered: every frame is drawn into a fresh BBS::FrameBuffer, compared with the last one sent, and only the changed cells go down the line. A terminal resize (NAWS) is noticed at the next frame: the whole screen is redrawn and every window gets relayout.
A shared notice board: a list of notes in a master-detail window, a form to write one, a menu with a door, status hints, a clock on the statusbar. It is the program the rest of the page refers to. Save it as notes.rb, run ruby notes.rb, then telnet localhost 2323.
require 'bbs'
# A tiny shared notice board: one list of notes for every caller.
class NoteStore
Note = Struct.new(:id, :author, :title, :body, :at)
def initialize
@notes = []
@mutex = Mutex.new
end
def all = @mutex.synchronize { @notes.dup }
def add(author, title, body)
return :empty if title.strip.empty?
@mutex.synchronize { @notes << Note.new(@notes.size + 1, author, title.strip, body, Time.now) }
:ok
end
end
# A door: any callable(session, context) gets the raw line.
DICE = lambda do |session, context|
session.write("\r\n #{context[:username]} rolls: #{rand(1..6)} and #{rand(1..6)}\r\n")
session.write(" Press ENTER to go back.\r\n")
session.readline
end
NOTES_APP = BBS::Application.define do
theme :synchronet do
accent "\e[1;33;44m"
end
services notes: NoteStore.new
door :dice, DICE, label: '&Dice'
helpers do
def me = context[:username] || 'guest'
def clock = Time.now.strftime('%H:%M')
def note_list
open = windows.find { |w| w.is_a?(BBS::Windows::MasterDetail) }
open ? raise_window(open) : open_window(:notes)
end
def refresh_notes
windows.grep(BBS::Windows::MasterDetail).each { |w| w.replace_items(services[:notes].all) }
end
end
window :notes, type: :master_detail do |w|
w.title ' Notes '
w.maximizable true
w.items { |ctx| ctx[:notes].all }
w.item_label { |note, _ctx| note.title }
w.render_detail do |note, width, _ctx|
["#{note.title} — #{note.author}", '', *BBS::FrameBuffer.wrap_ansi(note.body.to_s, width)]
end
w.bottom_meta { |note, _ctx| note.at.strftime('%Y-%m-%d %H:%M') }
w.empty_message ' No notes yet. Alt-N writes one.'
w.action(label: '&New') { |_win, ctx| ctx[:app].open_window(:new_note); :keep }
end
window :new_note, type: :form do |w|
w.title ' New note '
w.width 60
w.hint 'Alt-S saves, Esc cancels.'
w.field :title, label: 'Title', max_length: 40
w.field :body, type: :textarea, rows: 5
w.on_submit do |values, form, ctx|
if ctx[:notes].add(ctx[:app].me, values[:title], values[:body]) == :empty
form.error('A note needs a title.')
:keep
else
ctx[:app].refresh_notes
:close
end
end
end
menubar do
menu '&File' do
item '&Notes', shortcut: 'F2' do note_list end
item '&New note', shortcut: 'F3' do open_window(:new_note) end
separator
item 'E&xit', shortcut: 'Alt-X' do :halt end
end
menu '&Doors' do
doors
end
menu '&Help' do
item '&About' do BBS::Dialogs.message(self, "Notes desk for #{me}.", title: ' About ') end
end
end
status_hint('F2', 'Notes') { note_list }
status_hint('F3', 'New', hidden: true) { open_window(:new_note) }
status_hint('F10', 'Menu') { menubar.open(0) }
status_hint('Alt-X', 'Bye') { :halt }
on_tick { statusbar.right_text = "#{me} │ #{clock}" }
on_door { |door, ctx| warn "#{ctx[:username]} enters #{door.name}" }
init do
statusbar.right_text = "#{me} │ #{clock}"
note_list
end
end
BBS.configure do |c|
c.port = Integer(ENV.fetch('PORT', 2323))
c.mouse = true
c.flow = BBS::Flow.define do
ask :username, prompt: 'Your name'
call do |ctx, runner|
NOTES_APP.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
nil
end
say 'Bye!'
end
end
BBS.start if $PROGRAM_NAME == __FILE__
What the caller sees after typing a name and writing one note:
File Doors Help
╔═[■]═ Notes ══════════════════════════════════════════════════════1══[↑]═╗
║ ║
║ Hi Hi — Ann ║
║ ║
║ body ║
║ ║
...
║ 2026-10-27 23:16 ║
║ [ New ] [ Close ]║
╚══════════════════════════════════════════════════════════════════════════┘
F2 Notes F10 Menu Alt-X Bye Ann │ 23:16
Things to notice:
NOTES_APP is a definition (an Application::Builder), built once at load time and shared by every caller. Each call gets its own Application instance from it.NoteStore.new runs once, so every caller sees the same notes. That is why the store has a mutex.context (here :username, filled by the flow's ask).ctx hash; ctx[:app] is the running application, so helpers are reachable as ctx[:app].me.call step (the usual way)require 'bbs'
APP = BBS::Application.define do
status_hint('Alt-X', 'Quit') { :halt }
end
BBS.configure do |c|
c.flow = BBS::Flow.define do
ask :username, prompt: 'Name'
call do |ctx, runner|
APP.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
nil
end
say 'Goodbye!'
end
end
BBS.start
Builder#run builds the application, runs it, and when the caller left it through a door, runs the door and builds the application again (see The app-door loop). The flow's own ctx is passed as the application's context, so what the flow asked (:username) and what the application writes into context survive both ways. Returning nil from the call step lets the flow go on (say 'Goodbye!'); returning :halt would end the call.
app steprequire 'bbs'
APP = BBS::Application.define do
status_hint('Alt-X', 'Quit') { :halt }
end
FLOW = BBS::Flow.define do
ask :username, prompt: 'Name'
app APP
say 'Goodbye!'
end
The app step is shorter: it runs APP.run(...) (Builder#run) with the flow's ctx, so the caller goes through doors here as well (since 0.17.1; before that the step built the application once and a door only ended it). The call form is still the one to use when the step needs code around the run; all the boards of the network (Teletype BBS, Feedia BBS, Alexandria BBS) use it.
Builder#build(session:, session_id:, context: {}) returns a ready Application; its run is the event loop alone. Anything that has a BBS::Session can call it (a door, a test, a custom server):
require 'bbs'
APP = BBS::Application.define do
status_hint('Alt-X', 'Quit') { :halt }
end
def serve(session, session_id)
app = APP.build(session: session, session_id: session_id, context: { username: 'guest' })
app.run
end
BBS::Application.define { ... } evaluates the block on a fresh BBS::Application::Builder and returns it. Nothing is built yet: the builder records the definition, and build or run turns it into an application per caller.
| Method | Signature | Runs | What it does |
|---|---|---|---|
theme |
theme(base = nil) { ... } |
at define time | the colour theme |
services |
services(hash = nil) |
at define time | shared objects handed to window callbacks |
helpers |
helpers { def ... end } |
at define time | methods added to every application instance |
window |
window(name, type:) { \|w\| ... } |
at define time | a named window spec, see Windows |
door |
door(name, program, label: nil) |
at define time | a door the caller can step into |
menubar |
menubar { menu ... } |
per build | the menubar |
status_hint |
status_hint(key, label, hidden: false) { ... } |
per build | one statusbar entry |
body |
body { add widget } |
per build | widgets on the root panel |
init |
init { ... } |
per build, last | setup code on the new application |
on_tick |
on_tick { ... } |
about once a second while idle | periodic work |
on_idle |
on_idle { ... } |
when the caller reaches the idle limit | keep them on (:keep) or let the line drop |
on_door |
on_door { \|door, context\| ... } |
before each door | bookkeeping |
The order of the calls inside define does not matter. build always does: application, menubar, statusbar, init, body.
theme(base = nil, &block)
Sets the theme of the application, its menubar, statusbar and every window opened through it. The default is BBS::Theme.synchronet.
| Call | Result |
|---|---|
theme BBS::Theme.neumanntronics |
that theme as it is |
theme(:dosnavigator) { ... } |
the preset by name, with overrides from the block |
theme(BBS::Theme.mono) { ... } |
that theme with overrides |
theme { ... } |
synchronet with overrides |
Inside the block (a ThemeBuilder):
| Method | What it does |
|---|---|
KEY "\e[...m" |
any method with one argument overrides that style key (screen "\e[0;37;40m") |
style(key, value) |
the same, for keys that collide with Ruby method names |
inherit(name_or_theme) |
switch the base theme inside the block |
require 'bbs'
APP = BBS::Application.define do
theme :neumanntronics do
accent "\e[1;33;44m"
style :text, "\e[0;37;44m"
end
end
Without a block, pass a preset name or a BBS::Theme instance: theme :synchronet and theme BBS::Theme.synchronet are the same (since 0.17.1; in 0.17.0 the Symbol form crashed the first frame). theme with no arguments returns the theme set so far. The presets and the style keys are listed on Drawing.
services(hash = nil)
Merges hash into the services of the definition; call it as often as you like. Without an argument it returns the hash. The services become app.services and are spread into the ctx of every window spec callback (ctx[:notes]).
require 'bbs'
class Clock
def now = Time.now
end
APP = BBS::Application.define do
services clock: Clock.new
services motd: 'Be excellent to each other.'
window :motd, type: :info do |w|
w.title ' Message of the day '
w.body { |ctx| [ctx[:motd], :spacer, ctx[:clock].now.to_s] }
end
init { open_window(:motd) }
end
The hash and its objects are created once and shared by all callers. Keep per-caller state in context, and make shared objects thread-safe: every caller runs in its own thread.
helpers { def name ... end }
The block is evaluated into a module, and every application built from the definition is extended with it. Helper methods run on the application, so they can call the whole instance API (open_window, context, services, statusbar, ...), and everything else that runs on the application can call them: menu items, status hints, init, on_tick, and window callbacks through ctx[:app].
require 'bbs'
APP = BBS::Application.define do
helpers do
def greet = BBS::Dialogs.message(self, "Hello, #{context[:username]}!")
def sysop? = context[:username] == 'sysop'
end
menubar do
menu '&Hello' do
item '&Greet' do greet end
end
end
init { greet if sysop? }
end
helpers can be called more than once; the methods accumulate in the same module.
window(name, type:) { |w| ... }
Registers a BBS::WindowSpec under name; type is :info, :form, :master_detail or :stream. Open it with app.open_window(name, **extras). Every attribute, field and callback is documented on Windows.
require 'bbs'
APP = BBS::Application.define do
window :about, type: :info do |w|
w.title ' About '
w.body ['Notes desk 1.0', :spacer, { kv: [['Ruby', RUBY_VERSION]] }]
end
status_hint('F1', 'About') { open_window(:about) }
end
door(name, program, label: nil)
Declares a BBS::Door. program is a BBS::FakeOS definition, a BBS::Flow, or anything that responds to call(session, context); label is the text of its menu item (& marks the hotkey) and defaults to the upcased name. The door does not run by itself: app.open_door(name) leaves the application through it, and doors inside a menu adds one item per declared door. Door programs are covered on Doors.
require 'bbs'
COIN = ->(session, _context) { side = %w[Heads Tails].sample; session.write("\r\n #{side}!\r\n"); sleep 2 }
APP = BBS::Application.define do
door :coin, COIN, label: '&Coin toss'
menubar do
menu '&Doors' do
doors
end
end
status_hint('F9', 'Coin') { open_door(:coin) }
end
menubar { menu(label) { item ...; separator; doors } }
Declares the menubar. The block is evaluated at every build, on a MenubarBuilder:
| Method | Signature | What it does |
|---|---|---|
menu |
menu(label) { ... } |
a pulldown menu; & in the label marks its Alt hotkey |
item |
item(label, shortcut: nil, enabled: true) { \|item\| ... } |
a menu item; the block runs on the application |
separator |
separator |
a horizontal line; never selectable |
doors |
doors |
one item per declared door, in declaration order, each calling open_door |
item options:
| Option | Default | Meaning |
|---|---|---|
label |
— | the text; & before a letter marks the hotkey ('E&xit') |
shortcut: |
nil |
text drawn right-aligned in the dropdown ('Alt-X'). Only a label: bind the key itself with a status_hint |
enabled: |
true |
false greys the item out and makes it unselectable. A lambda is called on the application (self is the application) every time the menu is drawn or used: truthy means enabled, e.g. enabled: -> { context[:doc] } |
The item block runs with self set to the application and receives the Menubar::MenuItem. Its return value matters: :halt ends the application, anything else is ignored.
require 'bbs'
APP = BBS::Application.define do
menubar do
menu '&File' do
item '&Open...', shortcut: 'F3' do BBS::Dialogs.message(self, 'Nothing to open.') end
item '&Print', enabled: false
separator
item 'E&xit', shortcut: 'Alt-X' do :halt end
end
menu '&Help' do
item '&About' do |it| BBS::Dialogs.message(self, "You picked #{it.label.delete('&')}.") end
end
end
end
How the menubar behaves for the caller is in The menubar.
status_hint(key, label, hidden: false) { ... }
Adds one entry to the statusbar, in declaration order. The first status_hint creates the statusbar; a definition without one has no statusbar (and app.statusbar is nil).
| Argument | Meaning |
|---|---|
key |
the key as text: 'F1'…'F12', 'Alt-X', 'Esc', 'Tab', or any key name (see Key names) |
label |
the text after the key |
hidden: |
true: the key works but takes no room on the bar, leaving space for right_text |
| block | runs on the application when the key is pressed or the hint is clicked; :halt ends the application |
require 'bbs'
APP = BBS::Application.define do
status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'Alt+letter opens a menu.') }
status_hint('F3', 'Search', hidden: true) { BBS::Dialogs.input(self, prompt: 'Find:') }
status_hint('Alt-X', 'Bye') { :halt }
end
A status hint only gets the key if no window and no menu used it first (see Event dispatch). Function keys are always free; a letter is not.
body { add(widget) }
The block runs on a small BodyContext with two methods: add(widget) (adds to the root panel and applies the theme) and app. It is the place for desktop widgets; init can do the same with add.
require 'bbs'
APP = BBS::Application.define do
body do
title = BBS::Widgets::Label.new(text: 'NOTES DESK', style_key: :accent, align: :center)
title.layout(1, 3, app.cols, 1)
add title
end
end
body runs after init.
init { ... }
Runs on every freshly built application, after the menubar and the statusbar exist and before body. Open the first window, put widgets on the desktop, set statusbar.right_text, read context. It runs again after every door, because the application is rebuilt: guard one-off things with a context flag.
require 'bbs'
APP = BBS::Application.define do
window :welcome, type: :info do |w|
w.title ' Welcome '
w.body ['Glad you called.']
end
status_hint('Alt-X', 'Bye') { :halt }
init do
statusbar.right_text = Time.now.strftime('%H:%M')
unless context[:welcomed]
context[:welcomed] = true
open_window(:welcome)
end
end
end
on_tick { ... }
Runs on the application each time a second passes without input (the run loop reads keys with a one second timeout). Typical uses: a clock or a counter in statusbar.right_text, polling a service, presence updates. Windows have their own per-tick hook, Window#tick(app); on_tick runs first, then every open window's tick.
require 'bbs'
APP = BBS::Application.define do
status_hint('Alt-X', 'Bye') { :halt }
on_tick { statusbar.right_text = Time.now.strftime('%H:%M:%S') }
end
Ticks only fire while the caller is idle: someone typing steadily gets none. Do not rely on them for exact timing.
on_idle { ... }
Runs on the application when the caller has been silent for the session's idle_seconds (since 0.19.0). Return :keep and the caller stays on, with the idle count starting again; return anything else and the loop shows "Disconnected after N s idle." and ends, as it does without the block.
require 'bbs'
APP = BBS::Application.define do
on_idle do
next nil if @asked # asked last time, no answer: drop
@asked = true
BBS::Dialogs.confirm(self, 'Still there?', on_yes: -> { @asked = false })
:keep # one more idle period to answer
end
end
The dialog does not block: it opens a window and the loop goes on, so the block returns :keep to give the caller one more idle period to answer. Without idle_seconds (on the session or in the config) there is no limit and the block never runs.
on_door { |door, context| ... }
Called by Builder#run just before it hands the line to a door, with the BBS::Door (name, label, program) and the context. The application is already gone at this point, so the block is called as a plain block (self is wherever define was written), not on the application.
require 'bbs'
APP = BBS::Application.define do
door :coin, ->(session, _ctx) { session.write("Heads!\r\n"); sleep 1 }
on_door { |door, context| warn "#{context[:username]} -> door #{door.label}" }
status_hint('F9', 'Coin') { open_door(:coin) }
end
| Method | What it does |
|---|---|
build(session:, session_id:, context: {}) |
a new Application: menubar, statusbar, init, body done; not running yet |
run(session:, session_id:, context: {}) |
build + Application#run, in the app-door loop; returns when the caller leaves without a door or the line is lost |
The builder has no readers since 1.0: the registered window specs and doors are the running application's window_specs and doors.
Everything below is a method of the running BBS::Application. Helpers, menu items, status hints, init and on_tick call them directly; window callbacks through ctx[:app].
| Method | Returns |
|---|---|
session |
the caller's BBS::Session |
session_id |
the id given to build / run |
context |
the per-caller hash (see The context) |
services |
the shared services hash |
theme |
the BBS::Theme in use |
cols, rows |
terminal size from NAWS (80 and 24 when the client did not tell) |
root |
the root panel (BBS::Container) |
menubar, statusbar |
the bars, or nil |
windows |
the open windows, back to front (the last is active) |
window_specs, doors |
the registered window specs and doors, by name |
desktop_rect |
[x, y, width, height] between the menubar and the statusbar: where a maximized window goes |
| Method | What it does |
|---|---|
add(widget) |
adds a widget to the root panel, sets its theme, returns it |
menubar = menubar |
sets (or with nil removes) the menubar, applies the theme |
statusbar = statusbar |
the same for the statusbar |
require 'bbs'
APP = BBS::Application.define do
init do
hint = BBS::Widgets::Label.new(text: 'F10 opens the menu', style_key: :text_dim)
hint.layout(5, 5, 26, 1)
add hint
end
end
| Method | What it does |
|---|---|
open_window(name, **extras) |
opens the window spec registered as name; extras go into its ctx; raises ArgumentError for an unknown name; returns the window |
open_window(window) |
pushes a BBS::Window onto the stack and makes it active: sets its theme, its app, and (unless modal) the lowest free number 1–9 |
close_window(window = top_window) |
removes the window, activates the one under it, then calls the window's on_close with the window |
top_window |
the active window, or nil |
raise_window(window) |
brings an open window to the front and makes it active |
switchable_windows |
the non-modal windows sorted by number; empty while a modal window is open |
cycle_windows(step = 1) |
brings the next (1) or previous (-1) switchable window to the front; nil with fewer than two |
require 'bbs'
APP = BBS::Application.define do
window :a, type: :info do |w| w.title ' A ' end
window :b, type: :info do |w| w.title ' B ' end
status_hint('F2', 'Both') { open_window(:a); open_window(:b) }
status_hint('F3', 'A to front') do
a = windows.find { |w| w.title == ' A ' }
raise_window(a) if a
end
status_hint('F4', 'Close all') { close_window until windows.empty? }
end
Window numbers, switching, moving and sizing are covered on Windows.
| Method | What it does |
|---|---|
with_loading(text = 'Working...') { ... } |
shows a "Please wait" window with text, runs the block (in a thread, joined at once), closes the window, returns the block's value |
with_line { \|session\| ... } |
clears the screen, turns mouse reporting off, hands the raw session to the block, then turns the mouse back on (if BBS.config.mouse) and forces a full repaint; returns the block's value |
require 'bbs'
require 'net/http'
APP = BBS::Application.define do
helpers do
def fetch_motd
text = with_loading('Fetching...') do
Net::HTTP.get(URI('https://example.org/')).lines.first.to_s.strip
rescue StandardError
nil
end
BBS::Dialogs.message(self, text || 'No answer.', title: ' MOTD ')
end
def raw_screen
with_line do |session|
session.write("A plain terminal for a moment. Press ENTER.\r\n")
session.readline
end
end
end
status_hint('F2', 'MOTD') { fetch_motd }
status_hint('F3', 'Raw') { raw_screen }
end
The screen is not repainted while the with_loading block runs, and keys typed meanwhile wait in the line. with_line is the tool for ZMODEM downloads (BBS::Zmodem.send_files(session, ...), see Doors) and full-screen ANSI art viewers.
| Method | What it does |
|---|---|
run |
the event loop; returns when the application ends |
stop! |
ends the loop after the event being handled |
tick! |
runs on_tick and every window's tick now |
open_door(name) |
records the door in context[:door], calls stop! and returns :halt; raises ArgumentError for an unknown name |
line_lost? |
true when the run ended because the line did: a hang-up, the idle limit, an IOError; Builder#run opens no door then |
open_door works from any handler: a menu item, a status hint, a window's key handler, a Dialogs.confirm callback, and also a window button (Info buttons, MasterDetail actions, the Form buttons) or a MasterDetail on_activate, which swallow the return value. It stops the event loop itself, so the application ends after the event being handled and Builder#run opens the door:
require 'bbs'
APP = BBS::Application.define do
door :coin, ->(session, _ctx) { session.write("Heads!\r\n"); sleep 1 }
window :lobby, type: :info do |w|
w.title ' Lobby '
w.body ['Feeling lucky?']
w.button(label: '&Toss a coin') do |_win, ctx|
ctx[:app].open_door(:coin)
end
end
init { open_window(:lobby) }
end
In 0.17.0 a button had to follow open_door with ctx[:app].stop!; since 0.17.1 that is no longer needed (it does no harm). When the run ends because the line was lost (line_lost?), Builder#run drops context[:door] without opening it, so a door picked just before a hang-up does not open.
flowchart TD
R[render the frame, send the diff] --> K{"readkey, 1 s timeout"}
K -- nil: hung up --> E[end]
K -- idle --> T["tick!: on_tick, then window.tick"]
T --> I{"idle_seconds reached?"}
I -- no --> R
I -- yes --> M[message: Disconnected] --> E
K -- key or mouse --> D[dispatch the event]
D -- ":halt" --> E
D --> R
Application#run:
Session#readkey(timeout: 1.0)).tick!, and adds the second to the idle time. When the idle time reaches the session's idle_seconds (from BBS.config.idle_seconds; when set), runs the on_idle block: :keep starts the count again, anything else shows "Disconnected after N s idle." and ends.BBS::Event and dispatches it. :halt from the dispatch ends the loop.readkey returns nil, or IOError, EPIPE, ECONNRESET) ends the loop quietly.An exception raised by a handler (a menu item, a callback) is written to stderr with the first three backtrace lines, and the event is dropped: one bug does not end the caller's session. On the way out the loop shows the cursor and clears the screen.
Each event goes down this chain; the first one to take it stops it.
| Step | Who | Takes |
|---|---|---|
| 1 | the open menu | everything while a menu is open |
| 2 | window switching | F6, Shift-F6, Alt-1…9; a click on a window behind the active one raises it and goes on |
| 3 | the active window | its focused widget, then Tab, button hotkeys, F5, Ctrl-F5, its close key |
| 4 | the menubar | F10, Alt-letter of a menu, clicks on row 1 |
| 5 | the statusbar | the hint keys, clicks on a hint drawn on the bar |
| 6 | the root panel | Tab / Shift-Tab between root widgets, then the focused root widget |
Consequences worth knowing:
Every handler in the chain (handle_event of a widget or window, a menu item, a status hint) answers with its return value:
| Value | Meaning |
|---|---|
nil / false |
not mine: the event goes on down the chain |
anything else (:handled, true, a window) |
taken, the chain stops |
:halt |
taken, and the application ends |
:close_window |
from a window: the application closes it (Window#close returns this) |
Menu items and status hints count as taken whatever they return (except :halt).
Builder#run is the loop around Application#run that takes the caller through doors:
sequenceDiagram
participant F as Flow call step
participant B as Builder#run
participant A as Application
participant D as Door
F->>B: run(session:, session_id:, context: ctx)
loop until no door
B->>A: build + run
A-->>B: ended (context[:door] set by open_door?)
B->>B: door = context.delete(:door)
B->>D: on_door(door, ctx), door.run(session, ctx)
D-->>B: the program returned
end
B-->>F: returns
After a door the application is built again: init runs again, the windows that were open are gone, the menubar and the statusbar are new. Whatever must survive a door goes into context; the Teletype BBS keeps the unread count it has already rung the bell for in context[:mail_seen], and the start of the call in context[:call_started].
The loop also ends when the application ended because the line was lost (Application#line_lost?: a hang-up, the idle limit): a door already in context[:door] is dropped, not opened.
context is a plain Hash owned by the caller's call, not by the application.
ctx: :session_id, every ask answer (:username), whatever earlier steps stored.BBS::Flow door gets a copy of it (see Doors), a call(session, context) door gets the hash itself.open_door stores the door under :door for a moment; Builder#run removes it.Window spec callbacks do not get context directly but a ctx merged from three sources, the later winning on equal keys:
ctx = { app: app, **app.context, **app.services, **extras }
ctx is a new Hash for each opened window: writing ctx[:x] = 1 does not change the context. Write ctx[:app].context[:x] = 1.
BBS::Event (lib/bbs/event.rb) is the normalised input every widget and window receives:
Event = Struct.new(:kind, :key, :mouse, :x, :y, keyword_init: true)
| Field | Meaning |
|---|---|
kind |
:key, :mouse, :idle, :tick or :resize |
key |
for :key: a Symbol (:enter, :up, :f5, :alt_x, :ctrl_f5, :shift_tab) or a one-character String ('a', 'é') |
mouse |
for :mouse: the BBS::Telnet::MouseEvent (kind :press, :release, :drag, :wheel; button; x, y; mods) |
x, y |
for :mouse: the 1-based screen cell |
| Constructor / predicate | What it does |
|---|---|
Event.key(name) |
a key event |
Event.mouse(mouse_event) |
a mouse event, x/y copied from it |
Event.idle, Event.tick, Event.resize(cols, rows) |
the other kinds (the run loop itself only produces key and mouse events) |
key?, mouse? |
the kind |
char? |
a printable one-character key |
alt?, alt_char |
an :alt_x style key, and its letter ('x') |
require 'bbs'
event = BBS::Event.key(:alt_s)
p event.key? # => true
p event.alt? # => true
p event.alt_char # => "s"
p BBS::Event.key('q').char? # => true
A custom widget answers in handle_event(event):
require 'bbs'
class Counter < BBS::Widget
attr_reader :count
def initialize(**kw)
super
@count = 0
end
def focusable? = true
def handle_event(event)
return nil unless event.key? && event.key == '+'
@count += 1
:handled
end
def render(frame)
frame.move(bounds.x, bounds.y)
frame.write("Count: #{@count}", sgr: style(focused ? :input_focused : :text))
end
end
The full list of key symbols and the mouse model are on Telnet and the store; writing widgets is on Widgets.
BBS::FocusManager (lib/bbs/focus_manager.rb) keeps track of the focused widget inside one container. Every window has one (window.focus_manager), and the root panel gets one when it first receives an event.
| Method | What it does |
|---|---|
FocusManager.new(container) |
a manager focusing the first candidate |
candidates |
the container's visible, enabled, focusable descendants, in tree order |
focused |
the focused widget, or nil |
focus(widget) |
focuses it, if it is focusable and enabled |
focus_first, next_focus, prev_focus |
move the focus; next and previous wrap around |
handle_focus_key(event) |
Tab → next_focus, Shift-Tab → prev_focus; :handled when it took the key, otherwise nil |
dispatch(event) |
hands the event to the focused widget |
Moving the focus sets focused on both widgets and calls on_blur on the old one and on_focus on the new one. The candidates are looked up again on every move, so widgets added later take part; but the first focus is chosen when the manager is made. After building a window by hand, call window.reset_focus (a fresh manager), then window.focus_manager.focus(widget) to choose the start:
require 'bbs'
def open_search(app)
win = BBS::Window.new(title: ' Search ')
win.layout(20, 8, 40, 6)
query = BBS::Widgets::TextInput.new(placeholder: 'words...')
query.layout(22, 10, 36, 1)
win.add(query)
go = BBS::Widgets::Button.new(label: '&Go', on_click: ->(_b) { app.close_window(win) })
go.layout(36, 12, 8, 1)
win.add(go)
win.reset_focus
win.focus_manager.focus(query)
app.open_window(win)
end
A left click inside a window focuses the widget under the pointer; the mouse wheel goes to the widget under the pointer even when another one has the focus.
BBS::Menubar (lib/bbs/menu.rb) is the strip on row 1. The DSL builds it; it can also be made by hand.
&File → File).Menubar#handle_event; the key handling inside is private.:halt from it ends the application.| Method | What it does |
|---|---|
Menubar.new |
an empty menubar |
menus |
the Menubar::Menu structs (label, items, x); a hand-built bar pushes its menus here. add_menu and the MenuBuilder are gone in 1.0: the DSL's menubar do menu('&File') { item … } end is the way, and by hand an item's on_select is called with the item, not on the application, so close over app |
open(index), close, open? |
open a menu by position, close, ask |
opened_menu, selected_item |
what is open and highlighted |
handle_event(event) |
keys and clicks; the application calls it, an open menu first |
on_activate |
a callable run with the item after every pick |
Menubar::MenuItem is a struct of label, shortcut, enabled, on_select, separator, hint; hotkey and display (the label without &) are derived. enabled? is what the menubar asks: false for a separator, the lambda's answer when enabled is callable (called as it is; only the DSL's item runs it on the application), otherwise true unless enabled is false.
require 'bbs'
def run_plain_app(session, session_id)
app = BBS::Application.new(session: session, session_id: session_id, theme: BBS::Theme.dosnavigator)
item = ->(label, shortcut: nil, &block) { BBS::Menubar::MenuItem.new(label: label, shortcut: shortcut, on_select: block) }
app.menubar = BBS::Menubar.new
app.menubar.menus << BBS::Menubar::Menu.new(label: '&File', items: [
item.('&Hello') { BBS::Dialogs.message(app, 'Hello!') },
BBS::Menubar::MenuItem.new(label: '', separator: true, enabled: false),
item.('E&xit', shortcut: 'Alt-X') { :halt }
])
app.statusbar = BBS::Statusbar.new(right_text: 'plain')
app.statusbar.add_hint('F1', 'Hello') { BBS::Dialogs.message(app, 'Hello!') }
app.statusbar.add_hint('Alt-X', 'Quit') { :halt }
label = BBS::Widgets::Label.new(text: 'Background', style_key: :text)
label.layout(3, 3, 20, 1)
app.add(label)
app.run
end
BBS::Application.new(session:, session_id:, context: {}, theme: nil, on_idle: nil, on_tick: nil, helpers: nil, services: {}, window_specs: {}, doors: {}) is what build calls; on_tick is a callable taking the application. on_idle is a callable taking the application, called at the idle limit (see on_idle).
BBS::Statusbar (lib/bbs/statusbar.rb) is the strip on the last row: hints on the left (the key in the :statusbar_key style, then the label), and an optional text on the right.
| Member | What it does |
|---|---|
Statusbar.new(hints: [], right_text: nil) |
a statusbar |
add_hint(key, label, hidden: false) { ... } |
adds a hint; the block is called when its key is pressed or the hint is clicked (only a hint drawn on the bar can be clicked) |
hints |
the Statusbar::Hint structs (key, label, on_activate, hidden) |
shown |
the hints that are not hidden |
right_text, right_text= |
the right-aligned text; drawn only if it does not overlap the hints |
Hints that do not fit the width are left off, from the end; hidden hints take no room, which is the way to keep a key working while right_text gets the space. A hint whose block returns :halt ends the application; any other return value counts as handled.
require 'bbs'
APP = BBS::Application.define do
status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'No help yet.') }
status_hint('F5', 'Refresh', hidden: true) { statusbar.right_text = 'refreshed' }
status_hint('Alt-X', 'Bye') { :halt }
init { statusbar.right_text = "#{context[:username]} │ #{Time.now.strftime('%H:%M')}" }
on_tick { statusbar.right_text = "#{context[:username]} │ #{Time.now.strftime('%H:%M')}" }
end
| Hint key | Matches |
|---|---|
'F1' … 'F12' |
:f1 … :f12 |
'Alt-X' |
:alt_x (any letter or digit, case-insensitive) |
'Esc' |
:escape |
'Tab' |
:tab |
| anything else | the key's own name, case-insensitive: 'enter', 'ctrl_s', '?' |
'Ctrl-S' does not match anything (the key is :ctrl_s); write 'ctrl_s', or keep the key in a hidden hint and say "Ctrl-S" in the label of a visible one. Remember that Esc and Tab usually reach a window first.
What a caller can do in any rubbs application. Put a version of it in your Help menu; the Teletype BBS and Feedia BBS show one under Help → Keys.
| Key / action | What it does |
|---|---|
| F10 | opens the first menu; in an open menu, closes it |
| Alt-letter | opens the menu with that hotkey, or presses the button with that hotkey in the active window |
| Left / Right (menu open) | previous / next menu |
| Up / Down (menu open) | previous / next item |
| Enter / letter (menu open) | picks the highlighted item / the item with that hotkey |
| Esc | closes the open menu, or the active window |
| Tab / Shift-Tab | next / previous control in the window |
| Enter / Space | presses the focused button, toggles the focused checkbox |
| letter | presses the button with that hotkey, when the focused control does not take letters |
| F5 or Alt-Enter | maximizes the window and back (windows with the [↑] gadget) |
| F6 / Shift-F6 | next / previous window |
| Alt-1 … Alt-9 | the window with that number on its frame |
| Ctrl-F5 (or F6 when only one window is open) | move mode: arrows move, Shift+arrows (or Tab, then arrows) size, Enter or Esc ends |
| status bar keys | whatever the application put there (F1, F2, Alt-X, ...) |
| click a menu title / item | opens the menu / picks the item |
| click a status hint | same as its key |
click [■] |
closes the window |
click [↑] / [↕], double click the title bar |
maximize / restore |
| drag the title bar | moves the window |
drag the ┘ corner |
sizes the window |
| click a window behind | brings it to the front |
| mouse wheel | scrolls the list or text under the pointer |
The mouse works when the server has c.mouse = true and the terminal reports SGR or X10 mouse events (see Telnet and the store).
| Board | File | Worth a look for |
|---|---|---|
| Feedia | bbs/feedia-bbs/lib/ui/modern.rb |
a small complete application: two master-detail specs, two forms, helpers with with_loading, a masthead built in init, on_tick clock |
| Alexandria | bbs/alexandria-bbs/lib/ui/modern.rb |
per-caller language in context, replace_items for navigation, Markdown in the detail pane |
| Teletype BBS | bbs/teletype-bbs/lib/ui/modern.rb |
doors declared from a list, on_door presence, a hidden F3 hint, a root-panel button, a live dashboard widget refreshed from on_tick |
BBS::Window, the window DSL, Info, Form, MasterDetail, Stream, dialogs