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 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 09:28:21 +02:00
parent 5dfc2ed5c9
commit 75d1063572
3 changed files with 143 additions and 216 deletions

View File

@@ -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)

156
bbs.rb
View File

@@ -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

View File

@@ -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