diff --git a/lib/bbs.rb b/lib/bbs.rb index 7e065bd..8d6e4fc 100644 --- a/lib/bbs.rb +++ b/lib/bbs.rb @@ -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' diff --git a/lib/bbs/application.rb b/lib/bbs/application.rb index c0780ad..4cacce0 100644 --- a/lib/bbs/application.rb +++ b/lib/bbs/application.rb @@ -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) } diff --git a/lib/bbs/widgets/ansi_art.rb b/lib/bbs/widgets/ansi_art.rb new file mode 100644 index 0000000..efbb4e1 --- /dev/null +++ b/lib/bbs/widgets/ansi_art.rb @@ -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