diff --git a/lib/bbs.rb b/lib/bbs.rb index 8d6e4fc..0e79801 100644 --- a/lib/bbs.rb +++ b/lib/bbs.rb @@ -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' diff --git a/lib/bbs/application.rb b/lib/bbs/application.rb index ebeda21..1ef0f61 100644 --- a/lib/bbs/application.rb +++ b/lib/bbs/application.rb @@ -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) diff --git a/lib/bbs/markdown.rb b/lib/bbs/markdown.rb new file mode 100644 index 0000000..e3a5907 --- /dev/null +++ b/lib/bbs/markdown.rb @@ -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(/(?])/) { ::Regexp.last_match(1) } + end + end +end diff --git a/lib/bbs/window.rb b/lib/bbs/window.rb index efbe3e3..1b5a76b 100644 --- a/lib/bbs/window.rb +++ b/lib/bbs/window.rb @@ -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 diff --git a/lib/bbs/windows/button_bar.rb b/lib/bbs/windows/button_bar.rb new file mode 100644 index 0000000..f2b83fe --- /dev/null +++ b/lib/bbs/windows/button_bar.rb @@ -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 diff --git a/lib/bbs/windows/form.rb b/lib/bbs/windows/form.rb new file mode 100644 index 0000000..35e80e8 --- /dev/null +++ b/lib/bbs/windows/form.rb @@ -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 diff --git a/lib/bbs/windows/info.rb b/lib/bbs/windows/info.rb new file mode 100644 index 0000000..142f3be --- /dev/null +++ b/lib/bbs/windows/info.rb @@ -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 diff --git a/lib/bbs/windows/master_detail.rb b/lib/bbs/windows/master_detail.rb new file mode 100644 index 0000000..3b8e9ae --- /dev/null +++ b/lib/bbs/windows/master_detail.rb @@ -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 diff --git a/lib/bbs/windows/stream.rb b/lib/bbs/windows/stream.rb new file mode 100644 index 0000000..3d1811c --- /dev/null +++ b/lib/bbs/windows/stream.rb @@ -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