diff --git a/Gemfile.lock b/Gemfile.lock index ae9dd6c..dc38719 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://git.teletype.hu/tools/rubbs.git - revision: 5ced1013281a0e882dd39a9c57304313f0d43280 + revision: da5acfd55e5e9dcb9959437bba84db21e5540aa8 specs: bbs (0.3.0) artii (~> 2.1) diff --git a/bbs.rb b/bbs.rb index 1070313..0db4026 100644 --- a/bbs.rb +++ b/bbs.rb @@ -10,6 +10,7 @@ require_relative 'lib/service/online_service' require_relative 'lib/service/message_service' require_relative 'lib/service/wiki_service' require_relative 'lib/service/games_service' +require_relative 'lib/tui/helpers' ONLINE = OnlineService.new(OnlineUsersRepository.new) MESSAGES = MessageService.new(MessageBoardRepository.new(ENV.fetch('MESSAGES_PATH', 'data/messages.dat'))) @@ -22,9 +23,9 @@ MENU_ITEMS = [ 'System Info', 'Exit' ].freeze -LEFT_W = 21 -CONT_X = 23 -CONT_Y = 2 +LEFT_WIDTH = 21 +CONTENT_X = 23 +CONTENT_Y = 2 THEME = { bg: "\e[0;40m", @@ -42,9 +43,22 @@ THEME = { }.freeze MAIN_TUI = BBS::TUI.define do - helpers { include BBS::TUI::Helpers } + helpers { include TUIHelpers } + + init do + @menu_selection = 0 + @page_title = 'TELETYPE BBS' + @items = [] + @item_selection = 0 + @scroll = 0 + @detail = [] + @page_metadata = +'' + @input = +'' + @status = +'' + @previous_page = nil + @modal = nil + end - init { init_state } chrome { render_chrome } page :idle do @@ -52,11 +66,11 @@ MAIN_TUI = BBS::TUI.define do render do text 'Navigate the menu with ↑↓, select with Enter.', - x: CONT_X, y: CONT_Y + cont_h / 2, style: THEME[:normal] + x: CONTENT_X, y: CONTENT_Y + content_height / 2, style: THEME[:normal] if @modal == :sysinfo float title: ' SYSTEM INFO ', width: 56, height: 10, - style: THEME[:border], bg: :black do |fx, fy, fw, _| + style: THEME[:border], background: :black do |fx, fy, fw, _| [ "Online users #{ONLINE.count}", "Messages #{MESSAGES.count}", @@ -64,17 +78,17 @@ MAIN_TUI = BBS::TUI.define do "Games API https://teletypegames.org", "Platform #{RUBY_PLATFORM}", "Ruby #{RUBY_VERSION}", - ].each_with_index do |row, i| - text row[0, fw], x: fx, y: fy + i, style: THEME[:normal] + ].each_with_index do |row, index| + text row[0, fw], x: fx, y: fy + index, style: THEME[:normal] end text 'ESC close', x: fx, y: fy + 7, style: THEME[:label] end elsif @modal == :new_msg float title: ' POST MESSAGE ', width: 62, height: 9, - style: THEME[:border], bg: :black do |fx, fy, fw, _| + style: THEME[:border], background: :black do |fx, fy, fw, _| text 'Type your message and press Enter. Esc cancels.', x: fx, y: fy, style: THEME[:normal] - text "#{tc(:bright, @username)}: #{tc(:input, @input)}_", + text "#{themed_color(:bright, @username)}: #{themed_color(:input, @input)}_", x: fx, y: fy + 2 text @status, x: fx, y: fy + 4, style: THEME[:error] unless @status.empty? text "#{@input.length}/200 ESC cancel", @@ -91,7 +105,7 @@ MAIN_TUI = BBS::TUI.define do when :ok then @modal = nil end elsif @modal.nil? - case MENU_ITEMS[@menu_sel] + case MENU_ITEMS[@menu_selection] when 'Messages' then go :messages when 'Post Message' then @modal = :new_msg; @input = +''; @status = +'' when 'Blog Posts' then go :blog @@ -104,35 +118,67 @@ MAIN_TUI = BBS::TUI.define do end end key(:backspace) { @input.chop! if @modal == :new_msg && !@input.empty? } - key('q') { @modal ? @modal = nil : :halt } - key(:escape) { @modal ? @modal = nil : :halt } - printable { |ch| @input << ch if @modal == :new_msg && @input.length < 200 } + key('q') { @modal ? @modal = nil : :halt } + key(:escape) { @modal ? @modal = nil : :halt } + printable { |ch| @input << ch if @modal == :new_msg && @input.length < 200 } end page :messages do - enter { @page_title = 'MESSAGES'; load_messages(MESSAGES) } + enter do + @page_title = 'MESSAGES' + @items = MESSAGES.recent(30) + @scroll = 0 + end render { render_messages } - nav :scroll, content: -> { @items }, window: -> { cont_h } + nav :scroll, content: -> { @items }, window: -> { content_height } key('r') { reload } key('q') { go :idle } key(:escape) { go :idle } end page :blog do - enter { @page_title = 'BLOG'; load_wiki_list(WIKI, 'blog') } + enter do + @page_title = 'BLOG' + @items = WIKI.list('blog') + @item_selection = 0 + @scroll = 0 + end render { render_wiki_list } - nav :list, window: -> { cont_h - 3 } - key(:enter) { open_wiki_page(WIKI, back: :blog) } + nav :list, window: -> { content_height - 3 } + key(:enter) do + next unless (wiki_page = @items[@item_selection]) + @detail = wordwrap(WIKI.content(wiki_page.id), content_width) + @page_title = wiki_page.title + @page_metadata = "#{format_date(wiki_page.created_at)} " \ + "#{WIKI.page_url(wiki_page.locale, wiki_page.path)}" + @previous_page = :blog + @scroll = 0 + go :page_view + end key('r') { reload } key('q') { go :idle } key(:escape) { go :idle } end page :howto do - enter { @page_title = 'HOWTO GUIDES'; load_wiki_list(WIKI, 'howto') } + enter do + @page_title = 'HOWTO GUIDES' + @items = WIKI.list('howto') + @item_selection = 0 + @scroll = 0 + end render { render_wiki_list } - nav :list, window: -> { cont_h - 3 } - key(:enter) { open_wiki_page(WIKI, back: :howto) } + nav :list, window: -> { content_height - 3 } + key(:enter) do + next unless (wiki_page = @items[@item_selection]) + @detail = wordwrap(WIKI.content(wiki_page.id), content_width) + @page_title = wiki_page.title + @page_metadata = "#{format_date(wiki_page.created_at)} " \ + "#{WIKI.page_url(wiki_page.locale, wiki_page.path)}" + @previous_page = :howto + @scroll = 0 + go :page_view + end key('r') { reload } key('q') { go :idle } key(:escape) { go :idle } @@ -140,13 +186,17 @@ MAIN_TUI = BBS::TUI.define do page :page_view do render { render_page_view } - nav :scroll, content: -> { @detail }, window: -> { cont_h - 2 } - key('q') { @scroll = 0; go(@prev_page || :idle) } - key(:escape) { @scroll = 0; go(@prev_page || :idle) } + nav :scroll, content: -> { @detail }, window: -> { content_height - 2 } + key('q') { @scroll = 0; go(@previous_page || :idle) } + key(:escape) { @scroll = 0; go(@previous_page || :idle) } end page :games do - enter { @page_title = 'GAME CATALOG'; @items = GAMES.all; @item_sel = 0 } + enter do + @page_title = 'GAME CATALOG' + @items = GAMES.all + @item_selection = 0 + end render { render_game_card } nav :cycle key('r') { reload } @@ -155,7 +205,10 @@ MAIN_TUI = BBS::TUI.define do end page :online do - enter { @page_title = 'ONLINE USERS'; @items = ONLINE.user_list } + enter do + @page_title = 'ONLINE USERS' + @items = ONLINE.user_list + end render { render_online_users } key('r') { reload } key('q') { go :idle } @@ -171,7 +224,7 @@ BBS.configure do |c| c.flow = BBS::Flow.define do big_banner 'TELETYPE BBS', style: :success ask :username, prompt: 'Name (blank for Anonymous)', - transform: ->(v) { v.strip.empty? ? 'Anonymous' : v.strip[0...20] } + transform: ->(value) { value.strip.empty? ? 'Anonymous' : value.strip[0...20] } call { |ctx| ONLINE.add(ctx[:session_id], ctx[:username]) } tui MAIN_TUI say 'Goodbye!', style: :muted diff --git a/lib/tui/helpers.rb b/lib/tui/helpers.rb new file mode 100644 index 0000000..5d24237 --- /dev/null +++ b/lib/tui/helpers.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module TUIHelpers + # ── Layout ─────────────────────────────────────────────────────────────────── + + def content_width = term_cols - CONTENT_X - 1 + def content_height = term_rows - 4 + + # ── Theming ────────────────────────────────────────────────────────────────── + + def themed_color(style, text) + "#{THEME.fetch(style, THEME[:normal])}#{text}#{THEME[:reset]}" + end + + def format_date(iso_string) + Time.parse(iso_string.to_s).strftime('%Y-%m-%d') + rescue + iso_string.to_s + end + + # ── Chrome ─────────────────────────────────────────────────────────────────── + + def render_chrome + screen_fill background: :black + panel_height = term_rows - 1 + + box x: 1, y: 1, width: LEFT_WIDTH, height: panel_height, + style: THEME[:border], background: :black + box x: LEFT_WIDTH + 1, y: 1, width: term_cols - LEFT_WIDTH, height: panel_height, + title: " #{@page_title} │ #{@username} │ #{ONLINE.count} online ", + style: THEME[:border], background: :black + + list MENU_ITEMS.map { |item| item[0, LEFT_WIDTH - 4] }, + x: 2, y: CONTENT_Y, + selected: @menu_selection, + 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, content_height] || []).each_with_index do |message, index| + line = "#{themed_color(:label, message.timestamp)} " \ + "#{themed_color(:bright, message.username)}: #{message.text}" + text line[0, content_width], x: CONTENT_X, y: CONTENT_Y + index + end + text "#{@items.size} msg(s) ↑↓ scroll r refresh q back", + x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label] + end + + def render_wiki_list + list_height = content_height - 3 + visible = @items[@scroll, list_height] || [] + if visible.empty? + text 'No items found.', x: CONTENT_X, y: CONTENT_Y, style: THEME[:normal] + else + list visible.map { |wiki_page| wiki_page.title[0, content_width - 2] }, + x: CONTENT_X, + y: CONTENT_Y, + selected: @item_selection - @scroll, + style: THEME[:normal], + highlight: THEME[:selected] + if (wiki_page = @items[@item_selection]) + hint = wiki_page.description.to_s.strip[0, content_width] + text hint, x: CONTENT_X, y: CONTENT_Y + content_height - 2, style: THEME[:normal] unless hint.empty? + end + end + text '↑↓ navigate Enter read r refresh q back', + x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label] + end + + def render_page_view + text @page_metadata[0, content_width], x: CONTENT_X, y: CONTENT_Y, style: THEME[:label] + (@detail[@scroll, content_height - 2] || []).each_with_index do |line, index| + text line[0, content_width], x: CONTENT_X, y: CONTENT_Y + 2 + index, style: THEME[:normal] + end + text "#{@scroll + 1}/#{@detail.size} ↑↓ scroll q back", + x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label] + end + + def render_game_card + if @items.empty? + text 'No games found.', x: CONTENT_X, y: CONTENT_Y, style: THEME[:normal] + return + end + game = @items[@item_selection] + links = game.external_links + + text "#{@item_selection + 1}/#{@items.size} #{game.title}"[0, content_width], + x: CONTENT_X, y: CONTENT_Y, style: THEME[:bright] + text "#{game.platform} · #{game.author}"[0, content_width], + x: CONTENT_X, y: CONTENT_Y + 1, style: THEME[:normal] + + story_row_count = content_height - 4 - links.size + wordwrap(game.story, content_width).first(story_row_count).each_with_index do |line, index| + text line, x: CONTENT_X, y: CONTENT_Y + 3 + index, style: THEME[:normal] + end + + link_start_row = CONTENT_Y + content_height - links.size + links.each_with_index do |link, index| + text "#{themed_color(:label, link[:label])} #{link[:url]}"[0, content_width], + x: CONTENT_X, y: link_start_row + index + end + + text '↑↓ navigate r refresh q back', + x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label] + end + + def render_online_users + @items.each_with_index do |username, index| + indicator = username == @username ? themed_color(:label, ' ← you') : '' + text themed_color(:bright, username) + indicator, x: CONTENT_X, y: CONTENT_Y + index + end + text "#{@items.size} user(s) online r refresh q back", + x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label] + end +end