new windows types
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
GIT
|
GIT
|
||||||
remote: https://git.teletype.hu/tools/rubbs.git
|
remote: https://git.teletype.hu/tools/rubbs.git
|
||||||
revision: 26f4d2a4eeb766611a925845ddc50fd3ed60157d
|
revision: 2454521f19277c5f754770c2ab8bbe34ac0bdc5e
|
||||||
specs:
|
specs:
|
||||||
bbs (0.4.0)
|
bbs (0.4.0)
|
||||||
artii (~> 2.1)
|
artii (~> 2.1)
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ module UI
|
|||||||
# 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:)
|
def bulletin(app, services:)
|
||||||
width = 70
|
|
||||||
height = 17
|
|
||||||
window = BBS::Dialogs.centred_window(app, width: width, height: height,
|
|
||||||
title: ' Welcome ')
|
|
||||||
|
|
||||||
banner_lines = [
|
banner_lines = [
|
||||||
" \e[1;33m _____ _ _ ____ ____ _____\e[0m",
|
" \e[1;33m _____ _ _ ____ ____ _____\e[0m",
|
||||||
" \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",
|
||||||
" \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m",
|
" \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m",
|
||||||
]
|
]
|
||||||
banner_lines.each_with_index do |line, i|
|
caller_lines = services[:last_callers].recent(3).map do |c|
|
||||||
l = BBS::Widgets::Label.new(text: line, style_key: :accent)
|
" · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m"
|
||||||
l.layout(window.bounds.x + 1, window.bounds.y + 1 + i, width - 2, 1)
|
|
||||||
window.add(l)
|
|
||||||
end
|
end
|
||||||
|
BBS::Windows::Info.open(app,
|
||||||
summary = [
|
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",
|
"Logged in as \e[1;33m#{app.context[:username] || 'Anonymous'}\e[0m",
|
||||||
'',
|
:spacer,
|
||||||
"Online now: #{services[:online].count}",
|
"Online now: #{services[:online].count}",
|
||||||
"Total messages: #{services[:boards].total_count}",
|
"Total messages: #{services[:boards].total_count}",
|
||||||
'',
|
:spacer,
|
||||||
'Last callers:',
|
'Last callers:',
|
||||||
]
|
*caller_lines,
|
||||||
services[:last_callers].recent(3).each do |c|
|
],
|
||||||
summary << " · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m"
|
buttons: [{ label: '&Continue', primary: true, cancel: true }]
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,50 +5,17 @@ module UI
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def chat(app, services:)
|
def chat(app, services:)
|
||||||
cols = app.cols
|
username = app.context[:username] || 'Anonymous'
|
||||||
rows = app.rows
|
BBS::Windows::Stream.open(app,
|
||||||
w = cols - 8
|
title: ' Live Chat ',
|
||||||
h = rows - 6
|
initial_lines: services[:chat].history.map { |l| format_chat_line(l) },
|
||||||
window = BBS::Window.new(title: ' Live Chat ')
|
placeholder: 'Type a message and press Enter…',
|
||||||
window.layout(5, 3, w, h)
|
on_submit: ->(text, _a) { services[:chat].broadcast(username, text.strip) },
|
||||||
window.theme = app.theme
|
on_poll: ->(a) { services[:chat].drain(a.session_id).map { |l| format_chat_line(l) } }
|
||||||
|
)
|
||||||
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
|
|
||||||
end
|
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}"
|
" \e[2;37m#{line.timestamp}\e[0m \e[1;33m#{line.username}\e[0m: #{line.text}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,81 +6,28 @@ module UI
|
|||||||
|
|
||||||
# Game catalog browser. Left=titles, right=card with story + links.
|
# Game catalog browser. Left=titles, right=card with story + links.
|
||||||
def games(app, services:)
|
def games(app, services:)
|
||||||
cols = app.cols
|
BBS::Windows::MasterDetail.open(app,
|
||||||
rows = app.rows
|
title: ' Game Catalog ',
|
||||||
w = cols - 4
|
items: services[:games].all,
|
||||||
h = rows - 6
|
item_label: ->(g) { g.title },
|
||||||
window = BBS::Window.new(title: ' Game Catalog ')
|
list_width: 30,
|
||||||
window.layout(3, 3, w, h)
|
empty_message: ' (no games)',
|
||||||
window.theme = app.theme
|
render_detail: ->(game, width) { game_card_lines(game, width) }
|
||||||
|
|
||||||
games = services[:games].all
|
|
||||||
|
|
||||||
list = BBS::Widgets::ListBox.new(
|
|
||||||
items: games,
|
|
||||||
label: ->(g) { " #{g.title}".ljust(26) }
|
|
||||||
)
|
)
|
||||||
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
|
end
|
||||||
|
|
||||||
|
def game_card_lines(game, width)
|
||||||
lines = []
|
lines = []
|
||||||
lines << " \e[1;33m#{game.title}\e[0m"
|
lines << " \e[1;33m#{game.title}\e[0m"
|
||||||
lines << " \e[0;36m#{game.platform} · #{game.author}\e[0m"
|
lines << " \e[0;36m#{game.platform} · #{game.author}\e[0m"
|
||||||
lines << ''
|
lines << ''
|
||||||
wrap_para(game.desc.to_s, card_w - 4).each { |l| lines << " #{l}" }
|
BBS::FrameBuffer.wrap_ansi(game.desc.to_s, width - 4).each { |l| lines << " #{l}" }
|
||||||
lines << ''
|
lines << ''
|
||||||
wrap_para(game.story.to_s, card_w - 4).each { |l| lines << " #{l}" }
|
BBS::FrameBuffer.wrap_ansi(game.story.to_s, width - 4).each { |l| lines << " #{l}" }
|
||||||
lines << ''
|
lines << ''
|
||||||
game.external_links.each do |link|
|
game.external_links.each do |link|
|
||||||
lines << " \e[0;37m#{link[:label].ljust(10)}\e[0m \e[1;36m#{link[:url]}\e[0m"
|
lines << " \e[0;37m#{link[:label].ljust(10)}\e[0m \e[1;36m#{link[:url]}\e[0m"
|
||||||
end
|
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
|
|
||||||
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
|
|
||||||
end
|
|
||||||
lines << cur unless cur.empty?
|
|
||||||
lines
|
lines
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,154 +5,67 @@ module UI
|
|||||||
module_function
|
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 "n" or click [+ New] to compose, "r" to refresh.
|
# board. Press &New to compose, &Refresh to reload.
|
||||||
def messages(app, services:)
|
def messages(app, services:)
|
||||||
cols = app.cols
|
BBS::Windows::MasterDetail.open(app,
|
||||||
rows = app.rows
|
title: ' Message Boards ',
|
||||||
w = cols - 4
|
items: services[:boards].boards,
|
||||||
h = rows - 6
|
item_label: ->(b) { b.title },
|
||||||
window = BBS::Window.new(title: ' Message Boards ')
|
list_width: 20,
|
||||||
window.layout(3, 3, w, h)
|
empty_message: ' (no boards configured)',
|
||||||
window.theme = app.theme
|
render_detail: ->(board, width) { board_lines(services, board, width) },
|
||||||
|
actions: [
|
||||||
boards = services[:boards].boards
|
{ label: '&New', align: :left, on_click: ->(window) {
|
||||||
board_list = BBS::Widgets::ListBox.new(
|
board = window.list_widget.selected_item
|
||||||
items: boards,
|
compose_message(app, services: services, board: board,
|
||||||
label: ->(b) { " #{b.title}".ljust(18) }
|
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)
|
end
|
||||||
window.add(board_list)
|
|
||||||
|
|
||||||
msg_lines = BBS::Widgets::ScrollView.new(lines: [])
|
def board_lines(services, board, width)
|
||||||
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)
|
msgs = services[:boards].messages(board.id, 100)
|
||||||
wrap_width = msg_lines.bounds.width - 1
|
return [" (no messages on #{board.title} yet — press 'n' to start one)"] if msgs.empty?
|
||||||
lines = []
|
lines = []
|
||||||
if msgs.empty?
|
|
||||||
lines << " (no messages on #{board.title} yet — press 'n' to start one)"
|
|
||||||
else
|
|
||||||
msgs.each do |m|
|
msgs.each do |m|
|
||||||
head = "\e[2;37m#{m.timestamp}\e[0m \e[1;33m#{m.username}\e[0m: "
|
head = "\e[2;37m#{m.timestamp}\e[0m \e[1;33m#{m.username}\e[0m: "
|
||||||
body = m.text.to_s
|
BBS::FrameBuffer.wrap_ansi(head + m.text.to_s, width - 1).each { |l| lines << l }
|
||||||
wrap_text(head + body, wrap_width).each { |l| lines << l }
|
|
||||||
lines << ''
|
lines << ''
|
||||||
end
|
end
|
||||||
end
|
lines
|
||||||
msg_lines.lines = lines
|
|
||||||
msg_lines.scroll = [lines.size - msg_lines.bounds.height, 0].max
|
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def compose_message(app, services:, board:, on_posted: nil)
|
def compose_message(app, services:, board:, on_posted: nil)
|
||||||
return BBS::Dialogs.message(app, 'Select a board first.', title: ' Compose ') unless board
|
return BBS::Dialogs.message(app, 'Select a board first.', title: ' Compose ') unless board
|
||||||
|
|
||||||
w = [app.cols - 8, 70].min
|
username = app.context[:username] || 'Anonymous'
|
||||||
h = 14
|
width = [app.cols - 8, 70].min
|
||||||
window = BBS::Dialogs.centred_window(app, width: w, height: h,
|
BBS::Windows::Form.open(app,
|
||||||
title: " Post to #{board.title} ")
|
title: " Post to #{board.title} ",
|
||||||
|
width: width, height: 14,
|
||||||
hint = BBS::Widgets::Label.new(
|
hint: "From: #{username} — Ctrl-Enter sends",
|
||||||
text: "From: #{app.context[:username] || 'Anonymous'} — Ctrl-Enter sends",
|
submit_label: '&Send',
|
||||||
style_key: :input_label
|
cancel_label: '&Cancel',
|
||||||
)
|
fields: [
|
||||||
hint.layout(window.bounds.x + 2, window.bounds.y + 1, w - 4, 1)
|
{ key: :body, type: :textarea, value: '', max_length: 1000 }
|
||||||
window.add(hint)
|
],
|
||||||
|
on_submit: ->(values, form) {
|
||||||
area = BBS::Widgets::TextArea.new(max_length: 1000)
|
result = services[:boards].post(board.id, username, values[:body])
|
||||||
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)
|
|
||||||
if result == :empty
|
if result == :empty
|
||||||
status.text = 'Message cannot be empty.'
|
form.error('Message cannot be empty.')
|
||||||
|
:keep
|
||||||
else
|
else
|
||||||
app.close_window(window)
|
|
||||||
on_posted&.call
|
on_posted&.call
|
||||||
|
:close
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,56 +7,25 @@ module UI
|
|||||||
def profile(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,
|
||||||
w = 64
|
title: " Profile — #{username} ",
|
||||||
h = 14
|
width: 64, height: 14,
|
||||||
window = BBS::Dialogs.centred_window(app, width: w, height: h,
|
fields: [
|
||||||
title: " Profile — #{username} ")
|
{ key: :signature, label: 'Signature', value: existing['signature'].to_s },
|
||||||
|
{ key: :location, label: 'Location', value: existing['location'].to_s },
|
||||||
fields = [
|
{ key: :homepage, label: 'Homepage', value: existing['homepage'].to_s },
|
||||||
[:signature, 'Signature', existing['signature']],
|
{ key: :notes, label: 'Notes', value: existing['notes'].to_s }
|
||||||
[:location, 'Location', existing['location']],
|
],
|
||||||
[:homepage, 'Homepage', existing['homepage']],
|
on_submit: ->(values, _form) {
|
||||||
[: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 |_|
|
|
||||||
services[:profile].update(username,
|
services[:profile].update(username,
|
||||||
signature: inputs[:signature].value,
|
signature: values[:signature],
|
||||||
location: inputs[:location].value,
|
location: values[:location],
|
||||||
homepage: inputs[:homepage].value,
|
homepage: values[:homepage],
|
||||||
notes: inputs[:notes].value
|
notes: values[:notes]
|
||||||
)
|
)
|
||||||
app.close_window(window)
|
|
||||||
BBS::Dialogs.message(app, 'Profile saved.', title: ' Saved ')
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,54 +5,38 @@ module UI
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def sysop_console(app, services:)
|
def sysop_console(app, services:)
|
||||||
width = 70
|
body = []
|
||||||
height = 18
|
body << " Sysop: \e[1;33m#{app.context[:username]}\e[0m"
|
||||||
window = BBS::Dialogs.centred_window(app, width: width, height: height,
|
body << :spacer
|
||||||
title: ' Sysop Console ')
|
body << ' Stats'
|
||||||
|
body << " · Online users: #{services[:online].count}"
|
||||||
lines = []
|
body << " · Total messages: #{services[:boards].total_count}"
|
||||||
lines << " Sysop: \e[1;33m#{app.context[:username]}\e[0m"
|
body << " · Chat subscribers: #{services[:chat].subscriber_count}"
|
||||||
lines << ''
|
body << :spacer
|
||||||
lines << ' Stats'
|
body << ' Online users:'
|
||||||
lines << " · Online users: #{services[:online].count}"
|
services[:online].user_list.each { |u| body << " · #{u}" }
|
||||||
lines << " · Total messages: #{services[:boards].total_count}"
|
body << :spacer
|
||||||
lines << " · Chat subscribers: #{services[:chat].subscriber_count}"
|
body << ' Last 5 callers:'
|
||||||
lines << ''
|
|
||||||
lines << ' Online users:'
|
|
||||||
services[:online].user_list.each { |u| lines << " · #{u}" }
|
|
||||||
lines << ''
|
|
||||||
lines << ' Last 5 callers:'
|
|
||||||
services[:last_callers].recent(5).each do |c|
|
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
|
end
|
||||||
|
|
||||||
view = BBS::Widgets::ScrollView.new(lines: lines)
|
BBS::Windows::Info.open(app,
|
||||||
view.layout(window.bounds.x + 2, window.bounds.y + 1,
|
title: ' Sysop Console ', width: 70, height: 18, scroll: true,
|
||||||
width - 4, height - 4)
|
body: body,
|
||||||
window.add(view)
|
buttons: [
|
||||||
|
{ label: '&Broadcast', align: :left, on_click: ->(_window) {
|
||||||
broadcast = BBS::Widgets::Button.new(
|
|
||||||
label: '&Broadcast',
|
|
||||||
on_click: lambda do |_|
|
|
||||||
BBS::Dialogs.input(app, prompt: 'Sysop broadcast message:',
|
BBS::Dialogs.input(app, prompt: 'Sysop broadcast message:',
|
||||||
title: ' Broadcast ', width: 64,
|
title: ' Broadcast ', width: 64,
|
||||||
on_submit: lambda do |text|
|
on_submit: lambda do |text|
|
||||||
next if text.strip.empty?
|
next if text.strip.empty?
|
||||||
services[:chat].broadcast("[SYSOP] #{app.context[:username]}", text.strip)
|
services[:chat].broadcast("[SYSOP] #{app.context[:username]}", text.strip)
|
||||||
end)
|
end)
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ module UI
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def system_info(app, services:)
|
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 = [
|
rows = [
|
||||||
['Online users', services[:online].count.to_s],
|
['Online users', services[:online].count.to_s],
|
||||||
['Messages', services[:boards].total_count.to_s],
|
['Messages', services[:boards].total_count.to_s],
|
||||||
@@ -20,23 +15,10 @@ module UI
|
|||||||
['Ruby', RUBY_VERSION],
|
['Ruby', RUBY_VERSION],
|
||||||
['Uptime', format_uptime(services[:uptime])],
|
['Uptime', format_uptime(services[:uptime])],
|
||||||
]
|
]
|
||||||
|
BBS::Windows::Info.open(app,
|
||||||
rows.each_with_index do |(k, v), i|
|
title: ' System Info ', width: 60, height: 14,
|
||||||
label = BBS::Widgets::Label.new(text: k.ljust(15), style_key: :input_label)
|
body: [{ kv: rows, label_width: 15 }]
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_uptime(start)
|
def format_uptime(start)
|
||||||
|
|||||||
@@ -5,38 +5,23 @@ module UI
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def online_users(app, services:)
|
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
|
users = services[:online].user_list
|
||||||
lines = users.map do |u|
|
username = app.context[:username]
|
||||||
u == app.context[:username] ? " #{u} ← you" : " #{u}"
|
body = if users.empty?
|
||||||
|
[' (no one else here yet)']
|
||||||
|
else
|
||||||
|
users.map { |u| u == username ? " #{u} ← you" : " #{u}" }
|
||||||
end
|
end
|
||||||
lines = [' (no one else here yet)'] if lines.empty?
|
BBS::Windows::Info.open(app,
|
||||||
view = BBS::Widgets::ScrollView.new(lines: lines)
|
title: ' Online Users ',
|
||||||
view.layout(window.bounds.x + 2, window.bounds.y + 1,
|
width: 50, height: [users.size + 6, 10].max,
|
||||||
width - 4, height - 4)
|
scroll: true, body: body
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_callers(app, services:)
|
def last_callers(app, services:)
|
||||||
width = 64
|
|
||||||
callers = services[:last_callers].recent(15)
|
callers = services[:last_callers].recent(15)
|
||||||
height = [callers.size + 6, 10].max
|
body = if callers.empty?
|
||||||
window = BBS::Dialogs.centred_window(app, width: width, height: height,
|
|
||||||
title: ' Last Callers ')
|
|
||||||
|
|
||||||
lines = if callers.empty?
|
|
||||||
[' (no callers recorded yet)']
|
[' (no callers recorded yet)']
|
||||||
else
|
else
|
||||||
callers.map do |c|
|
callers.map do |c|
|
||||||
@@ -44,18 +29,11 @@ module UI
|
|||||||
" \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m"
|
" \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
view = BBS::Widgets::ScrollView.new(lines: lines)
|
BBS::Windows::Info.open(app,
|
||||||
view.layout(window.bounds.x + 2, window.bounds.y + 1,
|
title: ' Last Callers ',
|
||||||
width - 4, height - 4)
|
width: 64, height: [callers.size + 6, 10].max,
|
||||||
window.add(view)
|
scroll: true, body: body
|
||||||
|
)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,209 +7,22 @@ module UI
|
|||||||
# 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:)
|
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)
|
items = services[:wiki].list(category)
|
||||||
|
BBS::Windows::MasterDetail.open(app,
|
||||||
list = BBS::Widgets::ListBox.new(
|
title: " #{title} ",
|
||||||
items: items,
|
items: items,
|
||||||
label: ->(p) { " #{p.title}".ljust(28) }
|
item_label: ->(p) { p.title },
|
||||||
)
|
list_width: [32, app.cols / 3].max,
|
||||||
list_width = [32, (w / 3)].max
|
empty_message: ' (no pages found)',
|
||||||
list.layout(window.bounds.x + 2, window.bounds.y + 2, list_width, h - 5)
|
close_button: false,
|
||||||
window.add(list)
|
render_detail: ->(page, width) {
|
||||||
|
|
||||||
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)
|
text = services[:wiki].content(page.id)
|
||||||
preview.lines = format_md(text, preview.bounds.width - 1, app.theme)
|
BBS::Markdown.to_lines(text, width: width - 1, theme: app.theme)
|
||||||
preview.scroll = 0
|
},
|
||||||
end
|
bottom_meta: ->(page) {
|
||||||
|
"#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}"
|
||||||
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(/(?<![A-Za-z0-9*])\*([^*\s][^*]*?[^*\s]|\S)\*(?![A-Za-z0-9*])/) {
|
|
||||||
"#{styles[:italic]}#{::Regexp.last_match(1)}#{base_sgr}"
|
|
||||||
}
|
|
||||||
s = s.gsub(/(?<![A-Za-z0-9_])_([^_\s][^_]*?[^_\s]|\S)_(?![A-Za-z0-9_])/) {
|
|
||||||
"#{styles[:italic]}#{::Regexp.last_match(1)}#{base_sgr}"
|
|
||||||
}
|
|
||||||
# Strip stray backslash escapes for common markdown chars
|
|
||||||
s.gsub(/\\([\\`*_{}\[\]()#+\-.!>])/) { ::Regexp.last_match(1) }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user