EN /HU | Belépés

Application

A BBS::Application a rubbs asztal-rétege: teljes képernyős, egeret is kezelő eseményciklus menüsorral, státuszsorral, háttérpanellel és egymás között váltható window-k veremével, Turbo Vision-stílusban. Ez az oldal sorra veszi az Application.define DSL minden metódusát, a példány API-ját, a futási ciklust és az application–door kört, a context hasht, az eseményeket, a fókuszt, a menüsort és a státuszsort. Maguk a window-k a Window-k, a widgetek a Widgetek oldalon vannak.

A részek

Egy application egy gyökérkonténer, és fölötte néhány réteg. Minden képkocka ebben a sorrendben készül, hátulról előre:

Réteg Osztály Mi ez
képernyő — az egész terminál a téma :screen stílusával kitöltve
gyökérpanel BBS::Container (app.root) az asztal: az add-dal hozzáadott widgetek (ANSI-art, feliratok, egy gomb)
window-verem BBS::Window (app.windows) a window-k, az utolsó az aktív
menüsor BBS::Menubar (app.menubar) az 1. sor, Alt+betűvel nyíló legördülő menük
státuszsor BBS::Statusbar (app.statusbar) az utolsó sor, billentyű-tippek és egy jobbra igazított szöveg

A menüsor és a státuszsor csak akkor létezik, ha a definíció deklarálja (menubar, illetve legalább egy status_hint). Nélkülük a gyökérpanel és a window-k kapják az egész képernyőt.

A rajzolás kétpufferes: minden képkocka egy friss BBS::FrameBuffer-be készül, összevetjük a legutóbb elküldöttel, és csak a megváltozott cellák mennek ki a vonalra. A terminál átméretezését (NAWS) a következő képkocka veszi észre: újrarajzolja az egész képernyőt, és minden window relayout-ot kap.

Egy teljes példa

Egy közös üzenőfal: jegyzetek listája egy master-detail window-ban, egy űrlap az íráshoz, egy menü egy doorral, státusztippek, óra a státuszsoron. Az oldal többi része erre hivatkozik. Mentsd el notes.rb néven, futtasd: ruby notes.rb, aztán telnet localhost 2323.

RUBY
require 'bbs'

# A tiny shared notice board: one list of notes for every caller.
class NoteStore
  Note = Struct.new(:id, :author, :title, :body, :at)

  def initialize
    @notes = []
    @mutex = Mutex.new
  end

  def all = @mutex.synchronize { @notes.dup }

  def add(author, title, body)
    return :empty if title.strip.empty?

    @mutex.synchronize { @notes << Note.new(@notes.size + 1, author, title.strip, body, Time.now) }
    :ok
  end
end

# A door: any callable(session, context) gets the raw line.
DICE = lambda do |session, context|
  session.write("\r\n  #{context[:username]} rolls: #{rand(1..6)} and #{rand(1..6)}\r\n")
  session.write("  Press ENTER to go back.\r\n")
  session.readline
end

NOTES_APP = BBS::Application.define do
  theme :synchronet do
    accent "\e[1;33;44m"
  end

  services notes: NoteStore.new

  door :dice, DICE, label: '&Dice'

  helpers do
    def me = context[:username] || 'guest'
    def clock = Time.now.strftime('%H:%M')

    def note_list
      open = windows.find { |w| w.is_a?(BBS::Windows::MasterDetail) }
      open ? raise_window(open) : open_window(:notes)
    end

    def refresh_notes
      windows.grep(BBS::Windows::MasterDetail).each { |w| w.replace_items(services[:notes].all) }
    end
  end

  window :notes, type: :master_detail do |w|
    w.title       ' Notes '
    w.maximizable true
    w.items       { |ctx| ctx[:notes].all }
    w.item_label  { |note, _ctx| note.title }
    w.render_detail do |note, width, _ctx|
      ["#{note.title} — #{note.author}", '', *BBS::FrameBuffer.wrap_ansi(note.body.to_s, width)]
    end
    w.bottom_meta   { |note, _ctx| note.at.strftime('%Y-%m-%d %H:%M') }
    w.empty_message '  No notes yet. Alt-N writes one.'
    w.action(label: '&New') { |_win, ctx| ctx[:app].open_window(:new_note); :keep }
  end

  window :new_note, type: :form do |w|
    w.title ' New note '
    w.width 60
    w.hint  'Alt-S saves, Esc cancels.'
    w.field :title, label: 'Title', max_length: 40
    w.field :body,  type: :textarea, rows: 5
    w.on_submit do |values, form, ctx|
      if ctx[:notes].add(ctx[:app].me, values[:title], values[:body]) == :empty
        form.error('A note needs a title.')
        :keep
      else
        ctx[:app].refresh_notes
        :close
      end
    end
  end

  menubar do
    menu '&File' do
      item '&Notes', shortcut: 'F2' do note_list end
      item '&New note', shortcut: 'F3' do open_window(:new_note) end
      separator
      item 'E&xit', shortcut: 'Alt-X' do :halt end
    end
    menu '&Doors' do
      doors
    end
    menu '&Help' do
      item '&About' do BBS::Dialogs.message(self, "Notes desk for #{me}.", title: ' About ') end
    end
  end

  status_hint('F2', 'Notes') { note_list }
  status_hint('F3', 'New', hidden: true) { open_window(:new_note) }
  status_hint('F10', 'Menu') { menubar.open(0) }
  status_hint('Alt-X', 'Bye') { :halt }

  on_tick { statusbar.right_text = "#{me} │ #{clock}" }
  on_door { |door, ctx| warn "#{ctx[:username]} enters #{door.name}" }

  init do
    statusbar.right_text = "#{me} │ #{clock}"
    note_list
  end
end

BBS.configure do |c|
  c.port  = Integer(ENV.fetch('PORT', 2323))
  c.mouse = true
  c.flow  = BBS::Flow.define do
    ask :username, prompt: 'Your name'
    call do |ctx, runner|
      NOTES_APP.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
      nil
    end
    say 'Bye!'
  end
end

BBS.start if $PROGRAM_NAME == __FILE__

Ezt látja a hívó, miután beírta a nevét és írt egy jegyzetet:

TEXT
  File   Doors   Help

  ╔═[■]═  Notes ══════════════════════════════════════════════════════1══[↑]═╗
  ║                                                                          ║
  ║   Hi                       Hi — Ann                                      ║
  ║                                                                          ║
  ║                            body                                          ║
  ║                                                                          ║
  ...
  ║ 2026-10-27 23:16                                                         ║
  ║                                                         [ New ] [ Close ]║
  ╚══════════════════════════════════════════════════════════════════════════┘

 F2 Notes  F10 Menu  Alt-X Bye                                      Ann │ 23:16

Amit érdemes észrevenni:

  • A NOTES_APP egy definíció (egy Application::Builder): betöltéskor egyszer készül el, és minden hívó osztozik rajta. Minden hívás saját Application-példányt kap belőle.
  • A service-ek is közösek: a NoteStore.new egyszer fut le, így minden hívó ugyanazokat a jegyzeteket látja. Ezért van a store-ban mutex.
  • A hívónkénti állapot a context-be kerül (itt a :username, amit a flow ask-ja tölt ki).
  • A window-callbackek egy ctx hasht kapnak; a ctx[:app] a futó application, így a helperek ctx[:app].me alakban érhetők el.

Az application indítása

Egy flow call lépéséből (a szokásos mód)

RUBY
require 'bbs'

APP = BBS::Application.define do
  status_hint('Alt-X', 'Quit') { :halt }
end

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    ask :username, prompt: 'Name'
    call do |ctx, runner|
      APP.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
      nil
    end
    say 'Goodbye!'
  end
end

BBS.start

A Builder#run felépíti és lefuttatja az applicationt, és ha a hívó egy dooron át hagyta el, lefuttatja a doort, majd újraépíti az applicationt (lásd lent: Az application–door kör). A flow saját ctx-e lesz az application context-je, így amit a flow kérdezett (:username), és amit az application a context-be ír, mindkét irányban megmarad. Ha a call lépés nil-t ad vissza, a flow megy tovább (say 'Goodbye!'); :halt a hívást is befejezné.

A flow app lépésével

RUBY
require 'bbs'

APP = BBS::Application.define do
  status_hint('Alt-X', 'Quit') { :halt }
end

FLOW = BBS::Flow.define do
  ask :username, prompt: 'Name'
  app APP
  say 'Goodbye!'
end

Az app lépés rövidebb: az APP.run(...)-t (Builder#run) futtatja a flow ctx-ével, így a doorokon itt is átviszi a hívót (0.17.1 óta; előtte a lépés egyszer építette fel az applicationt, és egy door csak befejezte). A call forma akkor kell, ha a futtatás köré kód is kell; a hálózat összes boardja (Teletype BBS, Feedia BBS, Alexandria BBS) ezt használja.

Flow nélkül

A Builder#build(session:, session_id:, context: {}) egy kész Application-t ad vissza; a run-ja magában az eseményciklus. Bármi meghívhatja, aminek van BBS::Session-je (egy door, egy teszt, egy saját szerver):

RUBY
require 'bbs'

APP = BBS::Application.define do
  status_hint('Alt-X', 'Quit') { :halt }
end

def serve(session, session_id)
  app = APP.build(session: session, session_id: session_id, context: { username: 'guest' })
  app.run
end

A define DSL

A BBS::Application.define { ... } egy friss BBS::Application::Builder-en értékeli ki a blokkot, és azt adja vissza. Ekkor még semmi sem épül fel: a builder feljegyzi a definíciót, és a build vagy a run csinál belőle hívónként egy applicationt.

Metódus Szignatúra Mikor fut Mit csinál
theme theme(base = nil) { ... } definiáláskor a színtéma
services services(hash = nil) definiáláskor közös objektumok a window-callbackeknek
helpers helpers { def ... end } definiáláskor metódusok minden application-példányra
window window(name, type:) { \|w\| ... } definiáláskor egy nevesített window spec, lásd Window-k
door door(name, program, label: nil) definiáláskor egy door, amibe a hívó beléphet
menubar menubar { menu ... } minden buildnél a menüsor
status_hint status_hint(key, label, hidden: false) { ... } minden buildnél egy státuszsor-bejegyzés
body body { add widget } minden buildnél widgetek a gyökérpanelen
init init { ... } minden buildnél, utolsóként beállító kód az új applicationön
on_tick on_tick { ... } tétlenségben nagyjából másodpercenként időszakos munka
on_idle on_idle { ... } amikor a hívó eléri a tétlenségi limitet vonalban tartja (:keep), vagy hagyja bontani
on_door on_door { \|door, context\| ... } minden door előtt adminisztráció

A hívások sorrendje a define-on belül mindegy. A build sorrendje viszont rögzített: application, menüsor, státuszsor, init, body.

theme

TEXT
theme(base = nil, &block)

Beállítja az application, a menüsor, a státuszsor és minden rajta át nyitott window témáját. Alapértelmezés: BBS::Theme.synchronet.

Hívás Eredmény
theme BBS::Theme.neumanntronics az adott téma változatlanul
theme(:dosnavigator) { ... } a preset név szerint, a blokk felülírásaival
theme(BBS::Theme.mono) { ... } az adott téma felülírásokkal
theme { ... } synchronet felülírásokkal

A blokkon belül (ez egy ThemeBuilder):

Metódus Mit csinál
KULCS "\e[...m" bármely egyargumentumú metódus felülírja az azonos nevű stíluskulcsot (screen "\e[0;37;40m")
style(key, value) ugyanez, olyan kulcsokra, amik Ruby-metódusnévvel ütköznek
inherit(name_or_theme) a blokkon belül lecseréli az alaptémát
RUBY
require 'bbs'

APP = BBS::Application.define do
  theme :neumanntronics do
    accent      "\e[1;33;44m"
    style :text, "\e[0;37;44m"
  end
end

Blokk nélkül egy preset nevét vagy egy BBS::Theme-példányt adj át: a theme :synchronet és a theme BBS::Theme.synchronet ugyanaz (0.17.1 óta; a 0.17.0-ban a Symbol-forma az első képkockán elszállt). Argumentum nélkül a theme az eddig beállított témát adja vissza. A presetek és a stíluskulcsok a Rajzolás oldalon vannak.

services

TEXT
services(hash = nil)

A hash-t beolvasztja a definíció service-ei közé; akárhányszor hívható. Argumentum nélkül visszaadja a hasht. A service-ekből lesz az app.services, és minden window spec callbackjének ctx-ébe is bekerülnek (ctx[:notes]).

RUBY
require 'bbs'

class Clock
  def now = Time.now
end

APP = BBS::Application.define do
  services clock: Clock.new
  services motd: 'Be excellent to each other.'

  window :motd, type: :info do |w|
    w.title ' Message of the day '
    w.body  { |ctx| [ctx[:motd], :spacer, ctx[:clock].now.to_s] }
  end

  init { open_window(:motd) }
end

A hash és az objektumai egyszer jönnek létre, és minden hívó közösen használja őket. A hívónkénti állapot a context-be való, a közös objektumok legyenek thread-safe-ek: minden hívó saját szálon fut.

helpers

TEXT
helpers { def name ... end }

A blokk egy modulba értékelődik ki, és a definícióból épített minden application ezzel a modullal bővül (extend). A helper-metódusok az applicationön futnak, így a teljes példány-API-t hívhatják (open_window, context, services, statusbar, ...), és őket is hívhatja minden, ami az applicationön fut: a menüpontok, a státusztippek, az init, az on_tick, és a window-callbackek a ctx[:app]-on át.

RUBY
require 'bbs'

APP = BBS::Application.define do
  helpers do
    def greet = BBS::Dialogs.message(self, "Hello, #{context[:username]}!")
    def sysop? = context[:username] == 'sysop'
  end

  menubar do
    menu '&Hello' do
      item '&Greet' do greet end
    end
  end

  init { greet if sysop? }
end

A helpers többször is hívható; a metódusok ugyanabba a modulba gyűlnek.

window

TEXT
window(name, type:) { |w| ... }

Egy BBS::WindowSpec-et regisztrál name néven; a type lehet :info, :form, :master_detail vagy :stream. Megnyitás: app.open_window(name, **extras). Minden attribútum, mező és callback a Window-k oldalon, a window DSL résznél van leírva.

RUBY
require 'bbs'

APP = BBS::Application.define do
  window :about, type: :info do |w|
    w.title ' About '
    w.body  ['Notes desk 1.0', :spacer, { kv: [['Ruby', RUBY_VERSION]] }]
  end

  status_hint('F1', 'About') { open_window(:about) }
end

door

TEXT
door(name, program, label: nil)

Egy BBS::Door-t deklarál. A program egy BBS::FakeOS-definíció, egy BBS::Flow, vagy bármi, ami válaszol a call(session, context)-re; a label a menüpontja szövege (a & jelöli a gyorsbillentyűt), alapértelmezése a név nagybetűsítve. A door magától nem indul: az app.open_door(name) hagyja el rajta át az applicationt, a menu-n belüli doors pedig minden deklarált doorhoz felvesz egy menüpontot. A door-programok a Doorok oldalon vannak.

RUBY
require 'bbs'

COIN = ->(session, _context) { side = %w[Heads Tails].sample; session.write("\r\n  #{side}!\r\n"); sleep 2 }

APP = BBS::Application.define do
  door :coin, COIN, label: '&Coin toss'

  menubar do
    menu '&Doors' do
      doors
    end
  end
  status_hint('F9', 'Coin') { open_door(:coin) }
end

menubar

TEXT
menubar { menu(label) { item ...; separator; doors } }

A menüsort deklarálja. A blokk minden build-nél kiértékelődik, egy MenubarBuilder-en:

Metódus Szignatúra Mit csinál
menu menu(label) { ... } egy legördülő menü; a címkében a & jelöli az Alt-gyorsbillentyűt
item item(label, shortcut: nil, enabled: true) { \|item\| ... } egy menüpont; a blokk az applicationön fut
separator separator vízszintes vonal; sosem választható
doors doors minden deklarált doorhoz egy item, deklarálási sorrendben, mindegyik open_door-t hív

Az item opciói:

Opció Alapérték Jelentés
label — a szöveg; a betű előtti & jelöli a gyorsbillentyűt ('E&xit')
shortcut: nil a legördülő menüben jobbra igazítva kiírt szöveg ('Alt-X'). Csak felirat: magát a billentyűt status_hint-tel kösd be
enabled: true false esetén a pont szürke és nem választható. Egy lambdát a menü minden rajzolásakor és használatakor meghív az applicationön (self az application): ha truthy, a pont engedélyezett, pl. enabled: -> { context[:doc] }

A menüpont blokkja az applicationön fut (self az application), és megkapja a Menubar::MenuItem-et. A visszatérési érték számít: :halt befejezi az applicationt, minden más mindegy.

RUBY
require 'bbs'

APP = BBS::Application.define do
  menubar do
    menu '&File' do
      item '&Open...', shortcut: 'F3' do BBS::Dialogs.message(self, 'Nothing to open.') end
      item '&Print', enabled: false
      separator
      item 'E&xit', shortcut: 'Alt-X' do :halt end
    end
    menu '&Help' do
      item '&About' do |it| BBS::Dialogs.message(self, "You picked #{it.label.delete('&')}.") end
    end
  end
end

Hogy a hívó szemszögéből hogyan viselkedik a menüsor, az lent, A menüsor résznél van.

status_hint

TEXT
status_hint(key, label, hidden: false) { ... }

Egy bejegyzést vesz fel a státuszsorra, deklarálási sorrendben. Az első status_hint hozza létre a státuszsort; ha egy definícióban nincs ilyen, nincs státuszsor sem (és az app.statusbar nil).

Argumentum Jelentés
key a billentyű szövegként: 'F1'…'F12', 'Alt-X', 'Esc', 'Tab', vagy bármely billentyűnév (lásd lent: Billentyűnevek a tippekben)
label a billentyű utáni szöveg
hidden: true: a billentyű működik, de nem foglal helyet a soron, így marad hely a right_text-nek
blokk az applicationön fut, amikor megnyomják a billentyűt vagy rákattintanak a tippre; :halt befejezi az applicationt
RUBY
require 'bbs'

APP = BBS::Application.define do
  status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'Alt+letter opens a menu.') }
  status_hint('F3', 'Search', hidden: true) { BBS::Dialogs.input(self, prompt: 'Find:') }
  status_hint('Alt-X', 'Bye') { :halt }
end

Egy státusztipp csak akkor kapja meg a billentyűt, ha előtte sem egy window, sem egy menü nem használta fel (lásd lent: Eseménykézbesítés). A funkcióbillentyűk mindig szabadok, egy betű nem.

body

TEXT
body { add(widget) }

A blokk egy kis BodyContext-en fut, két metódussal: add(widget) (a gyökérpanelhez adja és rááll a téma) és app. Az asztali widgetek helye; ugyanezt az init is megteheti az add-dal.

RUBY
require 'bbs'

APP = BBS::Application.define do
  body do
    title = BBS::Widgets::Label.new(text: 'NOTES DESK', style_key: :accent, align: :center)
    title.layout(1, 3, app.cols, 1)
    add title
  end
end

A body az init után fut.

init

TEXT
init { ... }

Minden frissen épített applicationön lefut, miután a menüsor és a státuszsor már megvan, és a body előtt. Itt nyílik az első window, itt kerülnek widgetek az asztalra, itt kap értéket a statusbar.right_text, itt olvasható a context. Minden door után újra lefut, mert az application újraépül: az egyszeri dolgokat egy context-jelzővel védd.

RUBY
require 'bbs'

APP = BBS::Application.define do
  window :welcome, type: :info do |w|
    w.title ' Welcome '
    w.body  ['Glad you called.']
  end
  status_hint('Alt-X', 'Bye') { :halt }

  init do
    statusbar.right_text = Time.now.strftime('%H:%M')
    unless context[:welcomed]
      context[:welcomed] = true
      open_window(:welcome)
    end
  end
end

on_tick

TEXT
on_tick { ... }

Az applicationön fut minden olyan másodperc után, amikor nem jött bemenet (a futási ciklus egy másodperces időkorláttal olvassa a billentyűket). Tipikusan: óra vagy számláló a statusbar.right_text-ben, egy service lekérdezése, jelenlét-frissítés. A window-knak saját tick-horgonyuk van, a Window#tick(app); előbb az on_tick fut, aztán minden nyitott window tick-je.

RUBY
require 'bbs'

APP = BBS::Application.define do
  status_hint('Alt-X', 'Bye') { :halt }
  on_tick { statusbar.right_text = Time.now.strftime('%H:%M:%S') }
end

Tick csak akkor jön, ha a hívó tétlen: aki folyamatosan gépel, nem kap egyet sem. Pontos időzítésre ne építs rá.

on_idle

TEXT
on_idle { ... }

Az applicationön fut, amikor a hívó a session idle_seconds-áig csendben volt (a 0.19.0 óta). Ha :keep-et ad vissza, a hívó vonalban marad, és a tétlenség számolása elölről indul; bármi mást ad vissza, a hurok kiírja: „Disconnected after N s idle.”, és véget ér, ahogy a blokk nélkül is.

RUBY
require 'bbs'

APP = BBS::Application.define do
  on_idle do
    next nil if @asked                  # asked last time, no answer: drop
    @asked = true
    BBS::Dialogs.confirm(self, 'Still there?', on_yes: -> { @asked = false })
    :keep                               # one more idle period to answer
  end
end

A párbeszédablak nem blokkol: megnyit egy ablakot, és a hurok megy tovább, ezért a blokk :keep-et ad vissza, hogy a hívónak még egy tétlenségi ideje legyen válaszolni. idle_seconds nélkül (se a sessionön, se a configban) nincs limit, és a blokk sosem fut le.

on_door

TEXT
on_door { |door, context| ... }

A Builder#run hívja, közvetlenül mielőtt átadja a vonalat egy doornak, a BBS::Door-ral (name, label, program) és a contexttel. Az application ekkor már nem létezik, ezért a blokk sima blokként hívódik (a self az, ahol a define íródott), nem az applicationön.

RUBY
require 'bbs'

APP = BBS::Application.define do
  door :coin, ->(session, _ctx) { session.write("Heads!\r\n"); sleep 1 }
  on_door { |door, context| warn "#{context[:username]} -> door #{door.label}" }
  status_hint('F9', 'Coin') { open_door(:coin) }
end

A Builder metódusai

Metódus Mit csinál
build(session:, session_id:, context: {}) egy új Application: menüsor, státuszsor, init, body kész, de még nem fut
run(session:, session_id:, context: {}) build + Application#run, az application–door körben; akkor tér vissza, amikor a hívó door nélkül lép ki, vagy a vonal megszűnik

Az 1.0 óta a buildernek nincsenek olvasói: a regisztrált window specek és doorok a futó application window_specs-e és doors-a.

A példány API-ja

Minden itt felsorolt metódus a futó BBS::Application-é. A helperek, a menüpontok, a státusztippek, az init és az on_tick közvetlenül hívják őket, a window-callbackek a ctx[:app]-on át.

Állapot

Metódus Visszaadja
session a hívó BBS::Session-jét
session_id a build-nek / run-nak átadott azonosítót
context a hívónkénti hasht (lásd lent: A context)
services a közös service-hasht
theme a használt BBS::Theme-et
cols, rows a terminál méretét a NAWS-ból (80 és 24, ha a kliens nem közölte)
root a gyökérpanelt (BBS::Container)
menubar, statusbar a sorokat, vagy nil-t
windows a nyitott window-kat hátulról előre (az utolsó az aktív)
window_specs, doors a regisztrált window speceket és doorokat, név szerint
desktop_rect [x, y, width, height] a menüsor és a státuszsor között: ide kerül egy nagyított window

A képernyő felépítése

Metódus Mit csinál
add(widget) a gyökérpanelhez ad egy widgetet, beállítja a témáját, visszaadja
menubar = menubar beállítja (nil-lel eltávolítja) a menüsort, ráállítja a témát
statusbar = statusbar ugyanez a státuszsorral
RUBY
require 'bbs'

APP = BBS::Application.define do
  init do
    hint = BBS::Widgets::Label.new(text: 'F10 opens the menu', style_key: :text_dim)
    hint.layout(5, 5, 26, 1)
    add hint
  end
end

Window-k

Metódus Mit csinál
open_window(name, **extras) megnyitja a name néven regisztrált window specet; az extras a ctx-ébe kerül; ismeretlen névre ArgumentError; a window-t adja vissza
open_window(window) egy BBS::Window-t tesz a veremre és aktívvá teszi: beállítja a témáját, az app-ját, és (ha nem modal) a legkisebb szabad számot 1 és 9 között
close_window(window = top_window) eltávolítja a window-t, aktívvá teszi az alatta lévőt, aztán meghívja a window on_close-át a window-val
top_window az aktív window, vagy nil
raise_window(window) egy nyitott window-t előrehoz és aktívvá tesz
switchable_windows a nem modális window-k szám szerint rendezve; üres, amíg modális window van nyitva
cycle_windows(step = 1) előrehozza a következő (1) vagy előző (-1) váltható window-t; kettőnél kevesebbnél nil
RUBY
require 'bbs'

APP = BBS::Application.define do
  window :a, type: :info do |w| w.title ' A ' end
  window :b, type: :info do |w| w.title ' B ' end

  status_hint('F2', 'Both') { open_window(:a); open_window(:b) }
  status_hint('F3', 'A to front') do
    a = windows.find { |w| w.title == ' A ' }
    raise_window(a) if a
  end
  status_hint('F4', 'Close all') { close_window until windows.empty? }
end

A window-számok, a váltás, a mozgatás és a méretezés a Window-k oldalon van.

Hosszú munka és a nyers vonal

Metódus Mit csinál
with_loading(text = 'Working...') { ... } „Please wait” window-t mutat a text-tel, lefuttatja a blokkot (szálban, amit rögtön be is vár), bezárja a window-t, és a blokk értékét adja vissza
with_line { \|session\| ... } letörli a képernyőt, kikapcsolja az egérjelentést, a nyers sessiont átadja a blokknak, aztán visszakapcsolja az egeret (ha BBS.config.mouse), és teljes újrarajzolást kér; a blokk értékét adja vissza
RUBY
require 'bbs'
require 'net/http'

APP = BBS::Application.define do
  helpers do
    def fetch_motd
      text = with_loading('Fetching...') do
        Net::HTTP.get(URI('https://example.org/')).lines.first.to_s.strip
      rescue StandardError
        nil
      end
      BBS::Dialogs.message(self, text || 'No answer.', title: ' MOTD ')
    end

    def raw_screen
      with_line do |session|
        session.write("A plain terminal for a moment. Press ENTER.\r\n")
        session.readline
      end
    end
  end

  status_hint('F2', 'MOTD') { fetch_motd }
  status_hint('F3', 'Raw') { raw_screen }
end

A with_loading blokkja alatt a képernyő nem frissül, a közben leütött billentyűk a vonalon várnak. A with_line való a ZMODEM-letöltésekhez (BBS::Zmodem.send_files(session, ...), lásd Doorok) és a teljes képernyős ANSI-art nézegetőkhöz.

Futás és kilépés

Metódus Mit csinál
run az eseményciklus; akkor tér vissza, amikor az application véget ér
stop! a ciklus a most kezelt esemény után véget ér
tick! most azonnal lefuttatja az on_tick-et és minden window tick-jét
open_door(name) a doort a context[:door]-ba teszi, meghívja a stop!-ot, és :halt-ot ad vissza; ismeretlen névre ArgumentError
line_lost? igaz, ha a futás azért ért véget, mert a vonal megszűnt: bontás, az idle-limit, IOError; ilyenkor a Builder#run nem nyit doort

Az open_door bármelyik kezelőből működik: menüpontból, státusztippből, egy window billentyűkezelőjéből, egy Dialogs.confirm-callbackből, és a window-gombokból (az Info gombjai, a MasterDetail actionjei, a Form gombjai) vagy a MasterDetail on_activate-jéből is, pedig ezek elnyelik a visszatérési értéket. Az eseményciklust maga állítja le, így az application a most kezelt esemény után véget ér, és a Builder#run megnyitja a doort:

RUBY
require 'bbs'

APP = BBS::Application.define do
  door :coin, ->(session, _ctx) { session.write("Heads!\r\n"); sleep 1 }

  window :lobby, type: :info do |w|
    w.title ' Lobby '
    w.body  ['Feeling lucky?']
    w.button(label: '&Toss a coin') do |_win, ctx|
      ctx[:app].open_door(:coin)
    end
  end

  init { open_window(:lobby) }
end

A 0.17.0-ban egy gombnak az open_door után ctx[:app].stop!-ot is kellett hívnia; 0.17.1 óta erre nincs szükség (de nem is árt). Ha a futás azért ér véget, mert a vonal megszűnt (line_lost?), a Builder#run megnyitás nélkül eldobja a context[:door]-t, így a bontás előtt épp kiválasztott door nem nyílik meg.

A futási ciklus

MERMAID
flowchart TD
  R[képkocka rajzolása, a különbség kiküldése] --> K{"readkey, 1 mp időkorlát"}
  K -- nil: bontott --> E[vége]
  K -- tétlen --> T["tick!: on_tick, aztán window.tick"]
  T --> I{"elérte az idle_seconds-ot?"}
  I -- nem --> R
  I -- igen --> M[üzenet: Disconnected] --> E
  K -- billentyű vagy egér --> D[az esemény kézbesítése]
  D -- ":halt" --> E
  D --> R

Az Application#run:

  1. Kirajzolja a képkockát, és csak a változást küldi ki.
  2. Legfeljebb egy másodpercig vár egy billentyűre (Session#readkey(timeout: 1.0)).
  3. Ha abban a másodpercben nem jött semmi: lefuttatja a tick!-et, és a másodpercet a tétlen időhöz adja. Ha a tétlen idő eléri a session idle_seconds-át (a BBS.config.idle_seconds-ból; ha be van állítva), lefuttatja az on_idle blokkot: :keep esetén elölről kezdi a számolást, egyébként kiírja: „Disconnected after N s idle.”, és véget ér.
  4. Billentyű vagy egéresemény: nullázza a tétlen időt, BBS::Event-be csomagolja és kézbesíti. Ha a kézbesítés :halt-ot ad, a ciklus véget ér.
  5. Bontás (readkey nil-t ad, vagy IOError, EPIPE, ECONNRESET) csendben befejezi a ciklust.

Ha egy kezelő (egy menüpont, egy callback) kivételt dob, az a backtrace első három sorával a stderr-re kerül, az esemény pedig elvész: egyetlen hiba nem bontja a hívó sessionjét. Kilépéskor a ciklus visszakapcsolja a kurzort és letörli a képernyőt.

Eseménykézbesítés

Minden esemény végigmegy ezen a láncon; az első, aki elveszi, megállítja.

Lépés Ki Mit vesz el
1 a nyitott menü mindent, amíg menü van nyitva
2 window-váltás F6, Shift-F6, Alt-1…9; az aktív mögötti window-ra kattintás előrehozza, és a kattintás megy tovább
3 az aktív window a fókuszált widgete, aztán Tab, gomb-gyorsbillentyűk, F5, Ctrl-F5, a bezáró billentyűje
4 a menüsor F10, egy menü Alt+betűje, kattintás az 1. sorban
5 a státuszsor a tippek billentyűi, kattintás a sávra rajzolt tippre
6 a gyökérpanel Tab / Shift-Tab a gyökér-widgetek között, aztán a fókuszált gyökér-widget

Amit ebből érdemes tudni:

  • Az az Alt+betű, ami az aktív window egy gombjának gyorsbillentyűje, azt a gombot nyomja meg, akkor is, ha egy menünek ugyanaz a betűje: a window a menüsor előtt jön.
  • A szövegmezőbe gépelt betű sosem jut el egy státusztippig. Tippekhez funkcióbillentyűt és Alt-kombinációt használj.
  • Nyitott window mellett a Tab a window-é; a gyökér-widgetek addig nem kapnak billentyűt, amíg minden window be nincs zárva.
  • A gyökérpanelre kattintás csak a fókuszált gyökér-widgethez jut el. Ha két gomb van az asztalon, csak a fókuszált reagál a kattintásra, és a gyökérpanel gombjainak nincs betű-gyorsbillentyűjük.

A kezelők visszatérési értéke

A lánc minden kezelője (egy widget vagy window handle_event-je, egy menüpont, egy státusztipp) a visszatérési értékével válaszol:

Érték Jelentés
nil / false nem az enyém: az esemény megy tovább a láncon
bármi más (:handled, true, egy window) elvettem, a lánc megáll
:halt elvettem, és az application véget ér
:close_window window-tól: az application bezárja (a Window#close ezt adja vissza)

A menüpontok és a státusztippek bármit adnak vissza, elvettnek számítanak (kivéve a :halt-ot).

Az application–door kör

A Builder#run az Application#run köré épített kör, ami átviszi a hívót a doorokon:

MERMAID
sequenceDiagram
  participant F as Flow call lépés
  participant B as Builder#run
  participant A as Application
  participant D as Door
  F->>B: run(session:, session_id:, context: ctx)
  loop amíg nincs door
    B->>A: build + run
    A-->>B: véget ért (open_door beállította a context[:door]-t?)
    B->>B: door = context.delete(:door)
    B->>D: on_door(door, ctx), door.run(session, ctx)
    D-->>B: a program visszatért
  end
  B-->>F: visszatér

Egy door után az application újraépül: az init újra lefut, a korábban nyitott window-k eltűnnek, a menüsor és a státuszsor új. Ami túl kell élje a doort, az a context-be kerüljön; a Teletype BBS így tartja meg a context[:mail_seen]-ben, hány olvasatlan levélre csengetett már, és a context[:call_started]-ben a hívás kezdetét.

A kör akkor is véget ér, ha az application azért állt le, mert a vonal megszűnt (Application#line_lost?: bontás, az idle-limit): a context[:door]-ban már ott lévő doort ilyenkor eldobja, nem nyitja meg.

A context

A context egy sima Hash, ami a hívásé, nem az applicationé.

  • Flow-ból indítva maga a flow ctx-e: :session_id, minden ask-válasz (:username), és amit a korábbi lépések beleírtak.
  • Az application szabadon olvassa és írja; a flow az application visszatérése után látja a változásokat.
  • Túléli az application–door kört; egy BBS::Flow-door a másolatát kapja (lásd Doorok), egy call(session, context)-door magát a hasht.
  • Az open_door egy pillanatra a :door kulcs alá teszi a doort; a Builder#run kiveszi onnan.

A window spec callbackjei nem közvetlenül a context-et kapják, hanem egy három forrásból összefésült ctx-et, azonos kulcsnál a későbbi nyer:

TEXT
ctx = { app: app, **app.context, **app.services, **extras }

A ctx minden megnyitott window-nál új Hash: a ctx[:x] = 1 nem változtatja meg a contextet. Írd így: ctx[:app].context[:x] = 1.

Események

A BBS::Event (lib/bbs/event.rb) az egységesített bemenet, amit minden widget és window kap:

RUBY
Event = Struct.new(:kind, :key, :mouse, :x, :y, keyword_init: true)
Mező Jelentés
kind :key, :mouse, :idle, :tick vagy :resize
key :key esetén: Symbol (:enter, :up, :f5, :alt_x, :ctrl_f5, :shift_tab) vagy egykarakteres String ('a', 'é')
mouse :mouse esetén: a BBS::Telnet::MouseEvent (kind: :press, :release, :drag, :wheel; button; x, y; mods)
x, y :mouse esetén: az 1-től számozott képernyőcella
Konstruktor / predikátum Mit csinál
Event.key(name) billentyűesemény
Event.mouse(mouse_event) egéresemény, az x/y belőle másolva
Event.idle, Event.tick, Event.resize(cols, rows) a többi fajta (maga a futási ciklus csak billentyű- és egéreseményt gyárt)
key?, mouse? a fajta
char? nyomtatható, egykarakteres billentyű
alt?, alt_char :alt_x alakú billentyű, és a betűje ('x')
RUBY
require 'bbs'

event = BBS::Event.key(:alt_s)
p event.key?      # => true
p event.alt?      # => true
p event.alt_char  # => "s"
p BBS::Event.key('q').char?  # => true

Egy saját widget a handle_event(event)-ben válaszol:

RUBY
require 'bbs'

class Counter < BBS::Widget
  attr_reader :count

  def initialize(**kw)
    super
    @count = 0
  end

  def focusable? = true

  def handle_event(event)
    return nil unless event.key? && event.key == '+'

    @count += 1
    :handled
  end

  def render(frame)
    frame.move(bounds.x, bounds.y)
    frame.write("Count: #{@count}", sgr: style(focused ? :input_focused : :text))
  end
end

A billentyű-szimbólumok teljes listája és az egérmodell a Telnet és a store oldalon van; widgetet írni a Widgetek oldal tanít.

Fókusz

A BBS::FocusManager (lib/bbs/focus_manager.rb) tartja számon egy konténeren belül a fókuszált widgetet. Minden window-nak van egy (window.focus_manager), a gyökérpanel pedig az első eseményénél kap egyet.

Metódus Mit csinál
FocusManager.new(container) manager, ami az első jelöltre teszi a fókuszt
candidates a konténer látható, engedélyezett, fókuszálható leszármazottai, fa-sorrendben
focused a fókuszált widget, vagy nil
focus(widget) ráteszi a fókuszt, ha fókuszálható és engedélyezett
focus_first, next_focus, prev_focus a fókusz léptetése; a következő és az előző körbefordul
handle_focus_key(event) Tab → next_focus, Shift-Tab → prev_focus; :handled, ha elvette a billentyűt, különben nil
dispatch(event) továbbadja az eseményt a fókuszált widgetnek

A fókusz mozgatásakor mindkét widget focused-ja beállítódik, a régin on_blur, az újon on_focus hívódik. A jelölteket minden lépésnél újra kikeresi, így a később hozzáadott widgetek is részt vesznek; az első fókuszt viszont a manager létrehozásakor választja. Kézzel épített window után hívd meg a window.reset_focus-t (új manager), aztán a window.focus_manager.focus(widget)-et a kezdőponthoz:

RUBY
require 'bbs'

def open_search(app)
  win = BBS::Window.new(title: ' Search ')
  win.layout(20, 8, 40, 6)
  query = BBS::Widgets::TextInput.new(placeholder: 'words...')
  query.layout(22, 10, 36, 1)
  win.add(query)
  go = BBS::Widgets::Button.new(label: '&Go', on_click: ->(_b) { app.close_window(win) })
  go.layout(36, 12, 8, 1)
  win.add(go)
  win.reset_focus
  win.focus_manager.focus(query)
  app.open_window(win)
end

Egy window-n belüli bal kattintás a mutató alatti widgetre teszi a fókuszt; az egérgörgő akkor is a mutató alatti widgethez megy, ha a fókusz máshol van.

A menüsor

A BBS::Menubar (lib/bbs/menu.rb) az 1. sor sávja. A DSL építi fel, de kézzel is elkészíthető.

Viselkedés

  • Minden menücímke kiemelt gyorsbillentyű-betűvel jelenik meg (&File → File).
  • Az F10 az első menüt nyitja, az Alt+betű azt, amelyiknek az a gyorsbillentyűje. Menücímre kattintás kinyitja, egy második kattintás bezárja. Mindez a Menubar#handle_event-en megy át; a belső billentyűkezelés privát.
  • Amíg menü van nyitva, minden billentyűt és kattintást ő kap, így semmi nem szivárog át a window-khoz: a Bal/Jobb a szomszéd menüre lép (körbefordulva), a Fel/Le a pontok között lép, a separatorokat és a letiltott pontokat átugorva, az Enter választ, egy betű az azzal a gyorsbillentyűvel jelölt pontot választja, az Esc vagy az F10 bezár. Pontra kattintás választ, a menün kívüli kattintás bezárja a menüt.
  • Választáskor a menü bezárul, aztán lefut a pont blokkja. Ha az :halt-ot ad, az application véget ér.
  • A jobb szélhez közeli legördülő menü balra tolódik, hogy kiférjen a képernyőre.

Kézzel építve

Metódus Mit csinál
Menubar.new üres menüsor
menus a Menubar::Menu structok (label, items, x); a kézzel épített menüsor ide teszi a menüit. Az add_menu és a MenuBuilder az 1.0-ban megszűnt: a DSL menubar do menu('&File') { item … } end alakja az út, kézzel pedig a pont on_select-je a pontot kapja, és nem az applicationön fut, ezért az app-ot zárd be a blokkba
open(index), close, open? menü nyitása pozíció szerint, bezárás, lekérdezés
opened_menu, selected_item mi van nyitva és kijelölve
handle_event(event) billentyűk és kattintások; az application hívja, a nyitott menüt elsőként
on_activate callable, ami minden választás után megkapja a pontot

A Menubar::MenuItem egy struct: label, shortcut, enabled, on_select, separator, hint; a hotkey és a display (a címke & nélkül) ezekből számolódik. Az enabled?-et kérdezi a menüsor: szeparátorra hamis, ha az enabled hívható, a lambda válasza (úgy, ahogy van hívja; csak a DSL item-je futtatja az applicationön), különben igaz, hacsak az enabled nem false.

RUBY
require 'bbs'

def run_plain_app(session, session_id)
  app = BBS::Application.new(session: session, session_id: session_id, theme: BBS::Theme.dosnavigator)

  item = ->(label, shortcut: nil, &block) { BBS::Menubar::MenuItem.new(label: label, shortcut: shortcut, on_select: block) }
  app.menubar = BBS::Menubar.new
  app.menubar.menus << BBS::Menubar::Menu.new(label: '&File', items: [
    item.('&Hello') { BBS::Dialogs.message(app, 'Hello!') },
    BBS::Menubar::MenuItem.new(label: '', separator: true, enabled: false),
    item.('E&xit', shortcut: 'Alt-X') { :halt }
  ])

  app.statusbar = BBS::Statusbar.new(right_text: 'plain')
  app.statusbar.add_hint('F1', 'Hello') { BBS::Dialogs.message(app, 'Hello!') }
  app.statusbar.add_hint('Alt-X', 'Quit') { :halt }

  label = BBS::Widgets::Label.new(text: 'Background', style_key: :text)
  label.layout(3, 3, 20, 1)
  app.add(label)

  app.run
end

A BBS::Application.new(session:, session_id:, context: {}, theme: nil, on_idle: nil, on_tick: nil, helpers: nil, services: {}, window_specs: {}, doors: {}) az, amit a build hív; az on_tick egy callable, ami az applicationt kapja. Az on_idle egy callable, ami az applicationt kapja, és a tétlenségi limitnél hívódik (lásd on_idle).

A státuszsor

A BBS::Statusbar (lib/bbs/statusbar.rb) az utolsó sor sávja: balra a tippek (a billentyű a :statusbar_key stílusban, utána a címke), jobbra egy opcionális szöveg.

Tag Mit csinál
Statusbar.new(hints: [], right_text: nil) egy státuszsor
add_hint(key, label, hidden: false) { ... } felvesz egy tippet; a blokk akkor hívódik, amikor megnyomják a billentyűjét vagy rákattintanak (kattintani csak a sávra rajzolt tippre lehet)
hints a Statusbar::Hint structok (key, label, on_activate, hidden)
shown a nem rejtett tippek
right_text, right_text= a jobbra igazított szöveg; csak akkor rajzolódik ki, ha nem lóg rá a tippekre

Ami nem fér ki, az a végéről lemarad; a rejtett tippek nem foglalnak helyet, így lehet egy billentyűt működésben tartani úgy, hogy a hely a right_text-é legyen. Ha egy tipp blokkja :halt-ot ad, az application véget ér; minden más visszatérési érték elvettnek számít.

RUBY
require 'bbs'

APP = BBS::Application.define do
  status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'No help yet.') }
  status_hint('F5', 'Refresh', hidden: true) { statusbar.right_text = 'refreshed' }
  status_hint('Alt-X', 'Bye') { :halt }

  init    { statusbar.right_text = "#{context[:username]} │ #{Time.now.strftime('%H:%M')}" }
  on_tick { statusbar.right_text = "#{context[:username]} │ #{Time.now.strftime('%H:%M')}" }
end

Billentyűnevek a tippekben

Tipp-kulcs Erre illeszkedik
'F1' … 'F12' :f1 … :f12
'Alt-X' :alt_x (bármely betű vagy szám, kis-nagybetűtől függetlenül)
'Esc' :escape
'Tab' :tab
bármi más a billentyű saját neve, kis-nagybetűtől függetlenül: 'enter', 'ctrl_s', '?'

A 'Ctrl-S' semmire sem illeszkedik (a billentyű :ctrl_s); írd 'ctrl_s'-nek, vagy tedd a billentyűt egy rejtett tippbe, és egy látható tipp címkéjébe írd, hogy „Ctrl-S”. Az Esc és a Tab többnyire előbb egy window-hoz jut el.

Billentyűk és egér a hívónak

Ezt teheti a hívó bármely rubbs-applicationben. Érdemes egy változatát a Help menübe tenni; a Teletype BBS és a Feedia BBS a Help → Keys alatt mutat egyet.

Billentyű / művelet Mit csinál
F10 kinyitja az első menüt; nyitott menüt bezár
Alt+betű kinyitja az azzal a gyorsbillentyűvel jelölt menüt, vagy megnyomja az aktív window azzal jelölt gombját
Bal / Jobb (nyitott menü) előző / következő menü
Fel / Le (nyitott menü) előző / következő pont
Enter / betű (nyitott menü) a kijelölt pontot / az azzal a betűvel jelölt pontot választja
Esc bezárja a nyitott menüt, vagy az aktív window-t
Tab / Shift-Tab következő / előző vezérlő a window-ban
Enter / Szóköz megnyomja a fókuszált gombot, átbillenti a fókuszált jelölőnégyzetet
betű megnyomja az azzal a gyorsbillentyűvel jelölt gombot, ha a fókuszált vezérlő nem fogad betűt
F5 vagy Alt-Enter nagyítja a window-t és vissza (a [↑] gombos window-knál)
F6 / Shift-F6 következő / előző window
Alt-1 … Alt-9 a keretén azzal a számmal jelölt window
Ctrl-F5 (vagy F6, ha csak egy window van nyitva) mozgató mód: a nyilak mozgatnak, a Shift+nyilak (vagy Tab, aztán nyilak) méreteznek, Enter vagy Esc befejezi
státuszsor-billentyűk amit az application odatett (F1, F2, Alt-X, ...)
kattintás menücímre / pontra kinyitja a menüt / választja a pontot
kattintás státusztippre ugyanaz, mint a billentyűje
kattintás a [■]-ra bezárja a window-t
kattintás a [↑] / [↕]-ra, dupla kattintás a címsoron nagyítás / visszaállítás
a címsor húzása mozgatja a window-t
a ┘ sarok húzása méretezi a window-t
kattintás egy hátsó window-ra előrehozza
egérgörgő görgeti a mutató alatti listát vagy szöveget

Az egér akkor működik, ha a szerveren c.mouse = true, és a terminál SGR vagy X10 egéreseményeket küld (lásd Telnet és a store).

Valódi boardok

Board Fájl Mit érdemes megnézni benne
Feedia bbs/feedia-bbs/lib/ui/modern.rb egy kicsi, teljes application: két master-detail spec, két űrlap, with_loading-os helperek, init-ben épített fejléc, óra on_tick-kel
Alexandria bbs/alexandria-bbs/lib/ui/modern.rb hívónkénti nyelv a context-ben, replace_items a navigációhoz, Markdown a részletpanelen
Teletype BBS bbs/teletype-bbs/lib/ui/modern.rb listából deklarált doorok, jelenlét on_door-ral, rejtett F3-tipp, gomb a gyökérpanelen, on_tick-ből frissülő élő irányítópult-widget

Lásd még

  • Window-k — BBS::Window, a window DSL, Info, Form, MasterDetail, Stream, dialógusok
  • Widgetek — a vezérlők a window-kban és az asztalon
  • Rajzolás — témák és stíluskulcsok, a FrameBuffer
  • Flow — a soronkénti réteg, ami többnyire elindítja az applicationt
  • Doorok — door-programok, FakeOS, gatewayek, ZMODEM
  • Telnet és a store — billentyű-szimbólumok, az egér, a sessionök