new framework
This commit is contained in:
267
README.md
267
README.md
@@ -1,6 +1,16 @@
|
||||
# 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
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
## Quick start
|
||||
## Quick start — Flow + Application
|
||||
|
||||
```ruby
|
||||
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|
|
||||
c.screens_dir = 'screens'
|
||||
c.stores = [sessions]
|
||||
c.mouse = true
|
||||
c.idle_seconds = 600
|
||||
c.flow = BBS::Flow.define do
|
||||
screen :welcome
|
||||
ask :name, prompt: "What is your name?"
|
||||
say "Hello, %{name}!", style: :success
|
||||
persist :name, store: sessions
|
||||
big_banner 'MY BBS', style: :success
|
||||
ask :username, prompt: 'Name'
|
||||
app MAIN
|
||||
say 'Goodbye!'
|
||||
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
|
||||
|
||||
`BBS.configure` yields a `BBS::Config` object:
|
||||
`BBS.configure` yields a `BBS::Config`:
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `screens_dir` | `"screens"` | Directory containing ERB screen files |
|
||||
| `stores` | `[]` | List of `BBS::Store` instances |
|
||||
| `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 |
|
||||
| `banner "text", style:` | Print a box-drawing ASCII banner |
|
||||
| `big_banner "text", style:, font:` | Print a large FIGlet ASCII-art banner (default font: `slant`) |
|
||||
| `say "text", style:` | Print a single styled line |
|
||||
| `text "content", style:` | Print a line; accepts a block `{ \|ctx\| string }` for dynamic content |
|
||||
| `section "title", color:` | Print a decorated section header with box-drawing |
|
||||
| `line color:` | Print a horizontal rule |
|
||||
| `rows(empty: "…") { \|ctx\| array }` | Print a list of lines; shows `empty` when the array is empty |
|
||||
| `table { \|ctx\| hash }` | Print a two-column key/value table |
|
||||
| `body(width:, indent:) { \|ctx\| string }` | Word-wrap and print a block of text (strips basic Markdown) |
|
||||
| `output { \|ctx\| string }` | Write a raw string returned by the block verbatim |
|
||||
| `Label` | Static text, optionally aligned, themed via `style_key` |
|
||||
| `Button` | `&Save`-style hotkey, fires `on_click(self)`; mouse-clickable |
|
||||
| `TextInput` | Single-line input with cursor, scroll, `on_submit`/`on_change` |
|
||||
| `TextArea` | Multi-line editor with arrow-key navigation |
|
||||
| `Checkbox` | Toggle with `on_change(checked, self)` |
|
||||
| `ListBox` | Scrolling list with mouse + keyboard, wheel scrolling |
|
||||
| `ScrollView` | Read-only scrolling text view, with scrollbar |
|
||||
| `ProgressBar`, `Spinner` | Progress feedback |
|
||||
| `Panel`, `HBox`, `VBox` | Containers / layout |
|
||||
| `Window` | Top-level modal container with title bar, close gadget, shadow |
|
||||
|
||||
#### Input & control
|
||||
|
||||
| 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
|
||||
### Built-in dialogs (`BBS::Dialogs`)
|
||||
|
||||
```ruby
|
||||
option 'Blog Posts' do
|
||||
section 'Blog Posts', color: :blue
|
||||
fetch :pages, loading: 'Loading...' do |ctx|
|
||||
WIKI.list('blog')
|
||||
end
|
||||
pick from: :pages,
|
||||
empty: 'No results.',
|
||||
prompt: 'Enter number to read (blank to go back)',
|
||||
item: ->(p, i) { "#{i}. #{p.title}" },
|
||||
hint: ->(p, i) { p.description.to_s[0...65] } do
|
||||
fetch :body, loading: 'Loading page...' do |ctx|
|
||||
WIKI.content(ctx[:picked].id)
|
||||
end
|
||||
body { |ctx| ctx[:body] }
|
||||
wait_enter
|
||||
BBS::Dialogs.message(app, "Saved!", title: ' Done ')
|
||||
BBS::Dialogs.confirm(app, "Delete?", on_yes: -> { ... })
|
||||
BBS::Dialogs.input(app, prompt: 'Name?', on_submit: ->(value) { ... })
|
||||
```
|
||||
|
||||
### Menubar DSL
|
||||
|
||||
```ruby
|
||||
menubar do
|
||||
menu '&File' do
|
||||
item '&Open', shortcut: 'Ctrl-O' do … end
|
||||
separator
|
||||
item 'E&xit' do :halt 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
|
||||
menu ">", loop: true do
|
||||
option "Leave feedback" do
|
||||
ask :feedback, prompt: "Feedback"
|
||||
persist :feedback, store: sessions
|
||||
say "Recorded. Thank you.", style: :success
|
||||
end
|
||||
### Theming
|
||||
|
||||
option "Exit" do
|
||||
exit_menu
|
||||
end
|
||||
end
|
||||
`BBS::Theme.synchronet` (default) is a yellow/cyan Synchronet-inspired palette.
|
||||
`BBS::Theme.mono` provides a colour-free fallback. Create your own with
|
||||
`BBS::Theme.new(:my_theme, { window_border: "\e[1;36m", … })`.
|
||||
|
||||
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
|
||||
<%= banner("MISSION COMPLETE") %>
|
||||
## Input model
|
||||
|
||||
<%= white %>Congratulations!<%= reset %> You are <%= yellow %><%= @name %><%= reset %>.
|
||||
```
|
||||
`Session#readkey(timeout:)` returns one of:
|
||||
|
||||
### ANSI helpers
|
||||
|
||||
`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `reset`, `bold`, `dim`
|
||||
- a single-character `String` (printable input)
|
||||
- a `Symbol`: `:enter`, `:tab`, `:shift_tab`, `:backspace`, `:escape`,
|
||||
`: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
|
||||
|
||||
Each `persist` step writes to a `BBS::Store` (a thread-safe, append-and-upsert CSV file). `session_id` and `timestamp` are always written automatically.
|
||||
|
||||
```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.
|
||||
`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.
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `bbs/server.rb` | TCP accept loop, spawns one thread per client |
|
||||
| `bbs/session.rb` | Negotiates telnet options, runs the flow |
|
||||
| `bbs/telnet.rb` | Telnet protocol (IAC handling, echo control, readline) |
|
||||
| `bbs/flow.rb` | Flow DSL builder |
|
||||
| `bbs/flow_runner.rb` | Executes a Flow step by step against a session context |
|
||||
| `bbs/color.rb` | `BBS::Color` module with ANSI color map and `c()` helper |
|
||||
| `bbs/config.rb` | Configuration object |
|
||||
| `bbs/banner.rb` | Box-drawing and FIGlet banner renderer |
|
||||
| `bbs/renderer.rb` | ERB screen loader with ANSI colour helpers |
|
||||
| `bbs/store.rb` | Thread-safe CSV persistence |
|
||||
| `bbs/server.rb` | TCP accept loop, one thread per client |
|
||||
| `bbs/session.rb` | Telnet negotiation, mouse enable/disable, idle timer |
|
||||
| `bbs/telnet.rb` | Telnet protocol, full key parsing, xterm SGR mouse |
|
||||
| `bbs/flow.rb`, `bbs/flow_runner.rb` | Flow DSL + interpreter |
|
||||
| `bbs/tui.rb`, `bbs/tui_runner.rb` | Page-router with frame-buffer rendering |
|
||||
| `bbs/frame_buffer.rb` | Double-buffer, delta diff, drawing helpers |
|
||||
| `bbs/application.rb` | Widget event loop, window stack, ticking |
|
||||
| `bbs/widget.rb`, `bbs/container.rb` | Widget tree base |
|
||||
| `bbs/widgets.rb` | Built-in widgets |
|
||||
| `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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Gem::Specification.new do |s|
|
||||
s.name = 'bbs'
|
||||
s.version = '0.3.0'
|
||||
s.version = '0.4.0'
|
||||
s.summary = 'Universal telnet BBS server library'
|
||||
s.author = 'Zsolt Tasnadi'
|
||||
s.files = Dir['lib/**/*.rb']
|
||||
|
||||
13
lib/bbs.rb
13
lib/bbs.rb
@@ -9,6 +9,17 @@ require_relative 'bbs/flow_runner'
|
||||
require_relative 'bbs/renderer'
|
||||
require_relative 'bbs/store'
|
||||
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_runner'
|
||||
require_relative 'bbs/session'
|
||||
@@ -23,7 +34,7 @@ module BBS
|
||||
@config ||= Config.new
|
||||
end
|
||||
|
||||
def self.start(port: (ENV['BBS_PORT'] || 2323).to_i)
|
||||
def self.start(port: config.port)
|
||||
Server.new(port: port).run
|
||||
end
|
||||
end
|
||||
|
||||
338
lib/bbs/application.rb
Normal file
338
lib/bbs/application.rb
Normal 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
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
module BBS
|
||||
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
|
||||
|
||||
66
lib/bbs/container.rb
Normal file
66
lib/bbs/container.rb
Normal 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
114
lib/bbs/dialogs.rb
Normal 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
29
lib/bbs/event.rb
Normal 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
|
||||
@@ -122,6 +122,10 @@ module BBS
|
||||
def tui(definition)
|
||||
@steps << { type: :tui, definition: definition }
|
||||
end
|
||||
|
||||
def app(definition)
|
||||
@steps << { type: :app, definition: definition }
|
||||
end
|
||||
end
|
||||
|
||||
class MenuBuilder
|
||||
|
||||
@@ -71,6 +71,7 @@ module BBS
|
||||
when :body then run_body(step)
|
||||
when :output then run_output(step)
|
||||
when :tui then run_tui(step)
|
||||
when :app then run_app(step)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -349,6 +350,15 @@ module BBS
|
||||
:halt
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def strip_md(text)
|
||||
|
||||
76
lib/bbs/focus_manager.rb
Normal file
76
lib/bbs/focus_manager.rb
Normal 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
|
||||
@@ -80,6 +80,77 @@ module BBS
|
||||
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+.
|
||||
def diff(from)
|
||||
out = +""
|
||||
|
||||
318
lib/bbs/menu.rb
Normal file
318
lib/bbs/menu.rb
Normal 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
|
||||
@@ -12,13 +12,19 @@ module BBS
|
||||
server = TCPServer.new('0.0.0.0', @port)
|
||||
puts "BBS listening on port #{@port} — connect with: telnet localhost #{@port}"
|
||||
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|
|
||||
session = Session.new(c)
|
||||
begin
|
||||
session.run
|
||||
rescue => e
|
||||
nil
|
||||
rescue StandardError => e
|
||||
warn "BBS session error: #{e.class}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
||||
ensure
|
||||
BBS.config.on_session_end&.call(session)
|
||||
end
|
||||
|
||||
@@ -7,17 +7,21 @@ module BBS
|
||||
include Telnet
|
||||
|
||||
attr_reader :session_id, :term_cols, :term_rows
|
||||
attr_accessor :idle_seconds
|
||||
|
||||
def initialize(client)
|
||||
@client = client
|
||||
@session_id = SecureRandom.hex(8)
|
||||
@idle_seconds = BBS.config.idle_seconds
|
||||
end
|
||||
|
||||
def run
|
||||
negotiate
|
||||
enable_mouse if BBS.config.mouse
|
||||
flow = BBS.config.flow or raise "BBS.config.flow is not set"
|
||||
FlowRunner.new(self, @session_id, flow).run
|
||||
ensure
|
||||
disable_mouse rescue nil
|
||||
@client.close rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
88
lib/bbs/statusbar.rb
Normal file
88
lib/bbs/statusbar.rb
Normal 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
|
||||
@@ -6,40 +6,85 @@ require 'time'
|
||||
|
||||
module BBS
|
||||
class Store
|
||||
attr_reader :headers, :path
|
||||
|
||||
def initialize(path:, headers:)
|
||||
@path = path
|
||||
@headers = headers
|
||||
@mutex = Mutex.new
|
||||
@rows = nil
|
||||
end
|
||||
|
||||
def upsert(session_id:, **fields)
|
||||
@mutex.synchronize do
|
||||
ensure_file!
|
||||
rows = CSV.read(@path, headers: true)
|
||||
row = rows.find { |r| r['session_id'] == session_id }
|
||||
load_rows!
|
||||
row = @rows.find { |r| r['session_id'] == session_id }
|
||||
|
||||
if row
|
||||
fields.each { |k, v| row[k.to_s] = v }
|
||||
fields.each { |k, v| row[k.to_s] = v.to_s }
|
||||
flush!
|
||||
else
|
||||
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]
|
||||
@rows << build_row(session_id, fields)
|
||||
append_row(@rows.last)
|
||||
end
|
||||
end
|
||||
rows << CSV::Row.new(@headers, values)
|
||||
self
|
||||
end
|
||||
|
||||
CSV.open(@path, 'w') do |csv|
|
||||
csv << @headers
|
||||
rows.each { |r| csv << r.fields }
|
||||
def find(session_id)
|
||||
@mutex.synchronize do
|
||||
load_rows!
|
||||
row = @rows.find { |r| r['session_id'] == session_id }
|
||||
row ? row.to_h : nil
|
||||
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
|
||||
|
||||
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!
|
||||
FileUtils.mkdir_p(File.dirname(@path))
|
||||
return if File.exist?(@path)
|
||||
|
||||
@@ -14,6 +14,8 @@ module BBS
|
||||
SGA_OPT = 3
|
||||
NAWS_OPT = 31
|
||||
|
||||
MouseEvent = Struct.new(:kind, :button, :x, :y, :mods, keyword_init: true)
|
||||
|
||||
def negotiate
|
||||
@term_cols = 80
|
||||
@term_rows = 24
|
||||
@@ -23,9 +25,22 @@ module BBS
|
||||
send_raw [IAC, DO, NAWS_OPT].pack('C*')
|
||||
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
|
||||
raw = @client.read(1)
|
||||
raw = read_byte(timeout)
|
||||
return :idle if raw == :timeout
|
||||
return nil if raw.nil?
|
||||
byte = raw.ord
|
||||
|
||||
@@ -36,14 +51,23 @@ module BBS
|
||||
|
||||
case byte
|
||||
when 13, 10 then return :enter
|
||||
when 9 then return :tab
|
||||
when 27 then return read_escape_sequence
|
||||
when 8, 127 then return :backspace
|
||||
when 3 then return :interrupt
|
||||
when 1..26 then return :"ctrl_#{(byte + 96).chr}"
|
||||
when 32..126 then return raw
|
||||
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
|
||||
line = +""
|
||||
last_cr = false
|
||||
@@ -95,20 +119,122 @@ module BBS
|
||||
|
||||
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
|
||||
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)
|
||||
return :escape if b2.nil?
|
||||
|
||||
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 'B' then :down
|
||||
when 'C' then :right
|
||||
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
|
||||
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
|
||||
|
||||
def absorb_iac
|
||||
|
||||
128
lib/bbs/theme.rb
Normal file
128
lib/bbs/theme.rb
Normal 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
|
||||
@@ -42,6 +42,8 @@ module BBS
|
||||
def render(&block) = @render_block = block
|
||||
def key(k, &block) = (@key_bindings[k] = 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)
|
||||
coerce = ->(value) { value.is_a?(Proc) ? value : -> { value } }
|
||||
@@ -59,16 +61,34 @@ module BBS
|
||||
when :list
|
||||
window = coerce.(opts.fetch(:window))
|
||||
key(:up) do
|
||||
return if @item_selection <= 0
|
||||
next if @item_selection <= 0
|
||||
@item_selection -= 1
|
||||
@scroll = @item_selection if @item_selection < @scroll
|
||||
end
|
||||
key(:down) do
|
||||
return if @item_selection >= @items.size - 1
|
||||
next if @item_selection >= @items.size - 1
|
||||
@item_selection += 1
|
||||
window_size = instance_exec(&window)
|
||||
@scroll = @item_selection - window_size + 1 if @item_selection >= @scroll + window_size
|
||||
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
|
||||
content = opts.fetch(:content)
|
||||
@@ -78,6 +98,15 @@ module BBS
|
||||
max = [instance_exec(&content).size - instance_exec(&window), 0].max
|
||||
@scroll = [@scroll + 1, max].min
|
||||
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
|
||||
|
||||
@@ -15,10 +15,15 @@ module BBS
|
||||
context.instance_eval(&@tui.init_block) if @tui.init_block
|
||||
context.go(@tui.start_page)
|
||||
|
||||
idle = BBS.config.idle_seconds
|
||||
loop do
|
||||
context.do_render
|
||||
key = @session.readkey
|
||||
key = @session.readkey(timeout: idle)
|
||||
break if key.nil?
|
||||
if key == :idle
|
||||
break if context.dispatch_key(:idle) == :halt
|
||||
next
|
||||
end
|
||||
break if context.dispatch_key(key) == :halt
|
||||
end
|
||||
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
||||
@@ -80,14 +85,27 @@ module BBS
|
||||
|
||||
def dispatch_key(key)
|
||||
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 (printable_handler = page&.key_bindings&.[](:printable))
|
||||
if (printable_handler = page.key_bindings[:printable])
|
||||
return instance_exec(key, &printable_handler)
|
||||
end
|
||||
end
|
||||
|
||||
if handler.nil? && (any_handler = page.key_bindings[:any])
|
||||
return instance_exec(key, &any_handler)
|
||||
end
|
||||
|
||||
return nil unless handler
|
||||
instance_eval(&handler)
|
||||
end
|
||||
|
||||
82
lib/bbs/widget.rb
Normal file
82
lib/bbs/widget.rb
Normal 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
591
lib/bbs/widgets.rb
Normal 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
86
lib/bbs/window.rb
Normal 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
|
||||
Reference in New Issue
Block a user