---
title: Widgets
url: 'https://wiki.teletypegames.org/engines/rubbs/widgets'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/widgets.md'
lang: en
date: '2026-10-27'
description: 'The building blocks of a rubbs BBS::Application: the BBS::Widget base class, every widget in BBS::Widgets (Label, Button, TextInput, TextArea, Checkbox, ListBox, ScrollView, and Scene for drawings), the Container, and a complete custom widget. Windows are on Windows, the application itself on Appl…'
taxonomy:
  tag:
    - reference
---

# Widgets

> The building blocks of a [rubbs](https://wiki.teletypegames.org/engines/rubbs) `BBS::Application`: the `BBS::Widget` base class, every widget in `BBS::Widgets` (Label, Button, TextInput, TextArea, Checkbox, ListBox, ScrollView, and Scene for drawings), the `Container`, and a complete custom widget. Windows are on [Windows](https://wiki.teletypegames.org/engines/rubbs/windows), the application itself on [Application](https://wiki.teletypegames.org/engines/rubbs/application), colours and the frame buffer on [Drawing and theming](https://wiki.teletypegames.org/engines/rubbs/drawing). Everything here is checked against rubbs 1.0.0. Since 1.0 the widgets that no board used are gone: `ProgressBar`, `Spinner`, `HBox`, `VBox`, `Panel` and `AnsiArt` (drawings are `BBS::Art` and `Widgets::Scene`, see [Art and graphics screens](https://wiki.teletypegames.org/engines/rubbs/art)); widgets are laid out in a `BBS::Window`.

# How widgets work

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](https://wiki.teletypegames.org/engines/rubbs/drawing)), 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:

- **Coordinates are 1-based and absolute.** `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.
- **There is no automatic layout.** You call `layout` on every widget yourself, usually computed from the window's own position or from `cols` and `rows` of the application.
- **Colours come from the theme by role name.** A widget asks for a role such as `:button_focused`; the theme turns it into an ANSI SGR sequence (see [Drawing and theming](https://wiki.teletypegames.org/engines/rubbs/drawing)).
- **Focus is handled by a `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

`BBS::Widget` (lib/bbs/widget.rb) is the base class of every visual element. On its own it draws nothing and takes no focus.

```ruby
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. |

## Attributes

| 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`.

## Methods

| 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`. |

## Theme lookup

`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.

```ruby
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"
```

## Events

`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))`.

# Where widgets go

## In a window

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](https://wiki.teletypegames.org/engines/rubbs/windows).

```ruby
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`:

```text
                      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.

## On the desktop

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:

```ruby
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:

- It gets keys only when no window is open (the top window always gets the first chance, and it takes Tab).
- A mouse click goes to the focused desktop widget only; there is no hit test. A click on a second button does nothing until Tab has moved the focus there.
- Button hotkeys (`&M`) work only inside windows.

## Focus and keys inside a window

The order in which a window handles a key (`BBS::Window#handle_event`):

1. Tab and Shift-Tab move the focus.
2. The focused widget gets the key. If it returns something, that is the result.
3. A modified movement key nobody took (Shift-Down, Ctrl-PgUp…) is retried as the plain key, so Shift-Down in a list moves the selection.
4. A letter or an Alt-letter presses the enabled button whose hotkey it is.
5. The window's `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.

# Widgets

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`.

## Label

One line of text, clipped to the width. Not focusable.

```ruby
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.

```ruby
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) }
```

```text
left
       center
               right
```

## Button

A clickable button drawn as `[ Label ]`, centred in its width, with the background filled.

```ruby
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:

- **Keys:** Enter or Space when focused.
- **Hotkey:** inside a window, the hotkey letter alone or with Alt, when the focused widget does not take the key. A `TextInput` takes plain letters, so while a text field is focused it is Alt plus the letter.
- **Mouse:** a left press inside the button.
- **Styles:** `:button`, `:button_focused`, `:button_hotkey`, `:button_disabled` (when `enabled = false`; the hotkey is then not highlighted).

```ruby
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):

```text
  [ Save ]    [ Cancel ]
```

## TextInput

A one-line text field with a cursor, horizontal scrolling, a placeholder and an optional password mask.

```ruby
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 `_`.

```ruby
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`:

```text
Your name
******_
```

## TextArea

A multi-line editor.

```ruby
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`.

```ruby
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
```

## Checkbox

A toggle drawn as `[x] Label` or `[ ] Label`.

```ruby
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.

```ruby
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
```

```text
[x] Remember me
```

## ListBox

A scrolling, selectable list.

```ruby
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`.

```ruby
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):

```text
ALPHA
BETA
GAMMA
```

A typical master list in a window opens a detail on Enter:

```ruby
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) }
)
```

## ScrollView

A read-only, scrollable block of lines with a scrollbar. This is what shows Markdown and long texts.

```ruby
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.

```ruby
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:

```text
line 1             █
line 2             │
line 3             │
```

# Containers

## Container

`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.

```ruby
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. |

```ruby
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]
```

# Scene: a drawing as a widget

`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](https://wiki.teletypegames.org/engines/rubbs/art).

```ruby
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). |

# Writing your own widget

Subclass `BBS::Widget` (or `BBS::Container` if it holds children). The contract:

- Take your own keyword options, pass the rest (`**kw`) to `super`, so `id:`, `bounds:` and `theme:` keep working.
- Return `true` from `focusable?` if it takes input.
- In `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.
- In `handle_event(event)`, return `:handled` for what you used and `nil` for everything else, so Tab, Esc and the hotkeys keep working.
- Change state in the handler, never draw there: the application redraws after every event.

A star-rating widget, complete:

```ruby
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:

```ruby
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)
```

```text
[**...] 2/5
```

In a window it is used like any built-in widget:

```ruby
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
```

# See also

- [rubbs](https://wiki.teletypegames.org/engines/rubbs) — overview
- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — `define`, `init`, `add`, `open_window`, `on_tick`, `with_line`
- [Windows](https://wiki.teletypegames.org/engines/rubbs/windows) — `BBS::Window`, the ready-made window types, dialogs
- [Drawing and theming](https://wiki.teletypegames.org/engines/rubbs/drawing) — themes and roles, `FrameBuffer`, Markdown
- [Art and graphics screens](https://wiki.teletypegames.org/engines/rubbs/art) — `BBS::Art`, `Widgets::Scene`, seasonal layers

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

---

## Navigation

- Parent: [RuBBS](https://wiki.teletypegames.org/engines/rubbs.md)
- Previous: [Telnet, session and store](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store.md)
- Next: [Windows](https://wiki.teletypegames.org/engines/rubbs/windows.md)
