new windows types
This commit is contained in:
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