new windows types
This commit is contained in:
@@ -20,6 +20,12 @@ require_relative 'bbs/focus_manager'
|
|||||||
require_relative 'bbs/menu'
|
require_relative 'bbs/menu'
|
||||||
require_relative 'bbs/statusbar'
|
require_relative 'bbs/statusbar'
|
||||||
require_relative 'bbs/dialogs'
|
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/application'
|
||||||
require_relative 'bbs/tui'
|
require_relative 'bbs/tui'
|
||||||
require_relative 'bbs/tui_runner'
|
require_relative 'bbs/tui_runner'
|
||||||
|
|||||||
@@ -125,14 +125,11 @@ module BBS
|
|||||||
@running = false
|
@running = false
|
||||||
end
|
end
|
||||||
|
|
||||||
# Periodic tick: notify on_tick callback and any window that defines
|
# Periodic tick: notify on_tick callback and let each open window pull
|
||||||
# a public @tick method (used by chat for incoming messages).
|
# any asynchronous updates by overriding BBS::Window#tick.
|
||||||
def tick!
|
def tick!
|
||||||
@on_tick&.call(self)
|
@on_tick&.call(self)
|
||||||
@windows.each do |w|
|
@windows.each { |w| w.tick(self) }
|
||||||
poll = w.instance_variable_get(:@chat_poll)
|
|
||||||
poll&.call
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def stop! = (@running = false)
|
def stop! = (@running = false)
|
||||||
|
|||||||
146
lib/bbs/markdown.rb
Normal file
146
lib/bbs/markdown.rb
Normal 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
|
||||||
@@ -84,5 +84,9 @@ module BBS
|
|||||||
@on_close&.call(self)
|
@on_close&.call(self)
|
||||||
:close_window
|
:close_window
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
98
lib/bbs/windows/button_bar.rb
Normal file
98
lib/bbs/windows/button_bar.rb
Normal 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
216
lib/bbs/windows/form.rb
Normal 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
212
lib/bbs/windows/info.rb
Normal 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
|
||||||
128
lib/bbs/windows/master_detail.rb
Normal file
128
lib/bbs/windows/master_detail.rb
Normal 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
106
lib/bbs/windows/stream.rb
Normal 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
|
||||||
Reference in New Issue
Block a user