178 lines
7.3 KiB
Ruby
178 lines
7.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative '../widget'
|
|
|
|
module BBS
|
|
module Widgets
|
|
# AnsiArt — render a CP437 / ANSI art file (or any pre-encoded ASCII art)
|
|
# into a region of the screen.
|
|
#
|
|
# art = BBS::Widgets::AnsiArt.from_file('art/welcome.ans')
|
|
# art.layout(1, 2, 80, 22)
|
|
# container.add(art)
|
|
#
|
|
# File handling:
|
|
# .ans / .asc / .nfo → decoded as IBM437 → UTF-8
|
|
# .ansi / .txt → assumed to already be UTF-8 (or 7-bit ASCII +
|
|
# ANSI escapes)
|
|
# any other extension → tries UTF-8 first, falls back to IBM437
|
|
#
|
|
# SAUCE metadata (last 129 bytes, marked by \x1A SUB) is stripped.
|
|
#
|
|
# Layout options:
|
|
# align: :center (default), :left, :right — horizontal placement
|
|
# valign: :middle (default), :top, :bottom — vertical placement
|
|
# width — clip art to this many visible columns (default: auto)
|
|
#
|
|
# See AnsiArt.random(dir) for picking a random file from a directory.
|
|
class AnsiArt < Widget
|
|
attr_accessor :lines, :art_width, :art_height, :align, :valign, :fill_parent
|
|
|
|
def initialize(lines: [], align: :center, valign: :middle, fill_parent: true, **kw)
|
|
super(**kw)
|
|
@lines = lines
|
|
@align = align
|
|
@valign = valign
|
|
@fill_parent = fill_parent
|
|
@art_width = (lines.map { |l| visible_length(l) }.max || 0)
|
|
@art_height = lines.size
|
|
end
|
|
|
|
def self.from_file(path, **opts)
|
|
new(lines: load_file(path), **opts)
|
|
end
|
|
|
|
def self.from_string(text, **opts)
|
|
new(lines: split_lines(text), **opts)
|
|
end
|
|
|
|
# Pick a random file from +dir+. Returns a built-in fallback if the
|
|
# directory is missing or empty.
|
|
def self.random(dir, **opts)
|
|
files = Dir.glob(File.join(dir, '*.{ans,ansi,asc,nfo,txt}')).reject { |f| File.directory?(f) }
|
|
return new(lines: built_in_fallback, **opts) if files.empty?
|
|
from_file(files.sample, **opts)
|
|
rescue StandardError
|
|
new(lines: built_in_fallback, **opts)
|
|
end
|
|
|
|
def self.default_lines
|
|
built_in_fallback
|
|
end
|
|
|
|
def render(frame)
|
|
return if @lines.empty?
|
|
if @fill_parent && parent && parent.bounds
|
|
@bounds = parent.bounds.dup
|
|
end
|
|
return if bounds.width <= 0 || bounds.height <= 0
|
|
|
|
visible_h = [@art_height, bounds.height].min
|
|
y_offset = case @valign
|
|
when :top then 0
|
|
when :bottom then [bounds.height - visible_h, 0].max
|
|
else [(bounds.height - visible_h) / 2, 0].max
|
|
end
|
|
|
|
@lines.first(visible_h).each_with_index do |line, i|
|
|
line_width = visible_length(line)
|
|
clipped, clipped_width = clip_line(line, bounds.width)
|
|
x_offset = case @align
|
|
when :left then 0
|
|
when :right then [bounds.width - clipped_width, 0].max
|
|
else [(bounds.width - clipped_width) / 2, 0].max
|
|
end
|
|
frame.move(bounds.x + x_offset, bounds.y + y_offset + i)
|
|
frame.write_ansi(clipped, base_sgr: style(:text))
|
|
end
|
|
end
|
|
|
|
# ── File loading ────────────────────────────────────────────────────────
|
|
def self.load_file(path)
|
|
raw = File.binread(path)
|
|
raw = strip_sauce(raw)
|
|
text = transcode(raw, path)
|
|
split_lines(text)
|
|
end
|
|
|
|
def self.strip_sauce(bytes)
|
|
sub = bytes.index("\x1A".b)
|
|
sub ? bytes.byteslice(0, sub) : bytes
|
|
end
|
|
|
|
def self.transcode(bytes, path)
|
|
ext = File.extname(path).downcase
|
|
case ext
|
|
when '.ans', '.asc', '.nfo'
|
|
bytes.force_encoding('IBM437').encode('UTF-8', invalid: :replace, undef: :replace)
|
|
when '.ansi', '.txt'
|
|
bytes.force_encoding('UTF-8').scrub('?')
|
|
else
|
|
# try utf8 first
|
|
utf8 = bytes.dup.force_encoding('UTF-8')
|
|
utf8.valid_encoding? ? utf8 : bytes.force_encoding('IBM437').encode('UTF-8',
|
|
invalid: :replace, undef: :replace)
|
|
end
|
|
end
|
|
|
|
def self.split_lines(text)
|
|
text.gsub(/\r\n?/, "\n").split("\n", -1)
|
|
end
|
|
|
|
# Strip ANSI escapes and count visible characters.
|
|
def visible_length(line)
|
|
line.gsub(/\e\[[\d;?]*[A-Za-z]/, '').length
|
|
end
|
|
|
|
# Clip the visible portion of a line that may contain ANSI escapes.
|
|
def clip_line(line, max_visible)
|
|
visible = 0
|
|
out = +''
|
|
i = 0
|
|
s = line
|
|
while i < s.length
|
|
ch = s[i]
|
|
if ch == "\e" && s[i + 1] == '['
|
|
j = i + 2
|
|
j += 1 while j < s.length && !s[j].match?(/[A-Za-z]/)
|
|
out << s[i..j] if j < s.length
|
|
i = j + 1
|
|
else
|
|
break if visible >= max_visible
|
|
out << ch
|
|
visible += 1
|
|
i += 1
|
|
end
|
|
end
|
|
[out, visible]
|
|
end
|
|
|
|
# Default art used when no file is configured / available.
|
|
def self.built_in_fallback
|
|
sgr = ->(code) { "\e[#{code}m" }
|
|
y = sgr.call('1;33')
|
|
d = sgr.call('0;33')
|
|
c = sgr.call('1;36')
|
|
r = sgr.call('0')
|
|
[
|
|
'',
|
|
"#{y} ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄#{r}",
|
|
"#{y} █#{d}░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░#{y}█#{r}",
|
|
"#{y} █#{d}░ #{c}╔══════════════════════════════════════════════╗#{d} ░#{y}█#{r}",
|
|
"#{y} █#{d}░ #{c}║ #{y}▀█▀ █▀▀ █ █▀▀ ▀█▀ █▄█ █▀█ █▀▀ █▀▄ █▀▄ █▀#{c} ║#{d} ░#{y}█#{r}",
|
|
"#{y} █#{d}░ #{c}║ #{y} █ ██▄ █▄▄ ██▄ █ █ █▀▀ ██▄ █▄▀ █▄▀ ▄█#{c} ║#{d} ░#{y}█#{r}",
|
|
"#{y} █#{d}░ #{c}║ ║#{d} ░#{y}█#{r}",
|
|
"#{y} █#{d}░ #{c}║ #{r}an open call to old friends#{c} ║#{d} ░#{y}█#{r}",
|
|
"#{y} █#{d}░ #{c}║ ║#{d} ░#{y}█#{r}",
|
|
"#{y} █#{d}░ #{c}╚══════════════════════════════════════════════╝#{d} ░#{y}█#{r}",
|
|
"#{y} █#{d}░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░#{y}█#{r}",
|
|
"#{y} ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀#{r}",
|
|
'',
|
|
" #{d}F10#{r} open the top menu #{d}F1#{r} help",
|
|
" #{d}F2#{r} message boards #{d}F4#{r} chat",
|
|
]
|
|
end
|
|
end
|
|
end
|
|
end
|