EN /HU | Login

Widgets

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 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, Panel and AnsiArt (drawings are BBS::Art and Widgets::Scene, see Art and graphics screens); 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), 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).
  • 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.

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.

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