Render GFM tables and enable mouse-wheel scrolling
Markdown.to_lines now detects GFM tables (header + delimiter row) and renders them as box-drawn, alignment-aware tables that shrink to fit the pane width, instead of mangling the rows into a single paragraph. ScrollView now handles mouse-wheel events, and Window routes wheel events to the widget under the cursor, so a detail/content pane scrolls even when keyboard focus is on an adjacent list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,27 +39,26 @@ module BBS
|
|||||||
paragraph.clear
|
paragraph.clear
|
||||||
end
|
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?('```')
|
if line.lstrip.start_with?('```')
|
||||||
flush_paragraph.call
|
flush_paragraph.call
|
||||||
in_code = !in_code
|
in_code = !in_code
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if in_code
|
elsif in_code
|
||||||
flush_paragraph.call
|
flush_paragraph.call
|
||||||
clipped = FrameBuffer.clip_ansi(line, width)
|
clipped = FrameBuffer.clip_ansi(line, width)
|
||||||
result << "#{styles[:code]}#{clipped}#{base}"
|
result << "#{styles[:code]}#{clipped}#{base}"
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if line.strip.empty?
|
elsif line.strip.empty?
|
||||||
flush_paragraph.call
|
flush_paragraph.call
|
||||||
result << ''
|
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
|
level = m[1].length
|
||||||
heading = m[2].strip
|
heading = m[2].strip
|
||||||
flush_paragraph.call
|
flush_paragraph.call
|
||||||
@@ -67,44 +66,50 @@ module BBS
|
|||||||
FrameBuffer.wrap_ansi(inline(heading, styles, base), width).each do |l|
|
FrameBuffer.wrap_ansi(inline(heading, styles, base), width).each do |l|
|
||||||
result << "#{style}#{l}#{base}"
|
result << "#{style}#{l}#{base}"
|
||||||
end
|
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
|
flush_paragraph.call
|
||||||
result << "#{styles[:rule]}#{'─' * width}#{base}"
|
result << "#{styles[:rule]}#{'─' * width}#{base}"
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if (m = line.match(/\A\s*>\s?(.*)/))
|
elsif (m = line.match(/\A\s*>\s?(.*)/))
|
||||||
content = m[1]
|
content = m[1]
|
||||||
flush_paragraph.call
|
flush_paragraph.call
|
||||||
inner_w = [width - 2, 1].max
|
inner_w = [width - 2, 1].max
|
||||||
FrameBuffer.wrap_ansi(inline(content, styles, base), inner_w).each do |l|
|
FrameBuffer.wrap_ansi(inline(content, styles, base), inner_w).each do |l|
|
||||||
result << "#{styles[:quote]}│ #{base}#{l}"
|
result << "#{styles[:quote]}│ #{base}#{l}"
|
||||||
end
|
end
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if (m = line.match(/\A(\s*)[-*+]\s+(.+)/))
|
elsif (m = line.match(/\A(\s*)[-*+]\s+(.+)/))
|
||||||
indent = m[1].length
|
indent = m[1].length
|
||||||
body = m[2]
|
body = m[2]
|
||||||
flush_paragraph.call
|
flush_paragraph.call
|
||||||
prefix_w = indent + 2
|
prefix_w = indent + 2
|
||||||
inner_w = [width - prefix_w, 1].max
|
inner_w = [width - prefix_w, 1].max
|
||||||
wrapped = FrameBuffer.wrap_ansi(inline(body, styles, base), inner_w)
|
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
|
pad = ' ' * indent
|
||||||
result << if i.zero?
|
result << if k.zero?
|
||||||
"#{pad}#{styles[:bullet]}•#{base} #{l}"
|
"#{pad}#{styles[:bullet]}•#{base} #{l}"
|
||||||
else
|
else
|
||||||
"#{' ' * prefix_w}#{l}"
|
"#{' ' * prefix_w}#{l}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if (m = line.match(/\A(\s*)(\d+)\.\s+(.+)/))
|
elsif (m = line.match(/\A(\s*)(\d+)\.\s+(.+)/))
|
||||||
indent = m[1].length
|
indent = m[1].length
|
||||||
num = m[2]
|
num = m[2]
|
||||||
body = m[3]
|
body = m[3]
|
||||||
@@ -112,17 +117,19 @@ module BBS
|
|||||||
prefix = "#{' ' * indent}#{num}. "
|
prefix = "#{' ' * indent}#{num}. "
|
||||||
inner_w = [width - prefix.length, 1].max
|
inner_w = [width - prefix.length, 1].max
|
||||||
wrapped = FrameBuffer.wrap_ansi(inline(body, styles, base), inner_w)
|
wrapped = FrameBuffer.wrap_ansi(inline(body, styles, base), inner_w)
|
||||||
wrapped.each_with_index do |l, i|
|
wrapped.each_with_index do |l, k|
|
||||||
result << if i.zero?
|
result << if k.zero?
|
||||||
"#{' ' * indent}#{styles[:bullet]}#{num}.#{base} #{l}"
|
"#{' ' * indent}#{styles[:bullet]}#{num}.#{base} #{l}"
|
||||||
else
|
else
|
||||||
"#{' ' * prefix.length}#{l}"
|
"#{' ' * prefix.length}#{l}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
next
|
|
||||||
|
else
|
||||||
|
paragraph << line.strip
|
||||||
end
|
end
|
||||||
|
|
||||||
paragraph << line.strip
|
i += consumed
|
||||||
end
|
end
|
||||||
|
|
||||||
flush_paragraph.call
|
flush_paragraph.call
|
||||||
@@ -142,5 +149,104 @@ module BBS
|
|||||||
end
|
end
|
||||||
s.gsub(/\\([\\`*_{}\[\]()#+\-.!>])/) { ::Regexp.last_match(1) }
|
s.gsub(/\\([\\`*_{}\[\]()#+\-.!>])/) { ::Regexp.last_match(1) }
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -459,8 +459,17 @@ module BBS
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(event)
|
def handle_event(event)
|
||||||
return nil unless event.key?
|
|
||||||
max = [@lines.size - bounds.height, 0].max
|
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
|
case event.key
|
||||||
when :up then @scroll = [@scroll - 1, 0].max
|
when :up then @scroll = [@scroll - 1, 0].max
|
||||||
when :down then @scroll = [@scroll + 1, max].min
|
when :down then @scroll = [@scroll + 1, max].min
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ module BBS
|
|||||||
end
|
end
|
||||||
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.handle_focus_key(event) ? :handled : nil
|
||||||
result ||= focus_manager.dispatch(event)
|
result ||= focus_manager.dispatch(event)
|
||||||
return result if result
|
return result if result
|
||||||
|
|||||||
Reference in New Issue
Block a user