EN /HU | Login

Windows

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, MasterDetail, Stream), the ButtonBar helper and BBS::Dialogs. The controls inside windows are on 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, 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).

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 — open_window, the event dispatch, the menubar and the statusbar
  • Widgets — Label, Button, TextInput, TextArea, Checkbox, ListBox, ScrollView and writing your own
  • Drawing — the style keys (:window_text, :accent, :error, ...), FrameBuffer.wrap_ansi, Markdown
  • Telnet and the store — the key symbols and the mouse