new windows types

This commit is contained in:
2026-05-13 20:35:46 +02:00
parent 26f4d2a4ee
commit 2454521f19
9 changed files with 919 additions and 6 deletions

View File

@@ -20,6 +20,12 @@ require_relative 'bbs/focus_manager'
require_relative 'bbs/menu'
require_relative 'bbs/statusbar'
require_relative 'bbs/dialogs'
require_relative 'bbs/markdown'
require_relative 'bbs/windows/button_bar'
require_relative 'bbs/windows/info'
require_relative 'bbs/windows/form'
require_relative 'bbs/windows/master_detail'
require_relative 'bbs/windows/stream'
require_relative 'bbs/application'
require_relative 'bbs/tui'
require_relative 'bbs/tui_runner'

View File

@@ -125,14 +125,11 @@ module BBS
@running = false
end
# Periodic tick: notify on_tick callback and any window that defines
# a public @tick method (used by chat for incoming messages).
# Periodic tick: notify on_tick callback and let each open window pull
# any asynchronous updates by overriding BBS::Window#tick.
def tick!
@on_tick&.call(self)
@windows.each do |w|
poll = w.instance_variable_get(:@chat_poll)
poll&.call
end
@windows.each { |w| w.tick(self) }
end
def stop! = (@running = false)

146
lib/bbs/markdown.rb Normal file
View File

@@ -0,0 +1,146 @@
# frozen_string_literal: true
require_relative 'frame_buffer'
module BBS
# Markdown → ANSI line array renderer.
#
# BBS::Markdown.to_lines(text, width:, theme:)
#
# Returns an array of strings (one per terminal line) with embedded ANSI SGR
# codes — suitable for BBS::Widgets::ScrollView. Each inline span ends with
# the theme :text SGR so that trailing text keeps the right background.
module Markdown
module_function
def to_lines(text, width:, theme:)
styles = {
base: theme[:text],
h1: theme[:text_bright],
h2: theme[:accent],
h3: theme[:label],
bold: theme[:text_bright],
italic: theme[:text_dim],
code: theme[:label],
link: theme[:accent],
quote: theme[:text_dim],
bullet: theme[:accent],
rule: theme[:label],
}
result = []
paragraph = []
in_code = false
base = styles[:base]
flush_paragraph = lambda do
next if paragraph.empty?
joined = paragraph.join(' ').gsub(/\s+/, ' ').strip
FrameBuffer.wrap_ansi(inline(joined, styles, base), width).each { |l| result << l }
paragraph.clear
end
text.to_s.split(/\r?\n/).each do |line|
if line.lstrip.start_with?('```')
flush_paragraph.call
in_code = !in_code
next
end
if in_code
flush_paragraph.call
clipped = FrameBuffer.clip_ansi(line, width)
result << "#{styles[:code]}#{clipped}#{base}"
next
end
if line.strip.empty?
flush_paragraph.call
result << ''
next
end
if (m = line.match(/\A([#]{1,6})\s+(.+?)\s*#*\s*\z/))
level = m[1].length
heading = m[2].strip
flush_paragraph.call
style = level == 1 ? styles[:h1] : (level == 2 ? styles[:h2] : styles[:h3])
FrameBuffer.wrap_ansi(inline(heading, styles, base), width).each do |l|
result << "#{style}#{l}#{base}"
end
next
end
if line.match?(/\A\s*([-*_])\1{2,}\s*\z/)
flush_paragraph.call
result << "#{styles[:rule]}#{'─' * width}#{base}"
next
end
if (m = line.match(/\A\s*>\s?(.*)/))
content = m[1]
flush_paragraph.call
inner_w = [width - 2, 1].max
FrameBuffer.wrap_ansi(inline(content, styles, base), inner_w).each do |l|
result << "#{styles[:quote]}#{base}#{l}"
end
next
end
if (m = line.match(/\A(\s*)[-*+]\s+(.+)/))
indent = m[1].length
body = m[2]
flush_paragraph.call
prefix_w = indent + 2
inner_w = [width - prefix_w, 1].max
wrapped = FrameBuffer.wrap_ansi(inline(body, styles, base), inner_w)
wrapped.each_with_index do |l, i|
pad = ' ' * indent
result << if i.zero?
"#{pad}#{styles[:bullet]}#{base} #{l}"
else
"#{' ' * prefix_w}#{l}"
end
end
next
end
if (m = line.match(/\A(\s*)(\d+)\.\s+(.+)/))
indent = m[1].length
num = m[2]
body = m[3]
flush_paragraph.call
prefix = "#{' ' * indent}#{num}. "
inner_w = [width - prefix.length, 1].max
wrapped = FrameBuffer.wrap_ansi(inline(body, styles, base), inner_w)
wrapped.each_with_index do |l, i|
result << if i.zero?
"#{' ' * indent}#{styles[:bullet]}#{num}.#{base} #{l}"
else
"#{' ' * prefix.length}#{l}"
end
end
next
end
paragraph << line.strip
end
flush_paragraph.call
result
end
def inline(text, styles, base_sgr)
s = text.to_s
s = s.gsub(/\[([^\]]+)\]\(([^)]+)\)/) { "#{styles[:link]}#{::Regexp.last_match(1)}#{base_sgr}" }
s = s.gsub(/`([^`]+)`/) { "#{styles[:code]}#{::Regexp.last_match(1)}#{base_sgr}" }
s = s.gsub(/\*\*([^*]+)\*\*/) { "#{styles[:bold]}#{::Regexp.last_match(1)}#{base_sgr}" }
s = s.gsub(/(?<![A-Za-z0-9*])\*([^*\s][^*]*?[^*\s]|\S)\*(?![A-Za-z0-9*])/) do
"#{styles[:italic]}#{::Regexp.last_match(1)}#{base_sgr}"
end
s = s.gsub(/(?<![A-Za-z0-9_])_([^_\s][^_]*?[^_\s]|\S)_(?![A-Za-z0-9_])/) do
"#{styles[:italic]}#{::Regexp.last_match(1)}#{base_sgr}"
end
s.gsub(/\\([\\`*_{}\[\]()#+\-.!>])/) { ::Regexp.last_match(1) }
end
end
end

View File

@@ -84,5 +84,9 @@ module BBS
@on_close&.call(self)
:close_window
end
# Called once per Application idle tick (~1s). Override in subclasses that
# need to poll for asynchronous updates (e.g. BBS::Windows::Stream).
def tick(app); end
end
end

View File

@@ -0,0 +1,98 @@
# frozen_string_literal: true
require_relative '../widgets'
module BBS
module Windows
# Lays out a row of buttons at the bottom of a window.
#
# Button descriptor:
# { key:, label:, on_click:, primary:, cancel:, align:, keep_open: }
#
# `on_click` receives the BBS::Window as its only argument and may return
# :keep to keep the window open. Any other return value (or nil) causes
# the window to be closed after the handler.
#
# `primary: true` gets initial focus on the button.
# `cancel: true` is informational only — ESC closes the window through the
# standard Window#close_key path. If you also want a cancel-specific side
# effect on ESC, set `window.on_close = ...` manually.
# `align: :left | :right | :center` — default is :center for a single
# button, :right otherwise. Left-aligned buttons stack from the left edge;
# right-aligned buttons stack flush against the right edge in declaration
# order (so the last right-aligned button sits closest to the edge).
module ButtonBar
module_function
# Returns { widgets:, primary:, by_key: }.
def attach(window, app, descriptors)
return { widgets: [], primary: nil, by_key: {} } if descriptors.nil? || descriptors.empty?
widgets = descriptors.map do |d|
BBS::Widgets::Button.new(label: d[:label], on_click: build_handler(window, app, d))
end
default_align = descriptors.length == 1 ? :center : :right
aligns = descriptors.map { |d| d[:align] || default_align }
row_y = window.bounds.y + window.bounds.height - 2
left_x0 = window.bounds.x + 2
right_x1 = window.bounds.x + window.bounds.width - 2
win_x0 = window.bounds.x
win_w = window.bounds.width
x = left_x0
widgets.each_with_index do |w, i|
next unless aligns[i] == :left
bw = button_width(w)
w.layout(x, row_y, bw, 1)
x += bw + 1
end
right_indices = aligns.each_index.select { |i| aligns[i] == :right }
total_right = right_indices.sum { |i| button_width(widgets[i]) + 1 } - 1
total_right = [total_right, 0].max
x = right_x1 - total_right + 1
right_indices.each do |i|
bw = button_width(widgets[i])
widgets[i].layout(x, row_y, bw, 1)
x += bw + 1
end
center_indices = aligns.each_index.select { |i| aligns[i] == :center }
total_center = center_indices.sum { |i| button_width(widgets[i]) + 1 } - 1
total_center = [total_center, 0].max
x = win_x0 + (win_w - total_center) / 2
center_indices.each do |i|
bw = button_width(widgets[i])
widgets[i].layout(x, row_y, bw, 1)
x += bw + 1
end
widgets.each { |w| window.add(w) }
primary_idx = descriptors.index { |d| d[:primary] }
primary = primary_idx ? widgets[primary_idx] : nil
by_key = {}
descriptors.each_with_index { |d, i| by_key[d[:key]] = widgets[i] if d[:key] }
{ widgets: widgets, primary: primary, by_key: by_key }
end
def button_width(button)
"[ #{button.display_label} ]".length
end
def build_handler(window, app, descriptor)
on_click = descriptor[:on_click]
keep_open = descriptor[:keep_open]
lambda do |_btn|
result = on_click&.call(window)
app.close_window(window) unless keep_open || result == :keep
:handled
end
end
end
end
end

216
lib/bbs/windows/form.rb Normal file
View File

@@ -0,0 +1,216 @@
# frozen_string_literal: true
require_relative '../window'
require_relative '../widgets'
require_relative 'button_bar'
module BBS
module Windows
# A form dialog with labeled input rows, optional textarea, and a status
# line for inline validation feedback.
#
# BBS::Windows::Form.open(app,
# title: " Profile — #{u} ", width: 64,
# fields: [
# { key: :signature, label: 'Signature', value: '...' },
# { key: :location, label: 'Location', value: '...' },
# { key: :notes, label: 'Notes', value: '...' },
# ],
# on_submit: ->(values, form) {
# service.update(values); :close
# }
# )
#
# Field descriptor:
# { key:, label:, value:, type:, max_length:, placeholder:, password:, rows: }
#
# Field types:
# :input (default) — single-line TextInput; label on left.
# :textarea — multi-line TextArea; full width, label ignored.
# :checkbox — Checkbox; label is part of the widget.
#
# on_submit receives `(values_hash, self)`. Return :keep to keep the
# window open (e.g., after `form.error('...')`); any other value closes.
#
# `hint:` shows an info line above the fields (no label/value, no input).
class Form < Window
DEFAULT_PADDING = { sides: 2, top: 1, bottom: 2 }.freeze
def self.open(app, **kw)
new(app: app, **kw).tap(&:attach)
end
attr_reader :status_label
def initialize(app:, title:, fields:, on_submit:,
hint: nil, width: 60, height: nil,
submit_label: '&Save', cancel_label: '&Cancel',
padding: nil, on_cancel: nil)
super(title: title, modal: true)
self.theme = app.theme
@app = app
@fields = fields
@hint = hint
@on_submit = on_submit
@on_cancel = on_cancel
@submit_label = submit_label
@cancel_label = cancel_label
@width = width
@height = height
@padding = DEFAULT_PADDING.merge(padding || {})
@widgets = {}
build
end
def attach
@app.open_window(self)
reset_focus
first = @fields.first
focus_manager.focus(@widgets[first[:key]]) if first && @widgets[first[:key]]
self
end
def values
@fields.each_with_object({}) { |f, h| h[f[:key]] = field_value(f) }
end
def error(text)
return unless @status_label
@status_label.text = text.to_s
@status_label.style_key = :error
end
def info(text)
return unless @status_label
@status_label.text = text.to_s
@status_label.style_key = :input_label
end
def clear_status
@status_label&.text = ''
end
private
def field_value(field)
widget = @widgets[field[:key]]
return nil unless widget
return widget.checked if field[:type] == :checkbox
widget.value
end
def build
w = [@width, @app.cols - 4].min
heights = @fields.map { |f| natural_field_height(f) }
hint_block = @hint ? 2 : 0 # hint + spacer
spacing = [@fields.length - 1, 0].max
status_block = 1
buttons_block = 2 # spacer + button row
h_auto = @padding[:top] + hint_block + heights.sum + spacing +
1 + status_block + buttons_block + @padding[:bottom]
h = @height || h_auto
h = [h, @app.rows - 4].min
# If explicit height + exactly one textarea without :rows, expand it.
if @height
ta_indices = @fields.each_index.select do |i|
@fields[i][:type] == :textarea && !@fields[i][:rows]
end
if ta_indices.length == 1
idx = ta_indices.first
fixed_rest = heights.each_with_index.reject { |_, i| i == idx }.sum { |row, _| row }
available = h - @padding[:top] - @padding[:bottom] - hint_block -
spacing - 1 - status_block - buttons_block
heights[idx] = [available - fixed_rest, 1].max
end
end
x = (@app.cols - w) / 2 + 1
y = (@app.rows - h) / 2 + 1
layout(x, y, w, h)
inner_x = bounds.x + @padding[:sides]
inner_w = w - @padding[:sides] * 2
row_y = bounds.y + @padding[:top]
if @hint
hint = BBS::Widgets::Label.new(text: @hint, style_key: :input_label)
hint.layout(inner_x, row_y, inner_w, 1)
add(hint)
row_y += 2
end
label_w = compute_label_width
@fields.each_with_index do |f, i|
place_field(f, heights[i], inner_x, inner_w, row_y, label_w)
row_y += heights[i] + 1
end
@status_label = BBS::Widgets::Label.new(text: '', style_key: :error)
@status_label.layout(inner_x, bounds.y + h - 3, inner_w, 1)
add(@status_label)
ButtonBar.attach(self, @app, [
{ key: :submit, label: @submit_label, primary: true,
on_click: ->(_w) { @on_submit ? @on_submit.call(values, self) : :close } },
{ key: :cancel, label: @cancel_label, cancel: true,
on_click: ->(_w) { @on_cancel&.call(self) } }
])
end
def natural_field_height(field)
case field[:type]
when :textarea then field[:rows] || 6
else 1
end
end
def compute_label_width
with_labels = @fields.select { |f| f[:label] && f[:type] != :textarea && f[:type] != :checkbox }
return 0 if with_labels.empty?
max = with_labels.map { |f| f[:label].to_s.length }.max
[max + 1, 16].min.clamp(8, 16)
end
def place_field(field, height, inner_x, inner_w, y, label_w)
case field[:type]
when :textarea
widget = BBS::Widgets::TextArea.new(
value: field[:value].to_s,
max_length: field[:max_length] || 8_000
)
widget.layout(inner_x, y, inner_w, height)
when :checkbox
widget = BBS::Widgets::Checkbox.new(
label: field[:label].to_s,
checked: field[:value] ? true : false
)
widget.layout(inner_x, y, inner_w, 1)
else
if field[:label]
label = BBS::Widgets::Label.new(text: field[:label].to_s, style_key: :input_label)
label.layout(inner_x, y, label_w, 1)
add(label)
input_x = inner_x + label_w + 2
input_w = inner_w - label_w - 2
else
input_x = inner_x
input_w = inner_w
end
widget = BBS::Widgets::TextInput.new(
value: field[:value].to_s,
placeholder: field[:placeholder].to_s,
max_length: field[:max_length] || 256,
password: field[:password] ? true : false
)
widget.layout(input_x, y, input_w, 1)
end
add(widget)
@widgets[field[:key]] = widget
end
end
end
end

212
lib/bbs/windows/info.rb Normal file
View File

@@ -0,0 +1,212 @@
# frozen_string_literal: true
require_relative '../window'
require_relative '../widgets'
require_relative '../frame_buffer'
require_relative 'button_bar'
module BBS
module Windows
# A centered modal dialog for messages, splash screens, info displays.
#
# BBS::Windows::Info.open(app,
# title: ' Welcome ',
# width: 70, height: 17,
# body: [
# { text: banner_line, style: :accent },
# "plain label with optional \e[1;33mraw ANSI\e[0m",
# :spacer,
# :rule,
# { kv: [['Online users', '5'], ['Ruby', RUBY_VERSION]] },
# ],
# buttons: [{ label: '&Continue', primary: true }]
# )
#
# When `scroll: true`, body items are flattened into a single ScrollView
# filling the inner area. Use this for long, paginated content lists.
#
# Body item types:
# String → label with :window_text style
# :spacer | '' | nil → blank row
# :rule → horizontal ─── line
# { text:, style:, align: } → styled label
# { kv: [[k, v], ...], label_width: } → key/value rows
# { widget:, height: } → arbitrary widget (non-scroll only)
# BBS::Widget → as-is, 1 row (non-scroll only)
#
# Default buttons: a single centered `[ &Close ]` if `buttons:` is nil.
class Info < Window
DEFAULT_BUTTON = [{ label: '&Close', cancel: true }].freeze
DEFAULT_PADDING = { sides: 2, top: 1, bottom: 2 }.freeze
def self.open(app, **kw)
new(app: app, **kw).tap(&:attach)
end
attr_reader :scroll_view
def initialize(app:, title:, body: [], buttons: nil, width: 60, height: nil,
scroll: false, padding: nil)
super(title: title, modal: true)
self.theme = app.theme
@app = app
@body = body
@buttons = buttons.nil? ? DEFAULT_BUTTON.dup : Array(buttons)
@scroll = scroll
@width = width
@height = height
@padding = DEFAULT_PADDING.merge(padding || {})
build
end
def attach
@app.open_window(self)
reset_focus
focus_manager.focus(@primary_button) if @primary_button
self
end
private
def build
button_h = @buttons.any? ? 2 : 0
body_rows = @scroll ? 10 : @body.sum { |it| item_rows(it) }
h = @height || (@padding[:top] + body_rows + button_h + @padding[:bottom])
w = [@width, @app.cols - 4].min
h = [h, @app.rows - 4].min
x = (@app.cols - w) / 2 + 1
y = (@app.rows - h) / 2 + 1
layout(x, y, w, h)
inner_w = w - @padding[:sides] * 2
inner_x = bounds.x + @padding[:sides]
inner_y = bounds.y + @padding[:top]
body_h = h - @padding[:top] - @padding[:bottom] - button_h
body_h = [body_h, 0].max
if @scroll
place_scroll_body(inner_x, inner_y, inner_w, body_h)
else
place_static_body(inner_x, inner_y, inner_w)
end
@primary_button = ButtonBar.attach(self, @app, @buttons)[:primary]
end
def item_rows(item)
case item
when :spacer, :rule, '', nil then 1
when String then 1
when Hash
return item[:kv].length if item[:kv]
return item[:height] || 1 if item[:widget]
1
when BBS::Widget
h = item.bounds.height
h.positive? ? h : 1
else 1
end
end
def place_scroll_body(x, y, w, h)
lines = []
@body.each { |item| append_scroll_lines(lines, item, w) }
@scroll_view = BBS::Widgets::ScrollView.new(lines: lines)
@scroll_view.layout(x, y, w, h)
add(@scroll_view)
end
def append_scroll_lines(lines, item, width)
base = theme[:text]
case item
when :spacer, '', nil
lines << ''
when :rule
lines << "#{theme[:label]}#{'─' * width}#{base}"
when String
lines << item
when Hash
if item[:kv]
kw = item[:label_width] || default_kv_label_width(item[:kv])
item[:kv].each do |k, v|
lines << "#{theme[:input_label]}#{k.to_s.ljust(kw)}#{base} #{v}"
end
elsif item[:text]
sgr = item[:style] ? theme[item[:style]] : base
lines << "#{sgr}#{item[:text]}#{base}"
end
when BBS::Widget
raise ArgumentError, 'scroll mode does not support widget body items'
end
end
def place_static_body(x, y, w)
row = 0
@body.each { |item| row += place_static_item(item, x, y + row, w) }
end
def place_static_item(item, x, y, w)
case item
when :spacer, '', nil
1
when :rule
label = BBS::Widgets::Label.new(text: '─' * w, style_key: :label)
label.layout(x, y, w, 1)
add(label)
1
when String
label = BBS::Widgets::Label.new(text: item, style_key: :window_text)
label.layout(x, y, w, 1)
add(label)
1
when Hash
place_static_hash(item, x, y, w)
when BBS::Widget
h = item.bounds.height.positive? ? item.bounds.height : 1
item.layout(x, y, w, h)
add(item)
h
else
1
end
end
def place_static_hash(item, x, y, w)
if item[:kv]
kw = item[:label_width] || default_kv_label_width(item[:kv])
item[:kv].each_with_index do |(k, v), i|
label = BBS::Widgets::Label.new(text: k.to_s.ljust(kw), style_key: :input_label)
label.layout(x, y + i, kw, 1)
add(label)
value = BBS::Widgets::Label.new(text: v.to_s, style_key: :window_text)
value.layout(x + kw + 2, y + i, w - kw - 2, 1)
add(value)
end
item[:kv].length
elsif item[:widget]
wd = item[:widget]
h = item[:height] || (wd.bounds.height.positive? ? wd.bounds.height : 1)
wd.layout(x, y, w, h)
add(wd)
h
elsif item[:text]
label = BBS::Widgets::Label.new(
text: item[:text],
style_key: item[:style] || :window_text,
align: item[:align] || :left
)
label.layout(x, y, w, 1)
add(label)
1
else
1
end
end
def default_kv_label_width(pairs)
max = pairs.map { |k, _| k.to_s.length }.max || 0
[max + 1, 16].min.clamp(8, 16)
end
end
end
end

View File

@@ -0,0 +1,128 @@
# frozen_string_literal: true
require_relative '../window'
require_relative '../widgets'
require_relative 'button_bar'
module BBS
module Windows
# A two-pane browser: left ListBox of items, right ScrollView of details.
# Selecting an item in the list reloads the detail pane.
#
# BBS::Windows::MasterDetail.open(app,
# title: ' Game Catalog ',
# items: services[:games].all,
# item_label: ->(g) { g.title },
# list_width: 30,
# render_detail: ->(game, width) { GameCard.lines(game, width) },
# actions: [
# { label: '&New', on_click: ->(win) { ... } }
# ],
# bottom_meta: ->(item) { "...optional 1-line meta..." }
# )
#
# `render_detail` is called with `(item, width)` whenever the selection
# changes and on first paint. It must return an Array of strings (each one
# is one rendered line; ANSI is supported). Returning nil shows nothing.
#
# `bottom_meta` is optional; if given, it's a `->(item) { text }` lambda
# for a single info line below the detail pane.
#
# `actions:` are extra buttons (right-aligned by default). A `Close` button
# is always appended on the right unless the caller suppresses it via
# `close_button: false`.
class MasterDetail < Window
def self.open(app, **kw)
new(app: app, **kw).tap(&:attach)
end
attr_reader :list_widget, :detail_view, :meta_label
def initialize(app:, title:, items:, item_label:, render_detail:,
list_width: nil, bottom_meta: nil, actions: nil,
close_button: true, close_label: '&Close',
empty_message: ' (empty)',
width: nil, height: nil)
super(title: title, modal: true)
self.theme = app.theme
@app = app
@items = items
@item_label = item_label
@render_detail = render_detail
@bottom_meta = bottom_meta
@actions = (actions || []).dup
@actions << { label: close_label, cancel: true } if close_button
@list_width = list_width
@empty_message = empty_message
@width = width
@height = height
build
end
def attach
@app.open_window(self)
reset_focus
focus_manager.focus(@list_widget) if @list_widget
reload_detail
self
end
def reload_detail
item = @list_widget&.selected_item
if item.nil?
@detail_view.lines = [@empty_message]
@detail_view.scroll = 0
@meta_label.text = '' if @meta_label
else
lines = @render_detail.call(item, @detail_view.bounds.width - 1)
@detail_view.lines = Array(lines)
@detail_view.scroll = 0
@meta_label.text = @bottom_meta.call(item) if @meta_label && @bottom_meta
end
end
private
def build
w = @width || @app.cols - 4
h = @height || @app.rows - 6
x = (@app.cols - w) / 2 + 1
y = (@app.rows - h) / 2 + 1
layout(x, y, w, h)
has_buttons = !@actions.empty?
has_meta = !@bottom_meta.nil?
panel_h = h - 4 - (has_meta ? 1 : 0) - (has_buttons ? 1 : 0)
panel_h = [panel_h, 3].max
list_w = @list_width || [(w / 3).clamp(20, 40), w - 10].min
list_x = bounds.x + 2
list_y = bounds.y + 2
@list_widget = BBS::Widgets::ListBox.new(
items: @items,
label: ->(item) { " #{@item_label.call(item)}".ljust(list_w) }
)
@list_widget.layout(list_x, list_y, list_w, panel_h)
add(@list_widget)
detail_x = list_x + list_w + 2
detail_w = bounds.width - list_w - 5
@detail_view = BBS::Widgets::ScrollView.new(lines: [])
@detail_view.layout(detail_x, list_y, detail_w, panel_h)
add(@detail_view)
if has_meta
meta_y = bounds.y + h - (has_buttons ? 3 : 2)
@meta_label = BBS::Widgets::Label.new(text: '', style_key: :input_label)
@meta_label.layout(bounds.x + 2, meta_y, bounds.width - 4, 1)
add(@meta_label)
end
@list_widget.on_select = ->(_, _) { reload_detail }
ButtonBar.attach(self, @app, @actions)
end
end
end
end

106
lib/bbs/windows/stream.rb Normal file
View File

@@ -0,0 +1,106 @@
# frozen_string_literal: true
require_relative '../window'
require_relative '../widgets'
require_relative 'button_bar'
module BBS
module Windows
# A live stream window: scrollable history + single-line input + poll.
#
# BBS::Windows::Stream.open(app,
# title: ' Live Chat ',
# initial_lines: services[:chat].history.map { |l| fmt(l) },
# placeholder: 'Type a message and press Enter…',
# on_submit: ->(text, app) {
# services[:chat].broadcast(username, text.strip)
# },
# on_poll: ->(app) {
# services[:chat].drain(app.session_id).map { |l| fmt(l) }
# }
# )
#
# `on_submit(text, app)` is called when the user presses Enter on a
# non-empty input. The input is cleared automatically afterwards unless
# the handler returns `:keep`.
#
# `on_poll(app)` is invoked from the application's idle tick (~1s). It
# should return an Array of new lines to append (or nil/[]).
class Stream < Window
def self.open(app, **kw)
new(app: app, **kw).tap(&:attach)
end
attr_reader :history, :input
def initialize(app:, title:, initial_lines: [], placeholder: '',
on_submit: nil, on_poll: nil, buffer_size: 500,
width: nil, height: nil,
close_button: true, close_label: '&Close')
super(title: title, modal: true)
self.theme = app.theme
@app = app
@initial_lines = initial_lines
@placeholder = placeholder
@on_submit = on_submit
@on_poll = on_poll
@buffer_size = buffer_size
@width = width
@height = height
@close_button = close_button
@close_label = close_label
build
end
def attach
@app.open_window(self)
reset_focus
focus_manager.focus(@input)
self
end
def tick(_app)
return unless @on_poll
new_lines = @on_poll.call(@app)
return if new_lines.nil? || new_lines.empty?
@history.lines = (@history.lines + Array(new_lines)).last(@buffer_size)
@history.scroll = [@history.lines.size - @history.bounds.height, 0].max
end
private
def build
w = @width || @app.cols - 8
h = @height || @app.rows - 6
x = (@app.cols - w) / 2 + 1
y = (@app.rows - h) / 2 + 1
layout(x, y, w, h)
inner_x = bounds.x + 2
inner_w = bounds.width - 4
@history = BBS::Widgets::ScrollView.new(lines: @initial_lines)
@history.layout(inner_x, bounds.y + 1, inner_w, h - 4)
@history.scroll = [@history.lines.size - @history.bounds.height, 0].max
add(@history)
@input = BBS::Widgets::TextInput.new(placeholder: @placeholder)
@input.on_submit = method(:handle_input_submit)
@input.layout(inner_x, bounds.y + h - 3, inner_w, 1)
add(@input)
if @close_button
ButtonBar.attach(self, @app, [{ label: @close_label, cancel: true }])
end
end
def handle_input_submit(value, _input)
text = value.to_s
return :handled if text.strip.empty?
result = @on_submit&.call(text, @app)
@input.value = '' unless result == :keep
:handled
end
end
end
end