---
title: Windows
url: 'https://wiki.teletypegames.org/engines/rubbs/windows'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/windows.md'
lang: en
date: '2026-10-27'
description: 'Windows are the working surface of a rubbs Application: framed, numbered, switchable, movable, and sizable and maximizable when they can lay themselves out at any size, Turbo Vision style. This page covers BBS::Window, the window DSL (BBS::WindowSpec), the four pre-built window types (Info, Form, …'
taxonomy:
  tag:
    - reference
---

# Windows

> Windows are the working surface of a [rubbs](https://wiki.teletypegames.org/engines/rubbs) [Application](https://wiki.teletypegames.org/engines/rubbs/application): framed, numbered, switchable, movable, and sizable and maximizable when they can lay themselves out at any size, Turbo Vision style. This page covers `BBS::Window`, the window DSL (`BBS::WindowSpec`), the four pre-built window types (`Info`, `Form`, `MasterDetail`, `Stream`), the `ButtonBar` helper and `BBS::Dialogs`. The controls inside windows are on [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets).

# Three ways to make a window

| Way | When | Example |
|---|---|---|
| a pre-built type, imperatively | one-off windows from code | `BBS::Windows::Form.open(app, title: ..., fields: [...], on_submit: ...)` |
| a pre-built type, through the window DSL | windows of an application, opened by name | `window :profile, type: :form do \\|w\\| ... end`, then `open_window(:profile)` |
| `BBS::Window` by hand, or a subclass | anything the types do not cover | `BBS::Window.new(title: ...)`, `layout`, `add`, `open_window(win)` |

The pre-built types are `BBS::Window` subclasses: everything in the first section applies to them too.

All examples on this page are complete programs or complete definitions: put a definition into a flow's `call` step as shown on [Application](https://wiki.teletypegames.org/engines/rubbs/application), or run it from your own server.

# BBS::Window

`BBS::Window` (`lib/bbs/window.rb`) is a `BBS::Container` with a frame, a title, a shadow, a close gadget and its own focus manager. The application keeps the open windows in a stack; the last one is the active window and gets the input first.

## Constructor and attributes

```text
BBS::Window.new(title: nil, modal: false, shadow: true, on_close: nil,
                close_key: :escape, maximizable: false, movable: true)
```

| Attribute | Default | Meaning |
|---|---|---|
| `title` | `nil` | text in the top border; pad it with spaces (`' Inbox '`). Cut to fit when the gadgets need the room |
| `modal` | `false` | a modal window keeps the front until it closes: no switching while it is open, no number, always a double frame. The dialogs are modal; the pre-built types are not |
| `shadow` | `true` | the drop shadow (never drawn while maximized) |
| `on_close` | `nil` | called with the window when the application closes it. `false` removes the close gadget `[■]` (Esc still closes) |
| `close_key` | `:escape` | the key that closes the window when no widget took it; `nil` for none |
| `maximizable` | `false` | gives a `resizable?` window the zoom gadget |
| `movable` | `true` | lets the caller move it (and size it when `resizable?`) |
| `number` | set by `open_window` | 1–9, shown on the right of the top border; `Alt-N` picks the window |

All of them are read-write attributes (`win.title = ' New title '`). `app=` is set by `open_window`.

## A window built by hand

Position the window with `layout(x, y, width, height)` (1-based screen cells, the frame included), position each child in screen coordinates inside it, add them, then `reset_focus` and open it:

```ruby
require 'bbs'

def open_hello(app)
  win = BBS::Window.new(title: ' Hello ', on_close: ->(_w) { app.context[:closed] = true })
  win.layout(20, 6, 40, 7)
  label = BBS::Widgets::Label.new(text: 'A window built by hand.', style_key: :window_text)
  label.layout(22, 8, 36, 1)
  win.add(label)
  ok = BBS::Widgets::Button.new(label: '&OK', on_click: ->(_b) { app.close_window(win) })
  ok.layout(36, 10, 8, 1)
  win.add(ok)
  win.reset_focus
  app.open_window(win)
end

APP = BBS::Application.define do
  status_hint('F2', 'Hello') { open_hello(self) }
  status_hint('Alt-X', 'Bye') { :halt }
end
```

`BBS::Dialogs.centred_window(app, width:, height:, title:)` gives a centred, laid-out window to start from (modal; set `modal = false` if it should be switchable).

## Methods

| Method | What it does |
|---|---|
| `focus_manager` | the window's `BBS::FocusManager`, made on first use |
| `reset_focus` | a fresh focus manager on the first focusable child; call it after adding widgets |
| `close` | returns `:close_window`; a handler returning it makes the application close the window (and call `on_close` once) |
| `tick(app)` | called about once a second while the caller is idle; empty, override it to poll |
| `relayout(app)` | called after a terminal resize (see below) |
| `shift(dx, dy)` | moves the window and all its descendants |
| `move_to(x, y, width = current, height = current)` | moves (and, if `resizable?`, sizes) the window, kept on the desktop and at least 24×7; the window keeps that place from then on |
| `toggle_maximize` | maximize or restore, if `zoomable?` |
| `maximized?`, `busy?` | maximized; being moved or sized right now |
| `resizable?` | `false` here; true in windows that can lay out at any size |
| `zoomable?`, `movable?`, `sizable?` | `maximizable && resizable?`; `movable` and not maximized; movable and resizable |
| `normal_rect`, `place(x, y, w, h)` | the layout hooks of resizable windows (see below) |

## How a window handles input

`Window#handle_event`, in order:

1. While moving or sizing from the keyboard, every key belongs to that.
2. A mouse drag on the title bar or the corner follows the mouse.
3. **F5** or **Alt-Enter** toggles maximize (if `zoomable?`).
4. **Ctrl-F5** or **F6** starts keyboard move/size mode (if `movable?`). F6 only gets here when it did not switch windows, that is when this is the only switchable window.
5. A left click: on `[■]` closes, on `[↑]`/`[↕]` or a double click on the title bar toggles maximize, on the `┘` corner starts sizing, on the title bar starts moving; anywhere else focuses the widget under the pointer.
6. The mouse wheel goes to the widget under the pointer.
7. Tab and Shift-Tab move the focus; then the focused widget gets the event.
8. A Shift/Ctrl/Alt + arrow, Home, End, PgUp or PgDn that nobody took is tried again as the plain key (Shift-Down in a list moves down).
9. A letter or an Alt-letter that nobody took presses the enabled button whose hotkey it is (`'y'` or `Alt-Y` for `&Yes`).
10. The `close_key` closes the window.

Point 9 explains the hotkey rule of forms: in a text field the letter is typed, so only the Alt-letter reaches the buttons; on a checkbox or a button the plain letter presses them too.

## Frame and gadgets

```text
╔═[■]═  Notes ═══════════════════════════1══[↑]═╗   active: double frame
║                                               ║
║                                               ║
╚═══════════════════════════════════════════════┘   ┘ = size corner
┌─[■]─  Planets ─────────────────────2──[↑]─┐       inactive: single frame
```

| Element | Shown when | Click |
|---|---|---|
| `[■]` close gadget, left | `on_close` is not `false` and the window is at least 6 wide | closes |
| title | always | drag moves, double click maximizes |
| number | the window has one and is at least 20 wide | — |
| `[↑]` / `[↕]` zoom gadget, right | `zoomable?` and at least 14 wide | maximize / restore |
| `┘` corner | `sizable?` | drag sizes |

The active window, and every modal window, has a double frame and the focused title style; the others a single frame. While moving or sizing, the frame is highlighted and the title shows the keys.

## Maximizing

A `maximizable` window that is `resizable?` gets the zoom gadget. `[↑]`, **F5**, **Alt-Enter** or a double click on the title bar fills the desktop between the menubar and the statusbar (`app.desktop_rect`); `[↕]` and the same keys put it back. A maximized window stays maximized when the terminal is resized, and is neither movable nor sizable.

Resizable types: `MasterDetail`, `Stream`, `Form` (a single textarea without its own `:rows` takes the height left over), and `Info` with `scroll: true`. A static `Info` cannot be laid out at another size, so `maximizable` has no effect on it. Use it for windows with a lot to read, not for small dialogs.

## Moving and sizing

A window that is `movable?` (movable and not maximized) can be moved; one that is also `resizable?` can be sized.

- **Mouse:** drag the title bar to move, drag the `┘` corner to size.
- **Keyboard:** Ctrl-F5 (or F6 when it is the only window) starts move mode. Arrows move by one cell, Shift+arrows size, Tab switches the arrows between moving and sizing, Enter, Esc or Ctrl-F5 ends it.
- From code: `win.move_to(x, y, width, height)`.

The window stays on the desktop and at least 24×7 (or the desktop size, if smaller). Once moved or sized, it keeps that rect: a terminal resize pulls it back onto the screen if needed, and restoring from maximized returns to it.

## Switching windows

Several windows can be open at once, covering each other in the order they were last used.

- Every non-modal window gets the lowest free number 1–9 when it opens; a tenth window gets none.
- **F6** brings the next window (by number) to the front, **Shift-F6** the previous one, **Alt-1** … **Alt-9** the one with that number.
- A click on a window behind the active one brings it to the front, and the click goes on to it: a button, the close gadget or a title-bar drag answers at once.
- Closing the active window activates the one under it.
- While a modal window is open nothing switches: a question is answered first.
- From code: `app.raise_window(win)`, `app.cycle_windows(1)` / `(-1)`, `app.switchable_windows`.

## Terminal resize

On a resize the application calls `relayout(app)` on every window:

- a moved or sized window goes back to its own rect, pulled onto the screen;
- a resizable window is placed at `app.desktop_rect` if maximized, else at its `normal_rect`;
- any other window keeps its size and is centred again.

## A custom resizable window

A subclass joins in maximizing, sizing and resize handling by answering three methods: `resizable?` (true), `normal_rect` (its usual `[x, y, width, height]` for the current terminal) and `place(x, y, width, height)` (lay itself and its children out at that rect). `tick` gives it a heartbeat:

```ruby
require 'bbs'

class ClockWindow < BBS::Window
  def initialize(app)
    super(title: ' Clock ', maximizable: true)
    self.theme = app.theme
    @app   = app
    @label = BBS::Widgets::Label.new(text: now, style_key: :text_bright, align: :center)
    add(@label)
    place(*normal_rect)
  end

  def resizable? = true

  def normal_rect
    w = 30
    h = 5
    [(@app.cols - w) / 2 + 1, (@app.rows - h) / 2 + 1, w, h]
  end

  def place(x, y, width, height)
    layout(x, y, width, height)
    @label.layout(x + 2, y + height / 2, width - 4, 1)
  end

  def tick(_app) = (@label.text = now)

  private

  def now = Time.now.strftime('%H:%M:%S')
end

APP = BBS::Application.define do
  status_hint('F2', 'Clock') { open_window(ClockWindow.new(self)) }
  status_hint('Alt-X', 'Bye') { :halt }
end
```

The clock opens centred, F5 fills the screen with it, Ctrl-F5 and Shift+arrows make it bigger, and it updates every idle second.

# The window DSL

Inside `Application.define`, `window(name, type:) { |w| ... }` registers a `BBS::WindowSpec` (`lib/bbs/window_spec.rb`); `app.open_window(name, **extras)` opens it (through `WindowSpec#open(app, **extras)`, the one way a spec opens). The spec resolves its attributes at every open, so they can depend on the caller, the screen size or the extras.

```ruby
require 'bbs'

Board = Struct.new(:id, :title)

APP = BBS::Application.define do
  window :compose, type: :form do |w|
    w.title { |ctx| " Post to #{ctx[:board].title} " }
    w.width 70
    w.field :body, type: :textarea, max_length: 1000
    w.on_submit do |values, form, ctx|
      if values[:body].strip.empty?
        form.error('The message is empty.')
        :keep
      else
        ctx[:app].context[:last_post] = [ctx[:board].id, values[:body]]
        :close
      end
    end
  end

  status_hint('F3', 'Post') { open_window(:compose, board: Board.new(1, 'General')) }
end
```

## The ctx

Every block of a spec gets `ctx`:

```text
ctx = { app: app, **app.context, **app.services, **extras }
```

On equal keys the later source wins: an extra beats a service, a service beats a context key. `ctx` is built anew for every open; write lasting state through `ctx[:app].context`.

## Values and blocks

Every attribute takes either a value or a block:

```text
w.width 70                              # a value
w.width { |ctx| ctx[:app].cols - 10 }   # resolved with ctx at every open
w.width = 70                            # scalar attributes also have a setter
```

Value attributes given a block are called with `ctx` alone. Callback attributes (`item_label`, `render_detail`, `on_submit`, ...) are called later, with their own arguments and `ctx` last.

## Attributes per type

| Attribute | info | form | master\_detail | stream | Notes |
|---|---|---|---|---|---|
| `title` | ✓ | ✓ | ✓ | ✓ |  |
| `width`, `height` | ✓ | ✓ | ✓ | ✓ | defaults differ per type |
| `maximizable` | ✓ | ✓ | ✓ | ✓ | zoom gadget; no effect on a static info |
| `body` | ✓ |  |  |  | the body items |
| `scroll` | ✓ |  |  |  | one scrolling body |
| `padding` | ✓ |  |  |  | not passed to forms |
| `button(**opts) { \\|win, ctx\\| }` | ✓ |  |  |  | one button per call |
| `field(key, **opts)` |  | ✓ |  |  | one field per call |
| `prefill { \\|ctx\\| hash }` |  | ✓ |  |  | values for fields without `value:` |
| `hint`, `submit_label`, `cancel_label` |  | ✓ |  |  |  |
| `on_submit` |  | `\\|values, form, ctx\\|` |  | `\\|text, app, ctx\\|` | optional; without it a form's Save just closes |
| `on_cancel { \\|form, ctx\\| }` |  | ✓ |  |  |  |
| `items { \\|ctx\\| }` |  |  | ✓ |  | the list |
| `item_label { \\|item, ctx\\| }` |  |  | ✓ |  |  |
| `render_detail { \\|item, width, ctx\\| }` |  |  | ✓ |  |  |
| `bottom_meta { \\|item, ctx\\| }` |  |  | ✓ |  |  |
| `on_activate { \\|item, win, ctx\\| }` |  |  | ✓ |  | Enter on an item |
| `list_width`, `empty_message` |  |  | ✓ |  |  |
| `action(**opts) { \\|win, ctx\\| }` |  |  | ✓ |  | one button per call |
| `close_button`, `close_label` |  |  | ✓ | ✓ |  |
| `initial_lines { \\|ctx\\| }` |  |  |  | ✓ |  |
| `placeholder`, `buffer_size` |  |  |  | ✓ |  |
| `on_poll { \\|app, ctx\\| }` |  |  |  | ✓ |  |

An attribute set for a type that does not use it is ignored silently. Since 1.0 a `type: :form` spec needs no `on_submit`: without one, Save just closes the form.

Each type's section below shows its DSL form next to the imperative one. The four types take their required keywords (`app:`, `title:`, and a form's `fields:`, a master-detail's `items:`, `item_label:`, `render_detail:`) and a set of options checked against the type's `OPTIONS` constant, with the defaults in the tables below; an unknown option raises `ArgumentError`.

# BBS::Windows::Info

A centred window for messages, splash screens and key-value summaries (`lib/bbs/windows/info.rb`).

```text
BBS::Windows::Info.open(app, title:, body: [], buttons: nil, width: 60, height: nil,
                        scroll: false, padding: nil, maximizable: false)
```

| Option | Default | Meaning |
|---|---|---|
| `title` | — (required) | the title |
| `body` | `[]` | body items, see below |
| `buttons` | `nil` | button descriptors (see ButtonBar); `nil` gives one centred `[ Close ]`, `[]` none |
| `width` | `60` | at most the screen width minus 4 |
| `height` | natural | padding + body rows + 2 for buttons; at most the screen height minus 4; with `scroll: true` the body counts as 10 rows |
| `scroll` | `false` | `true`: the whole body becomes one `ScrollView` (resizable, maximizable) |
| `padding` | `{ sides: 2, top: 1, bottom: 2 }` | override any of the three |
| `maximizable` | `false` | only with `scroll: true` |

Body items:

| Item | Shows |
|---|---|
| `'text'` | one line in `:window_text` (in scroll mode the string may contain ANSI) |
| `:spacer`, `''`, `nil` | an empty row |
| `:rule` | a horizontal line |
| `{ text:, style:, align: }` | one line in a theme style (`:accent`, `:text_dim`, ...), `align` `:left` / `:center` / `:right` (not in scroll mode) |
| `{ kv: [[key, value], ...], label_width: }` | key-value rows; the key column defaults to the longest key + 1, between 8 and 16 |
| `{ widget:, height: }` | any widget, `height` rows (not in scroll mode) |
| a `BBS::Widget` | the widget as it is, its own height (not in scroll mode) |

Scroll mode raises `ArgumentError` for widget items. The focus starts on the `primary:` button, if any. `scroll_view` returns the `ScrollView` in scroll mode.

```ruby
require 'bbs'

def open_info(app)
  BBS::Windows::Info.open(app,
    title: ' System ',
    width: 50,
    body: [
      { text: 'Notes desk', style: :accent, align: :center },
      :spacer,
      { kv: [['Ruby', RUBY_VERSION], ['Screen', "#{app.cols}x#{app.rows}"]] },
      :rule,
      'Plain text line.'
    ],
    buttons: [{ label: '&OK', primary: true }])
end

APP = BBS::Application.define do
  status_hint('F2', 'Info') { open_info(self) }
end
```

```text
               ╔═[■]═  System ═══════════════════════════════1══╗
               ║                   Notes desk                   ║
               ║                                                ║
               ║ Ruby      3.3.1                                ║
               ║ Screen    80x25                                ║
               ║ ────────────────────────────────────────────── ║
               ║ Plain text line.                               ║
               ║                                                ║
               ║                                                ║
               ║                     [ OK ]                     ║
               ╚════════════════════════════════════════════════╝
```

The same through the DSL, plus a long scrolling one:

```ruby
require 'bbs'

APP = BBS::Application.define do
  window :system, type: :info do |w|
    w.title { |ctx| " #{ctx[:app].cols}x#{ctx[:app].rows} " }
    w.width 44
    w.body do |ctx|
      [{ text: 'Notes desk', style: :accent, align: :center }, :spacer,
       { kv: [['Caller', ctx[:username]], ['Ruby', RUBY_VERSION]] }]
    end
    w.button(label: '&OK', primary: true)
    w.button(label: '&Rules') { |_win, ctx| ctx[:app].open_window(:rules) }
  end

  window :rules, type: :info do |w|
    w.title       ' House rules '
    w.scroll      true
    w.maximizable true
    w.height      12
    w.body        { |_ctx| (1..30).map { |i| "#{i}. Be nice." } }
  end

  status_hint('F2', 'System') { open_window(:system) }
  status_hint('F3', 'Rules') { open_window(:rules) }
end
```

`&Rules` closes the system window and opens the rules: a button closes its window unless its block returns `:keep` (or it has `keep_open: true`).

# BBS::Windows::Form

A form: labelled fields one under the other, an optional hint line above them, a status line for validation messages and a Save / Cancel pair (`lib/bbs/windows/form.rb`).

```text
BBS::Windows::Form.open(app, title:, fields:, on_submit: nil, hint: nil, width: 60, height: nil,
                        submit_label: '&Save', cancel_label: '&Cancel',
                        padding: nil, on_cancel: nil, maximizable: false)
```

| Option | Default | Meaning |
|---|---|---|
| `title` | — (required) | the title |
| `fields` | — (required) | field descriptors, see below |
| `on_submit` | `nil` | `->(values, form) { ... }`; return `:keep` to stay open, anything else closes. `nil`: Save just closes |
| `on_cancel` | `nil` | `->(form) { ... }`, run by the Cancel button before the window closes |
| `hint` | `nil` | a line above the fields, in `:input_label` style |
| `width` | `60` | at most the screen width minus 4 |
| `height` | natural | sum of the field heights + fixed rows; with an explicit height a single textarea without `:rows` grows |
| `submit_label`, `cancel_label` | `'&Save'`, `'&Cancel'` | button labels; `&` marks the Alt hotkey |
| `padding` | `{ sides: 2, top: 1, bottom: 2 }` | inner margins |
| `maximizable` | `false` | the zoom gadget (forms are resizable) |

Field descriptor keys:

| Key | Types | Default | Meaning |
|---|---|---|---|
| `key` | all | — | the key in `values` |
| `type` | — | `:input` | `:input`, `:textarea` or `:checkbox` |
| `label` | input, checkbox | `nil` | input: the label on the left (the column is the longest label + 1, between 8 and 16); checkbox: the text after `[x]`; ignored for a textarea |
| `value` | all | `''` / unchecked | the starting value; for a checkbox any truthy value checks it |
| `max_length` | input, textarea | 256 / 8000 | the most characters |
| `placeholder` | input | `''` | grey text while empty |
| `password` | input | `false` | shows `*` instead of the characters |
| `rows` | textarea | 6 | the height of the textarea |

`values` is a Hash by field key: strings for inputs and textareas (lines joined with `\n`), `true`/`false` for checkboxes.

| Method | What it does |
|---|---|
| `form.values` | the current values |
| `form.error(text)` | the status line in the `:error` style |
| `form.info(text)` | the status line in the `:input_label` style |
| `form.info('')` | empties the status line |
| `form.status_label` | the status `Label` widget |

**Keys in a form.** The focus starts on the first field, Tab and Shift-Tab move between fields and buttons. **Enter in a text field does not submit** (it does nothing; in a textarea it starts a new line): the caller submits with the Save button, that is **Alt-S** for `&Save`, or Tab to it and Enter. Alt-C or Esc cancel; Esc closes without calling `on_cancel`. On a checkbox Space or Enter toggles it, and a plain `s` already presses Save. Say it in the `hint` ("Alt-S saves.").

```ruby
require 'bbs'

def open_profile(app)
  BBS::Windows::Form.open(app,
    title: ' Profile ',
    width: 56,
    hint: 'Alt-S saves, Esc closes.',
    fields: [
      { key: :name,   label: 'Name',     value: app.context[:username], max_length: 20 },
      { key: :pin,    label: 'PIN',      password: true, max_length: 4, placeholder: '4 digits' },
      { key: :public, label: 'Show me in Who is online', type: :checkbox, value: true },
      { key: :bio,    type: :textarea, rows: 4, max_length: 500 }
    ],
    on_submit: lambda do |values, form|
      unless values[:pin].match?(/\A\d{4}\z/)
        form.error('The PIN is 4 digits.')
        return :keep
      end
      app.context[:profile] = values
      :close
    end,
    on_cancel: ->(_form) { app.context[:profile_cancelled] = true })
end

APP = BBS::Application.define do
  status_hint('F2', 'Profile') { open_profile(self) }
end
```

After typing `12` into the PIN and pressing Alt-S:

```text
            ╔═[■]═  Profile ════════════════════════════════════1══╗
            ║ Alt-S saves, Esc closes.                             ║
            ║                                                      ║
            ║ Name      Ann                                        ║
            ║                                                      ║
            ║ PIN       **_                                        ║
            ║                                                      ║
            ║ [x] Show me in Who is online                         ║
            ║                                                      ║
            ║                                                      ║
            ║                                                      ║
            ║                                                      ║
            ║                                                      ║
            ║                                                      ║
            ║ The PIN is 4 digits.                                 ║
            ║                                   [ Save ] [ Cancel ]║
            ╚══════════════════════════════════════════════════════┘
```

The DSL form. `prefill` supplies values for fields that have no `value:` of their own; a field's `value:` may also be a block taking `ctx`:

```ruby
require 'bbs'

APP = BBS::Application.define do
  window :profile, type: :form do |w|
    w.title        ' Profile '
    w.width        56
    w.hint         'Alt-S saves, Esc closes.'
    w.field :name, label: 'Name', max_length: 20
    w.field :pin,  label: 'PIN', password: true, max_length: 4
    w.field :public, label: 'Show me in Who is online', type: :checkbox
    w.field :since, label: 'Since', value: ->(ctx) { ctx[:app].context[:since] || Time.now.year.to_s }
    w.prefill { |ctx| { name: ctx[:username], public: true } }
    w.submit_label '&Save'
    w.on_submit do |values, form, ctx|
      if values[:pin].match?(/\A\d{4}\z/)
        ctx[:app].context[:profile] = values
        :close
      else
        form.error('The PIN is 4 digits.')
        :keep
      end
    end
    w.on_cancel { |_form, ctx| ctx[:app].context[:profile_cancelled] = true }
  end

  status_hint('F2', 'Profile') { open_window(:profile) }
end
```

# BBS::Windows::MasterDetail

A two-pane browser: a list on the left, the selected item's details on the right, an optional meta line and a row of buttons (`lib/bbs/windows/master_detail.rb`). Moving in the list re-renders the detail.

```text
BBS::Windows::MasterDetail.open(app, title:, items:, item_label:, render_detail:,
                                list_width: nil, bottom_meta: nil, actions: nil, on_activate: nil,
                                close_button: true, close_label: '&Close',
                                empty_message: '  (empty)', width: nil, height: nil, maximizable: false)
```

| Option | Default | Meaning |
|---|---|---|
| `title` | — (required) | the title |
| `items` | — (required) | an Array of anything |
| `item_label` | — (required) | `->(item) { text }`, one list line |
| `render_detail` | — (required) | `->(item, width) { lines }`: an Array of strings (ANSI allowed) for the detail pane, `width` is the usable width. Called on open, on every selection change and after a resize |
| `list_width` | a third of the window, 20–40 | the list column, at most half the window |
| `bottom_meta` | `nil` | `->(item) { text }`: one line under the panes |
| `actions` | `nil` | extra button descriptors (see ButtonBar), drawn before Close |
| `on_activate` | `nil` | `->(item, window) { ... }`, called on Enter in the list |
| `close_button` | `true` | append a Close button |
| `close_label` | `'&Close'` | its label |
| `empty_message` | `'  (empty)'` | the detail text when the list is empty |
| `width`, `height` | screen − 4, screen − 6 | the size |
| `maximizable` | `false` | the zoom gadget (master-detail windows are resizable) |

| Member | What it does |
|---|---|
| `replace_items(items, title: nil)` | swaps the list in place, selects the first item, optionally changes the title: navigation in a file browser or a wiki |
| `reload_detail(keep_scroll: false)` | renders the detail of the selected item again |
| `list_widget` | the `ListBox`; `selected_item` is the current item |
| `detail_view` | the `ScrollView` on the right |
| `meta_label` | the meta `Label`, or `nil` |

The focus starts in the list. Up/Down, PgUp/PgDn, Home/End and the wheel move in the list; Tab goes to the buttons. With several buttons they align right, with Close alone it is centred.

```ruby
require 'bbs'

Planet = Struct.new(:name, :moons, :about)
PLANETS = [Planet.new('Mercury', 0, 'Closest to the Sun.'),
           Planet.new('Earth', 1, 'Home.'),
           Planet.new('Mars', 2, 'Red and dusty.')].freeze

def open_planets(app)
  BBS::Windows::MasterDetail.open(app,
    title: ' Planets ',
    items: PLANETS,
    item_label: ->(p) { p.name },
    render_detail: ->(p, width) { [p.name.upcase, '', *BBS::FrameBuffer.wrap_ansi(p.about, width)] },
    bottom_meta: ->(p) { "#{p.moons} moon(s)" },
    list_width: 20,
    width: 60, height: 14,
    maximizable: true,
    on_activate: ->(p, _win) { BBS::Dialogs.message(app, "You picked #{p.name}.") },
    actions: [
      { label: '&Rocky only', keep_open: true,
        on_click: ->(win) { win.replace_items(PLANETS.reject { |p| p.name == 'Earth' }, title: ' Rocky ') } }
    ])
end

APP = BBS::Application.define do
  status_hint('F2', 'Planets') { open_planets(self) }
end
```

```text
          ╔═[■]═  Planets ════════════════════════════════════1══[↑]═╗
          ║                                                          ║
          ║   Mercury             EARTH                              ║
          ║   Earth                                                  ║
          ║   Mars                Home.                              ║
          ║                                                          ║
          ...
          ║ 1 moon(s)                                                ║
          ║                                  [ Rocky only ] [ Close ]║
          ╚══════════════════════════════════════════════════════════╝
```

The DSL form; `items` is resolved at open, the callbacks get `ctx` last:

```ruby
require 'bbs'

Planet = Struct.new(:name, :moons, :about)

APP = BBS::Application.define do
  services planets: [Planet.new('Mercury', 0, 'Closest to the Sun.'),
                     Planet.new('Earth', 1, 'Home.'),
                     Planet.new('Mars', 2, 'Red and dusty.')]

  window :planets, type: :master_detail do |w|
    w.title         ' Planets '
    w.maximizable   true
    w.width         60
    w.height        14
    w.list_width    20
    w.items         { |ctx| ctx[:planets] }
    w.item_label    { |p, _ctx| p.name }
    w.render_detail { |p, width, _ctx| [p.name.upcase, '', *BBS::FrameBuffer.wrap_ansi(p.about, width)] }
    w.bottom_meta   { |p, _ctx| "#{p.moons} moon(s)" }
    w.empty_message '  (no planets)'
    w.on_activate   { |p, _win, ctx| BBS::Dialogs.message(ctx[:app], "You picked #{p.name}.") }
    w.action(label: '&Rocky only', keep_open: true) do |win, ctx|
      win.replace_items(ctx[:planets].reject { |p| p.name == 'Earth' }, title: ' Rocky ')
    end
  end

  status_hint('F2', 'Planets') { open_window(:planets) }
end
```

`render_detail` gets the pane width; wrap text to it with `BBS::FrameBuffer.wrap_ansi(text, width)` or render Markdown with `BBS::Markdown.to_lines(text, width: width, theme: theme)` (see [Drawing](https://wiki.teletypegames.org/engines/rubbs/drawing)).

# BBS::Windows::Stream

A live view: a scrolling history, an input line under it, and a poll on every idle tick (`lib/bbs/windows/stream.rb`). Built for chat and log windows.

```text
BBS::Windows::Stream.open(app, title:, initial_lines: [], placeholder: '',
                          on_submit: nil, on_poll: nil, buffer_size: 500,
                          width: nil, height: nil, close_button: true, close_label: '&Close',
                          maximizable: false)
```

| Option | Default | Meaning |
|---|---|---|
| `title` | — (required) | the title |
| `initial_lines` | `[]` | the history to start with (ANSI allowed) |
| `placeholder` | `''` | grey text in the empty input |
| `on_submit` | `nil` | `->(text, app) { ... }` on Enter with a non-blank line; the input is cleared unless it returns `:keep` |
| `on_poll` | `nil` | `->(app) { lines }` on every idle tick; the returned lines (or `nil` / `[]`) are appended and the history scrolls to the end |
| `buffer_size` | `500` | the most lines kept |
| `width`, `height` | screen − 8, screen − 6 | the size |
| `close_button`, `close_label` | `true`, `'&Close'` | a centred Close button |
| `maximizable` | `false` | the zoom gadget (streams are resizable) |

`history` returns the `ScrollView`, `input` the `TextInput`. The focus starts in the input.

`on_submit` does **not** add the line to the history: the window shows only what `initial_lines` and `on_poll` give it. Send the line to the service and let the next poll bring it back (the caller then sees the same order as everyone else), or append it yourself with `win.history.lines += [line]`.

```ruby
require 'bbs'

def open_log(app)
  BBS::Windows::Stream.open(app,
    title: ' Log ',
    initial_lines: ['Log opened.'],
    placeholder: 'Type a line, Enter adds it',
    buffer_size: 200,
    on_submit: ->(text, _app) { (app.context[:log] ||= []) << text },
    on_poll: ->(_app) { (app.context[:log] || []).shift(10).map { |l| "> #{l}" } })
end

APP = BBS::Application.define do
  status_hint('F2', 'Log') { open_log(self) }
end
```

A chat through the DSL, with a shared service. `initial_lines` notes how far the caller has read, `on_poll` brings what came since:

```ruby
require 'bbs'

class Chat
  def initialize
    @lines = []
    @mutex = Mutex.new
  end

  def history = @mutex.synchronize { @lines.last(50) }
  def say(who, text) = @mutex.synchronize { @lines << "#{who}: #{text}" }
  def since(n) = @mutex.synchronize { @lines.drop(n) }
  def size = @mutex.synchronize { @lines.size }
end

APP = BBS::Application.define do
  services chat: Chat.new

  window :chat, type: :stream do |w|
    w.title         ' Chat '
    w.maximizable   true
    w.initial_lines do |ctx|
      ctx[:app].context[:chat_seen] = ctx[:chat].size
      ctx[:chat].history
    end
    w.placeholder   'Say something, Enter sends'
    w.buffer_size   300
    w.on_submit     { |text, _app, ctx| ctx[:chat].say(ctx[:username], text) }
    w.on_poll do |_app, ctx|
      seen  = ctx[:app].context[:chat_seen]
      fresh = ctx[:chat].since(seen)
      ctx[:app].context[:chat_seen] = seen + fresh.size
      fresh
    end
  end

  status_hint('F4', 'Chat') { open_window(:chat) }
end
```

Polls only run while the caller is idle, about once a second; someone typing sees the new lines when they pause.

# BBS::Windows::ButtonBar

The helper that lays out the button rows of the pre-built windows, usable on your own windows too (`lib/bbs/windows/button_bar.rb`). The buttons go on the row above the bottom border.

| Function | What it does |
|---|---|
| `ButtonBar.attach(window, app, descriptors)` | makes the buttons, places them, adds them to the window; returns `{ widgets:, primary:, by_key: }` |
| `ButtonBar.place(window, widgets, descriptors)` | places existing buttons again against the window's current bounds (call it from `place`) |

Button descriptor (the same in `Info` `buttons:`, `MasterDetail` `actions:`, `w.button`, `w.action`):

| Key | Default | Meaning |
|---|---|---|
| `label` | — | the text; `&` marks the hotkey (letter or Alt-letter) |
| `on_click` | `nil` | `->(window) { ... }`; in the DSL the block, `\\|win, ctx\\|` |
| `keep_open` | `false` | `true`: the window stays open after the click |
| `primary` | `false` | this button gets the focus when an `Info` opens (`attach` returns it as `primary:`) |
| `cancel` | `false` | informational only; Esc closes through the window's `close_key` anyway |
| `align` | `:center` for one button, `:right` for more | `:left` stacks from the left edge, `:right` flush to the right edge in declaration order, `:center` in the middle |
| `key` | `nil` | a name for `by_key` |

After `on_click` the window is closed unless `keep_open` is set or the block returned `:keep`. The return value goes nowhere else: `:halt` from a button does not end the application (call `app.stop!` for that), but `app.open_door(name)` from a button does leave through the door, since it stops the loop itself.

```ruby
require 'bbs'

def open_vote(app)
  win = BBS::Dialogs.centred_window(app, width: 44, height: 7, title: ' Vote ')
  win.modal = false
  question = BBS::Widgets::Label.new(text: 'Pineapple on pizza?', style_key: :window_text)
  question.layout(win.bounds.x + 2, win.bounds.y + 2, 40, 1)
  win.add(question)
  bar = BBS::Windows::ButtonBar.attach(win, app, [
    { key: :help, label: '&Help', align: :left, keep_open: true,
      on_click: ->(_w) { BBS::Dialogs.message(app, 'Pick one.') } },
    { key: :yes, label: '&Yes', primary: true, on_click: ->(_w) { app.context[:vote] = :yes } },
    { key: :no,  label: '&No',  on_click: ->(_w) { app.context[:vote] = :no } }
  ])
  win.reset_focus
  win.focus_manager.focus(bar[:primary])
  app.open_window(win)
end

APP = BBS::Application.define do
  status_hint('F2', 'Vote') { open_vote(self) }
end
```

```text
                  ╔═[■]═  Vote ═══════════════════════════1══╗
                  ║                                          ║
                  ║ Pineapple on pizza?                      ║
                  ║                                          ║
                  ║                                          ║
                  ║ [ Help ]                   [ Yes ] [ No ]║
                  ╚══════════════════════════════════════════╝
```

# BBS::Dialogs

Small modal windows for the common questions (`lib/bbs/dialogs.rb`). All of them open at once and return the window; none of them blocks: the answer arrives in a callback. While a dialog is open nothing else can be switched to.

| Function | Default | What it does |
|---|---|---|
| `Dialogs.message(app, text, title: ' Message ', width: nil)` | width: the text wrapped to 50, at least 30 | the text and an OK button |
| `Dialogs.confirm(app, text, title: ' Confirm ', on_yes: nil, on_no: nil)` | at least 36 wide | the text, Yes and No |
| `Dialogs.input(app, prompt:, title: ' Input ', value: '', width: 50, on_submit: nil)` |  | a prompt, a text field, OK and Cancel |
| `Dialogs.centred_window(app, width:, height:, title: nil)` |  | a modal, centred, laid-out `Window`, not opened yet |

| Dialog | Answers |
|---|---|
| message | OK, `o`, Alt-O, Enter on OK or Esc close it |
| confirm | Yes (`y`, Alt-Y) closes and calls `on_yes`; No (`n`, Alt-N) closes and calls `on_no`; Esc closes **without** calling either |
| input | Enter in the field or OK closes and calls `on_submit` with the text; Cancel or Esc closes without a call |

The confirm callbacks' return value reaches the run loop, so `on_yes: -> { open_door(:x) }` from a helper leaves through the door. Long texts wrap; `message` with `width:` wraps to `width - 6`. To wrap text of your own, use `BBS::FrameBuffer.wrap_ansi(text, width)`, the one word-wrap of rubbs (it knows ANSI).

```ruby
require 'bbs'

def ask_delete(app)
  BBS::Dialogs.confirm(app, 'Delete every note?',
    on_yes: -> { BBS::Dialogs.message(app, 'Deleted.', title: ' Done ') },
    on_no:  -> { app.context[:kept] = true })
end

def ask_name(app)
  BBS::Dialogs.input(app, prompt: 'New name:', value: app.context[:username].to_s,
    on_submit: ->(value) { app.context[:username] = value })
end

APP = BBS::Application.define do
  status_hint('F2', 'Delete') { ask_delete(self) }
  status_hint('F3', 'Rename') { ask_name(self) }
  status_hint('F4', 'Hello') { BBS::Dialogs.message(self, "Hello, #{context[:username]}!", width: 40) }
end
```

```text
                         ╔═[■]═  Done ════════════════╗
                         ║ Deleted.                   ║
                         ║                            ║
                         ║                            ║
                         ║           [ OK ]           ║
                         ╚════════════════════════════╝
```

For anything with more than one field, use a `Form`.

# Keys and mouse in windows

| Key / action | What it does |
|---|---|
| **Tab / Shift-Tab** | next / previous control |
| **Enter / Space** | presses the focused button; toggles a checkbox |
| **Enter** in a list | `on_activate` of a MasterDetail |
| **Enter** in a text field | nothing in a Form; submits in a Stream and in `Dialogs.input` |
| **Alt-letter** / **letter** | presses the button with that hotkey (the plain letter only when the focused control does not take letters) |
| **Esc** | closes the window (`close_key`) |
| **F5**, **Alt-Enter** | maximize and back |
| **Ctrl-F5** | move mode: arrows move, Shift+arrows size, Tab toggles, Enter / Esc end |
| **F6 / Shift-F6**, **Alt-1…9** | switch windows |
| click `[■]` / `[↑]` `[↕]` | close / maximize, restore |
| drag the title / the `┘` | move / size |
| double click the title | maximize and back |
| wheel | scrolls the list or text under the pointer |

# See also

- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — `open_window`, the event dispatch, the menubar and the statusbar
- [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets) — Label, Button, TextInput, TextArea, Checkbox, ListBox, ScrollView and writing your own
- [Drawing](https://wiki.teletypegames.org/engines/rubbs/drawing) — the style keys (`:window_text`, `:accent`, `:error`, ...), `FrameBuffer.wrap_ansi`, Markdown
- [Telnet and the store](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store) — the key symbols and the mouse

'; 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: [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets.md)
