From 75d1063572e2411d45a82cbd44597bbf897967ed Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Thu, 30 Apr 2026 09:28:21 +0200 Subject: [PATCH] Replace display.rb with DSL primitives in bbs.rb All content rendering now lives in bbs.rb via the rubbs DSL. fetch/pick handle wiki list + article drill-down; output handles the multi-line game catalog. display.rb is gone. Co-Authored-By: Claude Sonnet 4.6 --- Gemfile.lock | 2 +- bbs.rb | 156 ++++++++++++++++++++++++++++++++++---- lib/display.rb | 201 ------------------------------------------------- 3 files changed, 143 insertions(+), 216 deletions(-) delete mode 100644 lib/display.rb diff --git a/Gemfile.lock b/Gemfile.lock index f4fda8f..19ba628 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://git.teletype.hu/tools/rubbs.git - revision: d9232eabb4ddba2b860921657cf20fe7f3cbd830 + revision: 272e75bd6f90a235eeac684d412ceb04c146c926 specs: bbs (0.1.0) artii (~> 2.1) diff --git a/bbs.rb b/bbs.rb index 593606a..6a5d887 100644 --- a/bbs.rb +++ b/bbs.rb @@ -1,17 +1,52 @@ # frozen_string_literal: true require 'bbs' +require 'time' require_relative 'lib/online_users' require_relative 'lib/message_board' require_relative 'lib/wiki' require_relative 'lib/catalog' -require_relative 'lib/display' ONLINE = OnlineUsers.new MESSAGES = MessageBoard.new(ENV.fetch('MESSAGES_PATH', 'data/messages.dat')) WIKI = WikiClient.new(token: ENV['WEBAPP_WIKIJS_TOKEN']) CATALOG = CatalogClient.new +module C + RESET = "\e[0m" + GRAY = "\e[0;37m" + YELLOW = "\e[0;33m" + WHITE = "\e[1;37m" + BLUE = "\e[0;34m" + CYAN = "\e[0;36m" + GREEN = "\e[1;32m" + MAGENTA = "\e[0;35m" + RED = "\e[1;31m" +end + +def fmt_date(iso) + Time.parse(iso.to_s).strftime('%Y-%m-%d') +rescue + iso.to_s +end + +def wrap_text(text, width: 60, indent: 4) + clean = text.to_s + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + .gsub(/[#*_`~>|\\]/, '') + .gsub(/\r?\n+/, ' ') + .strip + lines, line = [], +'' + clean.split.each do |word| + if line.empty? then line << word + elsif line.length + 1 + word.length <= width then line << ' ' << word + else lines << line.dup; line = +word + end + end + lines << line unless line.empty? + lines.map { |l| "#{' ' * indent}#{l}\r\n" }.join +end + BBS.configure do |c| c.on_session_end = ->(session) { ONLINE.remove(session.session_id) } @@ -21,41 +56,134 @@ BBS.configure do |c| ask :username, prompt: 'Name (blank for Anonymous)', transform: ->(v) { v.strip.empty? ? 'Anonymous' : v.strip[0...20] } - call do |ctx, runner| - ONLINE.add(runner.session_id, ctx[:username]) - end + call { |ctx| ONLINE.add(ctx[:session_id], ctx[:username]) } menu 'Choice', loop: true do option 'Message Board' do - call { |ctx, runner| Display.render_messages(MESSAGES.last(30), runner) } + section 'Message Board', color: :cyan + rows(empty: 'No messages yet.') do |ctx| + MESSAGES.last(30).map.with_index(1) do |msg, i| + "#{C::GRAY}[#{i}]#{C::RESET} #{C::YELLOW}#{msg.timestamp}#{C::RESET} " \ + "#{C::WHITE}#{msg.username}#{C::RESET}: #{C::GRAY}#{msg.text}#{C::RESET}" + end + end + wait_enter end option 'New Message' do ask :message_text, prompt: 'Message (max 200 chars)', validate: :non_empty - call do |ctx, runner| - MESSAGES.append(ctx[:username], ctx[:message_text][0...200]) - runner.write "\r\n #{Display::GREEN}Sent.#{Display::RESET}\r\n\r\n" - end + call { |ctx| MESSAGES.append(ctx[:username], ctx[:message_text][0...200]) } + say 'Sent.', style: :success end option 'Blog Posts' do - call { |ctx, runner| Display.render_wiki_list(WIKI, 'blog', 'Blog Posts', Display::BLUE, runner) } + section 'Blog Posts', color: :blue + fetch :pages, loading: 'Loading...' do |ctx| + WIKI.list('blog') + end + pick from: :pages, + empty: 'No results.', + prompt: 'Enter number to read (blank to go back)', + item: ->(p, i) { "#{C::GRAY}#{i}.#{C::RESET} #{C::BLUE}#{p['title']}#{C::RESET} #{C::GRAY}#{fmt_date(p['createdAt'])}#{C::RESET}" }, + hint: ->(p, i) { p['description'].to_s.strip[0...65] } do + fetch :page_body, loading: 'Loading page...' do |ctx| + WIKI.content(ctx[:picked]['id']) + end + line color: :blue + text(style: :white) { |ctx| ctx[:picked]['title'] } + text(style: :muted) { |ctx| "#{fmt_date(ctx[:picked]['createdAt'])} #{WIKI.page_url(ctx[:picked]['locale'], ctx[:picked]['path'])}" } + line color: :blue + body { |ctx| ctx[:page_body] } + wait_enter + end end option 'HowTo Guides' do - call { |ctx, runner| Display.render_wiki_list(WIKI, 'howto', 'HowTo Guides', Display::MAGENTA, runner) } + section 'HowTo Guides', color: :magenta + fetch :pages, loading: 'Loading...' do |ctx| + WIKI.list('howto') + end + pick from: :pages, + empty: 'No results.', + prompt: 'Enter number to read (blank to go back)', + item: ->(p, i) { "#{C::GRAY}#{i}.#{C::RESET} #{C::MAGENTA}#{p['title']}#{C::RESET} #{C::GRAY}#{fmt_date(p['createdAt'])}#{C::RESET}" }, + hint: ->(p, i) { p['description'].to_s.strip[0...65] } do + fetch :page_body, loading: 'Loading page...' do |ctx| + WIKI.content(ctx[:picked]['id']) + end + line color: :magenta + text(style: :white) { |ctx| ctx[:picked]['title'] } + text(style: :muted) { |ctx| "#{fmt_date(ctx[:picked]['createdAt'])} #{WIKI.page_url(ctx[:picked]['locale'], ctx[:picked]['path'])}" } + line color: :magenta + body { |ctx| ctx[:page_body] } + wait_enter + end end option 'Game Catalog' do - call { |ctx, runner| Display.render_catalog(CATALOG, runner) } + section 'Game Catalog', color: :cyan + fetch :games, loading: 'Loading...' do |ctx| + CATALOG.fetch + end + output do |ctx| + games = Array(ctx[:games]) + next " #{C::RED}Could not load catalog.#{C::RESET}\r\n\r\n" unless games.any? + + out = +'' + games.each_with_index do |entry, i| + next unless entry.is_a?(Hash) + sw = entry['software'] || {} + latest = entry['latestRelease'] || {} + count = (entry['releases'] || []).length + desc = sw['desc'].to_s.strip + + out << "\r\n #{C::GRAY}#{'─' * 66}#{C::RESET}\r\n" + out << " #{C::CYAN}#{i + 1}. #{sw['title']}#{C::RESET} " \ + "#{C::GRAY}#{sw['platform']} #{sw['author']}#{C::RESET}\r\n" + out << wrap_text(desc) unless desc.empty? + + badges = [] + badges << "#{C::GREEN}[▶ Play]#{C::RESET}" if latest['htmlFolderPath'].to_s != '' + badges << "#{C::YELLOW}[⬇ Download]#{C::RESET}" if latest['cartridgePath'].to_s != '' + badges << "#{C::BLUE}[Source]#{C::RESET}" if latest['sourcePath'].to_s != '' + badges << "#{C::MAGENTA}[Docs]#{C::RESET}" if latest['docsFolderPath'].to_s != '' + out << " #{badges.join(' ')}\r\n" unless badges.empty? + out << " #{C::GRAY}#{CATALOG.play_url(latest['htmlFolderPath'])}#{C::RESET}\r\n" if latest['htmlFolderPath'].to_s != '' + out << " #{C::GRAY}#{count} versions available#{C::RESET}\r\n" if count > 1 + end + + out << "\r\n #{C::GRAY}#{'─' * 66}#{C::RESET}\r\n" + out << " #{C::GRAY}#{CatalogClient::GAMES_URL}#{C::RESET}\r\n\r\n" + out + end + wait_enter end option 'Online Users' do - call { |ctx, runner| Display.render_online(ONLINE, runner.session_id, ctx[:username], runner) } + section 'Online Users', color: :yellow + rows do |ctx| + ONLINE.snapshot.sort.map do |sid, name| + marker = sid == ctx[:session_id] ? " #{C::GRAY}← you#{C::RESET}" : '' + "#{C::WHITE}#{name}#{C::RESET}#{marker}" + end + end + text(style: :muted) { |ctx| "#{ONLINE.count} user(s) online" } + wait_enter end option 'System Info' do - call { |ctx, runner| Display.render_sysinfo(ONLINE, MESSAGES, runner) } + section 'System Info', color: :magenta + table do |ctx| + { + 'Online users' => ONLINE.count, + 'Messages' => MESSAGES.count, + 'Wiki' => 'https://wiki.teletype.hu', + 'Games API' => 'https://games.teletype.hu', + 'Platform' => RUBY_PLATFORM, + 'Ruby' => RUBY_VERSION + } + end + wait_enter end option 'Exit' do diff --git a/lib/display.rb b/lib/display.rb deleted file mode 100644 index dccb95b..0000000 --- a/lib/display.rb +++ /dev/null @@ -1,201 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -module Display - W = 70 - - RESET = "\e[0m" - BOLD = "\e[1m" - CYAN = "\e[0;36m" - YELLOW = "\e[0;33m" - GREEN = "\e[1;32m" - RED = "\e[1;31m" - MAGENTA = "\e[0;35m" - WHITE = "\e[1;37m" - BLUE = "\e[0;34m" - GRAY = "\e[0;37m" - - module_function - - def hr(color = GRAY) - " #{color}#{'─' * (W - 4)}#{RESET}\r\n" - end - - def box_header(title, color = CYAN) - pad = [W - title.length - 6, 2].max - " #{color}┌─ #{WHITE}#{title} #{color}#{'─' * pad}┐#{RESET}\r\n" - end - - def strip_md(text) - text.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - .gsub(/[#*_`~>|\\]/, '') - .gsub(/\r?\n+/, ' ') - .strip - end - - def wrap(text, indent: 4, width: W - 6) - words = strip_md(text).split - lines = [] - line = +'' - words.each do |word| - if line.empty? - line << word - elsif line.length + 1 + word.length <= width - line << ' ' << word - else - lines << line.dup - line = +word - end - end - lines << line unless line.empty? - lines.map { |l| "#{' ' * indent}#{l}\r\n" }.join - end - - def fmt_date(iso) - Time.parse(iso).strftime('%Y-%m-%d') - rescue - iso.to_s - end - - def wait_enter(runner) - runner.write "\r\n #{GRAY}Press ENTER to continue...#{RESET}" - runner.readline - end - - def render_messages(messages, runner) - runner.write "\r\n" - runner.write box_header('Message Board') - runner.write "\r\n" - if messages.empty? - runner.write " #{GRAY}No messages yet.#{RESET}\r\n" - else - messages.each_with_index do |msg, i| - runner.write " #{GRAY}[#{i + 1}]#{RESET} #{YELLOW}#{msg.timestamp}#{RESET} #{WHITE}#{msg.username}#{RESET}: #{GRAY}#{msg.text}#{RESET}\r\n" - end - end - runner.write "\r\n" - wait_enter(runner) - end - - def render_wiki_list(wiki, tag, title, color, runner) - runner.write "\r\n" - runner.write box_header(title, color) - runner.write "\r\n #{GRAY}Loading...#{RESET}\r\n" - - pages = wiki.list(tag) - runner.write "\e[1A\e[2K" - - if pages.empty? - runner.write " #{RED}No results.#{RESET}\r\n\r\n" - wait_enter(runner) - return - end - - shown = pages.first(25) - shown.each_with_index do |page, i| - runner.write " #{GRAY}#{i + 1}.#{RESET} #{color}#{page['title']}#{RESET} #{GRAY}#{fmt_date(page['createdAt'])}#{RESET}\r\n" - desc = page['description'].to_s.strip - runner.write " #{GRAY}#{desc[0...65]}#{RESET}\r\n" unless desc.empty? - end - - runner.write "\r\n #{GRAY}Enter number to read (blank to go back):#{RESET} " - input = runner.readline&.strip - return if input.nil? || input.empty? - - idx = input.to_i - 1 - page = shown[idx] - unless page - runner.write "\r\n #{RED}Invalid selection.#{RESET}\r\n" - wait_enter(runner) - return - end - - runner.write "\r\n #{GRAY}Loading page...#{RESET}\r\n" - body = wiki.content(page['id']) - runner.write "\e[1A\e[2K" - - runner.write "\r\n" - runner.write hr(color) - runner.write " #{color}#{page['title']}#{RESET}\r\n" - runner.write " #{GRAY}#{fmt_date(page['createdAt'])} #{wiki.page_url(page['locale'], page['path'])}#{RESET}\r\n" - runner.write hr(color) - runner.write "\r\n" - runner.write wrap(body) - runner.write "\r\n" - wait_enter(runner) - end - - def render_catalog(catalog, runner) - runner.write "\r\n" - runner.write box_header('Game Catalog', CYAN) - runner.write "\r\n #{GRAY}Loading...#{RESET}\r\n" - - games = catalog.fetch - runner.write "\e[1A\e[2K" - - unless games.is_a?(Array) && !games.empty? - runner.write " #{RED}Could not load catalog.#{RESET}\r\n\r\n" - wait_enter(runner) - return - end - - games.each_with_index do |entry, i| - next unless entry.is_a?(Hash) - sw = entry['software'] || {} - latest = entry['latestRelease'] || {} - count = (entry['releases'] || []).length - - runner.write "\r\n" - runner.write hr - runner.write " #{CYAN}#{i + 1}. #{sw['title']}#{RESET} #{GRAY}#{sw['platform']} #{sw['author']}#{RESET}\r\n" - runner.write wrap(sw['desc'].to_s) unless sw['desc'].to_s.strip.empty? - - badges = [] - badges << "#{GREEN}[▶ Play]#{RESET}" if latest['htmlFolderPath'].to_s != '' - badges << "#{YELLOW}[⬇ Download]#{RESET}" if latest['cartridgePath'].to_s != '' - badges << "#{BLUE}[Source]#{RESET}" if latest['sourcePath'].to_s != '' - badges << "#{MAGENTA}[Docs]#{RESET}" if latest['docsFolderPath'].to_s != '' - runner.write " #{badges.join(' ')}\r\n" unless badges.empty? - - if latest['htmlFolderPath'].to_s != '' - runner.write " #{GRAY}#{catalog.play_url(latest['htmlFolderPath'])}#{RESET}\r\n" - end - runner.write " #{GRAY}#{count} versions available#{RESET}\r\n" if count > 1 - end - - runner.write "\r\n" - runner.write hr - runner.write " #{GRAY}#{CatalogClient::GAMES_URL}#{RESET}\r\n\r\n" - wait_enter(runner) - end - - def render_online(online, my_sid, my_name, runner) - runner.write "\r\n" - runner.write box_header('Online Users', YELLOW) - runner.write "\r\n" - - snapshot = online.snapshot - snapshot.sort.each do |sid, name| - marker = sid == my_sid ? " #{GRAY}← you#{RESET}" : '' - runner.write " #{WHITE}#{name}#{RESET}#{marker}\r\n" - end - - runner.write "\r\n #{GRAY}#{snapshot.size} user(s) online#{RESET}\r\n\r\n" - wait_enter(runner) - end - - def render_sysinfo(online, messages, runner) - runner.write "\r\n" - runner.write box_header('System Info', MAGENTA) - runner.write "\r\n" - runner.write " #{GRAY}Online users #{WHITE}#{online.count}#{RESET}\r\n" - runner.write " #{GRAY}Messages #{WHITE}#{messages.count}#{RESET}\r\n" - runner.write " #{GRAY}Wiki #{WHITE}https://wiki.teletype.hu#{RESET}\r\n" - runner.write " #{GRAY}Games API #{WHITE}https://games.teletype.hu#{RESET}\r\n" - runner.write " #{GRAY}Platform #{WHITE}#{RUBY_PLATFORM}#{RESET}\r\n" - runner.write " #{GRAY}Ruby #{WHITE}#{RUBY_VERSION}#{RESET}\r\n" - runner.write "\r\n" - wait_enter(runner) - end -end