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), theButtonBarhelper andBBS::Dialogs. The controls inside windows are on Widgets.
| 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 (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.
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.
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:
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).
| 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) |
Window#handle_event, in order:
zoomable?).movable?). F6 only gets here when it did not switch windows, that is when this is the only switchable window.[■] 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.'y' or Alt-Y for &Yes).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.
╔═[■]═ 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.
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.
A window that is movable? (movable and not maximized) can be moved; one that is also resizable? can be sized.
┘ corner to size.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.
Several windows can be open at once, covering each other in the order they were last used.
app.raise_window(win), app.cycle_windows(1) / (-1), app.switchable_windows.On a resize the application calls relayout(app) on every window:
app.desktop_rect if maximized, else at its normal_rect;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:
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.
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.
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
Every block of a spec gets ctx:
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.
Every attribute takes either a value or a block:
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.
| 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.
A centred window for messages, splash screens and key-value summaries (lib/bbs/windows/info.rb).
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.
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
╔═[■]═ System ═══════════════════════════════1══╗
║ Notes desk ║
║ ║
║ Ruby 3.3.1 ║
║ Screen 80x25 ║
║ ────────────────────────────────────────────── ║
║ Plain text line. ║
║ ║
║ ║
║ [ OK ] ║
╚════════════════════════════════════════════════╝
The same through the DSL, plus a long scrolling one:
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).
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).
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.").
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:
╔═[■]═ 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:
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
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.
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.
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
╔═[■]═ 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:
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).
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.
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].
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:
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.
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.
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
╔═[■]═ Vote ═══════════════════════════1══╗
║ ║
║ Pineapple on pizza? ║
║ ║
║ ║
║ [ Help ] [ Yes ] [ No ]║
╚══════════════════════════════════════════╝
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).
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
╔═[■]═ Done ════════════════╗
║ Deleted. ║
║ ║
║ ║
║ [ OK ] ║
╚════════════════════════════╝
For anything with more than one field, use a Form.
| 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 |
open_window, the event dispatch, the menubar and the statusbar:window_text, :accent, :error, ...), FrameBuffer.wrap_ansi, Markdown