From 26f4d2a4eeb766611a925845ddc50fd3ed60157d Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 13 May 2026 18:59:44 +0200 Subject: [PATCH] text format --- lib/bbs/frame_buffer.rb | 84 +++++++++++++++++++++++++++++++++++++++++ lib/bbs/widgets.rb | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/lib/bbs/frame_buffer.rb b/lib/bbs/frame_buffer.rb index 5b753ad..cd87d91 100644 --- a/lib/bbs/frame_buffer.rb +++ b/lib/bbs/frame_buffer.rb @@ -5,6 +5,90 @@ module BBS Cell = Struct.new(:char, :sgr) BLANK = Cell.new(' ', nil).freeze + ANSI_ESC_RE = /\e\[[\d;]*[A-Za-z~]/.freeze + + # Length of the visible portion of an ANSI-styled string. + def self.visible_width(text) + text.to_s.gsub(ANSI_ESC_RE, '').length + end + + # Truncate an ANSI-styled string so its visible width is ≤ max_width. + # SGR escape sequences are preserved (they have zero visible width). + def self.clip_ansi(text, max_width) + return '' if max_width <= 0 + out = +'' + visible = 0 + i = 0 + s = text.to_s + len = s.length + while i < len + if s[i] == "\e" && i + 1 < len && s[i + 1] == '[' + j = i + 2 + j += 1 while j < len && !s[j].match?(/[A-Za-z~]/) + break if j >= len + out << s[i..j] + i = j + 1 + next + end + break if visible >= max_width + out << s[i] + visible += 1 + i += 1 + end + out + end + + # Word-wrap an ANSI-styled string to lines of visible width ≤ width. + # SGR state is carried across wrapped lines: when the original text has + # an active color/attribute at a wrap point, the next line starts with + # that same SGR sequence so styling continues seamlessly. + def self.wrap_ansi(text, width) + return [''] if width <= 0 + s = text.to_s + return [s] if visible_width(s) <= width + + lines = [] + cur = +'' + cur_visible = 0 + active_sgr = nil + + flush = lambda do + lines << cur.rstrip + cur = +'' + cur_visible = 0 + cur << active_sgr if active_sgr + end + + s.scan(/(\e\[[\d;]*[A-Za-z~]|[^\s\e]+|\s+)/) do |(tok)| + if tok.start_with?("\e") + active_sgr = (tok == "\e[0m" || tok == "\e[m") ? nil : tok + cur << tok + next + end + + tok_visible = tok.gsub(ANSI_ESC_RE, '').length + + if tok.match?(/\A\s+\z/) + if cur_visible == 0 + cur << tok + cur_visible += tok_visible + elsif cur_visible + tok_visible <= width + cur << tok + cur_visible += tok_visible + else + flush.call + end + else + flush.call if cur_visible + tok_visible > width && cur_visible > 0 + cur << tok + cur_visible += tok_visible + end + end + + lines << cur.rstrip unless cur.empty? + lines.empty? ? [''] : lines + end + attr_reader :cols, :rows def initialize(cols, rows) diff --git a/lib/bbs/widgets.rb b/lib/bbs/widgets.rb index 74dbaf4..b9374a6 100644 --- a/lib/bbs/widgets.rb +++ b/lib/bbs/widgets.rb @@ -445,7 +445,7 @@ module BBS line = @lines[@scroll + r] next unless line frame.move(bounds.x, bounds.y + r) - frame.write_ansi(line[0, bounds.width].to_s, base_sgr: sgr) + frame.write_ansi(FrameBuffer.clip_ansi(line.to_s, bounds.width), base_sgr: sgr) end # right-side scrollbar if @lines.size > bounds.height && bounds.width > 1