diff --git a/lib/bbs/markdown.rb b/lib/bbs/markdown.rb index e3a5907..036d3d4 100644 --- a/lib/bbs/markdown.rb +++ b/lib/bbs/markdown.rb @@ -39,27 +39,26 @@ module BBS paragraph.clear end - text.to_s.split(/\r?\n/).each do |line| + lines = text.to_s.split(/\r?\n/) + i = 0 + while i < lines.size + line = lines[i] + consumed = 1 + if line.lstrip.start_with?('```') flush_paragraph.call in_code = !in_code - next - end - if in_code + elsif in_code flush_paragraph.call clipped = FrameBuffer.clip_ansi(line, width) result << "#{styles[:code]}#{clipped}#{base}" - next - end - if line.strip.empty? + elsif line.strip.empty? flush_paragraph.call result << '' - next - end - if (m = line.match(/\A([#]{1,6})\s+(.+?)\s*#*\s*\z/)) + elsif (m = line.match(/\A([#]{1,6})\s+(.+?)\s*#*\s*\z/)) level = m[1].length heading = m[2].strip flush_paragraph.call @@ -67,44 +66,50 @@ module BBS FrameBuffer.wrap_ansi(inline(heading, styles, base), width).each do |l| result << "#{style}#{l}#{base}" end - next - end - if line.match?(/\A\s*([-*_])\1{2,}\s*\z/) + elsif line.include?('|') && (i + 1) < lines.size && separator_row?(lines[i + 1]) + flush_paragraph.call + header = split_row(line) + aligns = parse_aligns(lines[i + 1], header.size) + rows = [] + j = i + 2 + while j < lines.size && lines[j].include?('|') && + !lines[j].strip.empty? && !lines[j].lstrip.start_with?('```') + rows << split_row(lines[j]) + j += 1 + end + render_table(header, aligns, rows, width, styles, base).each { |l| result << l } + consumed = j - i + + elsif line.match?(/\A\s*([-*_])\1{2,}\s*\z/) flush_paragraph.call result << "#{styles[:rule]}#{'─' * width}#{base}" - next - end - if (m = line.match(/\A\s*>\s?(.*)/)) + elsif (m = line.match(/\A\s*>\s?(.*)/)) content = m[1] flush_paragraph.call inner_w = [width - 2, 1].max FrameBuffer.wrap_ansi(inline(content, styles, base), inner_w).each do |l| result << "#{styles[:quote]}│ #{base}#{l}" end - next - end - if (m = line.match(/\A(\s*)[-*+]\s+(.+)/)) + elsif (m = line.match(/\A(\s*)[-*+]\s+(.+)/)) indent = m[1].length body = m[2] flush_paragraph.call prefix_w = indent + 2 inner_w = [width - prefix_w, 1].max wrapped = FrameBuffer.wrap_ansi(inline(body, styles, base), inner_w) - wrapped.each_with_index do |l, i| + wrapped.each_with_index do |l, k| pad = ' ' * indent - result << if i.zero? + result << if k.zero? "#{pad}#{styles[:bullet]}•#{base} #{l}" else "#{' ' * prefix_w}#{l}" end end - next - end - if (m = line.match(/\A(\s*)(\d+)\.\s+(.+)/)) + elsif (m = line.match(/\A(\s*)(\d+)\.\s+(.+)/)) indent = m[1].length num = m[2] body = m[3] @@ -112,17 +117,19 @@ module BBS prefix = "#{' ' * indent}#{num}. " inner_w = [width - prefix.length, 1].max wrapped = FrameBuffer.wrap_ansi(inline(body, styles, base), inner_w) - wrapped.each_with_index do |l, i| - result << if i.zero? + wrapped.each_with_index do |l, k| + result << if k.zero? "#{' ' * indent}#{styles[:bullet]}#{num}.#{base} #{l}" else "#{' ' * prefix.length}#{l}" end end - next + + else + paragraph << line.strip end - paragraph << line.strip + i += consumed end flush_paragraph.call @@ -142,5 +149,104 @@ module BBS end s.gsub(/\\([\\`*_{}\[\]()#+\-.!>])/) { ::Regexp.last_match(1) } end + + # ── GFM tables ─────────────────────────────────────────────────────────── + + # Split a `| a | b |` table row into trimmed cell strings, tolerating + # missing leading/trailing pipes. + def split_row(line) + s = line.strip + s = s[1..] if s.start_with?('|') + s = s[0..-2] if s.end_with?('|') + s.split('|').map(&:strip) + end + + # True when a line is a table delimiter row, e.g. `|---|:--:|---:|`. + def separator_row?(line) + return false unless line.include?('-') + cells = split_row(line) + !cells.empty? && cells.all? { |c| c.match?(/\A:?-{1,}:?\z/) } + end + + # Per-column alignment (:left / :center / :right) derived from the + # delimiter row, padded/truncated to `ncols`. + def parse_aligns(sep_line, ncols) + cells = split_row(sep_line) + aligns = cells.map do |c| + left = c.start_with?(':') + right = c.end_with?(':') + if left && right then :center + elsif right then :right + else :left + end + end + aligns << :left while aligns.size < ncols + aligns[0, ncols] + end + + # Render a parsed table as box-drawn ANSI lines that fit within `width`. + def render_table(header, aligns, rows, width, styles, base) + ncols = header.size + return [] if ncols.zero? + + all_rows = [header] + rows + # Normalise ragged rows to ncols and pre-render inline styling per cell. + rendered = all_rows.map do |row| + Array.new(ncols) { |c| inline(row[c].to_s, styles, base) } + end + + widths = Array.new(ncols, 1) + rendered.each do |row| + ncols.times do |c| + w = FrameBuffer.visible_width(row[c]) + widths[c] = w if w > widths[c] + end + end + + # Shrink columns proportionally if the table overflows the pane. + # Frame overhead: one space of padding each side of every cell plus the + # vertical borders (ncols + 1). + overhead = (ncols * 2) + (ncols + 1) + avail = [width - overhead, ncols].max + while widths.sum > avail + widest = widths.each_index.max_by { |c| widths[c] } + break if widths[widest] <= 1 + widths[widest] -= 1 + end + + bar = "#{styles[:rule]}│#{base}" + border = lambda do |left, mid, right| + segs = widths.map { |w| '─' * (w + 2) } + "#{styles[:rule]}#{left}#{segs.join(mid)}#{right}#{base}" + end + data_line = lambda do |row, header_row| + cells = ncols.times.map do |c| + " #{fit_cell(row[c], widths[c], aligns[c], styles, base, header_row)} " + end + bar + cells.join(bar) + bar + end + + out = [] + out << border.call('┌', '┬', '┐') + out << data_line.call(rendered[0], true) + out << border.call('├', '┼', '┤') + rendered[1..].each { |row| out << data_line.call(row, false) } + out << border.call('└', '┴', '┘') + out + end + + # Clip-or-pad a pre-styled cell to the column width, honouring alignment. + def fit_cell(styled, w, align, styles, base, header_row) + content = header_row ? "#{styles[:bold]}#{styled}#{base}" : styled + vis = FrameBuffer.visible_width(styled) + return "#{FrameBuffer.clip_ansi(content, w)}#{base}" if vis > w + + pad = w - vis + case align + when :right then "#{' ' * pad}#{content}" + when :center then "#{' ' * (pad / 2)}#{content}#{' ' * (pad - pad / 2)}" + else "#{content}#{' ' * pad}" + end + end end end diff --git a/lib/bbs/widgets.rb b/lib/bbs/widgets.rb index b9374a6..e29e57c 100644 --- a/lib/bbs/widgets.rb +++ b/lib/bbs/widgets.rb @@ -459,8 +459,17 @@ module BBS end def handle_event(event) - return nil unless event.key? max = [@lines.size - bounds.height, 0].max + if event.mouse? + return nil unless event.mouse.kind == :wheel + @scroll = if event.mouse.button == :up + [@scroll - 3, 0].max + else + [@scroll + 3, max].min + end + return :handled + end + return nil unless event.key? case event.key when :up then @scroll = [@scroll - 1, 0].max when :down then @scroll = [@scroll + 1, max].min diff --git a/lib/bbs/window.rb b/lib/bbs/window.rb index 1b5a76b..d6d8aff 100644 --- a/lib/bbs/window.rb +++ b/lib/bbs/window.rb @@ -70,6 +70,16 @@ module BBS end end + # Route wheel scrolling to whatever widget is under the cursor, so a + # scrollable pane responds even when keyboard focus is elsewhere. + if event.mouse? && event.mouse.kind == :wheel + hit = hit_test(event.x, event.y) + if hit && hit != focus_manager.focused + res = hit.handle_event(event) + return res if res + end + end + result = focus_manager.handle_focus_key(event) ? :handled : nil result ||= focus_manager.dispatch(event) return result if result