diff --git a/lib/bbs.rb b/lib/bbs.rb index 7ca414a..b51ce5f 100644 --- a/lib/bbs.rb +++ b/lib/bbs.rb @@ -10,7 +10,6 @@ require_relative 'bbs/renderer' require_relative 'bbs/store' require_relative 'bbs/frame_buffer' require_relative 'bbs/tui' -require_relative 'bbs/tui_helpers' require_relative 'bbs/tui_runner' require_relative 'bbs/session' require_relative 'bbs/server' diff --git a/lib/bbs/tui.rb b/lib/bbs/tui.rb index 1cbd64a..d4ebac6 100644 --- a/lib/bbs/tui.rb +++ b/lib/bbs/tui.rb @@ -25,9 +25,9 @@ module BBS end def page(name, &block) - p = Page.new(name) - p.instance_eval(&block) - @pages[name] = p + page_definition = Page.new(name) + page_definition.instance_eval(&block) + @pages[name] = page_definition end class Page @@ -44,35 +44,35 @@ module BBS def printable(&block) = (@key_bindings[:printable] = block) def nav(mode, **opts) - lift = ->(v) { v.is_a?(Proc) ? v : -> { v } } + coerce = ->(value) { value.is_a?(Proc) ? value : -> { value } } case mode when :menu - size = lift.(opts.fetch(:size)) - key(:up) { @menu_sel = (@menu_sel - 1) % instance_exec(&size) } - key(:down) { @menu_sel = (@menu_sel + 1) % instance_exec(&size) } + size = coerce.(opts.fetch(:size)) + key(:up) { @menu_selection = (@menu_selection - 1) % instance_exec(&size) } + key(:down) { @menu_selection = (@menu_selection + 1) % instance_exec(&size) } when :cycle - key(:up) { @item_sel = (@item_sel - 1) % @items.size if @items.any? } - key(:down) { @item_sel = (@item_sel + 1) % @items.size if @items.any? } + key(:up) { @item_selection = (@item_selection - 1) % @items.size if @items.any? } + key(:down) { @item_selection = (@item_selection + 1) % @items.size if @items.any? } when :list - window = lift.(opts.fetch(:window)) + window = coerce.(opts.fetch(:window)) key(:up) do - return if @item_sel <= 0 - @item_sel -= 1 - @scroll = @item_sel if @item_sel < @scroll + return if @item_selection <= 0 + @item_selection -= 1 + @scroll = @item_selection if @item_selection < @scroll end key(:down) do - return if @item_sel >= @items.size - 1 - @item_sel += 1 - wh = instance_exec(&window) - @scroll = @item_sel - wh + 1 if @item_sel >= @scroll + wh + return if @item_selection >= @items.size - 1 + @item_selection += 1 + window_size = instance_exec(&window) + @scroll = @item_selection - window_size + 1 if @item_selection >= @scroll + window_size end when :scroll content = opts.fetch(:content) - window = lift.(opts.fetch(:window)) + window = coerce.(opts.fetch(:window)) key(:up) { @scroll = [@scroll - 1, 0].max } key(:down) do max = [instance_exec(&content).size - instance_exec(&window), 0].max diff --git a/lib/bbs/tui_helpers.rb b/lib/bbs/tui_helpers.rb deleted file mode 100644 index 9985d24..0000000 --- a/lib/bbs/tui_helpers.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -module BBS - class TUI - module Helpers - # ── Primitives ─────────────────────────────────────────────────────────── - - def tc(style, str) = "#{THEME.fetch(style, THEME[:normal])}#{str}#{THEME[:reset]}" - def cont_w = term_cols - CONT_X - 1 - def cont_h = term_rows - 4 - - def fmt_date(iso) - Time.parse(iso.to_s).strftime('%Y-%m-%d') - rescue - iso.to_s - end - - def wordwrap(text, width) - words = text.to_s - .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - .gsub(/[#*_`~>|\\]/, '') - .gsub(/\r?\n+/, ' ') - .strip - .split - lines, line = [], +'' - words.each do |w| - if line.empty? then line << w - elsif line.length + 1 + w.length <= width then line << ' ' << w - else lines << line.dup; line = +w - end - end - lines << line unless line.empty? - lines - end - - # ── State ───────────────────────────────────────────────────────────────── - - def init_state - @menu_sel = 0 - @page_title = 'TELETYPE BBS' - @items = [] - @item_sel = 0 - @scroll = 0 - @detail = [] - @page_meta = +'' - @input = +'' - @status = +'' - @prev_page = nil - end - - # ── Data loaders ────────────────────────────────────────────────────────── - - def load_messages(service) - @items = service.recent(30).map { |m| - "#{tc(:label, m.timestamp)} #{tc(:bright, m.username)}: #{m.text}" - } - @scroll = 0 - end - - def load_wiki_list(service, category) - @items = service.list(category) - @item_sel = 0 - @scroll = 0 - end - - def open_wiki_page(service, back:) - return unless (page = @items[@item_sel]) - @detail = wordwrap(service.content(page.id), cont_w) - @page_title = page.title - @page_meta = "#{fmt_date(page.created_at)} #{service.page_url(page.locale, page.path)}" - @prev_page = back - @scroll = 0 - go :page_view - end - - # ── Chrome ──────────────────────────────────────────────────────────────── - - def render_chrome - screen_fill bg: :black - panel_h = term_rows - 1 - - box x: 1, y: 1, w: LEFT_W, h: panel_h, - style: THEME[:border], bg: :black - box x: LEFT_W + 1, y: 1, w: term_cols - LEFT_W, h: panel_h, - title: " #{@page_title} │ #{@username} │ #{ONLINE.count} online ", - style: THEME[:border], bg: :black - - list MENU_ITEMS.map { |i| i[0, LEFT_W - 4] }, - x: 2, y: CONT_Y, - selected: @menu_sel, - style: THEME[:normal], highlight: THEME[:selected] - - bar y: term_rows, - content: " \e[1;37;43m↑↓\e[0;30;43m Navigate \e[1;37;43mEnter\e[0;30;43m Select " \ - "\e[1;37;43mQ\e[0;30;43m Back \e[1;37;43mR\e[0;30;43m Refresh", - style: THEME[:status] - end - - # ── Page renderers ──────────────────────────────────────────────────────── - - def render_messages - (@items[@scroll, cont_h] || []).each_with_index do |line, i| - text line[0, cont_w], x: CONT_X, y: CONT_Y + i - end - text "#{@items.size} msg(s) ↑↓ scroll r refresh q back", - x: CONT_X, y: CONT_Y + cont_h, style: THEME[:label] - end - - def render_wiki_list - list_h = cont_h - 3 - visible = @items[@scroll, list_h] || [] - if visible.empty? - text 'No items found.', x: CONT_X, y: CONT_Y, style: THEME[:normal] - else - list visible.map { |p| p.title[0, cont_w - 2] }, - x: CONT_X, y: CONT_Y, - selected: @item_sel - @scroll, - style: THEME[:normal], highlight: THEME[:selected] - if (page = @items[@item_sel]) - hint = page.description.to_s.strip[0, cont_w] - text hint, x: CONT_X, y: CONT_Y + cont_h - 2, style: THEME[:normal] unless hint.empty? - end - end - text '↑↓ navigate Enter read r refresh q back', - x: CONT_X, y: CONT_Y + cont_h, style: THEME[:label] - end - - def render_page_view - text @page_meta[0, cont_w], x: CONT_X, y: CONT_Y, style: THEME[:label] - (@detail[@scroll, cont_h - 2] || []).each_with_index do |l, i| - text l[0, cont_w], x: CONT_X, y: CONT_Y + 2 + i, style: THEME[:normal] - end - text "#{@scroll + 1}/#{@detail.size} ↑↓ scroll q back", - x: CONT_X, y: CONT_Y + cont_h, style: THEME[:label] - end - - def render_game_card - if @items.empty? - text 'No games found.', x: CONT_X, y: CONT_Y, style: THEME[:normal] - return - end - game = @items[@item_sel] - links = game.external_links - - text "#{@item_sel + 1}/#{@items.size} #{game.title}"[0, cont_w], - x: CONT_X, y: CONT_Y, style: THEME[:bright] - text "#{game.platform} · #{game.author}"[0, cont_w], - x: CONT_X, y: CONT_Y + 1, style: THEME[:normal] - - story_rows = cont_h - 4 - links.size - wordwrap(game.story, cont_w).first(story_rows).each_with_index do |l, i| - text l, x: CONT_X, y: CONT_Y + 3 + i, style: THEME[:normal] - end - - link_y = CONT_Y + cont_h - links.size - links.each_with_index do |lnk, i| - text "#{tc(:label, lnk[:label])} #{lnk[:url]}"[0, cont_w], - x: CONT_X, y: link_y + i - end - - text '↑↓ navigate r refresh q back', - x: CONT_X, y: CONT_Y + cont_h, style: THEME[:label] - end - - def render_online_users - @items.each_with_index do |uname, i| - you = uname == @username ? tc(:label, ' ← you') : '' - text tc(:bright, uname) + you, x: CONT_X, y: CONT_Y + i - end - text "#{@items.size} user(s) online r refresh q back", - x: CONT_X, y: CONT_Y + cont_h, style: THEME[:label] - end - - end - end -end diff --git a/lib/bbs/tui_runner.rb b/lib/bbs/tui_runner.rb index 2fc5aaf..fb0f10d 100644 --- a/lib/bbs/tui_runner.rb +++ b/lib/bbs/tui_runner.rb @@ -10,16 +10,16 @@ module BBS end def run - ctx = Context.new(@session, @session_id, @tui) - @context.each { |k, v| ctx.instance_variable_set(:"@#{k}", v) } - ctx.instance_eval(&@tui.init_block) if @tui.init_block - ctx.go(@tui.start_page) + context = Context.new(@session, @session_id, @tui) + @context.each { |key, value| context.instance_variable_set(:"@#{key}", value) } + context.instance_eval(&@tui.init_block) if @tui.init_block + context.go(@tui.start_page) loop do - ctx.do_render + context.do_render key = @session.readkey break if key.nil? - break if ctx.dispatch_key(key) == :halt + break if context.dispatch_key(key) == :halt end rescue IOError, Errno::EPIPE, Errno::ECONNRESET nil @@ -30,18 +30,18 @@ module BBS class Context STYLES = FlowRunner::STYLES - BG_COLORS = { + BACKGROUND_COLORS = { black: "\e[40m", red: "\e[41m", green: "\e[42m", yellow: "\e[43m", blue: "\e[44m", cyan: "\e[46m", white: "\e[47m", }.freeze def initialize(session, session_id, tui) - @session = session - @session_id = session_id - @tui = tui - @current_page = nil - @committed_frame = nil + @session = session + @session_id = session_id + @tui = tui + @current_page = nil + @committed_frame = nil extend(@tui.helper_module) if @tui.helper_module end @@ -55,15 +55,15 @@ module BBS def reload = go(@current_page) def do_render - cols = term_cols - rows = term_rows + columns = term_cols + rows = term_rows first_render = @committed_frame.nil? resized = !first_render && - (@committed_frame.cols != cols || @committed_frame.rows != rows) + (@committed_frame.cols != columns || @committed_frame.rows != rows) - @committed_frame = FrameBuffer.new(cols, rows) if first_render || resized - @next_frame = FrameBuffer.new(cols, rows) + @committed_frame = FrameBuffer.new(columns, rows) if first_render || resized + @next_frame = FrameBuffer.new(columns, rows) instance_eval(&@tui.chrome_block) if @tui.chrome_block page = @tui.pages[@current_page] @@ -83,8 +83,8 @@ module BBS handler = page&.key_bindings&.[](key) if handler.nil? && key.is_a?(String) && key.length == 1 - if (ph = page&.key_bindings&.[](:printable)) - return instance_exec(key, &ph) + if (printable_handler = page&.key_bindings&.[](:printable)) + return instance_exec(key, &printable_handler) end end @@ -92,7 +92,7 @@ module BBS instance_eval(&handler) end - # ── drawing primitives ────────────────────────────────────────────────── + # ── Drawing primitives ──────────────────────────────────────────────────── def clear @next_frame&.clear @@ -101,8 +101,8 @@ module BBS def term_cols = @session.term_cols || 80 def term_rows = @session.term_rows || 24 - def at(col, row) - @next_frame.move(col, row) + def at(column, row) + @next_frame.move(column, row) end def text(content, x: nil, y: nil, style: nil) @@ -112,73 +112,95 @@ module BBS def bar(y:, content:, width: nil, style: :muted) width ||= term_cols - sgr = resolve_style(style) + style_code = resolve_style(style) at 1, y - @next_frame.write(' ' * width, sgr: sgr) + @next_frame.write(' ' * width, sgr: style_code) at 1, y - @next_frame.write_ansi(content.to_s, base_sgr: sgr) + @next_frame.write_ansi(content.to_s, base_sgr: style_code) end def list(items, x:, y:, selected: nil, style: :muted, highlight: :success) - norm = resolve_style(style) || STYLES[:muted] - hi = resolve_style(highlight) || STYLES[:success] - items.each_with_index do |item, i| - at x, y + i - if i == selected - @next_frame.write("▶ ", sgr: hi) - @next_frame.write_ansi(item.to_s, base_sgr: hi) + normal_style = resolve_style(style) || STYLES[:muted] + highlight_style = resolve_style(highlight) || STYLES[:success] + items.each_with_index do |item, index| + at x, y + index + if index == selected + @next_frame.write("▶ ", sgr: highlight_style) + @next_frame.write_ansi(item.to_s, base_sgr: highlight_style) else - @next_frame.write(" ", sgr: norm) - @next_frame.write_ansi(item.to_s, base_sgr: norm) + @next_frame.write(" ", sgr: normal_style) + @next_frame.write_ansi(item.to_s, base_sgr: normal_style) end end end - def fill(x:, y:, w:, h:, bg:) - sgr = BG_COLORS.fetch(bg, bg.to_s) - h.times do |dy| - at x, y + dy - @next_frame.write(' ' * w, sgr: sgr) + def fill(x:, y:, width:, height:, background:) + background_code = BACKGROUND_COLORS.fetch(background, background.to_s) + height.times do |row_offset| + at x, y + row_offset + @next_frame.write(' ' * width, sgr: background_code) end end - def screen_fill(bg:) - fill(x: 1, y: 1, w: term_cols, h: term_rows, bg: bg) + def screen_fill(background:) + fill(x: 1, y: 1, width: term_cols, height: term_rows, background: background) end - def box(x:, y:, w:, h:, title: nil, style: :muted, bg: nil) - color = resolve_style(style) || STYLES[:muted] - inner = w - 2 - bg_sgr = bg ? BG_COLORS.fetch(bg, bg.to_s) : nil + def box(x:, y:, width:, height:, title: nil, style: :muted, background: nil) + style_code = resolve_style(style) || STYLES[:muted] + inner_width = width - 2 + background_code = background ? BACKGROUND_COLORS.fetch(background, background.to_s) : nil at x, y if title - prefix = "══ #{title} " - pad = [inner - prefix.length, 0].max - @next_frame.write("╔#{prefix}#{'═' * pad}╗", sgr: color) + prefix = "══ #{title} " + padding = [inner_width - prefix.length, 0].max + @next_frame.write("╔#{prefix}#{'═' * padding}╗", sgr: style_code) else - @next_frame.write("╔#{'═' * inner}╗", sgr: color) + @next_frame.write("╔#{'═' * inner_width}╗", sgr: style_code) end - (1...h - 1).each do |dy| - at x, y + dy - @next_frame.write('║', sgr: color) - @next_frame.write(' ' * inner, sgr: bg_sgr) - @next_frame.write('║', sgr: color) + (1...height - 1).each do |row_offset| + at x, y + row_offset + @next_frame.write('║', sgr: style_code) + @next_frame.write(' ' * inner_width, sgr: background_code) + @next_frame.write('║', sgr: style_code) end - at x, y + h - 1 - @next_frame.write("╚#{'═' * inner}╝", sgr: color) + at x, y + height - 1 + @next_frame.write("╚#{'═' * inner_width}╝", sgr: style_code) - yield x + 1, y + 1, inner, h - 2 if block_given? + yield x + 1, y + 1, inner_width, height - 2 if block_given? end - def float(title: nil, width: 40, height: 20, style: :muted, bg: :black, &block) - w = [[width, term_cols - 4].min, 4].max - h = [[height, term_rows - 4].min, 3].max - x = (term_cols - w) / 2 + 1 - y = (term_rows - h) / 2 + 1 - box(x: x, y: y, w: w, h: h, title: title, style: style, bg: bg, &block) + def float(title: nil, width: 40, height: 20, style: :muted, background: :black, &block) + clamped_width = [[width, term_cols - 4].min, 4].max + clamped_height = [[height, term_rows - 4].min, 3].max + x = (term_cols - clamped_width) / 2 + 1 + y = (term_rows - clamped_height) / 2 + 1 + box(x: x, y: y, width: clamped_width, height: clamped_height, + title: title, style: style, background: background, &block) + end + + # ── Text utilities ──────────────────────────────────────────────────────── + + def wordwrap(text, width) + words = text.to_s + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + .gsub(/[#*_`~>|\\]/, '') + .gsub(/\r?\n+/, ' ') + .strip + .split + lines = [] + current = +'' + words.each do |word| + if current.empty? then current << word + elsif current.length + 1 + word.length <= width then current << ' ' << word + else lines << current.dup; current = +word + end + end + lines << current unless current.empty? + lines end private