EN /HU | Login

Application

BBS::Application is 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 the Application.define DSL 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.

The pieces

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 complete example

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.

RUBY
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:

TEXT
  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.
  • Services are shared too: NoteStore.new runs once, so every caller sees the same notes. That is why the store has a mutex.
  • Per-caller state goes in context (here :username, filled by the flow's ask).
  • Window callbacks get a ctx hash; ctx[:app] is the running application, so helpers are reachable as ctx[:app].me.

Starting an application

From a Flow call step (the usual way)

RUBY
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.

With the Flow app step

RUBY
require '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.

Without a flow

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):

RUBY
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

The define DSL

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

TEXT
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
RUBY
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

TEXT
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]).

RUBY
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

TEXT
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].

RUBY
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

TEXT
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.

RUBY
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

TEXT
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.

RUBY
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

TEXT
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.

RUBY
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

TEXT
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
RUBY
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

TEXT
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.

RUBY
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

TEXT
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.

RUBY
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

TEXT
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.

RUBY
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

TEXT
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.

RUBY
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

TEXT
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.

RUBY
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

Builder methods

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.

The instance API

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].

State

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

Building the screen

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
RUBY
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

Windows

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
RUBY
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.

Long work and the raw line

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
RUBY
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.

Running and leaving

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:

RUBY
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.

The run loop

MERMAID
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:

  1. Renders the frame and sends only what changed.
  2. Waits up to one second for a key (Session#readkey(timeout: 1.0)).
  3. No key in that second: runs 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.
  4. A key or a mouse event: resets the idle time, wraps it in a BBS::Event and dispatches it. :halt from the dispatch ends the loop.
  5. A hang-up (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.

Event dispatch

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:

  • An Alt-letter that is the hotkey of a button in the active window presses that button, even when a menu has the same letter: the window comes before the menubar.
  • A letter typed into a text field never reaches a status hint. Use function keys and Alt combinations for hints.
  • With a window open, Tab belongs to the window; root widgets get no keys until the windows are closed.
  • A click on the root panel goes to the focused root widget only. With two buttons on the desktop, only the focused one answers a click, and root buttons have no letter hotkeys.

Handler return values

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).

The app-door loop

Builder#run is the loop around Application#run that takes the caller through doors:

MERMAID
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.

The context

context is a plain Hash owned by the caller's call, not by the application.

  • Started from a flow, it is the flow's own ctx: :session_id, every ask answer (:username), whatever earlier steps stored.
  • The application reads and writes it freely; the flow sees the changes after the application returns.
  • It survives the app-door loop; a 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:

TEXT
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.

Events

BBS::Event (lib/bbs/event.rb) is the normalised input every widget and window receives:

RUBY
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')
RUBY
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):

RUBY
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.

Focus

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:

RUBY
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.

The menubar

BBS::Menubar (lib/bbs/menu.rb) is the strip on row 1. The DSL builds it; it can also be made by hand.

Behaviour

  • Each menu label is shown with its hotkey letter highlighted (&File → File).
  • F10 opens the first menu, Alt-letter the menu with that hotkey. A click on a menu title opens it, a second click closes it. All of it goes through Menubar#handle_event; the key handling inside is private.
  • While a menu is open it takes every key and click, so nothing leaks to the windows: Left/Right move to the neighbouring menu (wrapping), Up/Down move between items, skipping separators and disabled items, Enter picks, a letter picks the item with that hotkey, Esc or F10 closes. A click on an item picks it, a click outside closes the menu.
  • Picking an item closes the menu, then runs the item's block. :halt from it ends the application.
  • A dropdown near the right edge is shifted left so it fits on the screen.

Building one by hand

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.

RUBY
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).

The statusbar

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.

RUBY
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

Key names in hints

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.

Keys and mouse for the caller

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).

Real boards

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

See also

  • Windows — BBS::Window, the window DSL, Info, Form, MasterDetail, Stream, dialogs
  • Widgets — the controls inside windows and on the desktop
  • Drawing — themes and style keys, the FrameBuffer
  • Flow — the line-by-line layer that usually starts an application
  • Doors — door programs, FakeOS, gateways, ZMODEM
  • Telnet and the store — key symbols, the mouse, sessions