From 5c9cb269fbb903f3e8889e0740c02b9f56d6f285 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Mon, 11 May 2026 21:53:18 +0200 Subject: [PATCH] Redesign TUI with DOS Navigator look and two-panel layout Full blue background (screen_fill), double-line bordered panels side by side: left panel for the menu, right panel for content. The right panel's title bar shows the current page name, username and online count. Color theme (THEME constant) maps to combined fg+bg ANSI codes so all elements look native on the blue background: bright cyan borders, white normal text, yellow labels, black-on-cyan selection highlights. Status bar at the bottom mimics the NC function key bar with bright key labels on cyan background. Co-Authored-By: Claude Sonnet 4.6 --- Gemfile.lock | 2 +- bbs.rb | 169 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 107 insertions(+), 64 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d314700..3c3d13f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://git.teletype.hu/tools/rubbs.git - revision: cac10176a25eb5d917f559d4dfb6c7d9c0dc34da + revision: 148fd886600c814e3bdb13ec3b3fb3f812d99cb9 specs: bbs (0.2.0) artii (~> 2.1) diff --git a/bbs.rb b/bbs.rb index ceb8abb..a88c71e 100644 --- a/bbs.rb +++ b/bbs.rb @@ -7,8 +7,6 @@ require_relative 'lib/repository/message_board_repository' require_relative 'lib/repository/wiki_repository' require_relative 'lib/repository/catalog_repository' -include BBS::Color - ONLINE = OnlineUsersRepository.new MESSAGES = MessageBoardRepository.new(ENV.fetch('MESSAGES_PATH', 'data/messages.dat')) WIKI = WikiRepository.new(token: ENV['WEBAPP_WIKIJS_TOKEN']) @@ -20,14 +18,37 @@ MENU_ITEMS = [ 'System Info', 'Exit' ].freeze -SEP_X = 21 -CONT_X = 23 -CONT_Y = 3 +# ── Layout ────────────────────────────────────────────────────────────────── + +LEFT_W = 21 # left panel outer width +CONT_X = 23 # content start x inside right panel (LEFT_W + 2) +CONT_Y = 2 # content start y inside panels (top border + 1) + +# ── DOS Navigator color theme ──────────────────────────────────────────────── + +THEME = { + bg: "\e[0;44m", # blue background + normal: "\e[0;37;44m", # white on blue + bright: "\e[1;37;44m", # bright white on blue + border: "\e[1;36;44m", # bright cyan on blue + selected: "\e[0;30;46m", # black on cyan (highlighted menu item) + header: "\e[1;36;44m", # bright cyan on blue (section titles) + label: "\e[1;33;44m", # yellow on blue (timestamps, labels) + status: "\e[0;30;46m", # black on cyan (bottom status bar) + input: "\e[1;33;44m", # yellow on blue (text input) + success: "\e[1;32;44m", # green on blue + error: "\e[1;31;44m", # red on blue + reset: "\e[0m", +}.freeze + +# ── TUI definition ─────────────────────────────────────────────────────────── MAIN_TUI = BBS::TUI.define do helpers do + 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 - CONT_Y - 4 + def cont_h = term_rows - 4 def fmt_date(iso) Time.parse(iso.to_s).strftime('%Y-%m-%d') @@ -44,7 +65,7 @@ MAIN_TUI = BBS::TUI.define do .split lines, line = [], +'' words.each do |w| - if line.empty? then line << w + if line.empty? then line << w elsif line.length + 1 + w.length <= width then line << ' ' << w else lines << line.dup; line = +w end @@ -53,22 +74,23 @@ MAIN_TUI = BBS::TUI.define do lines end - def wiki_list_render(color:, title:) - text c(color, title), x: CONT_X, y: CONT_Y + def wiki_list_render(title) + text title, x: CONT_X, y: CONT_Y, style: THEME[:header] if @items.empty? - text c(:gray, 'No items found.'), x: CONT_X, y: CONT_Y + 2 + text 'No items found.', x: CONT_X, y: CONT_Y + 2, style: THEME[:normal] return end list_h = cont_h - 5 visible = @items[@scroll, list_h] || [] list visible.map { |p| p.title[0, cont_w - 2] }, x: CONT_X, y: CONT_Y + 2, - selected: @item_sel - @scroll, style: :gray, highlight: :white + 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 c(:gray, hint), x: CONT_X, y: CONT_Y + cont_h - 2 unless hint.empty? + text hint, x: CONT_X, y: CONT_Y + cont_h - 2, style: THEME[:normal] unless hint.empty? end - text c(:gray, '↑↓ navigate Enter read q back'), x: CONT_X, y: CONT_Y + cont_h + text '↑↓ navigate Enter read q back', x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal] end def scroll_list_up @@ -97,6 +119,7 @@ MAIN_TUI = BBS::TUI.define do init do @menu_sel = 0 + @page_title = 'TELETYPE BBS' @items = [] @item_sel = 0 @scroll = 0 @@ -109,23 +132,36 @@ MAIN_TUI = BBS::TUI.define do end chrome do - bar y: 1, - content: " TELETYPE BBS │ #{@username} │ #{ONLINE.count} online", - style: :success - list MENU_ITEMS.map { |i| i[0, 15] }, x: 2, y: CONT_Y, - selected: @menu_sel, style: :gray, highlight: :white - (CONT_Y..CONT_Y + cont_h).each { |r| text c(:gray, '│'), x: SEP_X, y: r } + screen_fill bg: :blue + + panel_h = term_rows - 1 + + box x: 1, y: 1, w: LEFT_W, h: panel_h, + style: THEME[:border], bg: :blue + + 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: :blue + + 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: ' ↑↓ Navigate Enter Select q Back/Quit r Refresh', - style: :muted + content: " \e[1;37;46m↑↓\e[0;30;46m Navigate \e[1;37;46mEnter\e[0;30;46m Select " \ + "\e[1;37;46mQ\e[0;30;46m Back \e[1;37;46mR\e[0;30;46m Refresh", + style: THEME[:status] end # ── Pages ─────────────────────────────────────────────────────────────────── page :idle do + enter { @page_title = 'TELETYPE BBS' } + render do - text c(:gray, 'Navigate with ↑↓, select with Enter'), - x: CONT_X, y: CONT_Y + cont_h / 2 + text 'Navigate the menu with ↑↓, select with Enter.', + x: CONT_X, y: CONT_Y + cont_h / 2, style: THEME[:normal] end key(:up) { @menu_sel = (@menu_sel - 1) % MENU_ITEMS.size } @@ -148,17 +184,18 @@ MAIN_TUI = BBS::TUI.define do page :messages do enter do - @items = MESSAGES.last(30).map { |m| "#{c(:yellow, m.timestamp)} #{c(:white, m.username)}: #{m.text}" } + @page_title = 'MESSAGES' + @items = MESSAGES.last(30).map { |m| "#{tc(:label, m.timestamp)} #{tc(:bright, m.username)}: #{m.text}" } @scroll = 0 end render do - text c(:cyan, '── Message Board ──'), x: CONT_X, y: CONT_Y + text '── Message Board ──', x: CONT_X, y: CONT_Y, style: THEME[:header] (@items[@scroll, cont_h - 2] || []).each_with_index do |line, i| text line[0, cont_w], x: CONT_X, y: CONT_Y + 2 + i end - text c(:gray, "#{@items.size} msg(s) ↑↓ scroll q back"), - x: CONT_X, y: CONT_Y + cont_h + text "#{@items.size} msg(s) ↑↓ scroll q back", + x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal] end key(:up) { @scroll = [@scroll - 1, 0].max } @@ -169,13 +206,16 @@ MAIN_TUI = BBS::TUI.define do end page :new_msg do - enter { @input = ''; @status = '' } + enter { @page_title = 'POST MESSAGE'; @input = ''; @status = '' } render do - text c(:cyan, '── Post Message ──'), x: CONT_X, y: CONT_Y - text c(:gray, 'Type message and press Enter. Esc cancels.'), x: CONT_X, y: CONT_Y + 2 - text c(:white, "#{@username}: #{@input}_"), x: CONT_X, y: CONT_Y + 4 - text c(:green, @status), x: CONT_X, y: CONT_Y + 6 unless @status.empty? + text '── Post Message ──', x: CONT_X, y: CONT_Y, style: THEME[:header] + text 'Type your message and press Enter. Esc cancels.', + x: CONT_X, y: CONT_Y + 2, style: THEME[:normal] + text "#{tc(:bright, @username)}: #{tc(:input, @input)}_", + x: CONT_X, y: CONT_Y + 4 + text @status, x: CONT_X, y: CONT_Y + 6, + style: @status.start_with?('Sent') ? THEME[:success] : THEME[:error] unless @status.empty? end key(:enter) do @@ -193,8 +233,8 @@ MAIN_TUI = BBS::TUI.define do end page :blog do - enter { @items = WIKI.list('blog'); @item_sel = 0; @scroll = 0 } - render { wiki_list_render(color: :blue, title: '── Blog Posts ──') } + enter { @page_title = 'BLOG'; @items = WIKI.list('blog'); @item_sel = 0; @scroll = 0 } + render { wiki_list_render('── Blog Posts ──') } key(:up) { scroll_list_up } key(:down) { scroll_list_down } key(:enter) { open_wiki_page(back: :blog) } @@ -204,8 +244,8 @@ MAIN_TUI = BBS::TUI.define do end page :howto do - enter { @items = WIKI.list('howto'); @item_sel = 0; @scroll = 0 } - render { wiki_list_render(color: :magenta, title: '── HowTo Guides ──') } + enter { @page_title = 'HOWTO'; @items = WIKI.list('howto'); @item_sel = 0; @scroll = 0 } + render { wiki_list_render('── HowTo Guides ──') } key(:up) { scroll_list_up } key(:down) { scroll_list_down } key(:enter) { open_wiki_page(back: :howto) } @@ -216,13 +256,13 @@ MAIN_TUI = BBS::TUI.define do page :page_view do render do - text c(:cyan, @page_header[0, cont_w]), x: CONT_X, y: CONT_Y - text c(:gray, @page_meta[0, cont_w]), x: CONT_X, y: CONT_Y + 1 + text @page_header[0, cont_w], x: CONT_X, y: CONT_Y, style: THEME[:bright] + text @page_meta[0, cont_w], x: CONT_X, y: CONT_Y + 1, style: THEME[:normal] (@detail[@scroll, cont_h - 3] || []).each_with_index do |l, i| - text c(:gray, l[0, cont_w]), x: CONT_X, y: CONT_Y + 3 + i + text l[0, cont_w], x: CONT_X, y: CONT_Y + 3 + i, style: THEME[:normal] end - text c(:gray, "#{@scroll + 1}/#{@detail.size} ↑↓ scroll q back"), - x: CONT_X, y: CONT_Y + cont_h + text "#{@scroll + 1}/#{@detail.size} ↑↓ scroll q back", + x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal] end key(:up) { @scroll = [@scroll - 1, 0].max } @@ -232,28 +272,28 @@ MAIN_TUI = BBS::TUI.define do end page :games do - enter { @items = CATALOG.fetch; @item_sel = 0 } + enter { @page_title = 'GAME CATALOG'; @items = CATALOG.fetch; @item_sel = 0 } render do - text c(:cyan, '── Game Catalog ──'), x: CONT_X, y: CONT_Y + text '── Game Catalog ──', x: CONT_X, y: CONT_Y, style: THEME[:header] if @items.empty? - text c(:gray, 'No games found.'), x: CONT_X, y: CONT_Y + 2 + text 'No games found.', x: CONT_X, y: CONT_Y + 2, style: THEME[:normal] else game = @items[@item_sel] - text c(:white, "#{@item_sel + 1}/#{@items.size} #{game.title}"[0, cont_w]), - x: CONT_X, y: CONT_Y + 1 - text c(:gray, "#{game.platform} #{game.author}"[0, cont_w]), - x: CONT_X, y: CONT_Y + 2 + text "#{@item_sel + 1}/#{@items.size} #{game.title}"[0, cont_w], + x: CONT_X, y: CONT_Y + 1, style: THEME[:bright] + text "#{game.platform} #{game.author}"[0, cont_w], + x: CONT_X, y: CONT_Y + 2, style: THEME[:normal] wordwrap(game.desc.to_s, cont_w).first(cont_h - 10).each_with_index do |l, i| - text c(:gray, l), x: CONT_X, y: CONT_Y + 4 + i + text l, x: CONT_X, y: CONT_Y + 4 + i, style: THEME[:normal] end badges = [] - badges << c(:green, '[▶ Play]') unless game.play_path.empty? - badges << c(:yellow, '[⬇ Download]') unless game.download_path.empty? - badges << c(:blue, '[Source]') unless game.source_path.empty? - badges << c(:magenta, '[Docs]') unless game.docs_path.empty? + badges << tc(:success, '[▶ Play]') unless game.play_path.empty? + badges << tc(:label, '[⬇ Download]') unless game.download_path.empty? + badges << tc(:bright, '[Source]') unless game.source_path.empty? + badges << tc(:header, '[Docs]') unless game.docs_path.empty? text badges.join(' '), x: CONT_X, y: CONT_Y + cont_h - 5 - text c(:gray, '↑↓ navigate q back'), x: CONT_X, y: CONT_Y + cont_h + text '↑↓ navigate q back', x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal] end end @@ -265,16 +305,16 @@ MAIN_TUI = BBS::TUI.define do end page :online do - enter { @items = ONLINE.snapshot.sort.map { |_, name| name } } + enter { @page_title = 'ONLINE USERS'; @items = ONLINE.snapshot.sort.map { |_, name| name } } render do - text c(:yellow, '── Online Users ──'), x: CONT_X, y: CONT_Y + text '── Online Users ──', x: CONT_X, y: CONT_Y, style: THEME[:header] @items.each_with_index do |uname, i| - you = uname == @username ? c(:gray, ' ← you') : '' - text c(:white, uname) + you, x: CONT_X, y: CONT_Y + 2 + i + you = uname == @username ? tc(:normal, ' ← you') : '' + text tc(:bright, uname) + you, x: CONT_X, y: CONT_Y + 2 + i end - text c(:gray, "#{@items.size} user(s) online q back"), - x: CONT_X, y: CONT_Y + cont_h + text "#{@items.size} user(s) online q back", + x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal] end key('r') { reload } @@ -284,6 +324,7 @@ MAIN_TUI = BBS::TUI.define do page :sysinfo do enter do + @page_title = 'SYSTEM INFO' @items = [ "Online users #{ONLINE.count}", "Messages #{MESSAGES.count}", @@ -295,11 +336,11 @@ MAIN_TUI = BBS::TUI.define do end render do - text c(:magenta, '── System Info ──'), x: CONT_X, y: CONT_Y + text '── System Info ──', x: CONT_X, y: CONT_Y, style: THEME[:header] @items.each_with_index do |row, i| - text c(:gray, row[0, cont_w]), x: CONT_X, y: CONT_Y + 2 + i + text row[0, cont_w], x: CONT_X, y: CONT_Y + 2 + i, style: THEME[:normal] end - text c(:gray, 'q back'), x: CONT_X, y: CONT_Y + cont_h + text 'q back', x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal] end key('q') { go :idle } @@ -309,6 +350,8 @@ MAIN_TUI = BBS::TUI.define do start :idle end +# ── App config ─────────────────────────────────────────────────────────────── + BBS.configure do |c| c.on_session_end = ->(session) { ONLINE.remove(session.session_id) }