new framework

This commit is contained in:
2026-05-12 22:15:07 +02:00
parent be21826ecc
commit 953498a62d
24 changed files with 2410 additions and 180 deletions

267
README.md
View File

@@ -1,6 +1,16 @@
# rubbs # rubbs
A Ruby gem for building telnet BBS servers with a declarative conversation flow DSL. A Ruby gem for building telnet BBS servers. Three interaction layers, from
simplest to richest:
| Layer | What it is | When to use |
|---|---|---|
| `BBS::Flow` | Line-oriented declarative dialogue DSL (ask / say / menu / persist) | Login, registration, simple wizards |
| `BBS::TUI` | Page-router with frame-buffer delta rendering | Single-purpose full-screen UIs |
| `BBS::Application` | Full widget framework: Menubar, Window, Button, TextInput, ListBox, … | Multi-window Synchronet-style BBSes |
A single session can switch between layers — typically `Flow` for login then
`Application` for the main shell.
## Installation ## Installation
@@ -9,192 +19,165 @@ A Ruby gem for building telnet BBS servers with a declarative conversation flow
gem 'bbs', git: 'https://git.teletype.hu/tools/rubbs.git' gem 'bbs', git: 'https://git.teletype.hu/tools/rubbs.git'
``` ```
## Quick start ## Quick start — Flow + Application
```ruby ```ruby
require 'bbs' require 'bbs'
sessions = BBS::Store.new(path: 'data/sessions.csv', headers: %w[session_id timestamp name]) MAIN = BBS::Application.define do
theme BBS::Theme.synchronet
menubar do
menu '&File' do
item '&About' do BBS::Dialogs.message(self, 'Hello from rubbs!') end
item 'E&xit' do :halt end
end
end
status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'No help yet.') }
status_hint('F10', 'Menu') { menubar.open(0) }
end
BBS.configure do |c| BBS.configure do |c|
c.screens_dir = 'screens' c.mouse = true
c.stores = [sessions] c.idle_seconds = 600
c.flow = BBS::Flow.define do c.flow = BBS::Flow.define do
screen :welcome big_banner 'MY BBS', style: :success
ask :name, prompt: "What is your name?" ask :username, prompt: 'Name'
say "Hello, %{name}!", style: :success app MAIN
persist :name, store: sessions say 'Goodbye!'
end end
end end
BBS::Server.start BBS.start
``` ```
Connect with `telnet localhost 2323`. The port defaults to `2323` and can be overridden with the `BBS_PORT` environment variable. Connect with `telnet localhost 2323`.
## Configuration ## Configuration
`BBS.configure` yields a `BBS::Config` object: `BBS.configure` yields a `BBS::Config`:
| Option | Default | Description | | Option | Default | Description |
|---|---|---| |---|---|---|
| `screens_dir` | `"screens"` | Directory containing ERB screen files |
| `stores` | `[]` | List of `BBS::Store` instances |
| `flow` | — | A `BBS::Flow` object (required) | | `flow` | — | A `BBS::Flow` object (required) |
| `port` | `ENV["BBS_PORT"] \|\| 2323` | TCP port to listen on | | `port` | `ENV["BBS_PORT"] \|\| 2323` | TCP port |
| `mouse` | `false` | Enable xterm SGR mouse reporting |
| `idle_seconds` | `nil` | Disconnect after N idle seconds (nil = never) |
| `theme` | — | Default theme for Applications (overridable per app) |
| `screens_dir` | `nil` | Directory for ERB screens used by `BBS::Flow#screen` |
| `on_session_end` | `nil` | Lambda called when a session ends |
## Flow DSL ## The Application layer
The entire dialogue is defined as a declarative `BBS::Flow.define` block. `BBS::Application` is a widget event loop with:
### Primitives - a root container drawn behind windows;
- an optional `Menubar` with Alt-letter pulldown menus and F10 access;
- an optional `Statusbar` with clickable hotkey hints;
- a stack of modal `Window`s (the top one receives input first);
- xterm SGR mouse events delivered to widgets;
- a 1 Hz internal tick for chat polling, clocks, etc.
#### Output ### Built-in widgets (`BBS::Widgets`)
| Primitive | Description | | Widget | Purpose |
|---|---| |---|---|
| `screen :name, **vars` | Render an ERB screen from `screens/`; vars override/extend the context | | `Label` | Static text, optionally aligned, themed via `style_key` |
| `banner "text", style:` | Print a box-drawing ASCII banner | | `Button` | `&Save`-style hotkey, fires `on_click(self)`; mouse-clickable |
| `big_banner "text", style:, font:` | Print a large FIGlet ASCII-art banner (default font: `slant`) | | `TextInput` | Single-line input with cursor, scroll, `on_submit`/`on_change` |
| `say "text", style:` | Print a single styled line | | `TextArea` | Multi-line editor with arrow-key navigation |
| `text "content", style:` | Print a line; accepts a block `{ \|ctx\| string }` for dynamic content | | `Checkbox` | Toggle with `on_change(checked, self)` |
| `section "title", color:` | Print a decorated section header with box-drawing | | `ListBox` | Scrolling list with mouse + keyboard, wheel scrolling |
| `line color:` | Print a horizontal rule | | `ScrollView` | Read-only scrolling text view, with scrollbar |
| `rows(empty: "…") { \|ctx\| array }` | Print a list of lines; shows `empty` when the array is empty | | `ProgressBar`, `Spinner` | Progress feedback |
| `table { \|ctx\| hash }` | Print a two-column key/value table | | `Panel`, `HBox`, `VBox` | Containers / layout |
| `body(width:, indent:) { \|ctx\| string }` | Word-wrap and print a block of text (strips basic Markdown) | | `Window` | Top-level modal container with title bar, close gadget, shadow |
| `output { \|ctx\| string }` | Write a raw string returned by the block verbatim |
#### Input & control ### Built-in dialogs (`BBS::Dialogs`)
| Primitive | Description |
|---|---|
| `ask :field, prompt:, transform:, validate:` | Read input and store it in the session context |
| `set :field, value` | Set a context variable directly from code |
| `persist :field, …, store:` | Write named context fields to a `BBS::Store` |
| `pause "message", seconds:` | Display a message and sleep |
| `wait_enter prompt:` | Print a prompt and block until the user presses Enter |
| `gate denied: "…" { \|ctx\| bool }` | Halt the flow unless the block returns true |
| `confirm "message", denied: "…"` | Yes/no gate — halts on no |
| `confirm "message", denied: "…" { }` | Yes/no branch — runs the block on yes, skips on no |
| `call { \|ctx\| }` | Run an arbitrary block; return `:halt` to end the session |
| `menu "prompt", loop: bool { }` | Numbered option menu; loops back after each selection when `loop: true` |
| `exit_menu` | Break out of the enclosing `menu` loop and continue the parent flow |
#### Data fetching
| Primitive | Description |
|---|---|
| `fetch :key, loading: "…" { \|ctx\| value }` | Show a loading message, call the block, store the result in `ctx[:key]`, erase the loading line |
| `pick from:, prompt:, empty:, item:, hint: { sub-flow }` | Show a numbered list from `ctx[from]`; on selection stores the item in `ctx[:picked]` and runs the sub-flow |
### `ask` options
| Option | Description |
|---|---|
| `prompt:` | The string printed before the cursor |
| `transform:` | A proc applied to the raw input before storing (e.g. `transform: :upcase.to_proc`) |
| `validate:` | A proc that returns `true` if the value is acceptable; the question is repeated on failure |
### Banner styles
`:success`, `:info`, `:error`, `:warning`, `:muted` — each maps to an ANSI colour.
### `fetch` + `pick` example
```ruby ```ruby
option 'Blog Posts' do BBS::Dialogs.message(app, "Saved!", title: ' Done ')
section 'Blog Posts', color: :blue BBS::Dialogs.confirm(app, "Delete?", on_yes: -> { ... })
fetch :pages, loading: 'Loading...' do |ctx| BBS::Dialogs.input(app, prompt: 'Name?', on_submit: ->(value) { ... })
WIKI.list('blog') ```
end
pick from: :pages, ### Menubar DSL
empty: 'No results.',
prompt: 'Enter number to read (blank to go back)', ```ruby
item: ->(p, i) { "#{i}. #{p.title}" }, menubar do
hint: ->(p, i) { p.description.to_s[0...65] } do menu '&File' do
fetch :body, loading: 'Loading page...' do |ctx| item '&Open', shortcut: 'Ctrl-O' do end
WIKI.content(ctx[:picked].id) separator
end item 'E&xit' do :halt end
body { |ctx| ctx[:body] }
wait_enter
end end
end end
``` ```
### Menu example The block runs in the Application's context (so you can call `open_window`,
`close_window`, `cols`, `rows`, etc.). Return `:halt` from any handler to end
the session.
```ruby ### Theming
menu ">", loop: true do
option "Leave feedback" do
ask :feedback, prompt: "Feedback"
persist :feedback, store: sessions
say "Recorded. Thank you.", style: :success
end
option "Exit" do `BBS::Theme.synchronet` (default) is a yellow/cyan Synchronet-inspired palette.
exit_menu `BBS::Theme.mono` provides a colour-free fallback. Create your own with
end `BBS::Theme.new(:my_theme, { window_border: "\e[1;36m", … })`.
end
say "Goodbye!", style: :muted ## The Flow layer
```
## Screens The DSL for line-based dialogue. Available steps:
ERB files in `screens/` have access to ANSI colour helpers and a `banner` helper. Context variables set by `ask`, `set`, or passed explicitly to `screen` are available as `@field`. | Step | Purpose |
|---|---|
| `screen :name` | Render an ERB screen from `screens/` |
| `banner`, `big_banner` | Box-drawing / FIGlet ASCII art header |
| `say`, `text`, `section`, `line`, `body` | Text output |
| `ask :field`, `set :field, value`, `persist :field, store:` | Input + persistence |
| `pause`, `wait_enter`, `gate`, `confirm` | Control flow |
| `menu`, `exit_menu` | Numbered option menus |
| `fetch`, `pick` | Data fetch + list selection |
| `rows`, `table` | Formatted output |
| `call { \|ctx, runner\| … }` | Arbitrary code; return `:halt` to end |
| `tui DEFINITION` | Hand off to a `BBS::TUI` page router |
| `app DEFINITION` | Hand off to a `BBS::Application` widget app |
```erb ## Input model
<%= banner("MISSION COMPLETE") %>
<%= white %>Congratulations!<%= reset %> You are <%= yellow %><%= @name %><%= reset %>. `Session#readkey(timeout:)` returns one of:
```
### ANSI helpers - a single-character `String` (printable input)
- a `Symbol`: `:enter`, `:tab`, `:shift_tab`, `:backspace`, `:escape`,
`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `reset`, `bold`, `dim` `:up`, `:down`, `:left`, `:right`, `:home`, `:end`,
`:page_up`, `:page_down`, `:insert`, `:delete`,
`:f1``:f12`, `:ctrl_a``:ctrl_z`,
`:alt_a` … (any printable character preceded by ESC),
`:interrupt`, `:idle`
- a `BBS::Telnet::MouseEvent` with `kind`, `button`, `x`, `y`, `mods`
delivered when `BBS.config.mouse = true`
## Stores ## Stores
Each `persist` step writes to a `BBS::Store` (a thread-safe, append-and-upsert CSV file). `session_id` and `timestamp` are always written automatically. `BBS::Store` is a thread-safe upsert-by-`session_id` CSV file with an
in-memory cache; appends are O(1), full rewrites only on update.
```ruby
identities = BBS::Store.new(path: 'data/identities.csv', headers: %w[session_id timestamp name code])
signups = BBS::Store.new(path: 'data/signups.csv', headers: %w[session_id timestamp email])
persist :name, :code, store: identities
persist :email, store: signups
```
Rows are keyed by `session_id`: the first `persist` for a session creates the row; subsequent ones update it in place.
## Colors
`BBS::Color` is a `module_function` module you can `include` to get the `c` helper anywhere — top-level, inside lambdas, and inside `instance_eval` blocks.
```ruby
include BBS::Color
c(:green, "[▶ Play]") # => "\e[1;32m[▶ Play]\e[0m"
c(:gray, "some text")
c(:yellow, msg.timestamp)
```
Available color symbols: `:reset`, `:gray`, `:yellow`, `:white`, `:blue`, `:cyan`, `:green`, `:magenta`, `:red`.
Raises `KeyError` on unknown symbols.
## Architecture ## Architecture
| File | Responsibility | | File | Responsibility |
|---|---| |---|---|
| `bbs/server.rb` | TCP accept loop, spawns one thread per client | | `bbs/server.rb` | TCP accept loop, one thread per client |
| `bbs/session.rb` | Negotiates telnet options, runs the flow | | `bbs/session.rb` | Telnet negotiation, mouse enable/disable, idle timer |
| `bbs/telnet.rb` | Telnet protocol (IAC handling, echo control, readline) | | `bbs/telnet.rb` | Telnet protocol, full key parsing, xterm SGR mouse |
| `bbs/flow.rb` | Flow DSL builder | | `bbs/flow.rb`, `bbs/flow_runner.rb` | Flow DSL + interpreter |
| `bbs/flow_runner.rb` | Executes a Flow step by step against a session context | | `bbs/tui.rb`, `bbs/tui_runner.rb` | Page-router with frame-buffer rendering |
| `bbs/color.rb` | `BBS::Color` module with ANSI color map and `c()` helper | | `bbs/frame_buffer.rb` | Double-buffer, delta diff, drawing helpers |
| `bbs/config.rb` | Configuration object | | `bbs/application.rb` | Widget event loop, window stack, ticking |
| `bbs/banner.rb` | Box-drawing and FIGlet banner renderer | | `bbs/widget.rb`, `bbs/container.rb` | Widget tree base |
| `bbs/renderer.rb` | ERB screen loader with ANSI colour helpers | | `bbs/widgets.rb` | Built-in widgets |
| `bbs/store.rb` | Thread-safe CSV persistence | | `bbs/window.rb`, `bbs/menu.rb`, `bbs/statusbar.rb`, `bbs/dialogs.rb` | Chrome |
| `bbs/focus_manager.rb` | Tab/Shift-Tab focus traversal |
| `bbs/theme.rb` | Style palette (synchronet / mono) |
| `bbs/event.rb` | Unified key + mouse event objects |
| `bbs/store.rb` | Thread-safe cached CSV upsert |
| `bbs/banner.rb`, `bbs/renderer.rb` | FIGlet + ERB |
| `bbs/color.rb` | Simple ANSI colour helper |

View File

@@ -1,6 +1,6 @@
Gem::Specification.new do |s| Gem::Specification.new do |s|
s.name = 'bbs' s.name = 'bbs'
s.version = '0.3.0' s.version = '0.4.0'
s.summary = 'Universal telnet BBS server library' s.summary = 'Universal telnet BBS server library'
s.author = 'Zsolt Tasnadi' s.author = 'Zsolt Tasnadi'
s.files = Dir['lib/**/*.rb'] s.files = Dir['lib/**/*.rb']

View File

@@ -9,6 +9,17 @@ require_relative 'bbs/flow_runner'
require_relative 'bbs/renderer' require_relative 'bbs/renderer'
require_relative 'bbs/store' require_relative 'bbs/store'
require_relative 'bbs/frame_buffer' require_relative 'bbs/frame_buffer'
require_relative 'bbs/event'
require_relative 'bbs/theme'
require_relative 'bbs/widget'
require_relative 'bbs/container'
require_relative 'bbs/widgets'
require_relative 'bbs/window'
require_relative 'bbs/focus_manager'
require_relative 'bbs/menu'
require_relative 'bbs/statusbar'
require_relative 'bbs/dialogs'
require_relative 'bbs/application'
require_relative 'bbs/tui' require_relative 'bbs/tui'
require_relative 'bbs/tui_runner' require_relative 'bbs/tui_runner'
require_relative 'bbs/session' require_relative 'bbs/session'
@@ -23,7 +34,7 @@ module BBS
@config ||= Config.new @config ||= Config.new
end end
def self.start(port: (ENV['BBS_PORT'] || 2323).to_i) def self.start(port: config.port)
Server.new(port: port).run Server.new(port: port).run
end end
end end

338
lib/bbs/application.rb Normal file
View File

@@ -0,0 +1,338 @@
# frozen_string_literal: true
require_relative 'event'
require_relative 'theme'
require_relative 'frame_buffer'
require_relative 'widget'
require_relative 'container'
require_relative 'window'
require_relative 'widgets'
require_relative 'menu'
require_relative 'statusbar'
require_relative 'dialogs'
module BBS
# The widget event loop. Acts as the root container:
#
# root_panel — base UI (drawn behind windows)
# menubar — optional, Alt-letter pulldown menus
# statusbar — optional, hotkey hints at the bottom
# window stack — modal windows on top of root_panel
#
# Build one with Application.define { ... } DSL, or instantiate directly
# and use add_window / menubar / statusbar.
class Application
attr_reader :session, :session_id, :context, :root, :menubar, :statusbar,
:windows, :theme
def self.define(&block)
builder = Builder.new
builder.instance_eval(&block) if block
builder
end
def initialize(session:, session_id:, context: {}, theme: nil, on_idle: nil,
on_tick: nil, helpers: nil)
@session = session
@session_id = session_id
@context = context
@theme = theme || Theme.synchronet
@root = Container.new
@root.theme = @theme
@menubar = nil
@statusbar = nil
@windows = []
@running = false
@committed = nil
@on_idle = on_idle
@on_tick = on_tick
@helpers = helpers
extend(@helpers) if @helpers
end
def cols = @session.term_cols || 80
def rows = @session.term_rows || 24
def menubar=(menubar)
@menubar = menubar
menubar.theme = @theme if menubar
end
def statusbar=(statusbar)
@statusbar = statusbar
statusbar.theme = @theme if statusbar
end
def add(child)
@root.add(child)
child.theme = @theme
child
end
def open_window(window)
window.theme = @theme
@windows << window
window.focused = true
window
end
def close_window(window = nil)
window ||= @windows.last
return unless window
@windows.delete(window)
window.focused = false
window.on_close&.call(window) if window.on_close
end
def top_window
@windows.last
end
# Main event loop. Uses a short read timeout to fire periodic ticks for
# UI polling (chat, clock) while still detecting hard idle disconnects.
def run
@running = true
idle_max = BBS.config.idle_seconds
tick_seconds = 1.0
idle_elapsed = 0.0
while @running
do_render
key = @session.readkey(timeout: tick_seconds)
break if key.nil?
if key == :idle
idle_elapsed += tick_seconds
tick!
if idle_max && idle_elapsed >= idle_max
BBS::Dialogs.message(self, "Disconnected after #{idle_max.to_i}s idle.",
title: ' Idle ')
do_render
sleep 0.5
break
end
next
end
idle_elapsed = 0.0
event = build_event(key)
result = dispatch(event)
break if result == :halt
end
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
nil
ensure
@session.write("\e[?25h\e[2J\e[H")
@running = false
end
# Periodic tick: notify on_tick callback and any window that defines
# a public @tick method (used by chat for incoming messages).
def tick!
@on_tick&.call(self)
@windows.each do |w|
poll = w.instance_variable_get(:@chat_poll)
poll&.call
end
end
def stop! = (@running = false)
def with_loading(text = 'Working...', &block)
win = Dialogs.message(self, text, title: ' Please wait ', width: text.length + 8)
Thread.new do
begin
@loading_result = block.call
ensure
close_window(win)
end
end.join
@loading_result
end
private
def build_event(key)
case key
when Telnet::MouseEvent then Event.mouse(key)
when :idle then Event.idle
else Event.key(key)
end
end
def dispatch(event)
relayout!
# Modal windows: top window gets first shot at events
if (win = top_window)
result = win.handle_event(event)
return :halt if result == :halt
if result == :close_window
close_window(win)
return nil
end
return nil if result
end
# Menubar handles Alt-letter / F10 / its own keys
if @menubar
result = @menubar.handle_event(event)
return :halt if result == :halt
return nil if result
end
# Statusbar mouse clicks
if @statusbar
result = @statusbar.handle_event(event)
return :halt if result == :halt
return nil if result
end
# Root panel (background UI) — try its focused widget
result = dispatch_to_root(event)
return :halt if result == :halt
result
end
def dispatch_to_root(event)
@root_focus ||= FocusManager.new(@root)
return :handled if @root_focus.handle_focus_key(event)
@root_focus.dispatch(event)
end
def relayout!
c = cols
r = rows
menubar_h = @menubar ? 1 : 0
statusbar_h = @statusbar ? 1 : 0
@menubar&.layout(1, 1, c, 1)
@statusbar&.layout(1, r, c, 1)
@root.layout(1, 1 + menubar_h, c, r - menubar_h - statusbar_h)
end
def do_render
c = cols
r = rows
first = @committed.nil?
resized = !first && (@committed.cols != c || @committed.rows != r)
@committed = FrameBuffer.new(c, r) if first || resized
@next = FrameBuffer.new(c, r)
@next.fill(x: 1, y: 1, width: c, height: r, sgr: @theme[:screen])
@root.render(@next)
@windows.each { |w| w.render(@next) }
@menubar&.render(@next)
@statusbar&.render(@next)
delta = @next.diff(@committed)
if first || resized || !delta.empty?
prefix = (first || resized) ? "\e[2J\e[H" : ''
@session.write("\e[?25l#{prefix}#{delta}\e[?25h")
@committed = @next
end
end
# ── Builder DSL ───────────────────────────────────────────────────────────
class Builder
attr_reader :init_block, :theme, :menubar_block, :statusbar_hints,
:helper_module
def initialize
@init_block = nil
@theme = nil
@menubar_block = nil
@statusbar_hints = []
@body_block = nil
@helper_module = Module.new
end
def theme(value) = (@theme = value)
def init(&block) = (@init_block = block)
def helpers(&block) = @helper_module.module_eval(&block)
def menubar(&block) = (@menubar_block = block)
def status_hint(key, label, &on_activate)
@statusbar_hints << [key, label, on_activate]
end
def body(&block)
@body_block = block
end
def build(session:, session_id:, context: {})
app = Application.new(
session: session, session_id: session_id, context: context,
theme: @theme || Theme.synchronet,
helpers: @helper_module
)
if @menubar_block
menubar_widget = Menubar.new
menubar_widget.theme = app.theme
menubar_builder = MenubarBuilder.new(menubar_widget, app)
menubar_builder.instance_eval(&@menubar_block)
app.menubar = menubar_widget
end
unless @statusbar_hints.empty?
sb = Statusbar.new
sb.theme = app.theme
@statusbar_hints.each do |k, l, blk|
wrapped = blk ? ->{ app.instance_exec(&blk) } : ->{ nil }
sb.add_hint(k, l, &wrapped)
end
app.statusbar = sb
end
app.instance_exec(&@init_block) if @init_block
if @body_block
ctx = BodyContext.new(app)
ctx.instance_exec(&@body_block)
end
app
end
end
# DSL inside `menubar do ... end`.
class MenubarBuilder
def initialize(menubar, app)
@menubar = menubar
@app = app
end
def menu(label, &block)
items_builder = ItemsBuilder.new(@app)
items_builder.instance_eval(&block) if block
@menubar.menus << Menubar::Menu.new(label: label, items: items_builder.items, x: nil)
end
end
class ItemsBuilder
attr_reader :items
def initialize(app)
@app = app
@items = []
end
def item(label, shortcut: nil, enabled: true, &block)
@items << Menubar::MenuItem.new(
label: label, shortcut: shortcut, enabled: enabled, separator: false,
on_select: block ? ->(item) { @app.instance_exec(item, &block) } : nil
)
end
def separator
@items << Menubar::MenuItem.new(label: '', shortcut: nil, enabled: false,
separator: true, on_select: nil)
end
end
class BodyContext
def initialize(app)
@app = app
end
def add(widget) = @app.add(widget)
def app = @app
end
end
end

View File

@@ -2,6 +2,13 @@
module BBS module BBS
class Config class Config
attr_accessor :screens_dir, :flow, :on_session_end attr_accessor :screens_dir, :flow, :on_session_end,
:port, :theme, :mouse, :idle_seconds
def initialize
@port = (ENV['BBS_PORT'] || 2323).to_i
@mouse = false
@idle_seconds = nil
end
end end
end end

66
lib/bbs/container.rb Normal file
View File

@@ -0,0 +1,66 @@
# frozen_string_literal: true
module BBS
# A Widget that holds children.
class Container < Widget
attr_reader :children
def initialize(**kw)
super
@children = []
end
def add(child)
child.parent = self
@children << child
child
end
def remove(child)
child.parent = nil if @children.delete(child)
end
def clear
@children.each { |c| c.parent = nil }
@children.clear
end
def focusable_descendants
result = []
walk { |w| result << w if w.focusable? && w.visible }
result
end
def walk(&block)
yield self
@children.each do |c|
c.is_a?(Container) ? c.walk(&block) : (yield c)
end
end
def find(id)
walk { |w| return w if w.id == id }
nil
end
def render(frame)
@children.each { |c| c.render(frame) if c.visible }
end
def hit_test(x, y)
return nil unless bounds.contains?(x, y) && visible
# iterate top-most last so we test in reverse
@children.reverse.each do |child|
next unless child.visible
hit = child.hit_test(x, y)
return hit if hit
end
self
end
def theme=(value)
super
@children.each { |c| c.theme = value }
end
end
end

114
lib/bbs/dialogs.rb Normal file
View File

@@ -0,0 +1,114 @@
# frozen_string_literal: true
require_relative 'window'
require_relative 'widgets'
module BBS
module Dialogs
# Build a centred Window with the given inner size.
def self.centred_window(app, width:, height:, title: nil)
cols = app.cols
rows = app.rows
w = [width, cols - 4].min
h = [height, rows - 4].min
x = (cols - w) / 2 + 1
y = (rows - h) / 2 + 1
win = Window.new(title: title, modal: true)
win.layout(x, y, w, h)
win
end
# Show a one-line message with an OK button.
def self.message(app, text, title: ' Message ', width: nil)
lines = wrap(text, 50)
w = (width || ([lines.map(&:length).max + 6, 30].max))
h = lines.size + 5
window = centred_window(app, width: w, height: h, title: title)
lines.each_with_index do |line, i|
label = Widgets::Label.new(text: line, style_key: :window_text)
label.layout(window.bounds.x + 2, window.bounds.y + 1 + i, w - 4, 1)
window.add(label)
end
btn = Widgets::Button.new(label: '&OK', on_click: ->(_) { app.close_window(window) })
btn.layout(window.bounds.x + (w - 8) / 2, window.bounds.y + h - 2, 8, 1)
window.add(btn)
window.reset_focus
app.open_window(window)
window
end
# Yes/No confirmation. Calls on_yes / on_no.
def self.confirm(app, text, title: ' Confirm ', on_yes: nil, on_no: nil)
lines = wrap(text, 50)
w = [lines.map(&:length).max + 6, 36].max
h = lines.size + 5
window = centred_window(app, width: w, height: h, title: title)
lines.each_with_index do |line, i|
label = Widgets::Label.new(text: line, style_key: :window_text)
label.layout(window.bounds.x + 2, window.bounds.y + 1 + i, w - 4, 1)
window.add(label)
end
yes = Widgets::Button.new(label: '&Yes',
on_click: ->(_) { app.close_window(window); on_yes&.call })
no = Widgets::Button.new(label: '&No',
on_click: ->(_) { app.close_window(window); on_no&.call })
yes.layout(window.bounds.x + w / 2 - 9, window.bounds.y + h - 2, 8, 1)
no.layout(window.bounds.x + w / 2 + 1, window.bounds.y + h - 2, 8, 1)
window.add(yes)
window.add(no)
window.reset_focus
app.open_window(window)
window
end
# Single-line text input dialog. Calls on_submit with the entered value.
def self.input(app, prompt:, title: ' Input ', value: '', width: 50, on_submit: nil)
w = width
h = 7
window = centred_window(app, width: w, height: h, title: title)
label = Widgets::Label.new(text: prompt, style_key: :window_text)
label.layout(window.bounds.x + 2, window.bounds.y + 1, w - 4, 1)
window.add(label)
input = Widgets::TextInput.new(value: value)
input.layout(window.bounds.x + 2, window.bounds.y + 3, w - 4, 1)
window.add(input)
ok = Widgets::Button.new(label: '&OK',
on_click: ->(_) { app.close_window(window); on_submit&.call(input.value) })
cancel = Widgets::Button.new(label: '&Cancel',
on_click: ->(_) { app.close_window(window) })
ok.layout(window.bounds.x + w / 2 - 9, window.bounds.y + h - 2, 8, 1)
cancel.layout(window.bounds.x + w / 2 + 1, window.bounds.y + h - 2, 10, 1)
window.add(ok)
window.add(cancel)
window.reset_focus
window.focus_manager.focus(input)
app.open_window(window)
window
end
def self.wrap(text, width)
words = text.to_s.split
lines = []
cur = +''
words.each do |w|
if cur.empty?
cur << w
elsif cur.length + 1 + w.length <= width
cur << ' ' << w
else
lines << cur.dup
cur = +w
end
end
lines << cur unless cur.empty?
lines
end
end
end

29
lib/bbs/event.rb Normal file
View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
module BBS
# A normalised event delivered to widgets.
#
# kind — :key, :mouse, :idle, :resize, :tick
# key — Symbol (:up, :enter, :f1, …) or single-char String (for :key)
# mouse — Telnet::MouseEvent (for :mouse)
# x, y — terminal-relative cursor position (for :mouse)
Event = Struct.new(:kind, :key, :mouse, :x, :y, keyword_init: true) do
def self.key(name)
new(kind: :key, key: name)
end
def self.mouse(mevt)
new(kind: :mouse, mouse: mevt, x: mevt.x, y: mevt.y)
end
def self.idle = new(kind: :idle)
def self.tick = new(kind: :tick)
def self.resize(cols, rows) = new(kind: :resize, x: cols, y: rows)
def key? = kind == :key
def mouse? = kind == :mouse
def alt? = key? && key.to_s.start_with?('alt_')
def alt_char = alt? ? key.to_s.sub('alt_', '') : nil
def char? = key? && key.is_a?(String) && key.length == 1
end
end

View File

@@ -122,6 +122,10 @@ module BBS
def tui(definition) def tui(definition)
@steps << { type: :tui, definition: definition } @steps << { type: :tui, definition: definition }
end end
def app(definition)
@steps << { type: :app, definition: definition }
end
end end
class MenuBuilder class MenuBuilder

View File

@@ -71,6 +71,7 @@ module BBS
when :body then run_body(step) when :body then run_body(step)
when :output then run_output(step) when :output then run_output(step)
when :tui then run_tui(step) when :tui then run_tui(step)
when :app then run_app(step)
end end
end end
@@ -349,6 +350,15 @@ module BBS
:halt :halt
end end
def run_app(step)
builder = step[:definition]
app = builder.build(session: @session, session_id: @session_id, context: @ctx)
app.run
nil
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
:halt
end
# ── helpers ──────────────────────────────────────────────────────────────── # ── helpers ────────────────────────────────────────────────────────────────
def strip_md(text) def strip_md(text)

76
lib/bbs/focus_manager.rb Normal file
View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
module BBS
# Tracks which widget within a Container currently has focus.
# Tab / Shift-Tab cycle through its focusable descendants.
class FocusManager
attr_reader :focused
def initialize(container)
@container = container
@focused = nil
focus_first
end
def candidates
@container.focusable_descendants.select { |w| w.enabled }
end
def focus_first
first = candidates.first
set_focus(first)
end
def focus(widget)
set_focus(widget) if widget && widget.focusable? && widget.enabled
end
def next_focus
list = candidates
return if list.empty?
idx = list.index(@focused) || -1
set_focus(list[(idx + 1) % list.size])
end
def prev_focus
list = candidates
return if list.empty?
idx = list.index(@focused) || 0
set_focus(list[(idx - 1) % list.size])
end
def handle_focus_key(event)
return false unless event.key?
case event.key
when :tab
next_focus
true
when :shift_tab
prev_focus
true
else
false
end
end
def dispatch(event)
return nil unless @focused
@focused.handle_event(event)
end
private
def set_focus(widget)
return if widget == @focused
if @focused
@focused.focused = false
@focused.on_blur
end
@focused = widget
if widget
widget.focused = true
widget.on_focus
end
end
end
end

View File

@@ -80,6 +80,77 @@ module BBS
end end
end end
# ── Drawing helpers (1-based coordinates) ────────────────────────────────
def fill(x:, y:, width:, height:, sgr: nil, char: ' ')
height.times do |r|
move(x, y + r)
write(char * width, sgr: sgr)
end
end
def hline(x:, y:, length:, char: '─', sgr: nil)
move(x, y)
write(char * length, sgr: sgr)
end
def vline(x:, y:, length:, char: '│', sgr: nil)
length.times do |r|
move(x, y + r)
write(char, sgr: sgr)
end
end
BOX_CHARS = {
single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
}.freeze
def box(x:, y:, width:, height:, sgr: nil, fill_sgr: nil, style: :single, title: nil, title_sgr: nil)
return if width < 2 || height < 2
g = BOX_CHARS[style] || BOX_CHARS[:single]
iw = width - 2
move(x, y)
write(g[:tl], sgr: sgr)
if title && title.length > 0
clipped = title[0, [iw - 2, 0].max]
prefix = "#{g[:h]} "
write(prefix, sgr: sgr)
write(clipped, sgr: title_sgr || sgr)
rest = iw - prefix.length - clipped.length
write(g[:h] * [rest, 0].max, sgr: sgr) if rest > 0
else
write(g[:h] * iw, sgr: sgr)
end
write(g[:tr], sgr: sgr)
(1...height - 1).each do |r|
move(x, y + r)
write(g[:v], sgr: sgr)
write(' ' * iw, sgr: fill_sgr) if fill_sgr
move(x + width - 1, y + r)
write(g[:v], sgr: sgr)
end
move(x, y + height - 1)
write(g[:bl], sgr: sgr)
write(g[:h] * iw, sgr: sgr)
write(g[:br], sgr: sgr)
end
def shadow(x:, y:, width:, height:, sgr: nil)
sgr ||= "\e[40m"
# right edge
height.times do |r|
move(x + width, y + 1 + r)
write(' ', sgr: sgr) if x + width < @cols
end
# bottom edge
move(x + 2, y + height)
write(' ' * width, sgr: sgr) if y + height <= @rows
end
# Compute the minimal ANSI byte stream to transform +from+ into +self+. # Compute the minimal ANSI byte stream to transform +from+ into +self+.
def diff(from) def diff(from)
out = +"" out = +""

318
lib/bbs/menu.rb Normal file
View File

@@ -0,0 +1,318 @@
# frozen_string_literal: true
require_relative 'widget'
require_relative 'window'
module BBS
# Menubar — a horizontal strip at the top of the screen with named
# menus. Each menu has a label like "&File" (Alt-F opens it) and a list of
# MenuItem objects.
class Menubar < Widget
Menu = Struct.new(:label, :items, :x, keyword_init: true) do
def hotkey
idx = label.index('&')
idx && idx + 1 < label.length ? label[idx + 1].downcase : nil
end
def display = label.sub('&', '')
end
MenuItem = Struct.new(:label, :shortcut, :enabled, :on_select, :separator,
keyword_init: true) do
def hotkey
return nil if separator
idx = label.index('&')
idx && idx + 1 < label.length ? label[idx + 1].downcase : nil
end
def display = separator ? '─' : label.sub('&', '')
end
attr_reader :menus
attr_accessor :on_activate
def initialize(**kw)
super
@menus = []
@open_index = nil
@item_index = 0
@on_activate = nil
end
def add_menu(label, &block)
builder = MenuBuilder.new
builder.instance_eval(&block) if block
@menus << Menu.new(label: label, items: builder.items, x: nil)
self
end
def open? = !@open_index.nil?
def opened_menu = @open_index ? @menus[@open_index] : nil
def open_index = @open_index
def selected_item = opened_menu&.items&.[](@item_index)
def open(index)
idx = index.clamp(0, @menus.size - 1)
@open_index = idx
@item_index = first_selectable_index(@menus[idx].items) || 0
end
def close
@open_index = nil
@item_index = 0
end
def focusable? = false
def render(frame)
sgr = style(:menubar)
sel = style(:menubar_selected)
hotkey = style(:menubar_hotkey)
sel_hot = style(:menubar_sel_hot)
frame.move(bounds.x, bounds.y)
frame.write(' ' * bounds.width, sgr: sgr)
cx = bounds.x + 1
@menus.each_with_index do |menu, i|
menu.x = cx
is_open = (@open_index == i)
slabel = is_open ? sel : sgr
shotkey = is_open ? sel_hot : hotkey
frame.move(cx, bounds.y)
frame.write(' ', sgr: slabel)
render_hotkey_label(frame, menu.label, cx + 1, bounds.y, slabel, shotkey)
frame.move(cx + 1 + menu.display.length, bounds.y)
frame.write(' ', sgr: slabel)
cx += menu.display.length + 3
end
render_dropdown(frame) if open?
end
def handle_event(event)
return open_via_keyboard(event) if !open? && event.key?
return navigate(event) if open?
nil
end
def navigate(event)
if event.mouse?
if event.mouse.kind == :press && event.mouse.button == :left
return handle_mouse_press(event)
end
return :handled
end
menu = opened_menu
return nil unless menu
case event.key
when :left
@open_index = (@open_index - 1) % @menus.size
@item_index = first_selectable_index(opened_menu.items) || 0
when :right
@open_index = (@open_index + 1) % @menus.size
@item_index = first_selectable_index(opened_menu.items) || 0
when :up
@item_index = prev_selectable_index(menu.items, @item_index)
when :down
@item_index = next_selectable_index(menu.items, @item_index)
when :escape, :f10
close
when :enter
activate_current
else
if event.char?
activated = activate_by_hotkey(menu.items, event.key)
return activated if activated
end
end
:handled
end
def open_via_keyboard(event)
if event.key == :f10
open(0)
return :handled
end
if event.alt? && event.alt_char
idx = @menus.find_index { |m| m.hotkey == event.alt_char.downcase }
if idx
open(idx)
return :handled
end
end
if event.mouse? && event.mouse.kind == :press && event.mouse.button == :left
return handle_mouse_press(event)
end
nil
end
private
def handle_mouse_press(event)
if event.y == bounds.y
# menubar click
idx = @menus.find_index do |m|
m.x && event.x >= m.x && event.x < m.x + m.display.length + 2
end
if idx
@open_index == idx ? close : open(idx)
return :handled
end
close
return :handled
end
if open?
menu = opened_menu
x0 = menu.x
y0 = bounds.y + 1
w = menu_width(menu)
h = menu.items.size + 2
if event.x >= x0 && event.x < x0 + w && event.y >= y0 && event.y < y0 + h
inner_y = event.y - y0 - 1
if inner_y >= 0 && inner_y < menu.items.size
item = menu.items[inner_y]
unless item.separator || item.enabled == false
@item_index = inner_y
return activate_current
end
end
return :handled
end
close
return :handled
end
nil
end
def activate_current
item = selected_item
return :handled unless item && !item.separator && item.enabled != false
close
result = item.on_select&.call(item)
@on_activate&.call(item)
result == :halt ? :halt : :handled
end
def activate_by_hotkey(items, ch)
down = ch.downcase
idx = items.find_index { |i| !i.separator && i.enabled != false && i.hotkey == down }
return nil unless idx
@item_index = idx
activate_current
end
def first_selectable_index(items)
items.find_index { |i| !i.separator && i.enabled != false }
end
def next_selectable_index(items, from)
n = items.size
i = from
n.times do
i = (i + 1) % n
return i if !items[i].separator && items[i].enabled != false
end
from
end
def prev_selectable_index(items, from)
n = items.size
i = from
n.times do
i = (i - 1) % n
return i if !items[i].separator && items[i].enabled != false
end
from
end
def menu_width(menu)
max_label = menu.items.reject(&:separator).map { |i| i.display.length }.max || 0
max_short = menu.items.reject(&:separator).map { |i| i.shortcut.to_s.length }.max || 0
max_label + max_short + 6
end
def render_dropdown(frame)
menu = opened_menu
return unless menu
x = menu.x
y = bounds.y + 1
w = menu_width(menu)
h = menu.items.size + 2
frame.shadow(x: x, y: y, width: w, height: h, sgr: style(:window_shadow))
frame.box(x: x, y: y, width: w, height: h,
sgr: style(:menu_border), fill_sgr: style(:menu_bg))
menu.items.each_with_index do |item, idx|
row_y = y + 1 + idx
if item.separator
frame.hline(x: x + 1, y: row_y, length: w - 2, char: '─', sgr: style(:menu_border))
next
end
selected = (idx == @item_index)
sgr = if !item.enabled.nil? && item.enabled == false
style(:menu_disabled)
elsif selected
style(:menu_selected)
else
style(:menu_text)
end
frame.move(x + 1, row_y)
frame.write(' ' * (w - 2), sgr: sgr)
# label with hotkey highlight
frame.move(x + 2, row_y)
label = item.label
idx_amp = label.index('&')
if idx_amp && item.enabled != false
frame.write(label[0, idx_amp], sgr: sgr)
frame.write(label[idx_amp + 1], sgr: selected ? sgr : style(:menu_hotkey))
rest = label[(idx_amp + 2)..]
frame.write(rest, sgr: sgr) if rest
else
frame.write(item.display, sgr: sgr)
end
if item.shortcut
short = item.shortcut.to_s
frame.move(x + w - 1 - short.length, row_y)
frame.write(short, sgr: sgr)
end
end
end
def render_hotkey_label(frame, label, x, y, sgr, hotkey_sgr)
idx = label.index('&')
if idx
frame.move(x, y)
frame.write(label[0, idx], sgr: sgr) if idx > 0
frame.write(label[idx + 1], sgr: hotkey_sgr)
rest = label[(idx + 2)..]
frame.write(rest, sgr: sgr) if rest && !rest.empty?
else
frame.move(x, y)
frame.write(label, sgr: sgr)
end
end
class MenuBuilder
attr_reader :items
def initialize
@items = []
end
def item(label, shortcut: nil, enabled: true, &block)
@items << MenuItem.new(label: label, shortcut: shortcut,
enabled: enabled, on_select: block, separator: false)
end
def separator
@items << MenuItem.new(label: '', shortcut: nil, enabled: false,
on_select: nil, separator: true)
end
end
end
end

View File

@@ -12,13 +12,19 @@ module BBS
server = TCPServer.new('0.0.0.0', @port) server = TCPServer.new('0.0.0.0', @port)
puts "BBS listening on port #{@port} — connect with: telnet localhost #{@port}" puts "BBS listening on port #{@port} — connect with: telnet localhost #{@port}"
loop do loop do
client = server.accept client = begin
server.accept
rescue Errno::ECONNABORTED, Errno::EMFILE, Errno::ENFILE, IOError => e
warn "BBS accept error: #{e.class}: #{e.message}"
next
end
Thread.new(client) do |c| Thread.new(client) do |c|
session = Session.new(c) session = Session.new(c)
begin begin
session.run session.run
rescue => e rescue StandardError => e
nil warn "BBS session error: #{e.class}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
ensure ensure
BBS.config.on_session_end&.call(session) BBS.config.on_session_end&.call(session)
end end

View File

@@ -7,17 +7,21 @@ module BBS
include Telnet include Telnet
attr_reader :session_id, :term_cols, :term_rows attr_reader :session_id, :term_cols, :term_rows
attr_accessor :idle_seconds
def initialize(client) def initialize(client)
@client = client @client = client
@session_id = SecureRandom.hex(8) @session_id = SecureRandom.hex(8)
@idle_seconds = BBS.config.idle_seconds
end end
def run def run
negotiate negotiate
enable_mouse if BBS.config.mouse
flow = BBS.config.flow or raise "BBS.config.flow is not set" flow = BBS.config.flow or raise "BBS.config.flow is not set"
FlowRunner.new(self, @session_id, flow).run FlowRunner.new(self, @session_id, flow).run
ensure ensure
disable_mouse rescue nil
@client.close rescue nil @client.close rescue nil
end end
end end

88
lib/bbs/statusbar.rb Normal file
View File

@@ -0,0 +1,88 @@
# frozen_string_literal: true
require_relative 'widget'
module BBS
# A status bar at the bottom of the screen. Render a list of hotkey hints
# like "F1=Help F2=Save Esc=Cancel". Optionally a right-aligned status text.
class Statusbar < Widget
Hint = Struct.new(:key, :label, :on_activate, keyword_init: true)
attr_accessor :hints, :right_text
def initialize(hints: [], right_text: nil, **kw)
super(**kw)
@hints = hints
@right_text = right_text
end
def add_hint(key, label, &on_activate)
@hints << Hint.new(key: key, label: label, on_activate: on_activate)
end
def render(frame)
sgr = style(:statusbar)
key_sgr = style(:statusbar_key)
frame.move(bounds.x, bounds.y)
frame.write(' ' * bounds.width, sgr: sgr)
cx = bounds.x + 1
@hints.each do |hint|
text_key = hint.key.to_s
text_lbl = " #{hint.label} "
break if cx + text_key.length + text_lbl.length > bounds.x + bounds.width - 1
frame.move(cx, bounds.y)
frame.write(text_key, sgr: key_sgr)
frame.write(text_lbl, sgr: sgr)
cx += text_key.length + text_lbl.length + 1
end
if @right_text
txt = @right_text.to_s
rx = bounds.x + bounds.width - txt.length - 1
if rx >= cx
frame.move(rx, bounds.y)
frame.write(txt, sgr: sgr)
end
end
end
def handle_event(event)
if event.key?
hint = match_key(event.key)
return hint.on_activate&.call || :handled if hint
return nil
end
return nil unless event.mouse? && event.mouse.kind == :press &&
event.mouse.button == :left && event.y == bounds.y
cx = bounds.x + 1
@hints.each do |hint|
text_key = hint.key.to_s
text_lbl = " #{hint.label} "
width = text_key.length + text_lbl.length
if event.x >= cx && event.x < cx + width
return hint.on_activate&.call || :handled
end
cx += width + 1
end
nil
end
private
def match_key(key)
key_str = key.to_s
@hints.find do |hint|
label = hint.key.to_s
case label
when /\AF(\d+)\z/i then key == :"f#{::Regexp.last_match(1)}"
when /\AAlt-(.)\z/i then key == :"alt_#{::Regexp.last_match(1).downcase}"
when /\AEsc\z/i then key == :escape
when /\ATab\z/i then key == :tab
else key_str.casecmp?(label)
end
end
end
end
end

View File

@@ -6,40 +6,85 @@ require 'time'
module BBS module BBS
class Store class Store
attr_reader :headers, :path
def initialize(path:, headers:) def initialize(path:, headers:)
@path = path @path = path
@headers = headers @headers = headers
@mutex = Mutex.new @mutex = Mutex.new
@rows = nil
end end
def upsert(session_id:, **fields) def upsert(session_id:, **fields)
@mutex.synchronize do @mutex.synchronize do
ensure_file! load_rows!
rows = CSV.read(@path, headers: true) row = @rows.find { |r| r['session_id'] == session_id }
row = rows.find { |r| r['session_id'] == session_id }
if row if row
fields.each { |k, v| row[k.to_s] = v } fields.each { |k, v| row[k.to_s] = v.to_s }
flush!
else else
values = @headers.map do |h| @rows << build_row(session_id, fields)
case h append_row(@rows.last)
when 'session_id' then session_id
when 'timestamp' then Time.now.utc.iso8601
else fields[h.to_sym]
end end
end end
rows << CSV::Row.new(@headers, values) self
end end
CSV.open(@path, 'w') do |csv| def find(session_id)
csv << @headers @mutex.synchronize do
rows.each { |r| csv << r.fields } load_rows!
row = @rows.find { |r| r['session_id'] == session_id }
row ? row.to_h : nil
end end
end end
def all
@mutex.synchronize do
load_rows!
@rows.map(&:to_h)
end
end
def delete(session_id)
@mutex.synchronize do
load_rows!
removed = @rows.reject! { |r| r['session_id'] == session_id }
flush! if removed
end
end end
private private
def load_rows!
return @rows if @rows
ensure_file!
table = CSV.read(@path, headers: true)
@rows = table.map { |row| row }
end
def build_row(session_id, fields)
values = @headers.map do |h|
case h
when 'session_id' then session_id
when 'timestamp' then Time.now.utc.iso8601
else fields[h.to_sym]&.to_s
end
end
CSV::Row.new(@headers, values)
end
def append_row(row)
CSV.open(@path, 'a') { |csv| csv << row.fields }
end
def flush!
CSV.open(@path, 'w') do |csv|
csv << @headers
@rows.each { |r| csv << r.fields }
end
end
def ensure_file! def ensure_file!
FileUtils.mkdir_p(File.dirname(@path)) FileUtils.mkdir_p(File.dirname(@path))
return if File.exist?(@path) return if File.exist?(@path)

View File

@@ -14,6 +14,8 @@ module BBS
SGA_OPT = 3 SGA_OPT = 3
NAWS_OPT = 31 NAWS_OPT = 31
MouseEvent = Struct.new(:kind, :button, :x, :y, :mods, keyword_init: true)
def negotiate def negotiate
@term_cols = 80 @term_cols = 80
@term_rows = 24 @term_rows = 24
@@ -23,9 +25,22 @@ module BBS
send_raw [IAC, DO, NAWS_OPT].pack('C*') send_raw [IAC, DO, NAWS_OPT].pack('C*')
end end
def readkey def enable_mouse
return if @mouse_enabled
write "\e[?1000h\e[?1006h"
@mouse_enabled = true
end
def disable_mouse
return unless @mouse_enabled
write "\e[?1006l\e[?1000l"
@mouse_enabled = false
end
def readkey(timeout: nil)
loop do loop do
raw = @client.read(1) raw = read_byte(timeout)
return :idle if raw == :timeout
return nil if raw.nil? return nil if raw.nil?
byte = raw.ord byte = raw.ord
@@ -36,14 +51,23 @@ module BBS
case byte case byte
when 13, 10 then return :enter when 13, 10 then return :enter
when 9 then return :tab
when 27 then return read_escape_sequence when 27 then return read_escape_sequence
when 8, 127 then return :backspace when 8, 127 then return :backspace
when 3 then return :interrupt when 3 then return :interrupt
when 1..26 then return :"ctrl_#{(byte + 96).chr}"
when 32..126 then return raw when 32..126 then return raw
end end
end end
end end
def read_byte(timeout = nil)
return @client.read(1) if timeout.nil?
ready = IO.select([@client], nil, nil, timeout)
return :timeout unless ready
@client.read(1)
end
def readline def readline
line = +"" line = +""
last_cr = false last_cr = false
@@ -95,20 +119,122 @@ module BBS
private private
# Reads a CSI / SS3 sequence after ESC. Returns a Symbol (key name),
# a Symbol :"alt_<x>" (Alt-modified key), a MouseEvent, or :escape.
def read_escape_sequence def read_escape_sequence
b1 = @client.read(1) b1 = @client.read(1)
return :escape if b1.nil? || b1 != '[' return :escape if b1.nil?
case b1
when '['
read_csi
when 'O'
# SS3 — many terminals send F1-F4 this way
b2 = @client.read(1) b2 = @client.read(1)
return :escape if b2.nil? return :escape if b2.nil?
case b2 case b2
when 'P' then :f1
when 'Q' then :f2
when 'R' then :f3
when 'S' then :f4
when 'H' then :home
when 'F' then :end
else :escape
end
else
# Treat ESC + printable as Alt+<char>
if b1.ord.between?(32, 126)
:"alt_#{b1.downcase}"
else
:escape
end
end
end
def read_csi
# Read CSI parameters: digits, ';', '<', '?', then a single final byte.
raw = +''
final = nil
loop do
c = @client.read(1)
return :escape if c.nil?
if c.match?(/[A-Za-z~]/)
final = c
break
end
raw << c
break if raw.length > 32 # safety
end
return :escape if final.nil?
# SGR mouse: ESC [ < b ; x ; y (M|m)
if raw.start_with?('<') && (final == 'M' || final == 'm')
parts = raw[1..].split(';')
return :escape if parts.size != 3
b = parts[0].to_i
x = parts[1].to_i
y = parts[2].to_i
return decode_mouse(b, x, y, pressed: final == 'M')
end
case final
when 'A' then :up when 'A' then :up
when 'B' then :down when 'B' then :down
when 'C' then :right when 'C' then :right
when 'D' then :left when 'D' then :left
when 'H' then :home
when 'F' then :end
when 'Z' then :shift_tab
when '~'
case raw.to_i
when 1, 7 then :home
when 2 then :insert
when 3 then :delete
when 4, 8 then :end
when 5 then :page_up
when 6 then :page_down
when 11 then :f1
when 12 then :f2
when 13 then :f3
when 14 then :f4
when 15 then :f5
when 17 then :f6
when 18 then :f7
when 19 then :f8
when 20 then :f9
when 21 then :f10
when 23 then :f11
when 24 then :f12
else :escape else :escape
end end
else :escape
end
end
def decode_mouse(b, x, y, pressed:)
mods = []
mods << :shift if b & 4 != 0
mods << :meta if b & 8 != 0
mods << :ctrl if b & 16 != 0
motion = (b & 32) != 0
wheel = (b & 64) != 0
button_id = b & 3
if wheel
kind = :wheel
button = button_id == 0 ? :up : :down
else
button = case button_id
when 0 then :left
when 1 then :middle
when 2 then :right
else :none
end
kind = motion ? :move : (pressed ? :press : :release)
end
MouseEvent.new(kind: kind, button: button, x: x, y: y, mods: mods)
end end
def absorb_iac def absorb_iac

128
lib/bbs/theme.rb Normal file
View File

@@ -0,0 +1,128 @@
# frozen_string_literal: true
module BBS
# A Theme maps named style roles (e.g. :window_border, :button_focused)
# to ANSI SGR sequences. Widgets fetch styles via Widget#style(key).
#
# Two presets are provided:
# Theme.synchronet — yellow/cyan Synchronet-inspired palette
# Theme.mono — black & white, for terminals without color
class Theme
attr_reader :styles, :name
def initialize(name, styles)
@name = name
@styles = styles
end
def style(key) = @styles[key]
def [](key) = @styles[key]
def merge(overrides) = Theme.new(@name, @styles.merge(overrides))
def self.synchronet
Theme.new(:synchronet, {
screen: "\e[40m",
text: "\e[0;37;40m",
text_dim: "\e[0;2;37;40m",
text_bright: "\e[1;37;40m",
label: "\e[0;36;40m",
accent: "\e[1;33;40m",
success: "\e[1;32;40m",
warning: "\e[1;33;40m",
error: "\e[1;31;40m",
window_bg: "\e[44m",
window_border: "\e[1;37;44m",
window_title: "\e[1;33;44m",
window_title_focused: "\e[1;30;47m",
window_text: "\e[0;37;44m",
window_shadow: "\e[40m",
menubar: "\e[1;30;47m",
menubar_hotkey: "\e[1;31;47m",
menubar_selected: "\e[1;37;42m",
menubar_sel_hot: "\e[1;33;42m",
menu_bg: "\e[1;37;44m",
menu_text: "\e[0;37;44m",
menu_hotkey: "\e[1;33;44m",
menu_selected: "\e[1;37;46m",
menu_disabled: "\e[0;2;37;44m",
menu_border: "\e[1;37;44m",
statusbar: "\e[1;30;47m",
statusbar_key: "\e[1;31;47m",
button: "\e[0;30;47m",
button_focused: "\e[1;37;42m",
button_hotkey: "\e[1;31;47m",
button_disabled: "\e[0;2;30;47m",
input: "\e[1;37;44m",
input_focused: "\e[1;30;47m",
input_label: "\e[0;36;40m",
listbox: "\e[0;37;44m",
listbox_selected: "\e[1;37;46m",
listbox_focused: "\e[1;30;47m",
listbox_header: "\e[1;33;44m",
scrollbar: "\e[0;36;44m",
scrollbar_thumb: "\e[1;37;46m",
check_on: "\e[1;32;44m",
check_off: "\e[0;37;44m",
reset: "\e[0m",
}.freeze).freeze
end
def self.mono
Theme.new(:mono, {
screen: "",
text: "\e[0;37m",
text_dim: "\e[0;2;37m",
text_bright: "\e[1;37m",
label: "\e[0;37m",
accent: "\e[1;37m",
success: "\e[1;37m",
warning: "\e[1;37m",
error: "\e[7m",
window_bg: "",
window_border: "\e[1;37m",
window_title: "\e[1;37m",
window_title_focused: "\e[7m",
window_text: "\e[0;37m",
window_shadow: "\e[2;37m",
menubar: "\e[7m",
menubar_hotkey: "\e[1;7m",
menubar_selected: "\e[1m",
menubar_sel_hot: "\e[1;4m",
menu_bg: "\e[7m",
menu_text: "\e[7m",
menu_hotkey: "\e[1;7m",
menu_selected: "\e[1;4m",
menu_disabled: "\e[2;7m",
menu_border: "\e[7m",
statusbar: "\e[7m",
statusbar_key: "\e[1;4;7m",
button: "\e[7m",
button_focused: "\e[1;4;7m",
button_hotkey: "\e[1;4;7m",
button_disabled: "\e[2;7m",
input: "\e[4m",
input_focused: "\e[1;4m",
input_label: "\e[0;37m",
listbox: "\e[0;37m",
listbox_selected: "\e[7m",
listbox_focused: "\e[1;7m",
listbox_header: "\e[1;4m",
scrollbar: "\e[2;37m",
scrollbar_thumb: "\e[1;37m",
check_on: "\e[1;7m",
check_off: "\e[0;37m",
reset: "\e[0m",
}.freeze).freeze
end
end
end

View File

@@ -42,6 +42,8 @@ module BBS
def render(&block) = @render_block = block def render(&block) = @render_block = block
def key(k, &block) = (@key_bindings[k] = block) def key(k, &block) = (@key_bindings[k] = block)
def printable(&block) = (@key_bindings[:printable] = block) def printable(&block) = (@key_bindings[:printable] = block)
def mouse(&block) = (@key_bindings[:mouse] = block)
def any_key(&block) = (@key_bindings[:any] = block)
def nav(mode, **opts) def nav(mode, **opts)
coerce = ->(value) { value.is_a?(Proc) ? value : -> { value } } coerce = ->(value) { value.is_a?(Proc) ? value : -> { value } }
@@ -59,16 +61,34 @@ module BBS
when :list when :list
window = coerce.(opts.fetch(:window)) window = coerce.(opts.fetch(:window))
key(:up) do key(:up) do
return if @item_selection <= 0 next if @item_selection <= 0
@item_selection -= 1 @item_selection -= 1
@scroll = @item_selection if @item_selection < @scroll @scroll = @item_selection if @item_selection < @scroll
end end
key(:down) do key(:down) do
return if @item_selection >= @items.size - 1 next if @item_selection >= @items.size - 1
@item_selection += 1 @item_selection += 1
window_size = instance_exec(&window) window_size = instance_exec(&window)
@scroll = @item_selection - window_size + 1 if @item_selection >= @scroll + window_size @scroll = @item_selection - window_size + 1 if @item_selection >= @scroll + window_size
end end
key(:page_up) do
next if @item_selection <= 0
step = instance_exec(&window)
@item_selection = [@item_selection - step, 0].max
@scroll = [@scroll - step, 0].max
end
key(:page_down) do
next if @item_selection >= @items.size - 1
step = instance_exec(&window)
@item_selection = [@item_selection + step, @items.size - 1].min
@scroll = [[@scroll + step, @item_selection].min, [@items.size - step, 0].max].min
end
key(:home) { @item_selection = 0; @scroll = 0 }
key(:end) do
step = instance_exec(&window)
@item_selection = @items.size - 1
@scroll = [@items.size - step, 0].max
end
when :scroll when :scroll
content = opts.fetch(:content) content = opts.fetch(:content)
@@ -78,6 +98,15 @@ module BBS
max = [instance_exec(&content).size - instance_exec(&window), 0].max max = [instance_exec(&content).size - instance_exec(&window), 0].max
@scroll = [@scroll + 1, max].min @scroll = [@scroll + 1, max].min
end end
key(:page_up) { @scroll = [@scroll - instance_exec(&window), 0].max }
key(:page_down) do
max = [instance_exec(&content).size - instance_exec(&window), 0].max
@scroll = [@scroll + instance_exec(&window), max].min
end
key(:home) { @scroll = 0 }
key(:end) do
@scroll = [instance_exec(&content).size - instance_exec(&window), 0].max
end
end end
end end
end end

View File

@@ -15,10 +15,15 @@ module BBS
context.instance_eval(&@tui.init_block) if @tui.init_block context.instance_eval(&@tui.init_block) if @tui.init_block
context.go(@tui.start_page) context.go(@tui.start_page)
idle = BBS.config.idle_seconds
loop do loop do
context.do_render context.do_render
key = @session.readkey key = @session.readkey(timeout: idle)
break if key.nil? break if key.nil?
if key == :idle
break if context.dispatch_key(:idle) == :halt
next
end
break if context.dispatch_key(key) == :halt break if context.dispatch_key(key) == :halt
end end
rescue IOError, Errno::EPIPE, Errno::ECONNRESET rescue IOError, Errno::EPIPE, Errno::ECONNRESET
@@ -80,14 +85,27 @@ module BBS
def dispatch_key(key) def dispatch_key(key)
page = @tui.pages[@current_page] page = @tui.pages[@current_page]
handler = page&.key_bindings&.[](key) return nil unless page
if key.is_a?(Telnet::MouseEvent)
if (mouse_handler = page.key_bindings[:mouse])
return instance_exec(key, &mouse_handler)
end
return nil
end
handler = page.key_bindings[key]
if handler.nil? && key.is_a?(String) && key.length == 1 if handler.nil? && key.is_a?(String) && key.length == 1
if (printable_handler = page&.key_bindings&.[](:printable)) if (printable_handler = page.key_bindings[:printable])
return instance_exec(key, &printable_handler) return instance_exec(key, &printable_handler)
end end
end end
if handler.nil? && (any_handler = page.key_bindings[:any])
return instance_exec(key, &any_handler)
end
return nil unless handler return nil unless handler
instance_eval(&handler) instance_eval(&handler)
end end

82
lib/bbs/widget.rb Normal file
View File

@@ -0,0 +1,82 @@
# frozen_string_literal: true
module BBS
# Base widget. All visual elements inherit from this.
#
# bounds — Rect (x, y, width, height) in screen coordinates
# parent — enclosing Container, or nil
# id — optional symbol for lookup via Container#find
#
# Subclasses override render(frame) and optionally handle_event(event).
class Widget
Rect = Struct.new(:x, :y, :width, :height) do
def contains?(px, py)
px >= x && px < x + width && py >= y && py < y + height
end
def to_s = "##{x},#{y} #{width}x#{height}"
end
attr_accessor :bounds, :parent, :id, :visible, :focused, :enabled
attr_reader :theme
def initialize(id: nil, bounds: nil, theme: nil)
@id = id
@bounds = bounds || Rect.new(1, 1, 0, 0)
@parent = nil
@visible = true
@focused = false
@enabled = true
@theme = theme
end
# Override in subclasses. By default a widget does not accept focus.
def focusable? = false
# Called when this widget gains focus. Subclasses may override.
def on_focus = nil
# Called when this widget loses focus.
def on_blur = nil
# Render this widget into the supplied FrameBuffer.
def render(frame); end
# Handle a keyboard or mouse event. Return :halt to end the application,
# :handled to stop propagation, or nil/false to continue.
def handle_event(_event) = nil
# Convenience: yield this widget. Subclasses override to walk a tree.
def each(&block)
yield self
nil
end
# Hit test: returns the deepest widget whose bounds contain (x, y).
def hit_test(x, y)
bounds.contains?(x, y) ? self : nil
end
# Apply a theme. Subclasses with children propagate via Container#theme=.
def theme=(value)
@theme = value
end
# Move/resize. Subclasses may override to relayout children.
def layout(x, y, width, height)
@bounds = Rect.new(x, y, width, height)
end
# Request a redraw — set by Application as a callback if needed.
def redraw!
@parent&.redraw!
end
protected
def style(key)
th = @theme || (parent && parent.theme)
th ? th.style(key) : nil
end
end
end

591
lib/bbs/widgets.rb Normal file
View File

@@ -0,0 +1,591 @@
# frozen_string_literal: true
require_relative 'widget'
require_relative 'container'
module BBS
module Widgets
# ── Label ─────────────────────────────────────────────────────────────────
class Label < Widget
attr_accessor :text, :align, :style_key
def initialize(text: '', align: :left, style_key: :text, **kw)
super(**kw)
@text = text
@align = align
@style_key = style_key
end
def render(frame)
return if bounds.width <= 0
line = @text.to_s
clipped = line[0, bounds.width].to_s
x = case @align
when :center then bounds.x + ([bounds.width - clipped.length, 0].max / 2)
when :right then bounds.x + bounds.width - clipped.length
else bounds.x
end
frame.move(x, bounds.y)
frame.write_ansi(clipped, base_sgr: style(@style_key))
end
end
# ── Button ────────────────────────────────────────────────────────────────
# Label syntax: "Save" or "&Save" (ampersand marks the hotkey letter).
# Pressing Alt+letter or Enter (when focused) fires on_click.
class Button < Widget
attr_accessor :label, :on_click
def initialize(label: 'OK', on_click: nil, **kw)
super(**kw)
@label = label
@on_click = on_click
end
def focusable? = true
def hotkey
idx = @label.index('&')
return nil unless idx && idx + 1 < @label.length
@label[idx + 1].downcase
end
def display_label = @label.sub('&', '')
def render(frame)
return if bounds.width <= 0
sgr = focused ? style(:button_focused) : style(:button)
sgr = style(:button_disabled) unless enabled
text = "[ #{display_label} ]"
text = text[0, bounds.width]
frame.move(bounds.x, bounds.y)
frame.write(' ' * bounds.width, sgr: sgr)
frame.move(bounds.x + [(bounds.width - text.length) / 2, 0].max, bounds.y)
# write with hotkey highlighting
idx = @label.index('&')
if idx && enabled
left = @label[0, idx]
hot = @label[idx + 1]
right = @label[(idx + 2)..] || ''
frame.write('[ ', sgr: sgr)
frame.write(left, sgr: sgr) unless left.empty?
frame.write(hot, sgr: style(:button_hotkey))
frame.write(right, sgr: sgr)
frame.write(' ]', sgr: sgr)
else
frame.write(text, sgr: sgr)
end
end
def fire
return :handled if !enabled
result = @on_click&.call(self)
result == :halt ? :halt : :handled
end
def handle_event(event)
return nil unless enabled
if event.key?
return fire if event.key == :enter || event.key == ' '
elsif event.mouse?
if event.mouse.kind == :press && event.mouse.button == :left &&
bounds.contains?(event.x, event.y)
return fire
end
end
nil
end
end
# ── TextInput ─────────────────────────────────────────────────────────────
class TextInput < Widget
attr_accessor :value, :placeholder, :max_length, :on_submit, :on_change, :password
def initialize(value: '', placeholder: '', max_length: 256,
on_submit: nil, on_change: nil, password: false, **kw)
super(**kw)
@value = value.dup
@placeholder = placeholder
@max_length = max_length
@on_submit = on_submit
@on_change = on_change
@password = password
@cursor = @value.length
@scroll = 0
end
def focusable? = true
def render(frame)
return if bounds.width <= 0 || bounds.height <= 0
sgr = focused ? style(:input_focused) : style(:input)
frame.move(bounds.x, bounds.y)
frame.write(' ' * bounds.width, sgr: sgr)
display = @password ? '*' * @value.length : @value
visible = display[@scroll, bounds.width - 1] || ''
frame.move(bounds.x, bounds.y)
if visible.empty? && !focused && !@placeholder.empty?
ph = @placeholder[0, bounds.width - 1].to_s
frame.write(ph, sgr: style(:text_dim))
else
frame.write(visible, sgr: sgr)
end
if focused
cx = bounds.x + (@cursor - @scroll)
cx = bounds.x + bounds.width - 1 if cx >= bounds.x + bounds.width
frame.move(cx, bounds.y)
frame.write('_', sgr: sgr)
end
end
def handle_event(event)
return nil unless event.key? && enabled
case event.key
when :left
@cursor = [@cursor - 1, 0].max
adjust_scroll
when :right
@cursor = [@cursor + 1, @value.length].min
adjust_scroll
when :home
@cursor = 0; @scroll = 0
when :end
@cursor = @value.length; adjust_scroll
when :backspace
if @cursor > 0
@value.slice!(@cursor - 1)
@cursor -= 1
adjust_scroll
@on_change&.call(@value, self)
end
when :delete
if @cursor < @value.length
@value.slice!(@cursor)
@on_change&.call(@value, self)
end
when :enter
return @on_submit ? @on_submit.call(@value, self) || :handled : :handled
else
if event.char? && @value.length < @max_length
@value.insert(@cursor, event.key)
@cursor += 1
adjust_scroll
@on_change&.call(@value, self)
return :handled
end
end
:handled
end
private
def adjust_scroll
view_width = bounds.width - 1
@scroll = @cursor if @cursor < @scroll
@scroll = @cursor - view_width + 1 if @cursor >= @scroll + view_width
@scroll = [@scroll, 0].max
end
end
# ── TextArea ──────────────────────────────────────────────────────────────
class TextArea < Widget
attr_accessor :on_change
def initialize(value: '', max_length: 8_000, on_change: nil, **kw)
super(**kw)
@lines = value.empty? ? [+''] : value.split("\n", -1).map { |l| +l }
@max_length = max_length
@on_change = on_change
@cursor_r = 0
@cursor_c = 0
@scroll_r = 0
end
def focusable? = true
def value = @lines.join("\n")
def length = value.length
def render(frame)
return if bounds.width <= 0 || bounds.height <= 0
sgr = focused ? style(:input_focused) : style(:input)
bounds.height.times do |r|
frame.move(bounds.x, bounds.y + r)
frame.write(' ' * bounds.width, sgr: sgr)
line = @lines[@scroll_r + r]
next unless line
frame.move(bounds.x, bounds.y + r)
frame.write(line[0, bounds.width].to_s, sgr: sgr)
end
if focused
cy = bounds.y + (@cursor_r - @scroll_r)
cx = bounds.x + [@cursor_c, bounds.width - 1].min
if cy >= bounds.y && cy < bounds.y + bounds.height
frame.move(cx, cy)
frame.write('_', sgr: sgr)
end
end
end
def handle_event(event)
return nil unless event.key? && enabled
case event.key
when :left then move_cursor(-1, :col)
when :right then move_cursor(1, :col)
when :up then move_cursor(-1, :row)
when :down then move_cursor(1, :row)
when :home then @cursor_c = 0
when :end then @cursor_c = @lines[@cursor_r].length
when :enter
tail = +(@lines[@cursor_r][@cursor_c..] || '')
@lines[@cursor_r] = +(@lines[@cursor_r][0, @cursor_c] || '')
@lines.insert(@cursor_r + 1, tail)
@cursor_r += 1
@cursor_c = 0
ensure_visible
@on_change&.call(value, self)
when :backspace
if @cursor_c > 0
@lines[@cursor_r].slice!(@cursor_c - 1)
@cursor_c -= 1
elsif @cursor_r > 0
prev_len = @lines[@cursor_r - 1].length
@lines[@cursor_r - 1] += @lines.delete_at(@cursor_r)
@cursor_r -= 1
@cursor_c = prev_len
ensure_visible
end
@on_change&.call(value, self)
else
if event.char? && length < @max_length
@lines[@cursor_r].insert(@cursor_c, event.key)
@cursor_c += 1
@on_change&.call(value, self)
return :handled
end
end
:handled
end
private
def move_cursor(delta, axis)
if axis == :col
@cursor_c += delta
if @cursor_c < 0 && @cursor_r > 0
@cursor_r -= 1
@cursor_c = @lines[@cursor_r].length
elsif @cursor_c > @lines[@cursor_r].length && @cursor_r < @lines.size - 1
@cursor_r += 1
@cursor_c = 0
end
@cursor_c = @cursor_c.clamp(0, @lines[@cursor_r].length)
else
@cursor_r = (@cursor_r + delta).clamp(0, @lines.size - 1)
@cursor_c = @cursor_c.clamp(0, @lines[@cursor_r].length)
end
ensure_visible
end
def ensure_visible
@scroll_r = @cursor_r if @cursor_r < @scroll_r
@scroll_r = @cursor_r - bounds.height + 1 if @cursor_r >= @scroll_r + bounds.height
@scroll_r = [@scroll_r, 0].max
end
end
# ── Checkbox ──────────────────────────────────────────────────────────────
class Checkbox < Widget
attr_accessor :label, :checked, :on_change
def initialize(label: '', checked: false, on_change: nil, **kw)
super(**kw)
@label = label
@checked = checked
@on_change = on_change
end
def focusable? = true
def render(frame)
sgr = focused ? style(:button_focused) : style(:text)
mark = @checked ? '[x]' : '[ ]'
text = "#{mark} #{@label}"[0, bounds.width]
frame.move(bounds.x, bounds.y)
frame.write(text, sgr: sgr)
end
def handle_event(event)
return nil unless enabled && event.key?
if event.key == :enter || event.key == ' '
@checked = !@checked
@on_change&.call(@checked, self)
return :handled
end
nil
end
end
# ── ListBox ───────────────────────────────────────────────────────────────
class ListBox < Widget
attr_accessor :items, :selected, :scroll, :on_select, :on_activate, :label
def initialize(items: [], on_select: nil, on_activate: nil, label: nil, **kw)
super(**kw)
@items = items
@selected = 0
@scroll = 0
@on_select = on_select
@on_activate = on_activate
@label = label
end
def focusable? = true
def selected_item = @items[@selected]
def render(frame)
return if bounds.width <= 0 || bounds.height <= 0
bg = style(:listbox)
bounds.height.times do |r|
idx = @scroll + r
frame.move(bounds.x, bounds.y + r)
frame.write(' ' * bounds.width, sgr: bg)
item = @items[idx]
next unless item
text = (@label ? @label.call(item) : item.to_s)[0, bounds.width]
line_sgr = if idx == @selected
focused ? style(:listbox_focused) : style(:listbox_selected)
else
bg
end
frame.move(bounds.x, bounds.y + r)
frame.write(' ' * bounds.width, sgr: line_sgr) if idx == @selected
frame.move(bounds.x, bounds.y + r)
frame.write(text, sgr: line_sgr)
end
end
def handle_event(event)
return nil unless enabled
if event.key?
case event.key
when :up then move(-1)
when :down then move(1)
when :page_up then move(-bounds.height)
when :page_down then move(bounds.height)
when :home then move_to(0)
when :end then move_to(@items.size - 1)
when :enter then return @on_activate&.call(selected_item, self) || :handled
else return nil
end
:handled
elsif event.mouse?
if event.mouse.kind == :press && event.mouse.button == :left &&
bounds.contains?(event.x, event.y)
row = event.y - bounds.y
idx = @scroll + row
if idx < @items.size
prev = @selected
@selected = idx
@on_select&.call(selected_item, self) if prev != idx
end
return :handled
elsif event.mouse.kind == :wheel && bounds.contains?(event.x, event.y)
event.mouse.button == :up ? move(-3) : move(3)
return :handled
end
nil
end
end
private
def move(delta)
return if @items.empty?
prev = @selected
@selected = (@selected + delta).clamp(0, @items.size - 1)
adjust_scroll
@on_select&.call(selected_item, self) if prev != @selected
end
def move_to(idx)
return if @items.empty?
prev = @selected
@selected = idx.clamp(0, @items.size - 1)
adjust_scroll
@on_select&.call(selected_item, self) if prev != @selected
end
def adjust_scroll
@scroll = @selected if @selected < @scroll
@scroll = @selected - bounds.height + 1 if @selected >= @scroll + bounds.height
@scroll = [@scroll, 0].max
end
end
# ── ScrollView: a scrollable text buffer (lines: Array[String]) ───────────
class ScrollView < Widget
attr_accessor :lines, :scroll
def initialize(lines: [], **kw)
super(**kw)
@lines = lines
@scroll = 0
end
def focusable? = true
def render(frame)
sgr = style(:text)
bounds.height.times do |r|
frame.move(bounds.x, bounds.y + r)
frame.write(' ' * bounds.width, sgr: sgr)
line = @lines[@scroll + r]
next unless line
frame.move(bounds.x, bounds.y + r)
frame.write_ansi(line[0, bounds.width].to_s, base_sgr: sgr)
end
# right-side scrollbar
if @lines.size > bounds.height && bounds.width > 1
track_h = bounds.height
thumb_y = bounds.y + (@scroll * (track_h - 1).to_f / [@lines.size - bounds.height, 1].max).to_i
frame.vline(x: bounds.x + bounds.width - 1, y: bounds.y, length: track_h,
char: '│', sgr: style(:scrollbar))
frame.move(bounds.x + bounds.width - 1, thumb_y)
frame.write('█', sgr: style(:scrollbar_thumb))
end
end
def handle_event(event)
return nil unless event.key?
max = [@lines.size - bounds.height, 0].max
case event.key
when :up then @scroll = [@scroll - 1, 0].max
when :down then @scroll = [@scroll + 1, max].min
when :page_up then @scroll = [@scroll - bounds.height, 0].max
when :page_down then @scroll = [@scroll + bounds.height, max].min
when :home then @scroll = 0
when :end then @scroll = max
else return nil
end
:handled
end
end
# ── ProgressBar / Spinner ─────────────────────────────────────────────────
class ProgressBar < Widget
attr_accessor :value, :max, :label
def initialize(value: 0, max: 100, label: nil, **kw)
super(**kw)
@value = value
@max = max
@label = label
end
def render(frame)
sgr = style(:text)
fill = style(:button_focused)
return if bounds.width <= 0
ratio = @max.zero? ? 0.0 : @value.to_f / @max
bars = (ratio * bounds.width).to_i.clamp(0, bounds.width)
frame.move(bounds.x, bounds.y)
frame.write('█' * bars, sgr: fill)
frame.write('░' * (bounds.width - bars), sgr: sgr)
if @label
text = @label[0, bounds.width]
frame.move(bounds.x + (bounds.width - text.length) / 2, bounds.y)
frame.write_ansi(text, base_sgr: sgr)
end
end
end
class Spinner < Widget
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
attr_accessor :label
def initialize(label: 'Loading...', **kw)
super(**kw)
@label = label
@frame = 0
end
def tick
@frame = (@frame + 1) % FRAMES.size
end
def render(frame)
sgr = style(:accent)
text = "#{FRAMES[@frame]} #{@label}"
frame.move(bounds.x, bounds.y)
frame.write(text[0, bounds.width], sgr: sgr)
end
end
# ── HBox / VBox ───────────────────────────────────────────────────────────
class HBox < Container
attr_accessor :gap
def initialize(gap: 1, **kw)
super(**kw)
@gap = gap
end
def layout(x, y, w, h)
super
return if @children.empty?
per = ((w - (@children.size - 1) * @gap) / @children.size.to_f)
cx = x
@children.each_with_index do |c, i|
width = (per * (i + 1)).to_i - (per * i).to_i
c.layout(cx, y, width, h)
cx += width + @gap
end
end
end
class VBox < Container
attr_accessor :gap
def initialize(gap: 0, **kw)
super(**kw)
@gap = gap
end
def layout(x, y, w, h)
super
return if @children.empty?
per = ((h - (@children.size - 1) * @gap) / @children.size.to_f)
cy = y
@children.each_with_index do |c, i|
height = (per * (i + 1)).to_i - (per * i).to_i
c.layout(x, cy, w, height)
cy += height + @gap
end
end
end
# ── Panel: a Container with a border and optional title ──────────────────
class Panel < Container
attr_accessor :title, :box_style
def initialize(title: nil, box_style: :single, **kw)
super(**kw)
@title = title
@box_style = box_style
end
def render(frame)
frame.box(
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
sgr: style(:window_border), fill_sgr: style(:window_bg),
style: @box_style, title: @title, title_sgr: style(:window_title),
)
super
end
end
end
end

86
lib/bbs/window.rb Normal file
View File

@@ -0,0 +1,86 @@
# frozen_string_literal: true
require_relative 'container'
require_relative 'focus_manager'
module BBS
# A Window is a top-level Container with its own border, title, and focus
# manager. The Application stacks windows; the top window is the modal /
# active one and receives input first.
class Window < Container
attr_accessor :title, :modal, :shadow, :on_close, :close_key
def initialize(title: nil, modal: false, shadow: true, on_close: nil,
close_key: :escape, **kw)
super(**kw)
@title = title
@modal = modal
@shadow = shadow
@on_close = on_close
@close_key = close_key
@focus_mgr = nil
end
def focus_manager
@focus_mgr ||= FocusManager.new(self)
end
def reset_focus
@focus_mgr = FocusManager.new(self)
end
def render(frame)
if @shadow
frame.shadow(x: bounds.x, y: bounds.y, width: bounds.width,
height: bounds.height, sgr: style(:window_shadow))
end
frame.box(
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
sgr: style(:window_border), fill_sgr: style(:window_bg),
style: :double, title: @title,
title_sgr: focused ? style(:window_title_focused) : style(:window_title)
)
# Close gadget — Turbo Vision style "[■]"
if @on_close != false && bounds.width >= 6
frame.move(bounds.x + 2, bounds.y)
frame.write('[■]', sgr: style(:window_title))
end
@children.each { |c| c.render(frame) if c.visible }
end
def hit_test(x, y)
# close gadget area
if @on_close != false && y == bounds.y &&
x >= bounds.x + 2 && x <= bounds.x + 4
return self
end
super
end
def handle_event(event)
if event.mouse? && event.mouse.kind == :press && event.mouse.button == :left
if event.y == bounds.y && event.x >= bounds.x + 2 && event.x <= bounds.x + 4
return close
end
if event.mouse.button == :left
hit = hit_test(event.x, event.y)
focus_manager.focus(hit) if hit&.focusable?
end
end
result = focus_manager.handle_focus_key(event) ? :handled : nil
result ||= focus_manager.dispatch(event)
return result if result
if event.key? && event.key == @close_key
return close
end
nil
end
def close
@on_close&.call(self)
:close_window
end
end
end