The building blocks of a rubbs
BBS::Application: theBBS::Widgetbase class, every widget inBBS::Widgets(Label, Button, TextInput, TextArea, Checkbox, ListBox, ScrollView, and Scene for drawings), theContainer, and a complete custom widget. Windows are on Windows, the application itself on Application, colours and the frame buffer on Drawing and theming. Everything here is checked against rubbs 1.0.0. Since 1.0 the widgets that no board used are gone:ProgressBar,Spinner,HBox,VBox,PanelandAnsiArt(drawings areBBS::ArtandWidgets::Scene, see Art and graphics screens); widgets are laid out in aBBS::Window.
A widget is a Ruby object that knows its rectangle on the screen, draws itself into a BBS::FrameBuffer and, if it is focusable, answers keyboard and mouse events. The application redraws the whole screen into a fresh frame buffer after every event and sends only the cells that changed (see Drawing and theming), so a widget never writes to the line itself: it only changes its own state and draws that state when asked.
Things that hold for every widget:
layout(x, y, width, height) takes screen columns and rows, (1, 1) is the top left corner of the terminal. A widget inside a window is placed in screen coordinates too, not relative to the window. When a window moves, it shifts all of its descendants with it.layout on every widget yourself, usually computed from the window's own position or from cols and rows of the application.:button_focused; the theme turns it into an ANSI SGR sequence (see Drawing and theming).BBS::FocusManager per window (and one for the desktop): Tab and Shift-Tab cycle the focusable, visible, enabled widgets in the order they were added, and only the focused widget receives keys.handle_event returns a verdict: :handled stops the event, :halt ends the application, nil (or false) lets it go on to the next handler.BBS::Widget (lib/bbs/widget.rb) is the base class of every visual element. On its own it draws nothing and takes no focus.
BBS::Widget.new(id: nil, bounds: nil, theme: nil)
| Option | Default | Meaning |
|---|---|---|
id: |
nil |
Any value, usually a Symbol, so that Container#find(id) can look the widget up later. |
bounds: |
Rect.new(1, 1, 0, 0) |
The initial rectangle. A zero width means "draw nothing"; most code leaves it out and calls layout instead. |
theme: |
nil |
A BBS::Theme. Normally left out: the application or window sets it when the widget is added. |
| Attribute | Access | Default | Meaning |
|---|---|---|---|
bounds |
read/write | see above | A BBS::Widget::Rect with x, y, width, height. |
parent |
read/write | nil |
The enclosing Container; set by Container#add. |
id |
read/write | nil |
The lookup key for Container#find. |
visible |
read/write | true |
Invisible widgets are neither drawn nor focusable. |
focused |
read/write | false |
Set by the focus manager. Widgets use it to pick the focused style. |
enabled |
read/write | true |
Disabled widgets are skipped by Tab, ignore input, and buttons draw greyed out. |
theme |
read (theme= to set) |
nil |
The widget's own theme; see "Theme lookup" below. |
BBS::Widget::Rect is a Struct with contains?(px, py) (true when the point is inside) and a to_s such as #2,2 8x1.
| Method | What it does |
|---|---|
layout(x, y, width, height) |
Sets bounds. A widget that arranges parts of itself can override it. |
render(frame) |
Draws the widget into a BBS::FrameBuffer. Empty in the base class. |
handle_event(event) |
Answers a BBS::Event. Returns nil in the base class. |
focusable? |
false in the base class; interactive widgets return true. |
on_focus / on_blur |
Called by the focus manager when the widget gains or loses focus. Empty by default. |
theme=(theme) |
Sets the theme. On a Container it is passed down to every child. |
hit_test(x, y) |
Returns the widget itself when the point is inside bounds, else nil. Containers return the deepest child. |
each { ... } |
Yields the widget itself (containers walk their tree with walk). |
redraw! |
Passes the request up to the parent. The application redraws after every event anyway, so you rarely need it. |
style(key) |
Protected: the SGR string for a theme role, for use in render. |
style(key) uses the widget's own theme, or, when that is nil, its parent's theme. It looks up one level only. The application gives a theme to the whole tree at the moment you hand it over: add(widget) on the desktop and open_window(window) both call theme=, and Container#theme= passes it down to every child that exists at that time. So build the whole tree first, then add it. A widget added two levels deep after the window was opened gets no style (the terminal's default colours) unless you call widget.theme = app.theme yourself.
require 'bbs'
win = BBS::Window.new
win.theme = BBS::Theme.synchronet
box = BBS::Container.new
win.add(box) # box has no theme of its own, its parent has
label = BBS::Widgets::Label.new(text: 'late')
box.add(label) # two levels deep, added after theme=
p label.send(:style, :text) # => nil, drawn in the terminal's default colours
label.theme = win.theme # the fix
p label.send(:style, :text) # => "\e[0;37;40m"
handle_event receives a BBS::Event:
| Field / method | Meaning |
|---|---|
kind |
:key or :mouse for what reaches widgets. |
key |
A Symbol for named keys (:up, :down, :left, :right, :home, :end, :page_up, :page_down, :enter, :backspace, :delete, :tab, :shift_tab, :escape, :f1… , :alt_x, :shift_up, :ctrl_left…) or a one-character String for a printable key. |
key?, mouse? |
The kind. |
char? |
True for a one-character String key. |
alt?, alt_char |
True for an :alt_x key, and its letter ('x'). |
mouse |
A BBS::Telnet::MouseEvent with kind (:press, :release, :move, :wheel), button (:left, :middle, :right, or :up/:down for the wheel), x, y and mods. |
x, y |
The mouse position, 1-based screen coordinates. |
In tests you can make events by hand: BBS::Event.key(:down), BBS::Event.key('a'), BBS::Event.mouse(BBS::Telnet::MouseEvent.new(kind: :press, button: :left, x: 3, y: 1)).
Most widgets live in a BBS::Window. You create the window, lay it out, add the widgets with their screen positions, call reset_focus so the window's focus manager sees them, and open it. A helper method on the application is a good place for this. The ready-made window types (Info, Form, MasterDetail, Stream) build their widgets for you; see Windows.
require 'bbs'
APP = BBS::Application.define do
theme BBS::Theme.synchronet
status_hint 'Esc', 'Close'
helpers do
def settings_window
win = BBS::Window.new(title: ' Settings ')
win.layout(5, 3, 40, 11)
label = BBS::Widgets::Label.new(text: 'Handle:', style_key: :input_label)
label.layout(7, 5, 9, 1)
win.add(label)
handle = BBS::Widgets::TextInput.new(id: :handle, placeholder: 'Your handle', max_length: 20)
handle.layout(17, 5, 24, 1)
win.add(handle)
ansi = BBS::Widgets::Checkbox.new(id: :ansi, label: 'ANSI colours', checked: true)
ansi.layout(7, 7, 30, 1)
win.add(ansi)
themes = BBS::Widgets::ListBox.new(id: :theme, items: %i[synchronet nortoncommander mono])
themes.layout(7, 9, 20, 3)
win.add(themes)
save = BBS::Widgets::Button.new(label: '&Save', on_click: lambda { |_btn|
context[:handle] = win.find(:handle).value
close_window(win)
})
save.layout(30, 11, 12, 1)
win.add(save)
win.reset_focus
win
end
end
init do
title = BBS::Widgets::Label.new(text: 'My BBS', align: :center, style_key: :accent)
title.layout(1, 1, cols, 1)
add(title)
open_window(settings_window)
end
end
Run headless on a 50×15 screen, after typing tasi, Tab, Space (unticks the checkbox), it looks like this. The frame is bright white on blue, the title of the active window black on light grey, Handle: cyan, the input field and list white on blue, the button black on light grey with a red S:
My BBS
╔═[■]═ Settings ═══════════════════1══╗
║ ║
║ Handle: tasi ║
║ ║
║ [ ] ANSI colours ║
║ ║
║ synchronet ║
║ nortoncommander ║
║ mono [ Save ] ║
║ ║
╚══════════════════════════════════════╝
Esc Close
Pressing Alt-S (or S when the focused widget does not take letters) presses the button, stores tasi in context[:handle] and closes the window.
A callback that wants to close the window calls close_window(win) itself. A Button's on_click result is turned into :handled (or :halt), so returning :close_window from it does nothing.
Widgets added with add(widget) in the application's init block (or from any application method) go on the desktop, the area behind the windows. This is where a board puts its welcome art, a clock, or a big "open the main menu" button:
require 'bbs'
APP = BBS::Application.define do
theme BBS::Theme.neumanntronics
init do
art = BBS::Widgets::Scene.new(art: BBS::Art.load('art/desktop.yml'), align: :center, valign: :middle)
art.layout(1, 2, cols, rows - 2)
add(art)
open_menu = BBS::Widgets::Button.new(label: '&Menu', on_click: ->(_) { open_window(:main) })
open_menu.layout(cols - 12, rows - 2, 10, 1)
add(open_menu)
end
end
The desktop works like a window without a frame, with three differences, all checked against the source:
&M) work only inside windows.The order in which a window handles a key (BBS::Window#handle_event):
close_key (Esc by default) closes the window.A left click focuses the widget under the mouse, then the event goes to the focused widget. Wheel events go to the widget under the mouse even if it does not have the focus, so a ScrollView or ListBox scrolls without being clicked first.
All of these live in BBS::Widgets, one file each in lib/bbs/widgets/. Every constructor also takes id:, bounds: and theme: from BBS::Widget.
One line of text, clipped to the width. Not focusable.
BBS::Widgets::Label.new(text: '', align: :left, style_key: :text)
| Option | Default | Meaning |
|---|---|---|
text: |
'' |
The text. Anything with to_s. |
align: |
:left |
:left, :center or :right within the width. |
style_key: |
:text |
The theme role it is drawn with, e.g. :accent, :label, :error, :window_text, :input_label. |
All three are read/write attributes, so a label can be updated in place (status.text = 'Saved'). The text is written with write_ansi, so embedded SGR sequences colour parts of it; but clipping and alignment count the escape characters as visible, so a right- or centre-aligned label with escapes in it lands too far left. Keep coloured labels left-aligned, or use two labels.
require 'bbs'
l1 = BBS::Widgets::Label.new(text: 'left')
l2 = BBS::Widgets::Label.new(text: 'center', align: :center)
l3 = BBS::Widgets::Label.new(text: 'right', align: :right, style_key: :accent)
[l1, l2, l3].each_with_index { |l, i| l.layout(1, i + 1, 20, 1) }
left
center
right
A clickable button drawn as [ Label ], centred in its width, with the background filled.
BBS::Widgets::Button.new(label: 'OK', on_click: nil)
| Option | Default | Meaning |
|---|---|---|
label: |
'OK' |
The caption. An & marks the hotkey letter: '&Save' draws [ Save ] with the S in the :button_hotkey colour. |
on_click: |
nil |
->(button) { ... }. Return :halt to end the application; anything else counts as handled. |
Attributes: label, on_click (read/write). Methods: hotkey (the lower-case hotkey letter, or nil), display_label (the caption without the &), fire (calls on_click as a press would; a disabled button does nothing).
Behaviour:
TextInput takes plain letters, so while a text field is focused it is Alt plus the letter.:button, :button_focused, :button_hotkey, :button_disabled (when enabled = false; the hotkey is then not highlighted).require 'bbs'
quit = BBS::Widgets::Button.new(label: '&Quit', on_click: ->(_) { :halt })
p quit.hotkey # => "q"
p quit.display_label # => "Quit"
p quit.handle_event(BBS::Event.key(:enter)) # => :halt
ok = BBS::Widgets::Button.new(label: 'OK', on_click: ->(b) { puts "clicked #{b.label}" })
ok.handle_event(BBS::Event.key(' ')) # prints "clicked OK", returns :handled
A focused &Save button next to a disabled one (12 columns each):
[ Save ] [ Cancel ]
A one-line text field with a cursor, horizontal scrolling, a placeholder and an optional password mask.
BBS::Widgets::TextInput.new(value: '', placeholder: '', max_length: 256,
on_submit: nil, on_change: nil, password: false)
| Option | Default | Meaning |
|---|---|---|
value: |
'' |
The initial text (copied). |
placeholder: |
'' |
Grey hint (:text_dim) shown while the field is empty and not focused. |
max_length: |
256 |
Keys beyond this length are swallowed. |
on_submit: |
nil |
->(value, input) { ... } on Enter. Its result is returned (falls back to :handled). |
on_change: |
nil |
->(value, input) { ... } after every edit (typing, Backspace, Delete). |
password: |
false |
Draw * instead of the characters. |
Attributes: value (read; value= replaces the text and puts the cursor at its end), placeholder, max_length, on_submit, on_change, password (read/write).
Keys: Left, Right, Home, End move the cursor; Backspace and Delete edit; Enter submits; any printable character is inserted. Keys it has no use for (Esc, function keys, Alt-letters, Up/Down) return nil, so they go on to the window: Esc still closes the window from inside a text field, Alt-S still presses &Save. No mouse handling beyond the window giving it focus on click. Styles: :input, :input_focused, :text_dim for the placeholder. The cursor is drawn as _.
require 'bbs'
input = BBS::Widgets::TextInput.new(
placeholder: 'Say something',
on_change: ->(v, _) { puts "change #{v}" },
on_submit: lambda { |v, field|
puts "submit #{v}"
field.value = '' # clear for the next line, cursor goes back to 0
:handled
}
)
input.layout(1, 1, 30, 1)
'hi'.each_char { |c| input.handle_event(BBS::Event.key(c)) } # change h, change hi
p input.handle_event(BBS::Event.key(:escape)) # => nil (goes on to the window)
input.handle_event(BBS::Event.key(:enter)) # submit hi
p input.value # => ""
An empty, unfocused field with a placeholder, and a focused password field holding secret:
Your name
******_
A multi-line editor.
BBS::Widgets::TextArea.new(value: '', max_length: 8_000, on_change: nil)
| Option | Default | Meaning |
|---|---|---|
value: |
'' |
Initial text; "\n" separates lines. |
max_length: |
8_000 |
Total characters (newlines included); typing stops there. Enter still splits lines. |
on_change: |
nil |
->(value, area) { ... } after typing, Enter or Backspace. |
Methods: value (the lines joined with "\n"), length. on_change is read/write; there is no value=, so build a new TextArea to replace the text.
Keys: arrows move (Left at the start of a line goes to the end of the previous one, Right at the end goes to the next line), Home/End within the line, Enter splits the line at the cursor, Backspace deletes or joins with the previous line, printable characters insert. The view scrolls vertically to keep the cursor visible; long lines are clipped, not wrapped or scrolled sideways. There is no Delete key and no Tab character (Tab moves the focus). Styles: :input, :input_focused.
require 'bbs'
body = BBS::Widgets::TextArea.new(value: "one\ntwo")
body.layout(1, 1, 40, 6)
body.handle_event(BBS::Event.key(:end)) # end of "one"
body.handle_event(BBS::Event.key(:enter)) # new empty line after it
body.handle_event(BBS::Event.key('!'))
p body.value # => "one\n!\ntwo"
p body.length # => 9
A toggle drawn as [x] Label or [ ] Label.
BBS::Widgets::Checkbox.new(label: '', checked: false, on_change: nil)
| Option | Default | Meaning |
|---|---|---|
label: |
'' |
Text after the box. |
checked: |
false |
Initial state. |
on_change: |
nil |
->(checked, box) { ... } after a toggle. |
Attributes: label, checked, on_change (read/write). Space or Enter toggles. A click focuses it (through the window) but does not toggle it. Styles: :text, and :button_focused while focused.
require 'bbs'
remember = BBS::Widgets::Checkbox.new(label: 'Remember me', checked: true,
on_change: ->(on, _) { puts "checked=#{on}" })
remember.handle_event(BBS::Event.key(' ')) # prints checked=false
[x] Remember me
A scrolling, selectable list.
BBS::Widgets::ListBox.new(items: [], on_select: nil, on_activate: nil, label: nil)
| Option | Default | Meaning |
|---|---|---|
items: |
[] |
Any objects. |
label: |
nil |
->(item) { ... } returning the text of a row; nil means item.to_s. |
on_select: |
nil |
->(item, list) { ... } when the selection moves to another row (keys, click, wheel). Not called when it stays put. |
on_activate: |
nil |
->(item, list) { ... } on Enter. Its result is returned (falls back to :handled). |
Attributes (read/write): items, selected (index, starts at 0), scroll (index of the top row), on_select, on_activate, label. Method: selected_item.
Keys: Up, Down, PgUp and PgDn (by the height), Home, End move the selection and scroll it into view; Enter activates. Other keys return nil. Mouse: a left click selects the row under it (it does not activate; there is no double-click); the wheel moves the selection by three rows. Styles: :listbox for rows, :listbox_selected for the selected row when the list is not focused, :listbox_focused when it is.
When you replace items, reset selected and scroll yourself; setting selected directly does not scroll it into view. With an empty list, Enter calls on_activate with nil.
require 'bbs'
list = BBS::Widgets::ListBox.new(
items: %w[alpha beta gamma delta],
label: ->(name) { name.upcase },
on_select: ->(item, _) { puts "selected #{item}" },
on_activate: ->(item, _) { puts "activated #{item}"; :handled }
)
list.layout(1, 1, 20, 2)
list.handle_event(BBS::Event.key(:down)) # selected beta
list.handle_event(BBS::Event.key(:end)) # selected delta
p list.scroll # => 2, the view followed
list.handle_event(BBS::Event.key(:enter)) # activated delta
wheel = BBS::Telnet::MouseEvent.new(kind: :wheel, button: :up, x: 3, y: 1)
list.handle_event(BBS::Event.mouse(wheel)) # selected alpha
The same list three rows high with beta selected (the row is drawn in the selected colour across the full width):
ALPHA
BETA
GAMMA
A typical master list in a window opens a detail on Enter:
threads = BBS::Widgets::ListBox.new(
items: services[:boards].threads(:general),
label: ->(t) { "#{t.subject.ljust(30)} #{t.author}" },
on_activate: ->(t, _) { open_window(:thread, thread: t) }
)
A read-only, scrollable block of lines with a scrollbar. This is what shows Markdown and long texts.
BBS::Widgets::ScrollView.new(lines: [])
| Option | Default | Meaning |
|---|---|---|
lines: |
[] |
An Array of Strings, one per row. Embedded ANSI SGR sequences are kept; each row is clipped to the width. |
Attributes (read/write): lines, scroll (index of the top line). Focusable.
Keys: Up, Down, PgUp, PgDn, Home, End. Mouse wheel: three lines a notch (inside a window it works without focus). When there are more lines than rows, the last column becomes a scrollbar: │ in :scrollbar, a █ thumb in :scrollbar_thumb. Text is drawn with :text as the base style.
require 'bbs'
text = File.read('NEWS.md')
lines = BBS::Markdown.to_lines(text, width: 59, theme: BBS::Theme.synchronet)
news = BBS::Widgets::ScrollView.new(lines: lines)
news.layout(11, 5, 60, 15) # one column wider than the text: room for the scrollbar
Ten lines in a view three rows high:
line 1 █
line 2 │
line 3 │
BBS::Container (lib/bbs/container.rb) is a widget that holds children. BBS::Window is its subclass, and the container a board lays its widgets out in. On its own it does no layout: layout sets only its own bounds.
BBS::Container.new(id: nil, bounds: nil, theme: nil)
| Method | What it does |
|---|---|
children |
The child Array, in the order added (also the drawing and Tab order). |
add(child) |
Appends the child, sets its parent, returns the child. |
remove(child) |
Removes the child and clears its parent. |
clear |
Removes all children. |
walk { ... } |
Yields itself and every descendant, depth first. |
find(id) |
The first widget in walk order whose id matches, or nil. |
focusable_descendants |
Every visible, focusable descendant, in order. |
render(frame) |
Draws the visible children in order (later ones on top). |
hit_test(x, y) |
The top-most visible descendant under the point, or the container itself, or nil outside. |
theme=(theme) |
Sets the theme on itself and every child. |
require 'bbs'
form = BBS::Container.new
form.add(BBS::Widgets::TextInput.new(id: :name))
form.add(BBS::Widgets::Label.new(text: 'not focusable'))
form.add(BBS::Widgets::Checkbox.new(id: :news, label: 'Newsletter'))
p form.find(:news).label # => "Newsletter"
p form.focusable_descendants.map(&:id) # => [:name, :news]
BBS::Widgets::Scene (lib/bbs/widgets/scene.rb) shows a BBS::Art drawing (a YAML file with an ANSI and a PETSCII face) on the modern face, and fills its hotspots from live data at every render. Not focusable. It replaces the AnsiArt widget of 0.x; drawings, hotspots, seasonal layers and art-preview are on Art and graphics screens.
require 'bbs'
house = BBS::Art.load('art/house.yml')
scene = BBS::Widgets::Scene.new(art: house, align: :center, valign: :middle, fill_parent: true)
scene.fill(:sign) { |spot| "\e[1;37m#{inside_count}".ljust(spot.width) }
add(scene)
| Option | Default | Meaning |
|---|---|---|
art: |
— | The BBS::Art. |
fills: |
{} |
Hotspot name → block, the same as calling fill(name) { ... }. |
align: / valign: |
:left / :top |
Where the drawing sits in the bounds: :left, :center, :right / :top, :middle, :bottom. |
crop: |
true |
On a screen smaller than the drawing it is cut to the bounds; false draws nothing instead (fits? tells). |
fill_parent: |
false |
Take the parent's bounds at every render (the desktop's area, between the menubar and the statusbar). |
Subclass BBS::Widget (or BBS::Container if it holds children). The contract:
**kw) to super, so id:, bounds: and theme: keep working.true from focusable? if it takes input.render(frame), draw only inside bounds, starting with frame.move(bounds.x, bounds.y), and get colours with style(:role). Use existing theme roles so that every preset colours the widget sensibly.handle_event(event), return :handled for what you used and nil for everything else, so Tab, Esc and the hotkeys keep working.A star-rating widget, complete:
require 'bbs'
class Rating < BBS::Widget
attr_reader :value
attr_accessor :on_change
def initialize(value: 0, max: 5, on_change: nil, **kw)
super(**kw)
@value = value
@max = max
@on_change = on_change
end
def focusable? = true
def value=(v)
v = v.clamp(0, @max)
return if v == @value
@value = v
@on_change&.call(@value, self)
end
def render(frame)
return if bounds.width <= 0
stars = ('*' * @value).ljust(@max, '.')
frame.move(bounds.x, bounds.y)
frame.write("[#{stars}] #{@value}/#{@max}"[0, bounds.width],
sgr: style(focused ? :input_focused : :input))
end
def handle_event(event)
return nil unless enabled
if event.key?
case event.key
when :left, '-' then self.value = @value - 1
when :right, '+' then self.value = @value + 1
when '0'..'9' then self.value = event.key.to_i
else return nil
end
:handled
elsif event.mouse? && event.mouse.kind == :press && bounds.contains?(event.x, event.y)
self.value = event.x - bounds.x
:handled
end
end
end
Trying it without a server, straight into a frame buffer:
r = Rating.new(value: 2, on_change: ->(v, _) { puts "rating #{v}" })
r.theme = BBS::Theme.synchronet
r.layout(1, 1, 20, 1)
r.handle_event(BBS::Event.key(:right)) # rating 3
r.handle_event(BBS::Event.key('9')) # rating 5 (clamped)
p r.handle_event(BBS::Event.key(:up)) # => nil, not ours
click = BBS::Telnet::MouseEvent.new(kind: :press, button: :left, x: 3, y: 1)
r.handle_event(BBS::Event.mouse(click)) # rating 2: the second star was clicked
fb = BBS::FrameBuffer.new(20, 1)
r.render(fb)
[**...] 2/5
In a window it is used like any built-in widget:
stars = Rating.new(id: :stars, value: 3)
stars.layout(win.bounds.x + 2, win.bounds.y + 2, 16, 1)
win.add(stars)
win.reset_focus
define, init, add, open_window, on_tick, with_lineBBS::Window, the ready-made window types, dialogsFrameBuffer, MarkdownBBS::Art, Widgets::Scene, seasonal layers