This commit is contained in:
2026-05-13 19:03:35 +02:00
parent be68836f4a
commit 07c48fc252
2 changed files with 156 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
GIT GIT
remote: https://git.teletype.hu/tools/rubbs.git remote: https://git.teletype.hu/tools/rubbs.git
revision: dbb88cc6f914d96f404b6e3bda2389e09e814ba4 revision: 26f4d2a4eeb766611a925845ddc50fd3ed60157d
specs: specs:
bbs (0.4.0) bbs (0.4.0)
artii (~> 2.1) artii (~> 2.1)

View File

@@ -4,7 +4,8 @@ module UI
module Windows module Windows
module_function module_function
# Split-view wiki browser. Left=titles, right=selected page content. # Split-view wiki browser. Left=titles, right=selected page content
# (rendered with markdown-aware formatting).
def wiki(app, services:, category:, title:) def wiki(app, services:, category:, title:)
cols = app.cols cols = app.cols
rows = app.rows rows = app.rows
@@ -38,7 +39,7 @@ module UI
next unless page next unless page
meta.text = "#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}" meta.text = "#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}"
text = services[:wiki].content(page.id) text = services[:wiki].content(page.id)
preview.lines = wrap_md(text, preview.bounds.width - 1) preview.lines = format_md(text, preview.bounds.width - 1, app.theme)
preview.scroll = 0 preview.scroll = 0
end end
@@ -56,27 +57,159 @@ module UI
window window
end end
def wrap_md(text, width) # ── Markdown → ANSI line array ────────────────────────────────────────────
stripped = text.to_s #
.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') # Returns an array of strings (each is one rendered terminal line with
.gsub(/[#*_`~>|\\]/, '') # embedded ANSI SGR escapes) suitable for BBS::Widgets::ScrollView.
.gsub(/\r?\n+/, ' ') #
.strip # Each inline span is terminated by the theme :text SGR so the trailing
words = stripped.split # text on the same line keeps the right background color.
lines = [] def format_md(text, width, theme)
cur = +'' styles = {
words.each do |w| base: theme[:text],
if cur.empty? h1: theme[:text_bright],
cur << w h2: theme[:accent],
elsif cur.length + 1 + w.length <= width h3: theme[:label],
cur << ' ' << w bold: theme[:text_bright],
else italic: theme[:text_dim],
lines << cur.dup code: theme[:label],
cur = +w link: theme[:accent],
end quote: theme[:text_dim],
bullet: theme[:accent],
rule: theme[:label],
}
result = []
paragraph = []
in_code = false
base = styles[:base]
flush_paragraph = lambda do
next if paragraph.empty?
joined = paragraph.join(' ').gsub(/\s+/, ' ').strip
BBS::FrameBuffer.wrap_ansi(inline_md(joined, styles, base), width)
.each { |l| result << l }
paragraph.clear
end end
lines << cur unless cur.empty?
lines.empty? ? [''] : lines text.to_s.split(/\r?\n/).each do |line|
# Fenced code block toggle
if line.lstrip.start_with?('```')
flush_paragraph.call
in_code = !in_code
next
end
if in_code
flush_paragraph.call
BBS::FrameBuffer.clip_ansi(line, width).then do |clipped|
result << "#{styles[:code]}#{clipped}#{base}"
end
next
end
# Blank line: paragraph break
if line.strip.empty?
flush_paragraph.call
result << ''
next
end
# ATX header
if (m = line.match(/\A([#]{1,6})\s+(.+?)\s*#*\s*\z/))
level = m[1].length
heading = m[2].strip
flush_paragraph.call
style = level == 1 ? styles[:h1] : (level == 2 ? styles[:h2] : styles[:h3])
BBS::FrameBuffer.wrap_ansi(inline_md(heading, styles, base), width).each do |l|
result << "#{style}#{l}#{base}"
end
next
end
# Horizontal rule: --- *** ___
if line.match?(/\A\s*([-*_])\1{2,}\s*\z/)
flush_paragraph.call
result << "#{styles[:rule]}#{'─' * width}#{base}"
next
end
# Blockquote
if (m = line.match(/\A\s*>\s?(.*)/))
content = m[1]
flush_paragraph.call
inner_w = [width - 2, 1].max
BBS::FrameBuffer.wrap_ansi(inline_md(content, styles, base), inner_w).each do |l|
result << "#{styles[:quote]}#{base}#{l}"
end
next
end
# Unordered list item
if (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 = BBS::FrameBuffer.wrap_ansi(inline_md(body, styles, base), inner_w)
wrapped.each_with_index do |l, i|
pad = ' ' * indent
result << if i.zero?
"#{pad}#{styles[:bullet]}#{base} #{l}"
else
"#{' ' * prefix_w}#{l}"
end
end
next
end
# Ordered list item
if (m = line.match(/\A(\s*)(\d+)\.\s+(.+)/))
indent = m[1].length
num = m[2]
body = m[3]
flush_paragraph.call
prefix = "#{' ' * indent}#{num}. "
inner_w = [width - prefix.length, 1].max
wrapped = BBS::FrameBuffer.wrap_ansi(inline_md(body, styles, base), inner_w)
wrapped.each_with_index do |l, i|
result << if i.zero?
"#{' ' * indent}#{styles[:bullet]}#{num}.#{base} #{l}"
else
"#{' ' * prefix.length}#{l}"
end
end
next
end
# Regular paragraph line — buffer for reflow
paragraph << line.strip
end
flush_paragraph.call
result
end
# Apply inline markdown formatting (bold, italic, code, links) and
# return the string with embedded ANSI codes. Each closing marker emits
# `base_sgr` so that surrounding text keeps the right background.
def inline_md(text, styles, base_sgr)
s = text.to_s
# Links [text](url) — show the link text styled (URL dropped)
s = s.gsub(/\[([^\]]+)\]\(([^)]+)\)/) { "#{styles[:link]}#{::Regexp.last_match(1)}#{base_sgr}" }
# Inline code `code`
s = s.gsub(/`([^`]+)`/) { "#{styles[:code]}#{::Regexp.last_match(1)}#{base_sgr}" }
# Bold **text**
s = s.gsub(/\*\*([^*]+)\*\*/) { "#{styles[:bold]}#{::Regexp.last_match(1)}#{base_sgr}" }
# Italic *text* and _text_ (require word boundaries to avoid mid-word _)
s = s.gsub(/(?<![A-Za-z0-9*])\*([^*\s][^*]*?[^*\s]|\S)\*(?![A-Za-z0-9*])/) {
"#{styles[:italic]}#{::Regexp.last_match(1)}#{base_sgr}"
}
s = s.gsub(/(?<![A-Za-z0-9_])_([^_\s][^_]*?[^_\s]|\S)_(?![A-Za-z0-9_])/) {
"#{styles[:italic]}#{::Regexp.last_match(1)}#{base_sgr}"
}
# Strip stray backslash escapes for common markdown chars
s.gsub(/\\([\\`*_{}\[\]()#+\-.!>])/) { ::Regexp.last_match(1) }
end end
end end
end end