new framework
This commit is contained in:
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)
|
||||
@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]
|
||||
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)
|
||||
|
||||
@@ -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_<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?
|
||||
|
||||
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+<char>
|
||||
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?
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
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