diff --git a/lib/bbs.rb b/lib/bbs.rb index ad601b7..b51ce5f 100644 --- a/lib/bbs.rb +++ b/lib/bbs.rb @@ -8,6 +8,7 @@ require_relative 'bbs/flow' require_relative 'bbs/flow_runner' require_relative 'bbs/renderer' require_relative 'bbs/store' +require_relative 'bbs/frame_buffer' require_relative 'bbs/tui' require_relative 'bbs/tui_runner' require_relative 'bbs/session' diff --git a/lib/bbs/frame_buffer.rb b/lib/bbs/frame_buffer.rb new file mode 100644 index 0000000..6523c60 --- /dev/null +++ b/lib/bbs/frame_buffer.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module BBS + class FrameBuffer + Cell = Struct.new(:char, :sgr) + BLANK = Cell.new(' ', nil).freeze + + attr_reader :cols, :rows + + def initialize(cols, rows) + @cols = cols + @rows = rows + clear + end + + def clear + @cells = Array.new(@rows) { Array.new(@cols) { BLANK } } + @cx = 0 + @cy = 0 + end + + def move(col, row) + @cx = col.clamp(1, @cols) - 1 + @cy = row.clamp(1, @rows) - 1 + end + + def write(text, sgr: nil) + text.each_char do |ch| + break if @cy >= @rows + @cells[@cy][@cx] = Cell.new(ch, sgr) if @cx < @cols + @cx += 1 + end + end + + # Write text that may contain embedded ANSI SGR / cursor sequences. + # base_sgr is the initial style, overridden by any embedded \e[...m sequences. + def write_ansi(text, base_sgr: nil) + cur_sgr = base_sgr + s = text.to_s + i = 0 + len = s.length + + while i < len + ch = s[i] + + if ch == "\e" && i + 1 < len && s[i + 1] == '[' + j = i + 2 + j += 1 while j < len && !s[j].match?(/[A-Za-z]/) + + if j < len + term = s[j] + inner = s[(i + 2)...j] + + case term + when 'm' + seq = s[i..j] + cur_sgr = (seq == "\e[0m" || seq == "\e[m") ? nil : seq + when 'H', 'f' + parts = inner.split(';') + row = [parts[0].to_i, 1].max + col = [parts[1].to_i, 1].max + move(col, row) + when 'J' + clear if inner == '2' + end + + i = j + 1 + else + i += 1 + end + elsif ch.ord >= 32 + if @cy < @rows && @cx < @cols + @cells[@cy][@cx] = Cell.new(ch, cur_sgr) + end + @cx += 1 + i += 1 + else + i += 1 + end + end + end + + # Compute the minimal ANSI byte stream to transform +from+ into +self+. + def diff(from) + out = +"" + cur_sgr = :_none + exp_r = nil + exp_c = nil + + @rows.times do |r| + @cols.times do |c| + nc = @cells[r][c] + oc = from.cell_at(r, c) + next if nc == oc + + unless r == exp_r && c == exp_c + out << "\e[#{r + 1};#{c + 1}H" + end + + if nc.sgr != cur_sgr + out << (nc.sgr || "\e[0m") + cur_sgr = nc.sgr + end + + out << nc.char + exp_r = r + exp_c = c + 1 + end + end + + out << "\e[0m" if cur_sgr != :_none && cur_sgr + out + end + + protected + + def cell_at(row, col) + @cells[row][col] + end + end +end diff --git a/lib/bbs/tui_runner.rb b/lib/bbs/tui_runner.rb index 01e0040..00e4763 100644 --- a/lib/bbs/tui_runner.rb +++ b/lib/bbs/tui_runner.rb @@ -37,11 +37,11 @@ module BBS }.freeze def initialize(session, session_id, tui) - @session = session - @session_id = session_id - @tui = tui - @current_page = nil - @buf = +"" + @session = session + @session_id = session_id + @tui = tui + @current_page = nil + @committed_frame = nil extend(@tui.helper_module) if @tui.helper_module end @@ -55,13 +55,27 @@ module BBS def reload = go(@current_page) def do_render - @buf = +"\e[?25l" - clear + cols = term_cols + rows = term_rows + + first_render = @committed_frame.nil? + resized = !first_render && + (@committed_frame.cols != cols || @committed_frame.rows != rows) + + @committed_frame = FrameBuffer.new(cols, rows) if first_render || resized + @next_frame = FrameBuffer.new(cols, rows) + instance_eval(&@tui.chrome_block) if @tui.chrome_block page = @tui.pages[@current_page] instance_eval(&page.render_block) if page&.render_block - @buf << "\e[?25h" - @session.write(@buf) + + delta = @next_frame.diff(@committed_frame) + + if first_render || resized || !delta.empty? + prefix = (first_render || resized) ? "\e[2J\e[H" : "" + @session.write("\e[?25l#{prefix}#{delta}\e[?25h") + @committed_frame = @next_frame + end end def dispatch_key(key) @@ -80,39 +94,51 @@ module BBS # ── drawing primitives ────────────────────────────────────────────────── - def clear = @buf << "\e[2J\e[H" + def clear + @next_frame&.clear + end + def term_cols = @session.term_cols || 80 def term_rows = @session.term_rows || 24 def at(col, row) - @buf << "\e[#{row};#{col}H" + @next_frame.move(col, row) end def text(content, x: nil, y: nil, style: nil) at(x, y) if x && y - color = resolve_style(style) - @buf << (color ? "#{color}#{content}\e[0m" : content.to_s) + @next_frame.write_ansi(content.to_s, base_sgr: resolve_style(style)) end def bar(y:, content:, width: nil, style: :muted) width ||= term_cols - text content.to_s.ljust(width), x: 1, y: y, style: style + sgr = resolve_style(style) + at 1, y + @next_frame.write(' ' * width, sgr: sgr) + at 1, y + @next_frame.write_ansi(content.to_s, base_sgr: sgr) end def list(items, x:, y:, selected: nil, style: :muted, highlight: :success) - norm = resolve_style(style) || STYLES[:muted] + norm = resolve_style(style) || STYLES[:muted] hi = resolve_style(highlight) || STYLES[:success] items.each_with_index do |item, i| at x, y + i - @buf << (i == selected ? "#{hi}▶ #{item}\e[0m" : "#{norm} #{item}\e[0m") + if i == selected + @next_frame.write("▶ ", sgr: hi) + @next_frame.write_ansi(item.to_s, base_sgr: hi) + else + @next_frame.write(" ", sgr: norm) + @next_frame.write_ansi(item.to_s, base_sgr: norm) + end end end def fill(x:, y:, w:, h:, bg:) - color = BG_COLORS.fetch(bg, bg.to_s) + sgr = BG_COLORS.fetch(bg, bg.to_s) h.times do |dy| at x, y + dy - @buf << "#{color}#{' ' * w}\e[0m" + @next_frame.write(' ' * w, sgr: sgr) end end @@ -123,25 +149,26 @@ module BBS def box(x:, y:, w:, h:, title: nil, style: :muted, bg: nil) color = resolve_style(style) || STYLES[:muted] inner = w - 2 - bg_str = bg ? BG_COLORS.fetch(bg, bg.to_s) : nil + bg_sgr = bg ? BG_COLORS.fetch(bg, bg.to_s) : nil at x, y if title prefix = "══ #{title} " pad = [inner - prefix.length, 0].max - @buf << "#{color}╔#{prefix}#{'═' * pad}╗\e[0m" + @next_frame.write("╔#{prefix}#{'═' * pad}╗", sgr: color) else - @buf << "#{color}╔#{'═' * inner}╗\e[0m" + @next_frame.write("╔#{'═' * inner}╗", sgr: color) end (1...h - 1).each do |dy| at x, y + dy - interior = bg_str ? "#{bg_str}#{' ' * inner}\e[0m" : ' ' * inner - @buf << "#{color}║\e[0m#{interior}#{color}║\e[0m" + @next_frame.write('║', sgr: color) + @next_frame.write(' ' * inner, sgr: bg_sgr) + @next_frame.write('║', sgr: color) end at x, y + h - 1 - @buf << "#{color}╚#{'═' * inner}╝\e[0m" + @next_frame.write("╚#{'═' * inner}╝", sgr: color) yield x + 1, y + 1, inner, h - 2 if block_given? end