ascii art
This commit is contained in:
@@ -14,6 +14,7 @@ require_relative 'bbs/theme'
|
||||
require_relative 'bbs/widget'
|
||||
require_relative 'bbs/container'
|
||||
require_relative 'bbs/widgets'
|
||||
require_relative 'bbs/widgets/ansi_art'
|
||||
require_relative 'bbs/window'
|
||||
require_relative 'bbs/focus_manager'
|
||||
require_relative 'bbs/menu'
|
||||
|
||||
@@ -160,8 +160,6 @@ module BBS
|
||||
end
|
||||
|
||||
def dispatch(event)
|
||||
relayout!
|
||||
|
||||
# Modal windows: top window gets first shot at events
|
||||
if (win = top_window)
|
||||
result = win.handle_event(event)
|
||||
@@ -219,6 +217,8 @@ module BBS
|
||||
@committed = FrameBuffer.new(c, r) if first || resized
|
||||
@next = FrameBuffer.new(c, r)
|
||||
|
||||
relayout!
|
||||
|
||||
@next.fill(x: 1, y: 1, width: c, height: r, sgr: @theme[:screen])
|
||||
@root.render(@next)
|
||||
@windows.each { |w| w.render(@next) }
|
||||
|
||||
177
lib/bbs/widgets/ansi_art.rb
Normal file
177
lib/bbs/widgets/ansi_art.rb
Normal file
@@ -0,0 +1,177 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user