window.open

This commit is contained in:
2026-05-13 21:12:39 +02:00
parent 757d999b9a
commit f79a98229a
10 changed files with 277 additions and 269 deletions

30
bbs.rb
View File

@@ -65,41 +65,41 @@ MAIN_APP = BBS::Application.define do
menubar do menubar do
menu '&File' do menu '&File' do
item '&Bulletin' do UI::Windows.bulletin(self, services: SERVICES) end item '&Bulletin' do UI::Windows::Bulletin.open(self, services: SERVICES) end
item '&System Info' do UI::Windows.system_info(self, services: SERVICES) end item '&System Info' do UI::Windows::SystemInfo.open(self, services: SERVICES) end
separator separator
item 'E&xit', shortcut: 'Alt-X' do :halt end item 'E&xit', shortcut: 'Alt-X' do :halt end
end end
menu '&Messages' do menu '&Messages' do
item '&Boards…', shortcut: 'F2' do UI::Windows.messages(self, services: SERVICES) end item '&Boards…', shortcut: 'F2' do UI::Windows::Messages.open(self, services: SERVICES) end
item '&New Post…', shortcut: 'F3' do item '&New Post…', shortcut: 'F3' do
board = SERVICES[:boards].boards.first board = SERVICES[:boards].boards.first
UI::Windows.compose_message(self, services: SERVICES, board: board) UI::Windows::ComposeMessage.open(self, services: SERVICES, board: board)
end end
end end
menu '&Files' do menu '&Files' do
item '&Blog Posts' do UI::Windows.wiki(self, services: SERVICES, category: 'blog', title: 'Blog Posts') end item '&Blog Posts' do UI::Windows::Wiki.open(self, services: SERVICES, category: 'blog', title: 'Blog Posts') end
item '&HowTo Guides' do UI::Windows.wiki(self, services: SERVICES, category: 'howto', title: 'HowTo Guides') end item '&HowTo Guides' do UI::Windows::Wiki.open(self, services: SERVICES, category: 'howto', title: 'HowTo Guides') end
item '&Game Catalog' do UI::Windows.games(self, services: SERVICES) end item '&Game Catalog' do UI::Windows::Games.open(self, services: SERVICES) end
end end
menu '&Users' do menu '&Users' do
item '&Online' do UI::Windows.online_users(self, services: SERVICES) end item '&Online' do UI::Windows::OnlineUsers.open(self, services: SERVICES) end
item '&Last Callers' do UI::Windows.last_callers(self, services: SERVICES) end item '&Last Callers' do UI::Windows::LastCallers.open(self, services: SERVICES) end
item '&Profile…' do UI::Windows.profile(self, services: SERVICES) end item '&Profile…' do UI::Windows::Profile.open(self, services: SERVICES) end
end end
menu '&Chat' do menu '&Chat' do
item '&Open chat', shortcut: 'F4' do UI::Windows.chat(self, services: SERVICES) end item '&Open chat', shortcut: 'F4' do UI::Windows::Chat.open(self, services: SERVICES) end
end end
menu 'S&ysop' do menu 'S&ysop' do
item '&Console', shortcut: 'F10' do item '&Console', shortcut: 'F10' do
name = (context[:username] || '').downcase name = (context[:username] || '').downcase
if SYSOPS.include?(name) if SYSOPS.include?(name)
UI::Windows.sysop_console(self, services: SERVICES) UI::Windows::SysopConsole.open(self, services: SERVICES)
else else
BBS::Dialogs.message(self, 'Sysop access denied.', title: ' Sysop ') BBS::Dialogs.message(self, 'Sysop access denied.', title: ' Sysop ')
end end
@@ -123,12 +123,12 @@ MAIN_APP = BBS::Application.define do
end end
status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'Top menu: Alt+letter — Windows: Tab/Shift-Tab — Close: Esc') } status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'Top menu: Alt+letter — Windows: Tab/Shift-Tab — Close: Esc') }
status_hint('F2', 'Boards') { UI::Windows.messages(self, services: SERVICES) } status_hint('F2', 'Boards') { UI::Windows::Messages.open(self, services: SERVICES) }
status_hint('F3', 'Post') { status_hint('F3', 'Post') {
board = SERVICES[:boards].boards.first board = SERVICES[:boards].boards.first
UI::Windows.compose_message(self, services: SERVICES, board: board) UI::Windows::ComposeMessage.open(self, services: SERVICES, board: board)
} }
status_hint('F4', 'Chat') { UI::Windows.chat(self, services: SERVICES) } status_hint('F4', 'Chat') { UI::Windows::Chat.open(self, services: SERVICES) }
status_hint('F10', 'Menu') { menubar.open(0) } status_hint('F10', 'Menu') { menubar.open(0) }
status_hint('Alt-X', 'Exit') { :halt } status_hint('Alt-X', 'Exit') { :halt }

View File

@@ -2,36 +2,36 @@
module UI module UI
module Windows module Windows
module_function
# Login splash: ASCII banner + last callers + new message counts. Closed # Login splash: ASCII banner + last callers + new message counts. Closed
# with Enter / Esc / clicking the OK button. # with Enter / Esc / clicking the OK button.
def bulletin(app, services:) class Bulletin
banner_lines = [ def self.open(app, services:)
" \e[1;33m _____ _ _ ____ ____ _____\e[0m", banner_lines = [
" \e[1;33m|_ _|__ | | | |_ __ _ _ _ ___ | __ )| __ )/ ____|\e[0m", " \e[1;33m _____ _ _ ____ ____ _____\e[0m",
" \e[1;33m | |/ _ \\ | | | __|/ _` | '_/ _ \\ | _ \\| _ \\\\___ \\\e[0m", " \e[1;33m|_ _|__ | | | |_ __ _ _ _ ___ | __ )| __ )/ ____|\e[0m",
" \e[1;33m | | __/ | |___ | |_| (_| | || __/ | |_) | |_) |___) |\e[0m", " \e[1;33m | |/ _ \\ | | | __|/ _` | '_/ _ \\ | _ \\| _ \\\\___ \\\e[0m",
" \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m", " \e[1;33m | | __/ | |___ | |_| (_| | || __/ | |_) | |_) |___) |\e[0m",
] " \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m",
caller_lines = services[:last_callers].recent(3).map do |c| ]
" · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m" caller_lines = services[:last_callers].recent(3).map do |c|
" · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m"
end
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
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 end
end end

View File

@@ -2,21 +2,22 @@
module UI module UI
module Windows module Windows
module_function class Chat
def self.open(app, services:)
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 chat(app, services:) def self.format_chat_line(line)
username = app.context[:username] || 'Anonymous' " \e[2;37m#{line.timestamp}\e[0m \e[1;33m#{line.username}\e[0m: #{line.text}"
BBS::Windows::Stream.open(app, end
title: ' Live Chat ', private_class_method :format_chat_line
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_chat_line(line)
" \e[2;37m#{line.timestamp}\e[0m \e[1;33m#{line.username}\e[0m: #{line.text}"
end end
end end
end end

View File

@@ -2,33 +2,34 @@
module UI module UI
module Windows module Windows
module_function
# Game catalog browser. Left=titles, right=card with story + links. # Game catalog browser. Left=titles, right=card with story + links.
def games(app, services:) class Games
BBS::Windows::MasterDetail.open(app, def self.open(app, services:)
title: ' Game Catalog ', BBS::Windows::MasterDetail.open(app,
items: services[:games].all, title: ' Game Catalog ',
item_label: ->(g) { g.title }, items: services[:games].all,
list_width: 30, item_label: ->(g) { g.title },
empty_message: ' (no games)', list_width: 30,
render_detail: ->(game, width) { game_card_lines(game, width) } empty_message: ' (no games)',
) render_detail: ->(game, width) { game_card_lines(game, width) }
end )
def game_card_lines(game, width)
lines = []
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 end
lines
def self.game_card_lines(game, width)
lines = []
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
end
private_class_method :game_card_lines
end end
end end
end end

View File

@@ -2,70 +2,73 @@
module UI module UI
module Windows module Windows
module_function
# Two-pane message reader: left=board list, right=messages of the selected # Two-pane message reader: left=board list, right=messages of the selected
# board. Press &New to compose, &Refresh to reload. # board. Press &New to compose, &Refresh to reload.
def messages(app, services:) class Messages
BBS::Windows::MasterDetail.open(app, def self.open(app, services:)
title: ' Message Boards ', BBS::Windows::MasterDetail.open(app,
items: services[:boards].boards, title: ' Message Boards ',
item_label: ->(b) { b.title }, items: services[:boards].boards,
list_width: 20, item_label: ->(b) { b.title },
empty_message: ' (no boards configured)', list_width: 20,
render_detail: ->(board, width) { board_lines(services, board, width) }, empty_message: ' (no boards configured)',
actions: [ render_detail: ->(board, width) { board_lines(services, board, width) },
{ label: '&New', align: :left, on_click: ->(window) { actions: [
board = window.list_widget.selected_item { label: '&New', align: :left, on_click: ->(window) {
compose_message(app, services: services, board: board, board = window.list_widget.selected_item
on_posted: -> { window.reload_detail }) ComposeMessage.open(app, services: services, board: board,
:keep on_posted: -> { window.reload_detail })
} }, :keep
{ label: '&Refresh', align: :left, on_click: ->(window) { } },
window.reload_detail { label: '&Refresh', align: :left, on_click: ->(window) {
:keep window.reload_detail
} } :keep
] } }
) ]
end )
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 end
lines
def self.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
lines
end
private_class_method :board_lines
end end
def compose_message(app, services:, board:, on_posted: nil) class ComposeMessage
return BBS::Dialogs.message(app, 'Select a board first.', title: ' Compose ') unless board def self.open(app, services:, board:, on_posted: nil)
return BBS::Dialogs.message(app, 'Select a board first.', title: ' Compose ') unless board
username = app.context[:username] || 'Anonymous' username = app.context[:username] || 'Anonymous'
width = [app.cols - 8, 70].min width = [app.cols - 8, 70].min
BBS::Windows::Form.open(app, BBS::Windows::Form.open(app,
title: " Post to #{board.title} ", title: " Post to #{board.title} ",
width: width, height: 14, width: width, height: 14,
hint: "From: #{username} — Ctrl-Enter sends", hint: "From: #{username} — Ctrl-Enter sends",
submit_label: '&Send', submit_label: '&Send',
cancel_label: '&Cancel', cancel_label: '&Cancel',
fields: [ fields: [
{ key: :body, type: :textarea, value: '', max_length: 1000 } { key: :body, type: :textarea, value: '', max_length: 1000 }
], ],
on_submit: ->(values, form) { on_submit: ->(values, form) {
result = services[:boards].post(board.id, username, values[:body]) result = services[:boards].post(board.id, username, values[:body])
if result == :empty if result == :empty
form.error('Message cannot be empty.') form.error('Message cannot be empty.')
:keep :keep
else else
on_posted&.call on_posted&.call
:close :close
end end
} }
) )
end
end end
end end
end end

View File

@@ -2,30 +2,30 @@
module UI module UI
module Windows module Windows
module_function class Profile
def self.open(app, services:)
def profile(app, services:) username = app.context[:username] || 'Anonymous'
username = app.context[:username] || 'Anonymous' existing = services[:profile].find(username) || {}
existing = services[:profile].find(username) || {} BBS::Windows::Form.open(app,
BBS::Windows::Form.open(app, title: " Profile — #{username} ",
title: " Profile — #{username} ", width: 64, height: 14,
width: 64, height: 14, fields: [
fields: [ { key: :signature, label: 'Signature', value: existing['signature'].to_s },
{ key: :signature, label: 'Signature', value: existing['signature'].to_s }, { key: :location, label: 'Location', value: existing['location'].to_s },
{ key: :location, label: 'Location', value: existing['location'].to_s }, { key: :homepage, label: 'Homepage', value: existing['homepage'].to_s },
{ key: :homepage, label: 'Homepage', value: existing['homepage'].to_s }, { key: :notes, label: 'Notes', value: existing['notes'].to_s }
{ key: :notes, label: 'Notes', value: existing['notes'].to_s } ],
], on_submit: ->(values, _form) {
on_submit: ->(values, _form) { services[:profile].update(username,
services[:profile].update(username, signature: values[:signature],
signature: values[:signature], location: values[:location],
location: values[:location], homepage: values[:homepage],
homepage: values[:homepage], notes: values[:notes]
notes: values[:notes] )
) BBS::Dialogs.message(app, 'Profile saved.', title: ' Saved ')
BBS::Dialogs.message(app, 'Profile saved.', title: ' Saved ') }
} )
) end
end end
end end
end end

View File

@@ -2,41 +2,41 @@
module UI module UI
module Windows module Windows
module_function class SysopConsole
def self.open(app, services:)
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|
body << " · #{c.username} #{c.timestamp[0, 16].sub('T', ' ')}"
end
def sysop_console(app, services:) BBS::Windows::Info.open(app,
body = [] title: ' Sysop Console ', width: 70, height: 18, scroll: true,
body << " Sysop: \e[1;33m#{app.context[:username]}\e[0m" body: body,
body << :spacer buttons: [
body << ' Stats' { label: '&Broadcast', align: :left, on_click: ->(_window) {
body << " · Online users: #{services[:online].count}" BBS::Dialogs.input(app, prompt: 'Sysop broadcast message:',
body << " · Total messages: #{services[:boards].total_count}" title: ' Broadcast ', width: 64,
body << " · Chat subscribers: #{services[:chat].subscriber_count}" on_submit: lambda do |text|
body << :spacer next if text.strip.empty?
body << ' Online users:' services[:chat].broadcast("[SYSOP] #{app.context[:username]}", text.strip)
services[:online].user_list.each { |u| body << " · #{u}" } end)
body << :spacer :keep
body << ' Last 5 callers:' } },
services[:last_callers].recent(5).each do |c| { label: '&Close', cancel: true }
body << " · #{c.username} #{c.timestamp[0, 16].sub('T', ' ')}" ]
)
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 }
]
)
end end
end end
end end

View File

@@ -2,32 +2,33 @@
module UI module UI
module Windows module Windows
module_function class SystemInfo
def self.open(app, services:)
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])],
]
BBS::Windows::Info.open(app,
title: ' System Info ', width: 60, height: 14,
body: [{ kv: rows, label_width: 15 }]
)
end
def system_info(app, services:) def self.format_uptime(start)
rows = [ return '' unless start
['Online users', services[:online].count.to_s], seconds = (Time.now - start).to_i
['Messages', services[:boards].total_count.to_s], h = seconds / 3600
['Boards', services[:boards].boards.size.to_s], m = (seconds % 3600) / 60
['Wiki', 'https://wiki.teletypegames.org'], s = seconds % 60
['Games API', 'https://teletypegames.org'], "#{h}h #{m}m #{s}s"
['Platform', RUBY_PLATFORM], end
['Ruby', RUBY_VERSION], private_class_method :format_uptime
['Uptime', format_uptime(services[:uptime])],
]
BBS::Windows::Info.open(app,
title: ' System Info ', width: 60, height: 14,
body: [{ kv: rows, label_width: 15 }]
)
end
def format_uptime(start)
return '' unless start
seconds = (Time.now - start).to_i
h = seconds / 3600
m = (seconds % 3600) / 60
s = seconds % 60
"#{h}h #{m}m #{s}s"
end end
end end
end end

View File

@@ -2,38 +2,40 @@
module UI module UI
module Windows module Windows
module_function class OnlineUsers
def self.open(app, services:)
def online_users(app, services:) users = services[:online].user_list
users = services[:online].user_list username = app.context[:username]
username = app.context[:username] body = if users.empty?
body = if users.empty? [' (no one else here yet)']
[' (no one else here yet)'] else
else users.map { |u| u == username ? " #{u} ← you" : " #{u}" }
users.map { |u| u == username ? " #{u} ← you" : " #{u}" } end
end BBS::Windows::Info.open(app,
BBS::Windows::Info.open(app, title: ' Online Users ',
title: ' Online Users ', width: 50, height: [users.size + 6, 10].max,
width: 50, height: [users.size + 6, 10].max, scroll: true, body: body
scroll: true, body: body )
) end
end end
def last_callers(app, services:) class LastCallers
callers = services[:last_callers].recent(15) def self.open(app, services:)
body = if callers.empty? callers = services[:last_callers].recent(15)
[' (no callers recorded yet)'] body = if callers.empty?
else [' (no callers recorded yet)']
callers.map do |c| else
ts = c.timestamp.to_s[0, 19].sub('T', ' ') callers.map do |c|
" \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m" ts = c.timestamp.to_s[0, 19].sub('T', ' ')
" \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m"
end
end end
end BBS::Windows::Info.open(app,
BBS::Windows::Info.open(app, title: ' Last Callers ',
title: ' Last Callers ', width: 64, height: [callers.size + 6, 10].max,
width: 64, height: [callers.size + 6, 10].max, scroll: true, body: body
scroll: true, body: body )
) end
end end
end end
end end

View File

@@ -2,27 +2,27 @@
module UI module UI
module Windows module Windows
module_function
# Split-view wiki browser. Left=titles, right=selected page content # Split-view wiki browser. Left=titles, right=selected page content
# (rendered with markdown-aware formatting). # (rendered with markdown-aware formatting).
def wiki(app, services:, category:, title:) class Wiki
items = services[:wiki].list(category) def self.open(app, services:, category:, title:)
BBS::Windows::MasterDetail.open(app, items = services[:wiki].list(category)
title: " #{title} ", BBS::Windows::MasterDetail.open(app,
items: items, title: " #{title} ",
item_label: ->(p) { p.title }, items: items,
list_width: [32, app.cols / 3].max, item_label: ->(p) { p.title },
empty_message: ' (no pages found)', list_width: [32, app.cols / 3].max,
close_button: false, empty_message: ' (no pages found)',
render_detail: ->(page, width) { close_button: false,
text = services[:wiki].content(page.id) render_detail: ->(page, width) {
BBS::Markdown.to_lines(text, width: width - 1, theme: app.theme) 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)}" bottom_meta: ->(page) {
} "#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}"
) }
)
end
end end
end end
end end