From 757d999b9a6b0f9c919d2aeb6b2de3e1f347f250 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 13 May 2026 21:00:54 +0200 Subject: [PATCH] new windows types --- Gemfile.lock | 2 +- lib/ui/windows/bulletin_window.rb | 52 +++---- lib/ui/windows/chat_window.rb | 51 ++----- lib/ui/windows/games_window.rb | 87 +++-------- lib/ui/windows/messages_window.rb | 181 ++++++---------------- lib/ui/windows/profile_window.rb | 61 ++------ lib/ui/windows/sysop_window.rb | 72 ++++----- lib/ui/windows/system_info_window.rb | 42 ++---- lib/ui/windows/users_window.rb | 72 ++++----- lib/ui/windows/wiki_window.rb | 215 ++------------------------- 10 files changed, 185 insertions(+), 650 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b00f0f1..3d48945 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://git.teletype.hu/tools/rubbs.git - revision: 26f4d2a4eeb766611a925845ddc50fd3ed60157d + revision: 2454521f19277c5f754770c2ab8bbe34ac0bdc5e specs: bbs (0.4.0) artii (~> 2.1) diff --git a/lib/ui/windows/bulletin_window.rb b/lib/ui/windows/bulletin_window.rb index 3f96bf0..b0d0670 100644 --- a/lib/ui/windows/bulletin_window.rb +++ b/lib/ui/windows/bulletin_window.rb @@ -7,11 +7,6 @@ module UI # Login splash: ASCII banner + last callers + new message counts. Closed # with Enter / Esc / clicking the OK button. def bulletin(app, services:) - width = 70 - height = 17 - window = BBS::Dialogs.centred_window(app, width: width, height: height, - title: ' Welcome ') - banner_lines = [ " \e[1;33m _____ _ _ ____ ____ _____\e[0m", " \e[1;33m|_ _|__ | | | |_ __ _ _ _ ___ | __ )| __ )/ ____|\e[0m", @@ -19,37 +14,24 @@ module UI " \e[1;33m | | __/ | |___ | |_| (_| | || __/ | |_) | |_) |___) |\e[0m", " \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m", ] - banner_lines.each_with_index do |line, i| - l = BBS::Widgets::Label.new(text: line, style_key: :accent) - l.layout(window.bounds.x + 1, window.bounds.y + 1 + i, width - 2, 1) - window.add(l) + caller_lines = services[:last_callers].recent(3).map do |c| + " · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m" end - - summary = [ - "Logged in as \e[1;33m#{app.context[:username] || 'Anonymous'}\e[0m", - '', - "Online now: #{services[:online].count}", - "Total messages: #{services[:boards].total_count}", - '', - 'Last callers:', - ] - services[:last_callers].recent(3).each do |c| - summary << " · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m" - end - - summary.each_with_index do |line, i| - l = BBS::Widgets::Label.new(text: line, style_key: :window_text) - l.layout(window.bounds.x + 2, window.bounds.y + 7 + i, width - 4, 1) - window.add(l) - end - - ok = BBS::Widgets::Button.new(label: '&Continue', - on_click: ->(_) { app.close_window(window) }) - ok.layout(window.bounds.x + (width - 12) / 2, window.bounds.y + height - 2, 12, 1) - window.add(ok) - window.reset_focus - app.open_window(window) - window + BBS::Windows::Info.open(app, + title: ' Welcome ', width: 70, height: 17, + body: [ + *banner_lines.map { |l| { text: l, style: :accent } }, + :spacer, + "Logged in as \e[1;33m#{app.context[:username] || 'Anonymous'}\e[0m", + :spacer, + "Online now: #{services[:online].count}", + "Total messages: #{services[:boards].total_count}", + :spacer, + 'Last callers:', + *caller_lines, + ], + buttons: [{ label: '&Continue', primary: true, cancel: true }] + ) end end end diff --git a/lib/ui/windows/chat_window.rb b/lib/ui/windows/chat_window.rb index a115dab..6b6a586 100644 --- a/lib/ui/windows/chat_window.rb +++ b/lib/ui/windows/chat_window.rb @@ -5,50 +5,17 @@ module UI module_function def chat(app, services:) - cols = app.cols - rows = app.rows - w = cols - 8 - h = rows - 6 - window = BBS::Window.new(title: ' Live Chat ') - window.layout(5, 3, w, h) - window.theme = app.theme - - history = BBS::Widgets::ScrollView.new(lines: services[:chat].history.map { |l| format_line(l) }) - history.layout(window.bounds.x + 2, window.bounds.y + 1, w - 4, h - 5) - window.add(history) - history.scroll = [history.lines.size - history.bounds.height, 0].max - - input = BBS::Widgets::TextInput.new(placeholder: 'Type a message and press Enter…') - input.layout(window.bounds.x + 2, window.bounds.y + h - 3, w - 14, 1) - input.on_submit = lambda do |value, _| - next if value.strip.empty? - services[:chat].broadcast(app.context[:username] || 'Anonymous', value.strip) - input.value = '' - :handled - end - window.add(input) - - close = BBS::Widgets::Button.new(label: '&Close', - on_click: ->(_) { app.close_window(window) }) - close.layout(window.bounds.x + w - 11, window.bounds.y + h - 3, 9, 1) - window.add(close) - - # Stash a poll lambda on the window so the app tick can pull updates - window.instance_variable_set(:@chat_poll, lambda do - new_lines = services[:chat].drain(app.session_id).map { |l| format_line(l) } - unless new_lines.empty? - history.lines = (history.lines + new_lines).last(500) - history.scroll = [history.lines.size - history.bounds.height, 0].max - end - end) - - window.reset_focus - window.focus_manager.focus(input) - app.open_window(window) - window + username = app.context[:username] || 'Anonymous' + BBS::Windows::Stream.open(app, + title: ' Live Chat ', + initial_lines: services[:chat].history.map { |l| format_chat_line(l) }, + placeholder: 'Type a message and press Enter…', + on_submit: ->(text, _a) { services[:chat].broadcast(username, text.strip) }, + on_poll: ->(a) { services[:chat].drain(a.session_id).map { |l| format_chat_line(l) } } + ) end - def format_line(line) + def format_chat_line(line) " \e[2;37m#{line.timestamp}\e[0m \e[1;33m#{line.username}\e[0m: #{line.text}" end end diff --git a/lib/ui/windows/games_window.rb b/lib/ui/windows/games_window.rb index 65a2468..0ef740a 100644 --- a/lib/ui/windows/games_window.rb +++ b/lib/ui/windows/games_window.rb @@ -6,81 +6,28 @@ module UI # Game catalog browser. Left=titles, right=card with story + links. def games(app, services:) - cols = app.cols - rows = app.rows - w = cols - 4 - h = rows - 6 - window = BBS::Window.new(title: ' Game Catalog ') - window.layout(3, 3, w, h) - window.theme = app.theme - - games = services[:games].all - - list = BBS::Widgets::ListBox.new( - items: games, - label: ->(g) { " #{g.title}".ljust(26) } + BBS::Windows::MasterDetail.open(app, + title: ' Game Catalog ', + items: services[:games].all, + item_label: ->(g) { g.title }, + list_width: 30, + empty_message: ' (no games)', + render_detail: ->(game, width) { game_card_lines(game, width) } ) - list_width = 30 - list.layout(window.bounds.x + 2, window.bounds.y + 2, list_width, h - 5) - window.add(list) - - card = BBS::Widgets::ScrollView.new(lines: []) - card_x = window.bounds.x + list_width + 4 - card_w = w - list_width - 7 - card.layout(card_x, window.bounds.y + 2, card_w, h - 7) - window.add(card) - - load_card = lambda do - game = list.selected_item - if game.nil? - card.lines = [' (no games)'] - next - end - lines = [] - lines << " \e[1;33m#{game.title}\e[0m" - lines << " \e[0;36m#{game.platform} · #{game.author}\e[0m" - lines << '' - wrap_para(game.desc.to_s, card_w - 4).each { |l| lines << " #{l}" } - lines << '' - wrap_para(game.story.to_s, card_w - 4).each { |l| lines << " #{l}" } - lines << '' - game.external_links.each do |link| - lines << " \e[0;37m#{link[:label].ljust(10)}\e[0m \e[1;36m#{link[:url]}\e[0m" - end - card.lines = lines - card.scroll = 0 - end - - list.on_select = ->(_, _) { load_card.call } - - close = BBS::Widgets::Button.new(label: '&Close', - on_click: ->(_) { app.close_window(window) }) - close.layout(window.bounds.x + w - 11, window.bounds.y + h - 2, 9, 1) - window.add(close) - - load_card.call - window.reset_focus - window.focus_manager.focus(list) - app.open_window(window) - window end - def wrap_para(text, width) - return [] if text.empty? - words = text.split + def game_card_lines(game, width) lines = [] - cur = +'' - words.each do |w| - if cur.empty? - cur << w - elsif cur.length + 1 + w.length <= width - cur << ' ' << w - else - lines << cur.dup - cur = +w - end + lines << " \e[1;33m#{game.title}\e[0m" + lines << " \e[0;36m#{game.platform} · #{game.author}\e[0m" + lines << '' + BBS::FrameBuffer.wrap_ansi(game.desc.to_s, width - 4).each { |l| lines << " #{l}" } + lines << '' + BBS::FrameBuffer.wrap_ansi(game.story.to_s, width - 4).each { |l| lines << " #{l}" } + lines << '' + game.external_links.each do |link| + lines << " \e[0;37m#{link[:label].ljust(10)}\e[0m \e[1;36m#{link[:url]}\e[0m" end - lines << cur unless cur.empty? lines end end diff --git a/lib/ui/windows/messages_window.rb b/lib/ui/windows/messages_window.rb index 7d29b85..92de1c1 100644 --- a/lib/ui/windows/messages_window.rb +++ b/lib/ui/windows/messages_window.rb @@ -5,154 +5,67 @@ module UI module_function # Two-pane message reader: left=board list, right=messages of the selected - # board. Press "n" or click [+ New] to compose, "r" to refresh. + # board. Press &New to compose, &Refresh to reload. def messages(app, services:) - cols = app.cols - rows = app.rows - w = cols - 4 - h = rows - 6 - window = BBS::Window.new(title: ' Message Boards ') - window.layout(3, 3, w, h) - window.theme = app.theme - - boards = services[:boards].boards - board_list = BBS::Widgets::ListBox.new( - items: boards, - label: ->(b) { " #{b.title}".ljust(18) } + BBS::Windows::MasterDetail.open(app, + title: ' Message Boards ', + items: services[:boards].boards, + item_label: ->(b) { b.title }, + list_width: 20, + empty_message: ' (no boards configured)', + render_detail: ->(board, width) { board_lines(services, board, width) }, + actions: [ + { label: '&New', align: :left, on_click: ->(window) { + board = window.list_widget.selected_item + compose_message(app, services: services, board: board, + on_posted: -> { window.reload_detail }) + :keep + } }, + { label: '&Refresh', align: :left, on_click: ->(window) { + window.reload_detail + :keep + } } + ] ) - board_list.layout(window.bounds.x + 2, window.bounds.y + 2, 20, h - 5) - window.add(board_list) + end - msg_lines = BBS::Widgets::ScrollView.new(lines: []) - msg_lines.layout(window.bounds.x + 23, window.bounds.y + 2, w - 26, h - 5) - window.add(msg_lines) - - reload = lambda do - board = board_list.selected_item - next unless board - msgs = services[:boards].messages(board.id, 100) - wrap_width = msg_lines.bounds.width - 1 - lines = [] - if msgs.empty? - lines << " (no messages on #{board.title} yet — press 'n' to start one)" - else - msgs.each do |m| - head = "\e[2;37m#{m.timestamp}\e[0m \e[1;33m#{m.username}\e[0m: " - body = m.text.to_s - wrap_text(head + body, wrap_width).each { |l| lines << l } - lines << '' - end - end - msg_lines.lines = lines - msg_lines.scroll = [lines.size - msg_lines.bounds.height, 0].max + def board_lines(services, board, width) + msgs = services[:boards].messages(board.id, 100) + return [" (no messages on #{board.title} yet — press 'n' to start one)"] if msgs.empty? + lines = [] + msgs.each do |m| + head = "\e[2;37m#{m.timestamp}\e[0m \e[1;33m#{m.username}\e[0m: " + BBS::FrameBuffer.wrap_ansi(head + m.text.to_s, width - 1).each { |l| lines << l } + lines << '' end - - board_list.on_select = ->(_, _) { reload.call } - - compose = BBS::Widgets::Button.new( - label: '&New', - on_click: ->(_) { compose_message(app, services: services, board: board_list.selected_item, on_posted: reload) } - ) - compose.layout(window.bounds.x + 2, window.bounds.y + h - 2, 9, 1) - window.add(compose) - - refresh = BBS::Widgets::Button.new( - label: '&Refresh', - on_click: ->(_) { reload.call } - ) - refresh.layout(window.bounds.x + 13, window.bounds.y + h - 2, 13, 1) - window.add(refresh) - - close = BBS::Widgets::Button.new( - label: '&Close', - on_click: ->(_) { app.close_window(window) } - ) - close.layout(window.bounds.x + w - 11, window.bounds.y + h - 2, 9, 1) - window.add(close) - - reload.call - window.reset_focus - window.focus_manager.focus(board_list) - app.open_window(window) - window + lines end def compose_message(app, services:, board:, on_posted: nil) return BBS::Dialogs.message(app, 'Select a board first.', title: ' Compose ') unless board - w = [app.cols - 8, 70].min - h = 14 - window = BBS::Dialogs.centred_window(app, width: w, height: h, - title: " Post to #{board.title} ") - - hint = BBS::Widgets::Label.new( - text: "From: #{app.context[:username] || 'Anonymous'} — Ctrl-Enter sends", - style_key: :input_label - ) - hint.layout(window.bounds.x + 2, window.bounds.y + 1, w - 4, 1) - window.add(hint) - - area = BBS::Widgets::TextArea.new(max_length: 1000) - area.layout(window.bounds.x + 2, window.bounds.y + 3, w - 4, h - 6) - window.add(area) - - status = BBS::Widgets::Label.new(text: '', style_key: :error) - status.layout(window.bounds.x + 2, window.bounds.y + h - 3, w - 4, 1) - window.add(status) - - send_btn = BBS::Widgets::Button.new( - label: '&Send', - on_click: lambda do |_| - result = services[:boards].post(board.id, app.context[:username] || 'Anonymous', area.value) + username = app.context[:username] || 'Anonymous' + width = [app.cols - 8, 70].min + BBS::Windows::Form.open(app, + title: " Post to #{board.title} ", + width: width, height: 14, + hint: "From: #{username} — Ctrl-Enter sends", + submit_label: '&Send', + cancel_label: '&Cancel', + fields: [ + { key: :body, type: :textarea, value: '', max_length: 1000 } + ], + on_submit: ->(values, form) { + result = services[:boards].post(board.id, username, values[:body]) if result == :empty - status.text = 'Message cannot be empty.' + form.error('Message cannot be empty.') + :keep else - app.close_window(window) on_posted&.call + :close end - end + } ) - send_btn.layout(window.bounds.x + w - 22, window.bounds.y + h - 2, 9, 1) - window.add(send_btn) - - cancel = BBS::Widgets::Button.new( - label: '&Cancel', - on_click: ->(_) { app.close_window(window) } - ) - cancel.layout(window.bounds.x + w - 12, window.bounds.y + h - 2, 10, 1) - window.add(cancel) - - window.reset_focus - window.focus_manager.focus(area) - app.open_window(window) - window - end - - def wrap_text(text, width) - return [''] if width <= 1 - # Treat the text as containing ANSI; wrap on visible width approximately - # by splitting on whitespace. - visible = text.gsub(/\e\[[\d;]*m/, '') - return [text] if visible.length <= width - lines = [] - cur = +'' - cur_visible = +'' - text.scan(/(\e\[[\d;]*m|\S+\s*|\s+)/) do |(token)| - if token.start_with?("\e") - cur << token - next - end - visible_tok = token - if cur_visible.length + visible_tok.length > width && !cur_visible.empty? - lines << cur - cur = +'' - cur_visible = +'' - end - cur << token - cur_visible << token - end - lines << cur unless cur.empty? - lines end end end diff --git a/lib/ui/windows/profile_window.rb b/lib/ui/windows/profile_window.rb index 0e42699..15a4b29 100644 --- a/lib/ui/windows/profile_window.rb +++ b/lib/ui/windows/profile_window.rb @@ -7,56 +7,25 @@ module UI def profile(app, services:) username = app.context[:username] || 'Anonymous' existing = services[:profile].find(username) || {} - - w = 64 - h = 14 - window = BBS::Dialogs.centred_window(app, width: w, height: h, - title: " Profile — #{username} ") - - fields = [ - [:signature, 'Signature', existing['signature']], - [:location, 'Location', existing['location']], - [:homepage, 'Homepage', existing['homepage']], - [:notes, 'Notes', existing['notes']], - ] - - inputs = {} - fields.each_with_index do |(key, label, value), i| - l = BBS::Widgets::Label.new(text: label, style_key: :input_label) - l.layout(window.bounds.x + 2, window.bounds.y + 1 + i * 2, 12, 1) - window.add(l) - - input = BBS::Widgets::TextInput.new(value: value.to_s) - input.layout(window.bounds.x + 14, window.bounds.y + 1 + i * 2, w - 18, 1) - window.add(input) - inputs[key] = input - end - - save = BBS::Widgets::Button.new( - label: '&Save', - on_click: lambda do |_| + BBS::Windows::Form.open(app, + title: " Profile — #{username} ", + width: 64, height: 14, + fields: [ + { key: :signature, label: 'Signature', value: existing['signature'].to_s }, + { key: :location, label: 'Location', value: existing['location'].to_s }, + { key: :homepage, label: 'Homepage', value: existing['homepage'].to_s }, + { key: :notes, label: 'Notes', value: existing['notes'].to_s } + ], + on_submit: ->(values, _form) { services[:profile].update(username, - signature: inputs[:signature].value, - location: inputs[:location].value, - homepage: inputs[:homepage].value, - notes: inputs[:notes].value + signature: values[:signature], + location: values[:location], + homepage: values[:homepage], + notes: values[:notes] ) - app.close_window(window) BBS::Dialogs.message(app, 'Profile saved.', title: ' Saved ') - end + } ) - save.layout(window.bounds.x + w - 22, window.bounds.y + h - 2, 9, 1) - window.add(save) - - cancel = BBS::Widgets::Button.new(label: '&Cancel', - on_click: ->(_) { app.close_window(window) }) - cancel.layout(window.bounds.x + w - 12, window.bounds.y + h - 2, 10, 1) - window.add(cancel) - - window.reset_focus - window.focus_manager.focus(inputs[:signature]) - app.open_window(window) - window end end end diff --git a/lib/ui/windows/sysop_window.rb b/lib/ui/windows/sysop_window.rb index 9e3e801..a0d2615 100644 --- a/lib/ui/windows/sysop_window.rb +++ b/lib/ui/windows/sysop_window.rb @@ -5,54 +5,38 @@ module UI module_function def sysop_console(app, services:) - width = 70 - height = 18 - window = BBS::Dialogs.centred_window(app, width: width, height: height, - title: ' Sysop Console ') - - lines = [] - lines << " Sysop: \e[1;33m#{app.context[:username]}\e[0m" - lines << '' - lines << ' Stats' - lines << " · Online users: #{services[:online].count}" - lines << " · Total messages: #{services[:boards].total_count}" - lines << " · Chat subscribers: #{services[:chat].subscriber_count}" - lines << '' - lines << ' Online users:' - services[:online].user_list.each { |u| lines << " · #{u}" } - lines << '' - lines << ' Last 5 callers:' + body = [] + body << " Sysop: \e[1;33m#{app.context[:username]}\e[0m" + body << :spacer + body << ' Stats' + body << " · Online users: #{services[:online].count}" + body << " · Total messages: #{services[:boards].total_count}" + body << " · Chat subscribers: #{services[:chat].subscriber_count}" + body << :spacer + body << ' Online users:' + services[:online].user_list.each { |u| body << " · #{u}" } + body << :spacer + body << ' Last 5 callers:' services[:last_callers].recent(5).each do |c| - lines << " · #{c.username} #{c.timestamp[0, 16].sub('T', ' ')}" + body << " · #{c.username} #{c.timestamp[0, 16].sub('T', ' ')}" end - view = BBS::Widgets::ScrollView.new(lines: lines) - view.layout(window.bounds.x + 2, window.bounds.y + 1, - width - 4, height - 4) - window.add(view) - - broadcast = BBS::Widgets::Button.new( - label: '&Broadcast', - on_click: lambda do |_| - BBS::Dialogs.input(app, prompt: 'Sysop broadcast message:', - title: ' Broadcast ', width: 64, - on_submit: lambda do |text| - next if text.strip.empty? - services[:chat].broadcast("[SYSOP] #{app.context[:username]}", text.strip) - end) - end + BBS::Windows::Info.open(app, + title: ' Sysop Console ', width: 70, height: 18, scroll: true, + body: body, + buttons: [ + { label: '&Broadcast', align: :left, on_click: ->(_window) { + BBS::Dialogs.input(app, prompt: 'Sysop broadcast message:', + title: ' Broadcast ', width: 64, + on_submit: lambda do |text| + next if text.strip.empty? + services[:chat].broadcast("[SYSOP] #{app.context[:username]}", text.strip) + end) + :keep + } }, + { label: '&Close', cancel: true } + ] ) - broadcast.layout(window.bounds.x + 2, window.bounds.y + height - 2, 14, 1) - window.add(broadcast) - - close = BBS::Widgets::Button.new(label: '&Close', - on_click: ->(_) { app.close_window(window) }) - close.layout(window.bounds.x + width - 11, window.bounds.y + height - 2, 9, 1) - window.add(close) - - window.reset_focus - app.open_window(window) - window end end end diff --git a/lib/ui/windows/system_info_window.rb b/lib/ui/windows/system_info_window.rb index d6e6138..edff6f5 100644 --- a/lib/ui/windows/system_info_window.rb +++ b/lib/ui/windows/system_info_window.rb @@ -5,38 +5,20 @@ module UI module_function def system_info(app, services:) - width = 60 - height = 14 - window = BBS::Dialogs.centred_window(app, width: width, height: height, - title: ' System Info ') - x, y = window.bounds.x + 2, window.bounds.y + 1 rows = [ - ['Online users', services[:online].count.to_s], - ['Messages', services[:boards].total_count.to_s], - ['Boards', services[:boards].boards.size.to_s], - ['Wiki', 'https://wiki.teletypegames.org'], - ['Games API', 'https://teletypegames.org'], - ['Platform', RUBY_PLATFORM], - ['Ruby', RUBY_VERSION], - ['Uptime', format_uptime(services[:uptime])], + ['Online users', services[:online].count.to_s], + ['Messages', services[:boards].total_count.to_s], + ['Boards', services[:boards].boards.size.to_s], + ['Wiki', 'https://wiki.teletypegames.org'], + ['Games API', 'https://teletypegames.org'], + ['Platform', RUBY_PLATFORM], + ['Ruby', RUBY_VERSION], + ['Uptime', format_uptime(services[:uptime])], ] - - rows.each_with_index do |(k, v), i| - label = BBS::Widgets::Label.new(text: k.ljust(15), style_key: :input_label) - label.layout(x, y + i, 16, 1) - value = BBS::Widgets::Label.new(text: v.to_s, style_key: :window_text) - value.layout(x + 16, y + i, width - 20, 1) - window.add(label) - window.add(value) - end - - ok = BBS::Widgets::Button.new(label: '&Close', - on_click: ->(_) { app.close_window(window) }) - ok.layout(window.bounds.x + (width - 10) / 2, window.bounds.y + height - 2, 10, 1) - window.add(ok) - window.reset_focus - app.open_window(window) - window + BBS::Windows::Info.open(app, + title: ' System Info ', width: 60, height: 14, + body: [{ kv: rows, label_width: 15 }] + ) end def format_uptime(start) diff --git a/lib/ui/windows/users_window.rb b/lib/ui/windows/users_window.rb index ea6ae56..e12258d 100644 --- a/lib/ui/windows/users_window.rb +++ b/lib/ui/windows/users_window.rb @@ -5,57 +5,35 @@ module UI module_function def online_users(app, services:) - width = 50 - height = [services[:online].user_list.size + 6, 10].max - window = BBS::Dialogs.centred_window(app, width: width, height: height, - title: ' Online Users ') - - users = services[:online].user_list - lines = users.map do |u| - u == app.context[:username] ? " #{u} ← you" : " #{u}" - end - lines = [' (no one else here yet)'] if lines.empty? - view = BBS::Widgets::ScrollView.new(lines: lines) - view.layout(window.bounds.x + 2, window.bounds.y + 1, - width - 4, height - 4) - window.add(view) - - ok = BBS::Widgets::Button.new(label: '&Close', - on_click: ->(_) { app.close_window(window) }) - ok.layout(window.bounds.x + (width - 10) / 2, window.bounds.y + height - 2, 10, 1) - window.add(ok) - window.reset_focus - app.open_window(window) - window + users = services[:online].user_list + username = app.context[:username] + body = if users.empty? + [' (no one else here yet)'] + else + users.map { |u| u == username ? " #{u} ← you" : " #{u}" } + end + BBS::Windows::Info.open(app, + title: ' Online Users ', + width: 50, height: [users.size + 6, 10].max, + scroll: true, body: body + ) end def last_callers(app, services:) - width = 64 callers = services[:last_callers].recent(15) - height = [callers.size + 6, 10].max - window = BBS::Dialogs.centred_window(app, width: width, height: height, - title: ' Last Callers ') - - lines = if callers.empty? - [' (no callers recorded yet)'] - else - callers.map do |c| - ts = c.timestamp.to_s[0, 19].sub('T', ' ') - " \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m" - end - end - view = BBS::Widgets::ScrollView.new(lines: lines) - view.layout(window.bounds.x + 2, window.bounds.y + 1, - width - 4, height - 4) - window.add(view) - - ok = BBS::Widgets::Button.new(label: '&Close', - on_click: ->(_) { app.close_window(window) }) - ok.layout(window.bounds.x + (width - 10) / 2, window.bounds.y + height - 2, 10, 1) - window.add(ok) - window.reset_focus - app.open_window(window) - window + body = if callers.empty? + [' (no callers recorded yet)'] + else + callers.map do |c| + ts = c.timestamp.to_s[0, 19].sub('T', ' ') + " \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m" + end + end + BBS::Windows::Info.open(app, + title: ' Last Callers ', + width: 64, height: [callers.size + 6, 10].max, + scroll: true, body: body + ) end end end diff --git a/lib/ui/windows/wiki_window.rb b/lib/ui/windows/wiki_window.rb index c124d92..bc3d62c 100644 --- a/lib/ui/windows/wiki_window.rb +++ b/lib/ui/windows/wiki_window.rb @@ -7,209 +7,22 @@ module UI # Split-view wiki browser. Left=titles, right=selected page content # (rendered with markdown-aware formatting). def wiki(app, services:, category:, title:) - cols = app.cols - rows = app.rows - w = cols - 4 - h = rows - 6 - window = BBS::Window.new(title: " #{title} ") - window.layout(3, 3, w, h) - window.theme = app.theme - items = services[:wiki].list(category) - - list = BBS::Widgets::ListBox.new( - items: items, - label: ->(p) { " #{p.title}".ljust(28) } + BBS::Windows::MasterDetail.open(app, + title: " #{title} ", + items: items, + item_label: ->(p) { p.title }, + list_width: [32, app.cols / 3].max, + empty_message: ' (no pages found)', + close_button: false, + render_detail: ->(page, width) { + text = services[:wiki].content(page.id) + BBS::Markdown.to_lines(text, width: width - 1, theme: app.theme) + }, + bottom_meta: ->(page) { + "#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}" + } ) - list_width = [32, (w / 3)].max - list.layout(window.bounds.x + 2, window.bounds.y + 2, list_width, h - 5) - window.add(list) - - preview = BBS::Widgets::ScrollView.new(lines: []) - preview.layout(window.bounds.x + list_width + 4, window.bounds.y + 2, - w - list_width - 7, h - 5) - window.add(preview) - - meta = BBS::Widgets::Label.new(text: '', style_key: :input_label) - meta.layout(window.bounds.x + 2, window.bounds.y + h - 2, w - 4, 1) - window.add(meta) - - load_preview = lambda do - page = list.selected_item - next unless page - meta.text = "#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}" - text = services[:wiki].content(page.id) - preview.lines = format_md(text, preview.bounds.width - 1, app.theme) - preview.scroll = 0 - end - - list.on_select = ->(_, _) { load_preview.call } - - if items.empty? - preview.lines = [' (no pages found)'] - else - load_preview.call - end - - window.reset_focus - window.focus_manager.focus(list) - app.open_window(window) - window - end - - # ── Markdown → ANSI line array ──────────────────────────────────────────── - # - # Returns an array of strings (each is one rendered terminal line with - # embedded ANSI SGR escapes) suitable for BBS::Widgets::ScrollView. - # - # Each inline span is terminated by the theme :text SGR so the trailing - # text on the same line keeps the right background color. - def format_md(text, width, theme) - styles = { - base: theme[:text], - h1: theme[:text_bright], - h2: theme[:accent], - h3: theme[:label], - bold: theme[:text_bright], - italic: theme[:text_dim], - code: theme[:label], - link: theme[:accent], - quote: theme[:text_dim], - bullet: theme[:accent], - rule: theme[:label], - } - result = [] - paragraph = [] - in_code = false - base = styles[:base] - - flush_paragraph = lambda do - next if paragraph.empty? - joined = paragraph.join(' ').gsub(/\s+/, ' ').strip - BBS::FrameBuffer.wrap_ansi(inline_md(joined, styles, base), width) - .each { |l| result << l } - paragraph.clear - end - - text.to_s.split(/\r?\n/).each do |line| - # Fenced code block toggle - if line.lstrip.start_with?('```') - flush_paragraph.call - in_code = !in_code - next - end - - if in_code - flush_paragraph.call - BBS::FrameBuffer.clip_ansi(line, width).then do |clipped| - result << "#{styles[:code]}#{clipped}#{base}" - end - next - end - - # Blank line: paragraph break - if line.strip.empty? - flush_paragraph.call - result << '' - next - end - - # ATX header - if (m = line.match(/\A([#]{1,6})\s+(.+?)\s*#*\s*\z/)) - level = m[1].length - heading = m[2].strip - flush_paragraph.call - style = level == 1 ? styles[:h1] : (level == 2 ? styles[:h2] : styles[:h3]) - BBS::FrameBuffer.wrap_ansi(inline_md(heading, styles, base), width).each do |l| - result << "#{style}#{l}#{base}" - end - next - end - - # Horizontal rule: --- *** ___ - if line.match?(/\A\s*([-*_])\1{2,}\s*\z/) - flush_paragraph.call - result << "#{styles[:rule]}#{'─' * width}#{base}" - next - end - - # Blockquote - if (m = line.match(/\A\s*>\s?(.*)/)) - content = m[1] - flush_paragraph.call - inner_w = [width - 2, 1].max - BBS::FrameBuffer.wrap_ansi(inline_md(content, styles, base), inner_w).each do |l| - result << "#{styles[:quote]}│ #{base}#{l}" - end - next - end - - # Unordered list item - if (m = line.match(/\A(\s*)[-*+]\s+(.+)/)) - indent = m[1].length - body = m[2] - flush_paragraph.call - prefix_w = indent + 2 - inner_w = [width - prefix_w, 1].max - wrapped = BBS::FrameBuffer.wrap_ansi(inline_md(body, styles, base), inner_w) - wrapped.each_with_index do |l, i| - pad = ' ' * indent - result << if i.zero? - "#{pad}#{styles[:bullet]}•#{base} #{l}" - else - "#{' ' * prefix_w}#{l}" - end - end - next - end - - # Ordered list item - if (m = line.match(/\A(\s*)(\d+)\.\s+(.+)/)) - indent = m[1].length - num = m[2] - body = m[3] - flush_paragraph.call - prefix = "#{' ' * indent}#{num}. " - inner_w = [width - prefix.length, 1].max - wrapped = BBS::FrameBuffer.wrap_ansi(inline_md(body, styles, base), inner_w) - wrapped.each_with_index do |l, i| - result << if i.zero? - "#{' ' * indent}#{styles[:bullet]}#{num}.#{base} #{l}" - else - "#{' ' * prefix.length}#{l}" - end - end - next - end - - # Regular paragraph line — buffer for reflow - paragraph << line.strip - end - - flush_paragraph.call - result - end - - # Apply inline markdown formatting (bold, italic, code, links) and - # return the string with embedded ANSI codes. Each closing marker emits - # `base_sgr` so that surrounding text keeps the right background. - def inline_md(text, styles, base_sgr) - s = text.to_s - # Links [text](url) — show the link text styled (URL dropped) - s = s.gsub(/\[([^\]]+)\]\(([^)]+)\)/) { "#{styles[:link]}#{::Regexp.last_match(1)}#{base_sgr}" } - # Inline code `code` - s = s.gsub(/`([^`]+)`/) { "#{styles[:code]}#{::Regexp.last_match(1)}#{base_sgr}" } - # Bold **text** - s = s.gsub(/\*\*([^*]+)\*\*/) { "#{styles[:bold]}#{::Regexp.last_match(1)}#{base_sgr}" } - # Italic *text* and _text_ (require word boundaries to avoid mid-word _) - s = s.gsub(/(?])/) { ::Regexp.last_match(1) } end end end