From 953498a62dd74eed41f4c54fd2b5970ef648c85f Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Tue, 12 May 2026 22:15:07 +0200 Subject: [PATCH] new framework --- README.md | 269 +++++++++--------- bbs.gemspec | 2 +- lib/bbs.rb | 13 +- lib/bbs/application.rb | 338 ++++++++++++++++++++++ lib/bbs/config.rb | 9 +- lib/bbs/container.rb | 66 +++++ lib/bbs/dialogs.rb | 114 ++++++++ lib/bbs/event.rb | 29 ++ lib/bbs/flow.rb | 4 + lib/bbs/flow_runner.rb | 10 + lib/bbs/focus_manager.rb | 76 +++++ lib/bbs/frame_buffer.rb | 71 +++++ lib/bbs/menu.rb | 318 +++++++++++++++++++++ lib/bbs/server.rb | 12 +- lib/bbs/session.rb | 8 +- lib/bbs/statusbar.rb | 88 ++++++ lib/bbs/store.rb | 77 +++-- lib/bbs/telnet.rb | 140 +++++++++- lib/bbs/theme.rb | 128 +++++++++ lib/bbs/tui.rb | 33 ++- lib/bbs/tui_runner.rb | 26 +- lib/bbs/widget.rb | 82 ++++++ lib/bbs/widgets.rb | 591 +++++++++++++++++++++++++++++++++++++++ lib/bbs/window.rb | 86 ++++++ 24 files changed, 2410 insertions(+), 180 deletions(-) create mode 100644 lib/bbs/application.rb create mode 100644 lib/bbs/container.rb create mode 100644 lib/bbs/dialogs.rb create mode 100644 lib/bbs/event.rb create mode 100644 lib/bbs/focus_manager.rb create mode 100644 lib/bbs/menu.rb create mode 100644 lib/bbs/statusbar.rb create mode 100644 lib/bbs/theme.rb create mode 100644 lib/bbs/widget.rb create mode 100644 lib/bbs/widgets.rb create mode 100644 lib/bbs/window.rb diff --git a/README.md b/README.md index 6b0046c..18d99cd 100644 --- a/README.md +++ b/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.flow = BBS::Flow.define do - screen :welcome - ask :name, prompt: "What is your name?" - say "Hello, %{name}!", style: :success - persist :name, store: sessions + c.mouse = true + c.idle_seconds = 600 + c.flow = BBS::Flow.define do + 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 | diff --git a/bbs.gemspec b/bbs.gemspec index 1540ad8..39d09f7 100644 --- a/bbs.gemspec +++ b/bbs.gemspec @@ -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'] diff --git a/lib/bbs.rb b/lib/bbs.rb index b51ce5f..7e065bd 100644 --- a/lib/bbs.rb +++ b/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 diff --git a/lib/bbs/application.rb b/lib/bbs/application.rb new file mode 100644 index 0000000..c0780ad --- /dev/null +++ b/lib/bbs/application.rb @@ -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 diff --git a/lib/bbs/config.rb b/lib/bbs/config.rb index 29a1b26..9dc90dc 100644 --- a/lib/bbs/config.rb +++ b/lib/bbs/config.rb @@ -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 diff --git a/lib/bbs/container.rb b/lib/bbs/container.rb new file mode 100644 index 0000000..2a1dca6 --- /dev/null +++ b/lib/bbs/container.rb @@ -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 diff --git a/lib/bbs/dialogs.rb b/lib/bbs/dialogs.rb new file mode 100644 index 0000000..f8f831c --- /dev/null +++ b/lib/bbs/dialogs.rb @@ -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 diff --git a/lib/bbs/event.rb b/lib/bbs/event.rb new file mode 100644 index 0000000..3c1eae2 --- /dev/null +++ b/lib/bbs/event.rb @@ -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 diff --git a/lib/bbs/flow.rb b/lib/bbs/flow.rb index 440e64f..8525923 100644 --- a/lib/bbs/flow.rb +++ b/lib/bbs/flow.rb @@ -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 diff --git a/lib/bbs/flow_runner.rb b/lib/bbs/flow_runner.rb index 07feb12..43f890b 100644 --- a/lib/bbs/flow_runner.rb +++ b/lib/bbs/flow_runner.rb @@ -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) diff --git a/lib/bbs/focus_manager.rb b/lib/bbs/focus_manager.rb new file mode 100644 index 0000000..b236503 --- /dev/null +++ b/lib/bbs/focus_manager.rb @@ -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 diff --git a/lib/bbs/frame_buffer.rb b/lib/bbs/frame_buffer.rb index 6523c60..4d517df 100644 --- a/lib/bbs/frame_buffer.rb +++ b/lib/bbs/frame_buffer.rb @@ -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 = +"" diff --git a/lib/bbs/menu.rb b/lib/bbs/menu.rb new file mode 100644 index 0000000..463bc1d --- /dev/null +++ b/lib/bbs/menu.rb @@ -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 diff --git a/lib/bbs/server.rb b/lib/bbs/server.rb index 9bd7025..5ffd2a9 100644 --- a/lib/bbs/server.rb +++ b/lib/bbs/server.rb @@ -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 diff --git a/lib/bbs/session.rb b/lib/bbs/session.rb index 57b2b19..96b65f3 100644 --- a/lib/bbs/session.rb +++ b/lib/bbs/session.rb @@ -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) + @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 diff --git a/lib/bbs/statusbar.rb b/lib/bbs/statusbar.rb new file mode 100644 index 0000000..84e37f2 --- /dev/null +++ b/lib/bbs/statusbar.rb @@ -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 diff --git a/lib/bbs/store.rb b/lib/bbs/store.rb index e9b140e..759fa0a 100644 --- a/lib/bbs/store.rb +++ b/lib/bbs/store.rb @@ -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] - end - end - rows << CSV::Row.new(@headers, values) + @rows << build_row(session_id, fields) + append_row(@rows.last) end + end + self + end - CSV.open(@path, 'w') do |csv| - csv << @headers - rows.each { |r| csv << r.fields } - end + 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) diff --git a/lib/bbs/telnet.rb b/lib/bbs/telnet.rb index 84eaf3a..62d5858 100644 --- a/lib/bbs/telnet.rb +++ b/lib/bbs/telnet.rb @@ -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,22 +119,124 @@ module BBS private + # Reads a CSI / SS3 sequence after ESC. Returns a Symbol (key name), + # a Symbol :"alt_" (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? - b2 = @client.read(1) - return :escape if b2.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+ + if b1.ord.between?(32, 126) + :"alt_#{b1.downcase}" + else + :escape + end + end + end - case b2 + 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 - else :escape + 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 cmd = @client.read(1) return if cmd.nil? diff --git a/lib/bbs/theme.rb b/lib/bbs/theme.rb new file mode 100644 index 0000000..7b8c3fa --- /dev/null +++ b/lib/bbs/theme.rb @@ -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 diff --git a/lib/bbs/tui.rb b/lib/bbs/tui.rb index d4ebac6..113455b 100644 --- a/lib/bbs/tui.rb +++ b/lib/bbs/tui.rb @@ -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 diff --git a/lib/bbs/tui_runner.rb b/lib/bbs/tui_runner.rb index 0c7f623..ab85113 100644 --- a/lib/bbs/tui_runner.rb +++ b/lib/bbs/tui_runner.rb @@ -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 @@ -79,15 +84,28 @@ module BBS end def dispatch_key(key) - page = @tui.pages[@current_page] - handler = page&.key_bindings&.[](key) + page = @tui.pages[@current_page] + 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 diff --git a/lib/bbs/widget.rb b/lib/bbs/widget.rb new file mode 100644 index 0000000..8871e71 --- /dev/null +++ b/lib/bbs/widget.rb @@ -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 diff --git a/lib/bbs/widgets.rb b/lib/bbs/widgets.rb new file mode 100644 index 0000000..74dbaf4 --- /dev/null +++ b/lib/bbs/widgets.rb @@ -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 diff --git a/lib/bbs/window.rb b/lib/bbs/window.rb new file mode 100644 index 0000000..fb8a631 --- /dev/null +++ b/lib/bbs/window.rb @@ -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