---
title: Application
url: 'https://wiki.teletypegames.org/engines/rubbs/application'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/application.md'
lang: en
date: '2026-10-27'
description: '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-d…'
taxonomy:
  tag:
    - reference
---

# Application

> `BBS::Application` is the desktop layer of [rubbs](https://wiki.teletypegames.org/engines/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](https://wiki.teletypegames.org/engines/rubbs/windows), the widgets on [Widgets](https://wiki.teletypegames.org/engines/rubbs/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-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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/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](#key-names-in-hints)) |
| `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](#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](https://wiki.teletypegames.org/engines/rubbs/windows#switching-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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store); writing widgets is on [Widgets](https://wiki.teletypegames.org/engines/rubbs/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` → **F**ile).
- **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](https://wiki.teletypegames.org/engines/rubbs/telnet-and-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](https://wiki.teletypegames.org/engines/rubbs/windows) — `BBS::Window`, the window DSL, Info, Form, MasterDetail, Stream, dialogs
- [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets) — the controls inside windows and on the desktop
- [Drawing](https://wiki.teletypegames.org/engines/rubbs/drawing) — themes and style keys, the FrameBuffer
- [Flow](https://wiki.teletypegames.org/engines/rubbs/flow) — the line-by-line layer that usually starts an application
- [Doors](https://wiki.teletypegames.org/engines/rubbs/doors) — door programs, FakeOS, gateways, ZMODEM
- [Telnet and the store](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store) — key symbols, the mouse, sessions

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [RuBBS](https://wiki.teletypegames.org/engines/rubbs.md)
- Previous: [AI characters](https://wiki.teletypegames.org/engines/rubbs/ai.md)
- Next: [Art and graphics screens](https://wiki.teletypegames.org/engines/rubbs/art.md)
